Repository: ansible/tower-cli
Pull request #325 (2017-07-11 16:14:18)

3.1.5 release updates

coveralls: 
[![Coverage Status](https://coveralls.io/builds/12344063/badge)](https://coveralls.io/builds/12344063)

Coverage remained the same at 92.173% when pulling **0a22a339cbe877668660985e67a8caa296dde489 on jangsutsr:3_1_5_release_updates** into **50a3750d37100697e3b742292936df09d8c71c8c on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/12346058/badge)](https://coveralls.io/builds/12346058)

Coverage remained the same at 92.086% when pulling **b7bd6bf2ff9b965d140e4653587b95086ef3bb78 on jangsutsr:3_1_5_release_updates** into **0d5eda2b77cdd3e2f476e0ad49b777e4c6e04219 on ansible:master**.


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 6e318f1..4d760ea 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -1,6 +1,22 @@
 Release History
 ===============
 
+3.1.5 (2017-07-12)
+------------------
+
+- Major code base file structure refactor. Now all click-related logics are moved to `tower_cli/cli/` directory,
+  and `exceptions.py` as well as `compat.py` are moved out of utils directory into base directory.
+- Categorize help text options for resource action commands (like `update`) to increase readability.
+- Behavior change of workflow schema command. Now schema will both create new nodes and delete existing nodes when
+  needed to make the resulting workflow topology exactly the same as described in schema file.
+- Add command `job_template callback` to enable conducting provisioning callback via Tower CLI.
+- Add new format option to just echo id.
+- Expand some resource fields, including hipchat rooms for notification template and allow_simultaneous for job
+  templates.
+- Lookup related inventory sources with "starts with" logic if its name is not fully qualified.
+- Fixed a python 3.5 compatibility issue that causes job monitor traceback.
+- Minor typo and help text updates.
+
 3.1.4 (2017-06-07)
 ------------------
 
diff --git a/VERSION b/VERSION
index 0aec50e..3ad0595 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-3.1.4
+3.1.5
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
index 292dd0c..7407b80 100644
--- a/docs/WORKFLOWS.md
+++ b/docs/WORKFLOWS.md
@@ -66,9 +66,9 @@ tower-cli workflow schema workflow1
 
 Here, "workflow1" is the name of the workflow.
 
-### Creating/updating a Schema Definition
+### Creating a Schema Definition
 
-To bulk-create or buld-update a workflow node network, use the workflow schema command.
+To bulk-create a workflow node network, use the workflow schema command.
 The schema is JSON or YAML content, and can be passed in the CLI
 argument, or pointed to a file. The schema is passed as a second positional
 argument, where the first argument references the workflow.
@@ -153,27 +153,22 @@ tower-cli workflow schema workflow2 @schema.yml
 The workflow schema feature populates the workflow node network based on the
 hierarchy structure. Before creating each node, it attempts to find an
 existing node with the specified properties in that location in the
-tree, and will not create a new node if it exists. Also, if an existing node
-has no correspondence in the schema, the entire sub-tree based on that node will
-be deleted.
+tree, and will not create a new node if it exists.
 
-Thus, after running the schema command, the resulting workflow topology will always
-be exactly the same as what is specified in the given schema file. To continue with
-the previous example, subsequent invocations of:
+Thus, running the command multiple times should not change the workflow
+structure. To continue with the previous example, subsequent
+invocations of:
 
 ```bash
 tower-cli workflow schema workflow2 @schema.yml
 tower-cli workflow schema workflow2 @schema.yml
 ```
 
-should not change the network of workflow2, since `schema.yml` file itself remains
-unchanged. However
+should not change the network of workflow2.
 
-```bash
-tower-cli workflow schema workflow2 @new_schema.yml
-```
-
-will modify topology of workflow2 to exactly the same as what is specified in `new_schema.yml`.
+The schema command can not delete nodes, so running multiple commands with
+different schemas will result in creating multiple branches and/or
+sub-branches.
 
 ## Launching Workflow Jobs
 
diff --git a/docs/examples/data/schema_a.yml b/docs/examples/data/schema_a.yml
deleted file mode 100644
index b68cd07..0000000
--- a/docs/examples/data/schema_a.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-# Branch that has deeply nested content removed  going to schema B
-- job_template: workflow JT
-  failure_nodes:
-    - inventory_source: tower script1 (workflow demo
-  success_nodes:
-  - project: Ansible Examples
-    always_nodes:
-    - job_template: workflow JT
-      success_nodes:
-      - job_template: workflow JT
-
-# Branch that is modified going to schema B
-- job_template: workflow JT2
-  failure_nodes:
-  - job_template: workflow JT2
-  - job_template: workflow JT
-  success_nodes:
-  - job_template: workflow JT2
-  - job_template: workflow JT
-
-# Branch that is deleted going to schema B
-- project: Ansible Examples
-  always_nodes:
-  - inventory_source: tower script1 (workflow demo
-  - inventory_source: tower script2 (workflow demo
diff --git a/docs/examples/data/schema_b.yml b/docs/examples/data/schema_b.yml
deleted file mode 100644
index 6889df8..0000000
--- a/docs/examples/data/schema_b.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# Branch that has deeply nested content removed  going to schema B
-- job_template: workflow JT
-  failure_nodes:
-    - inventory_source: tower script1 (workflow demo
-  success_nodes:
-  - project: Ansible Examples
-    always_nodes:
-    - job_template: workflow JT
-
-# Branch that is modified going to schema B
-- job_template: workflow JT2
-  failure_nodes:
-  - inventory_source: tower script1 (workflow demo
-  - inventory_source: tower script2 (workflow demo
-  success_nodes:
-  - project: Ansible Examples
-  - job_template: workflow JT
diff --git a/docs/examples/data/schema_simple.yml b/docs/examples/data/schema_simple.yml
deleted file mode 100644
index b272253..0000000
--- a/docs/examples/data/schema_simple.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-- job_template: workflow JT
-  failure:
-  - inventory_source: tower script1 (workflow demo
-  success:
-  - project: Ansible Examples
-    always:
-    - job_template: workflow JT
-      success:
-      - job_template: workflow JT
\ No newline at end of file
diff --git a/docs/examples/data/schema_tiny.yml b/docs/examples/data/schema_tiny.yml
deleted file mode 100644
index 614ff42..0000000
--- a/docs/examples/data/schema_tiny.yml
+++ /dev/null
@@ -1,1 +0,0 @@
-- job_template: workflow JT
\ No newline at end of file
diff --git a/docs/examples/fake_data_creator.sh b/docs/examples/fake_data_creator.sh
index 8081223..95066ea 100644
--- a/docs/examples/fake_data_creator.sh
+++ b/docs/examples/fake_data_creator.sh
@@ -37,6 +37,8 @@ echo " current configuration settings:"
 echo $hostval
 echo $userval
 
+tower-cli config verify_ssl false
+
 echo "Tower-CLI DATA FAKER: creating orgs and teams"
 # Data regarding Hyrule Ventures was taken from
 # https://github.com/jsmartin/tower_populator
diff --git a/docs/examples/inventory_script_example.py b/docs/examples/inventory_script_example.py
deleted file mode 100755
index e7bcfec..0000000
--- a/docs/examples/inventory_script_example.py
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env python
-
-import json
-
-inv = {
-    '_meta': {
-        'hostvars': {}
-    },
-    'hosts': []
-}
-
-for num in range(0, 3):
-    host = u"host-%0.2d" % num
-    inv['hosts'].append(host)
-    inv['_meta']['hostvars'][host] = dict(ansible_ssh_host='127.0.0.1', ansible_connection='local')
-
-print(json.dumps(inv, indent=2))
diff --git a/docs/examples/workflow_demo.sh b/docs/examples/workflow_demo.sh
deleted file mode 100644
index 5509d3c..0000000
--- a/docs/examples/workflow_demo.sh
+++ /dev/null
@@ -1,60 +0,0 @@
-# Copyright 2017, Ansible by Red Hat
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-# This bash script can populate an instance of Ansible Tower with
-# fake data, using the command line interface (tower-cli) to do so.
-
-echo " "
-echo "        == Tower-CLI WORKFLOW DEMO == "
-echo " "
-
-echo "current location: ${BASH_SOURCE%/*}"
-
-tower-cli organization create --name="Default"
-
-tower-cli project create --name="Ansible Examples" --scm-type=git --scm-url="https://github.com/ansible/ansible-examples" --wait
-tower-cli project create --name="Ansible Examples2" --scm-type=git --scm-url="https://github.com/ansible/ansible-examples" --wait
-
-tower-cli inventory create --name="workflow demo" --organization=Default
-tower-cli inventory_script create --name="example inv script" --organization="Default" --script="@${BASH_SOURCE%/*}/inventory_script_example.py"
-# Note that these 2 groups need to have distinct names without one starting with the other
-tower-cli group create --name="tower script1" --inventory="workflow demo" --source="custom" --source-script="example inv script"
-tower-cli group create --name="tower script2" --inventory="workflow demo" --source="custom" --source-script="example inv script"
-
-tower-cli inventory create --name="localhost"
-tower-cli host create --inventory="localhost" --name="localhost" --variables='{"connection": "local", "ansible_host": "localhost"}'
-
-tower-cli credential create --name="Local SSH" --kind=ssh --organization="Default" --ssh-key-data="${BASH_SOURCE%/*}/insecure_private_key"
-
-tower-cli job_template create --name="workflow JT" --inventory="localhost" --machine-credential="Local SSH" --project="Ansible Examples" --playbook="language_features/loop_nested.yml"
-tower-cli job_template create --name="workflow JT2" --inventory="localhost" --machine-credential="Local SSH" --project="Ansible Examples" --playbook="language_features/loop_nested.yml"
-
-# Create the WFJT resource itself
-tower-cli workflow create --name="workflow demo" --organization="Default"
-
-echo ''
-echo '   -- application of simple schema --'
-tower-cli workflow schema "workflow demo" "@${BASH_SOURCE%/*}/data/schema_simple.yml"
-
-echo ''
-echo '   -- application of tiny schema --'
-tower-cli workflow schema "workflow demo" "@${BASH_SOURCE%/*}/data/schema_tiny.yml"
-
-echo ''
-echo '   -- application of schema a --'
-tower-cli workflow schema "workflow demo" "@${BASH_SOURCE%/*}/data/schema_a.yml"
-
-echo ''
-echo '   -- application of schema a --'
-tower-cli workflow schema "workflow demo" "@${BASH_SOURCE%/*}/data/schema_b.yml"
diff --git a/packaging/rpm/ansible-tower-cli.spec b/packaging/rpm/ansible-tower-cli.spec
index 6cf72a5..492b105 100644
--- a/packaging/rpm/ansible-tower-cli.spec
+++ b/packaging/rpm/ansible-tower-cli.spec
@@ -2,7 +2,7 @@
 %global srcname tower-cli
 
 Name:           ansible-%{srcname}
-Version:        3.1.4
+Version:        3.1.5
 Release:        2%{?dist}
 Summary:        Commandline interface for Ansible Tower
 Group:          Development/Tools
diff --git a/tower_cli/cli/resource.py b/tower_cli/cli/resource.py
index 96b94a6..8872c5f 100644
--- a/tower_cli/cli/resource.py
+++ b/tower_cli/cli/resource.py
@@ -29,7 +29,6 @@ import click
 from tower_cli.conf import settings, with_global_options
 from tower_cli.utils import parser, debug, secho
 from tower_cli.cli.action import ActionSubcommand
-from tower_cli.exceptions import MultipleRelatedError
 
 
 class ResSubcommand(click.MultiCommand):
@@ -130,15 +129,6 @@ class ResSubcommand(click.MultiCommand):
         return parser.ordered_dump(payload, Dumper=yaml.SafeDumper,
                                    default_flow_style=False)
 
-    def _format_id(self, payload):
-        """Echos only the id"""
-        if 'id' in payload:
-            return str(payload['id'])
-        if 'results' in payload and payload['count'] == 1:
-            return str(payload['results'][0]['id'])
-        raise MultipleRelatedError(
-            'Can not use id format when multiple objects are returned.')
-
     def _format_human(self, payload):
         """Convert the payload into an ASCII table suitable for
         printing on screen and return it.
diff --git a/tower_cli/cli/types.py b/tower_cli/cli/types.py
index 6e0feea..99e11ff 100644
--- a/tower_cli/cli/types.py
+++ b/tower_cli/cli/types.py
@@ -137,6 +137,13 @@ class Related(click.types.ParamType):
             debug.log('The %s field is given as a name; '
                       'looking it up.' % param.name, header='details')
             lookup_data = {resource.identity[-1]: value}
+            # Special case to look up inventory sources by partial name
+            # TODO: Remove with v1 deprecation
+            if (self.resource_name == 'inventory_source' and
+                    not resource._is_full_v1_name(value)):
+                lookup_data = {
+                    "query": [('name__startswith', value)]
+                }
             rel = resource.get(**lookup_data)
         except exc.MultipleResults as ex:
             raise exc.MultipleRelatedError(
diff --git a/tower_cli/conf.py b/tower_cli/conf.py
index b66026d..70c1c71 100644
--- a/tower_cli/conf.py
+++ b/tower_cli/conf.py
@@ -369,8 +369,8 @@ def with_global_options(method):
         '-f', '--format',
         help='Output format. The "human" format is intended for humans '
              'reading output on the CLI; the "json" and "yaml" formats '
-             'provide more data, and "id" echos the object id only.',
-        type=click.Choice(['human', 'json', 'yaml', 'id']),
+             'provide more data.',
+        type=click.Choice(['human', 'json', 'yaml']),
         required=False, callback=_apply_runtime_setting,
         is_eager=True
     )(method)
diff --git a/tower_cli/resources/inventory_source.py b/tower_cli/resources/inventory_source.py
index fc82063..181b3ef 100644
--- a/tower_cli/resources/inventory_source.py
+++ b/tower_cli/resources/inventory_source.py
@@ -58,19 +58,6 @@ class Resource(models.MonitorableResource):
     def _is_full_v1_name(self, name):
         return bool(re.match(r'^.+\s\(.+\s-\s\d+\)$', name))
 
-    def read(self, pk=None, fail_on_no_results=False,
-             fail_on_multiple_results=False, **kwargs):
-        # Special case to look up inventory sources by partial name
-        # TODO: Remove with v1 deprecation
-        if (kwargs.get('name', None) and not kwargs.get('group', None) and
-                not self._is_full_v1_name(kwargs['name'])):
-            kwargs.setdefault('query', [])
-            kwargs['query'] += [('name__startswith', kwargs['name'])]
-            kwargs.pop('name')
-        return super(Resource, self).read(
-            pk, fail_on_no_results=fail_on_no_results,
-            fail_on_multiple_results=fail_on_multiple_results, **kwargs)
-
     @click.argument('inventory_source', type=types.Related('inventory_source'))
     @click.option('--monitor', is_flag=True, default=False,
                   help='If sent, immediately calls `monitor` on the newly '
diff --git a/tower_cli/resources/setting.py b/tower_cli/resources/setting.py
index d3f2a2a..ba9df21 100644
--- a/tower_cli/resources/setting.py
+++ b/tower_cli/resources/setting.py
@@ -71,11 +71,7 @@ class Resource(models.Resource):
     @click.argument('value', default=None, required=False,
                     type=types.Variables())
     def modify(self, setting, value):
-        """Modify an already existing object.
-
-        Positional argument SETTING is the setting name and VALUE is its value,
-        which can be provided directly or obtained from a file name if prefixed with '@'.
-        """
+        """Modify an already existing object."""
         prev_value = new_value = self.get(setting)['value']
         answer = OrderedDict()
         encrypted = '$encrypted$' in six.text_type(prev_value)
diff --git a/tower_cli/resources/workflow.py b/tower_cli/resources/workflow.py
index d81d1ce..fba2c2e 100644
--- a/tower_cli/resources/workflow.py
+++ b/tower_cli/resources/workflow.py
@@ -26,130 +26,6 @@ import click
 from collections import deque
 
 
-class TreeNode(object):
-    def __init__(self, data, wfjt, include_id=False):
-        ujt_attrs = list(JOB_TYPES.values())
-        FK_FIELDS = ujt_attrs + ['inventory', 'credential']
-        node_attrs = {}
-        attr_names = NODE_STANDARD_FIELDS + ujt_attrs
-        if include_id:
-            attr_names.append('id')
-        for fd in attr_names:
-            if fd not in data:
-                continue
-            if fd in FK_FIELDS and not isinstance(data[fd], int):
-                # Node's template was given by name, do lookup
-                ujt_res = get_resource(fd)
-                ujt_data = ujt_res.get(name=data[fd])
-                node_attrs[fd] = ujt_data['id']
-            else:
-                node_attrs[fd] = data[fd]
-        node_attrs['workflow_job_template'] = wfjt
-        for ujt_name in ujt_attrs:
-            if ujt_name not in node_attrs:
-                continue
-            if 'unified_job_template' not in node_attrs:
-                node_attrs['unified_job_template'] = node_attrs.pop(ujt_name)
-            else:
-                raise BadRequest(
-                    'You should not provide more than one of the attributes'
-                    ' job_template, project and inventory_source.'
-                )
-        self.unified_job_template = node_attrs.get('unified_job_template', None)
-        self.node_attrs = node_attrs
-        for rel in ['success_nodes', 'failure_nodes', 'always_nodes']:
-            setattr(
-                self, rel,
-                [TreeNode(x, wfjt, include_id=include_id) for x in data.get(rel, data.get(rel[: -6], []))]
-            )
-
-    def create(self, node_res):
-        self.node_attrs['id'] = node_res.create(**self.node_attrs)['id']
-        queue = deque()
-        queue.append(self)
-        while queue:
-            node = queue.popleft()
-            for rel in ['success_nodes', 'failure_nodes', 'always_nodes']:
-                for sub_node in getattr(node, rel, []):
-                    sub_node.node_attrs['id'] = node_res.create(**sub_node.node_attrs)['id']
-                    getattr(node_res, 'associate_%s' % rel[:-1])(
-                        node.node_attrs['id'], child=sub_node.node_attrs['id']
-                    )
-                    queue.append(sub_node)
-
-    def delete(self, node_res):
-        for rel in ['success_nodes', 'failure_nodes', 'always_nodes']:
-            for sub_node in getattr(self, rel, []):
-                sub_node.delete(node_res)
-        node_res.delete(pk=self.node_attrs['id'])
-
-
-def _compare_node_lists(old, new):
-    '''
-    Investigate two lists of workflow TreeNodes and categorize them.
-
-    There will be three types of nodes after categorization:
-        1. Nodes that only exists in the new list. These nodes will later be
-        created recursively.
-        2. Nodes that only exists in the old list. These nodes will later be
-        deleted recursively.
-        3. Node pairs that makes an exact match. These nodes will be further
-        investigated.
-
-    Corresponding nodes of old and new lists will be distinguished by their
-    unified_job_template value. A special case is that both the old and the new
-    lists contain one type of node, say A, and at least one of them contains
-    duplicates. In this case all A nodes in the old list will be categorized as
-    to-be-deleted and all A nodes in the new list will be categorized as
-    to-be-created.
-    '''
-    to_expand = []
-    to_delete = []
-    to_recurse = []
-    old_records = {}
-    new_records = {}
-    for tree_node in old:
-        old_records.setdefault(tree_node.unified_job_template, [])
-        old_records[tree_node.unified_job_template].append(tree_node)
-    for tree_node in new:
-        new_records.setdefault(tree_node.unified_job_template, [])
-        new_records[tree_node.unified_job_template].append(tree_node)
-    for ujt_id in old_records:
-        if ujt_id not in new_records:
-            to_delete.extend(old_records[ujt_id])
-            continue
-        old_list = old_records[ujt_id]
-        new_list = new_records.pop(ujt_id)
-        if len(old_list) == 1 and len(new_list) == 1:
-            to_recurse.append((old_list[0], new_list[0]))
-        else:
-            to_delete.extend(old_list)
-            to_expand.extend(new_list)
-    for nodes in new_records.values():
-        to_expand.extend(nodes)
-    return to_expand, to_delete, to_recurse
-
-
-def _do_update_workflow(existing_roots, updated_roots, node_res):
-    to_expand, to_delete, to_recurse = _compare_node_lists(existing_roots, updated_roots)
-    for node in to_delete:
-        node.delete(node_res)
-    for node in to_expand:
-        node.create(node_res)
-    for old_node, new_node in to_recurse:
-        for rel in ['success_nodes', 'failure_nodes', 'always_nodes']:
-            to_assoc = _do_update_workflow(getattr(old_node, rel, []), getattr(new_node, rel, []), node_res)
-            for sub_node in to_assoc:
-                getattr(node_res, 'associate_%s' % rel[:-1])(old_node.node_attrs['id'], child=sub_node.node_attrs['id'])
-    return to_expand
-
-
-def _update_workflow(existing_roots, updated_roots):
-    # Node resource should be fetched *only once*.
-    node_res = get_resource('node')
-    _do_update_workflow(existing_roots, updated_roots, node_res)
-
-
 class Resource(models.SurveyResource):
     cli_help = 'Manage workflow job templates.'
     endpoint = '/workflow_job_templates/'
@@ -209,7 +85,7 @@ class Resource(models.SurveyResource):
         def branch_schema(node_id):
             i = node_list_pos[node_id]
             node_dict = node_results[i]
-            ret_dict = {"id": node_id}
+            ret_dict = {}
             for fd in NODE_STANDARD_FIELDS:
                 val = node_dict.get(fd, None)
                 if val is not None:
@@ -255,23 +131,71 @@ class Resource(models.SurveyResource):
         node_network param is given.
         If not, print a YAML representation of the node network.
         """
-        existing_network = self._get_schema(wfjt)
-        if not isinstance(existing_network, list):
-            existing_network = []
         if node_network is None:
             if settings.format == 'human':
                 settings.format = 'yaml'
-            return existing_network
+            return self._get_schema(wfjt)
+
+        node_res = get_resource('node')
+
+        def create_node(node_branch, parent, relationship):
+            # Create node with data specified by top-level keys
+            create_data = {}
+            FK_FIELDS = JOB_TYPES.values() + ['inventory', 'credential']
+            for fd in NODE_STANDARD_FIELDS + JOB_TYPES.values():
+                if fd in node_branch:
+                    if (
+                            fd in FK_FIELDS and
+                            not isinstance(node_branch[fd], int)):
+                        # Node's template was given by name, do lookup
+                        ujt_res = get_resource(fd)
+                        ujt_data = ujt_res.get(name=node_branch[fd])
+                        create_data[fd] = ujt_data['id']
+                    else:
+                        create_data[fd] = node_branch[fd]
+            create_data['workflow_job_template'] = wfjt
+            return node_res._get_or_create_child(
+                parent, relationship, **create_data)
+
+        def get_adj_list(node_branch):
+            ret = {}
+            for fd in node_branch:
+                for rel in ['success', 'failure', 'always']:
+                    if fd.startswith(rel):
+                        sub_branch_list = node_branch[fd]
+                        if not isinstance(sub_branch_list, list):
+                            raise BadRequest(
+                                'Sublists in spec must be lists.'
+                                'Encountered in {0} at {1}'.format(
+                                    fd, sub_branch_list))
+                        ret[rel] = sub_branch_list
+                        break
+            return ret
+
+        def create_node_recursive(node_network):
+            queue = deque()
+            id_queue = deque()
+            for base_node in node_network:
+                queue.append(base_node)
+                id_queue.append(create_node(base_node, None, None)['id'])
+                while (len(queue) != 0):
+                    to_expand = queue.popleft()
+                    parent_id = id_queue.popleft()
+                    adj_list = get_adj_list(to_expand)
+                    for rel in adj_list:
+                        for sub_node in adj_list[rel]:
+                            id_queue.append(create_node(sub_node, parent_id,
+                                                        rel)['id'])
+                            queue.append(sub_node)
+                            node_res._assoc(node_res._forward_rel_name(rel),
+                                            parent_id, id_queue[-1])
 
         if hasattr(node_network, 'read'):
             node_network = node_network.read()
         node_network = string_to_dict(
             node_network, allow_kv=False, require_dict=False)
-        if not isinstance(node_network, list):
-            node_network = []
 
-        _update_workflow([TreeNode(x, wfjt, include_id=True) for x in existing_network],
-                         [TreeNode(x, wfjt) for x in node_network])
+        create_node_recursive(node_network)
 
         if settings.format == 'human':
             settings.format = 'yaml'


Test patch:
diff --git a/tests/test_cli_resource.py b/tests/test_cli_resource.py
index 3972d22..696c3bb 100644
--- a/tests/test_cli_resource.py
+++ b/tests/test_cli_resource.py
@@ -352,11 +352,3 @@ class SubcommandTests(unittest.TestCase):
         self.assertIn('spam', output)
         self.assertIn('bar', output)
         self.assertIn('eggs', output)
-
-    def test_echo_id(self):
-        func = self.command._echo_method(lambda: {'id': 5})
-        with mock.patch.object(click, 'secho') as secho:
-            with settings.runtime_values(format='id'):
-                func()
-            output = secho.mock_calls[-1][1][0]
-        self.assertEqual('5', output)
diff --git a/tests/test_resources_workflow.py b/tests/test_resources_workflow.py
index d8dc00b..0ba4e32 100644
--- a/tests/test_resources_workflow.py
+++ b/tests/test_resources_workflow.py
@@ -13,8 +13,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import json
-
 import tower_cli
 from tower_cli.resources.workflow import Resource
 
@@ -49,344 +47,10 @@ EXAMPLE_NODE_LIST = [
 ]
 
 
-CUR_WORKFLOW = [
-    {
-        'id': 1,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [2, 3],
-        'workflow_job_template': 1,
-        'unified_job_template': 1,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'project_update'
-            }
-        }
-    },
-    {
-        'id': 2,
-        'success_nodes': [4],
-        'failure_nodes': [5],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 2,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'project_update'
-            }
-        }
-    },
-    {
-        'id': 3,
-        'success_nodes': [],
-        'failure_nodes': [6, 7],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 3,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'project_update'
-            }
-        }
-    },
-    {
-        'id': 4,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 4,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'job'
-            }
-        }
-    },
-    {
-        'id': 5,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 5,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'job'
-            }
-        }
-    },
-    {
-        'id': 6,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 6,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'job'
-            }
-        }
-    },
-    {
-        'id': 7,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 7,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'job'
-            }
-        }
-    },
-    {
-        'id': 8,
-        'success_nodes': [9, 10],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 8,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'inventory_update'
-            }
-        }
-    },
-    {
-        'id': 9,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [11],
-        'workflow_job_template': 1,
-        'unified_job_template': 9,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'inventory_update'
-            }
-        }
-    },
-    {
-        'id': 10,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [12],
-        'workflow_job_template': 1,
-        'unified_job_template': 9,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'inventory_update'
-            }
-        }
-    },
-    {
-        'id': 11,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 10,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'job'
-            }
-        }
-    },
-    {
-        'id': 12,
-        'success_nodes': [],
-        'failure_nodes': [],
-        'always_nodes': [],
-        'workflow_job_template': 1,
-        'unified_job_template': 10,
-        'summary_fields': {
-            'unified_job_template': {
-                'unified_job_type': 'job'
-            }
-        }
-    },
-]
-
-
-UPDATED_WORKFLOW = [
-    {
-        u'project': 1,
-        u'always': [
-            {
-                u'project': 2,
-                u'success': [
-                    {
-                        u'job_template': 4
-                    }
-                ],
-                u'failure': [
-                    {
-                        u'job_template': 5,
-                        u'success': [
-                            {
-                                u'job_template': 6
-                            },
-                            {
-                                u'job_template': 7
-                            }
-                        ]
-                    }
-                ]
-            }
-        ]
-    },
-    {
-        u'inventory_source': 8,
-        u'success': [
-            {
-                u'inventory_source': 9,
-                u'always': [
-                    {
-                        u'job_template': 10,
-                    }
-                ]
-            },
-            {
-                u'inventory_source': 9,
-                u'always': [
-                    {
-                        u'job_template': 10,
-                    }
-                ]
-            }
-        ]
-    }
-]
-
-
-UPDATED_RESULT = [
-    {
-        u'project': 1,
-        u'always_nodes': [
-            {
-                u'project': 2,
-                u'success_nodes': [
-                    {
-                        u'job_template': 4
-                    }
-                ],
-                u'failure_nodes': [
-                    {
-                        u'job_template': 5,
-                        u'success_nodes': [
-                            {
-                                u'job_template': 6
-                            },
-                            {
-                                u'job_template': 7
-                            }
-                        ]
-                    }
-                ]
-            }
-        ]
-    },
-    {
-        u'inventory_source': 8,
-        u'success_nodes': [
-            {
-                u'job_template': 9,
-                u'always_nodes': [
-                    {
-                        u'job_template': 10,
-                    }
-                ]
-            },
-            {
-                u'job_template': 9,
-                u'always_nodes': [
-                    {
-                        u'job_template': 10,
-                    }
-                ]
-            }
-        ]
-    }
-]
-
-
-class NodeResMock(object):
-    def __init__(self, init_list):
-        self.db = {}
-        self.cur_pk = 0
-        for node in init_list:
-            self.db[node['id']] = node
-            self.cur_pk = max(self.cur_pk, node['id'])
-        self.create_cnt = 0
-        self.delete_cnt = 0
-        self.associate_success_cnt = 0
-        self.associate_failure_cnt = 0
-        self.associate_always_cnt = 0
-
-    def create(self, **kwargs):
-        self.cur_pk += 1
-        new_record = {
-            'success_nodes': [],
-            'failure_nodes': [],
-            'always_nodes': [],
-            'id': self.cur_pk,
-            'summary_fields': {
-                'unified_job_template': {
-                    'unified_job_type': 'job'
-                }
-            }
-        }
-        new_record.update(kwargs)
-        self.db[new_record['id']] = new_record
-        self.create_cnt += 1
-        return new_record
-
-    def list(self, **kwargs):
-        return {
-            'count': len(self.db),
-            'results': list(self.db.values()),
-            'next': None,
-            'previous': None
-        }
-
-    def delete(self, pk=None):
-        if not pk:
-            return
-        self.db.pop(pk, None)
-        for record in self.db.values():
-            for rel in ['success_nodes', 'failure_nodes', 'always_nodes']:
-                try:
-                    record[rel].remove(pk)
-                except ValueError:
-                    pass
-        self.delete_cnt += 1
-
-    def associate_success_node(self, id, child=None):
-        if not child or id not in self.db:
-            return
-        self.db[id]['success_nodes'].append(child)
-        self.associate_success_cnt += 1
-
-    def associate_failure_node(self, id, child=None):
-        if not child or id not in self.db:
-            return
-        self.db[id]['failure_nodes'].append(child)
-        self.associate_failure_cnt += 1
-
-    def associate_always_node(self, id, child=None):
-        if not child or id not in self.db:
-            return
-        self.db[id]['always_nodes'].append(child)
-        self.associate_always_cnt += 1
-
-
 class SchemaTests(unittest.TestCase):
     """
     Tests for the schema file in the tower_cli utils
     """
-    def setUp(self):
-        self.res = tower_cli.get_resource('workflow')
 
     def test_expected_output(self):
         """
@@ -399,10 +63,8 @@ class SchemaTests(unittest.TestCase):
             [
                 {
                     'job_template': 48,
-                    'id': 1,
                     'always_nodes': [
                         {
-                            'id': 2,
                             'project': 98
                         }
                     ]
@@ -410,25 +72,6 @@ class SchemaTests(unittest.TestCase):
             ]
         )
 
-    def _remove_ids(self, input_list):
-        for node in input_list:
-            node.pop('id', None)
-            for rel in ['success_nodes', 'failure_nodes', 'always_nodes']:
-                if rel in node:
-                    self._remove_ids(node[rel])
-        return input_list
-
-    def test_schema_update(self):
-        node_res_mock = NodeResMock(CUR_WORKFLOW)
-        with mock.patch('tower_cli.resources.workflow.get_resource', return_value=node_res_mock):
-            res = self._remove_ids(self.res.schema(1, node_network=json.dumps(UPDATED_WORKFLOW)))
-            self.assertEqual(res, UPDATED_RESULT)
-            self.assertEqual(node_res_mock.create_cnt, 6)
-            self.assertEqual(node_res_mock.delete_cnt, 7)
-            self.assertEqual(node_res_mock.associate_success_cnt, 4)
-            self.assertEqual(node_res_mock.associate_failure_cnt, 0)
-            self.assertEqual(node_res_mock.associate_always_cnt, 2)
-
 
 class NodeModelTests(unittest.TestCase):
     """