From 2ff277a0e2b081b7c5ee9507c7c516c9c7bb157d Mon Sep 17 00:00:00 2001 From: Chris Ladd Date: Fri, 2 Oct 2020 12:01:22 -0700 Subject: [PATCH] FEATURE: Ansible module to manage Stacki hosts An Ansible module for adding, editing, and removing Stacki hosts. The module takes these parameters: `name` - The name of the host to manage `appliance` - The appliance used for the host `box` - The box used for the host `comment` - Freeform text to attach to the host `environment` - Environment to assign the host `groups` - List of groups to add or remove the host from. Each item has parameters: `name` - The name of the group `state` - If present, then a host will be added to this group.If absent, then the host will be removed from this group. `installaction` - The install boot action for the host `interfaces` - List of network interfaces for the host. Each item has parameters: `channel` - Channel for this interface `default` - Is the interface is the default for the hosts `interface` - Device for this interface `ip` - IP address for this interface `name` - Logical name for this interface `network` - Network attached to this interface `mac` - Hardware MAC address for this interface `module` - Device module for this interface `options` - Module options for this interface `vlan` - The VLAN ID for this interface `state` - If present, then an interface will be added to the host, if needed, and options updates. If absent, then the interface will be removed from the host. If update_mac, then the interface device is used to update the mac. If update_interface, then the mac is used to update the interface device. Note: The interface device and mac are both used to match for updating an existing interface. `osaction` - The os boot action for the host `rack` - By convention, the number of the rack where the host is located `rank` - By convention, the position of the host in the rack `state` - If present, then a host will be added (if needed) and options are set to match. If absent, then the host will be removed. Example playbook: ``` --- - hosts: localhost tasks: - name: Add a host stacki_host: name: test-backend appliance: backend box: default comment: "test host" groups: - name: test installaction: console interfaces: - default: true interface: eth0 ip: "10.10.10.10" mac: "00:11:22:33:44:55" rack: "10" rank: "4" register: result - name: Add host output debug: var: result - name: Modify a host stacki_host: name: test-backend groups: - name: test state: absent installaction: default interfaces: - interface: eth0 state: absent - interface: eth1 ip: "10.10.2.1" mac: "11:22:33:44:55:66" rack: "0" rank: "0" register: result - name: Modify host output debug: var: result - name: Remove a host stacki_host: name: test-backend state: absent register: result - name: Remove host output debug: var: result ``` Output of the debug commands, showing the structure of the data returned: ``` TASK [Add host output] ************************************************************************** ok: [localhost] => { "result": { "changed": true, "failed": false } } TASK [Modify host output] *********************************************************************** ok: [localhost] => { "result": { "changed": true, "failed": false } } TASK [Remove host output] *********************************************************************** ok: [localhost] => { "result": { "changed": true, "failed": false } } ``` --- .../ansible/plugins/modules/stacki_host.py | 466 ++++++++++++++++++ .../integration/files/ansible/add_host.yaml | 19 + .../integration/files/ansible/edit_host.yaml | 22 + .../files/ansible/remove_host.yaml | 7 + .../files/ansible/update_host_interface.yaml | 10 + .../files/ansible/update_host_mac.yaml | 10 + .../tests/ansible/test_stacki_host.py | 240 +++++++++ 7 files changed, 774 insertions(+) create mode 100644 common/src/stack/ansible/plugins/modules/stacki_host.py create mode 100644 test-framework/test-suites/integration/files/ansible/add_host.yaml create mode 100644 test-framework/test-suites/integration/files/ansible/edit_host.yaml create mode 100644 test-framework/test-suites/integration/files/ansible/remove_host.yaml create mode 100644 test-framework/test-suites/integration/files/ansible/update_host_interface.yaml create mode 100644 test-framework/test-suites/integration/files/ansible/update_host_mac.yaml create mode 100644 test-framework/test-suites/integration/tests/ansible/test_stacki_host.py diff --git a/common/src/stack/ansible/plugins/modules/stacki_host.py b/common/src/stack/ansible/plugins/modules/stacki_host.py new file mode 100644 index 000000000..e4c86da44 --- /dev/null +++ b/common/src/stack/ansible/plugins/modules/stacki_host.py @@ -0,0 +1,466 @@ +# @copyright@ +# Copyright (c) 2006 - 2020 Teradata +# All rights reserved. Stacki(r) v5.x stacki.com +# https://github.com/Teradata/stacki/blob/master/LICENSE.txt +# @copyright@ + +DOCUMENTATION = """ +module: stacki_host +short_description: Manage Stacki hosts +description: + - Add, modify, and remove Stacki hosts + +options: + name: + description: + - The name of the host to manage + required: true + type: str + + appliance: + description: + - The appliance used for the host + required: Only for new hosts + type: str + + box: + description: + - The box used for the host + required: false + type: str + default: default + + comment: + description: + - Freeform text to attach to the host + required: false + type: str + + environment: + description: + - Environment to assign the host + required: false + type: str + default: None + + groups: + description: + - Groups to add or remove the host from + required: false + type: list + elements: dict + suboptions: + name: + description: + - The name of the group + required: true + type: str + + state: + description: + - If present, then a host will be added to this group + - If absent, then the host will be removed from this group + type: str + choices: [ absent, present ] + default: present + + installaction: + description: + - The install boot action for the host + required: false + type: str + default: default + + interfaces: + description: + - List of network interfaces for the host + type: list + elements: dict + suboptions: + channel: + description: + - Channel for this interface + type: str + + default: + description: + - Is the interface is the default for the host + type: bool + default: false + + interface: + description: + - Device for this interface + type: str + + ip: + description: + - IP address for this interface + type: str + + name: + description: + - Logical name for this interface + type: str + + network: + description: + - Network attached to this interface + type: str + + mac: + description: + - Hardware MAC address for this interface + type: str + + module: + description: + - Device module for this interface + type: str + + options: + description: + - Module options for this interface + type: str + + vlan: + description: + - The VLAN ID for this interface + type: str + + state: + description: + - If present, then an interface will be added to the host, if needed, and options updates. + - If absent, then the interface will be removed from the host. + - If update_mac, then the interface device is used to update the mac. + - If update_interface, then the mac is used to update the interface device. + - Note: The interface device and mac are both used to match for updating an existing interface. + type: str + choices: [ absent, present ] + default: present + + osaction: + description: + - The os boot action for the host + required: false + type: str + default: default + + rack: + description: + - By convention, the number of the rack where the host is located + required: Only for new hosts + type: str + + rank: + description: + - By convention, the position of the host in the rack + required: Only for new hosts + type: str + + state: + description: + - If present, then a host will be added (if needed) and options are set to match + - If absent, then the host will be removed + type: str + choices: [ absent, present ] + default: present +""" + +EXAMPLES = """ +- name: Add a host + stacki_host: + name: test-backend + appliance: backend + box: default + comment: "test host" + groups: + - name: test + installaction: console + interfaces: + - default: true + interface: eth0 + ip: "10.10.10.10" + mac: "00:11:22:33:44:55" + rack: "10" + rank: "4" + +- name: Remove a host + stacki_host: + name: test-backend + state: absent +""" + +RETURN = """ # """ + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.stacki import run_stack_command, StackCommandError + + +def _add_host(module): + args = [module.params["name"]] + for field in ( + "appliance", "box", "environment", + "installaction", "osaction", "rack", "rank" + ): + if module.params[field]: + args.append(f"{field}={module.params[field]}") + + run_stack_command("add.host", args) + + # Add the host comment, if needed + if module.params["comment"]: + run_stack_command("set.host.comment", [ + module.params["name"], + f'comment={module.params["comment"]}' + ]) + + # Process the groups + if module.params["groups"]: + for group in module.params["groups"]: + if group["state"] == "present": + run_stack_command("add.host.group", [ + module.params["name"], + f'group={group["name"]}' + ]) + + # Process the interfaces + if module.params["interfaces"]: + for interface in module.params["interfaces"]: + if interface["state"] == "present": + args = [module.params["name"]] + for field in ( + "interface", "channel", "default", "ip", "mac", + "module", "name", "network", "options", "vlan" + ): + if interface[field]: + args.append(f"{field}={interface[field]}") + + run_stack_command("add.host.interface", args) + + +def _update_host(host, module): + changed = False + + for field in ( + "appliance", "box", "comment", "environment", + "installaction", "osaction", "rack", "rank" + ): + # Did the playbook specify a value for the field? + if module.params[field] is not None: + # Do we need to modify the field? + if module.params[field] != host[field]: + if field in ("installaction", "osaction"): + run_stack_command(f"set.host.bootaction", [ + module.params["name"], + f"type={field[:-6]}", + f"action={module.params[field]}" + ]) + else: + run_stack_command(f"set.host.{field}", [ + module.params["name"], + f"{field}={module.params[field]}" + ]) + + changed = True + + # Process the groups + if module.params["groups"]: + # Get the existing host groups + existing_groups = set(run_stack_command( + "list.host.group", [module.params["name"]] + )[0]["groups"].split()) + + # Process the requested changes + for group in module.params["groups"]: + if group["state"] == "present" and group["name"] not in existing_groups: + # Add the new host group + run_stack_command("add.host.group", [ + module.params["name"], + f'group={group["name"]}' + ]) + changed = True + + elif group["state"] == "absent" and group["name"] in existing_groups: + # Remove an existing host group + run_stack_command("remove.host.group", [ + module.params["name"], + f'group={group["name"]}' + ]) + changed = True + + # Updating existing interfaces in Stacki + if module.params["interfaces"]: + if _update_interfaces(module): + changed = True + + return changed + + +def _update_interfaces(module): + changed = False + + # Create two lookup tables of existing interfaces + lookup_dev = {} + lookup_mac = {} + + for row in run_stack_command( + "list.host.interface", [module.params["name"]] + ): + if row["interface"]: + lookup_dev[row["interface"]] = row + + if row["mac"]: + lookup_mac[row["mac"]] = row + + # Process the requested changes + for interface in module.params["interfaces"]: + # Try to find a matching existing interface + existing = None + if interface["state"] == "update_mac": + if interface["interface"] in lookup_dev: + existing = lookup_dev[interface["interface"]] + elif interface["state"] == "update_interface": + if interface["mac"] in lookup_mac: + existing = lookup_mac[interface["mac"]] + else: + if interface["interface"] in lookup_dev: + existing = lookup_dev[interface["interface"]] + elif interface["mac"] in lookup_mac: + existing = lookup_mac[interface["mac"]] + + # All the commands will need these base args + args = [module.params["name"]] + for field in ("interface", "mac"): + if interface[field]: + args.append(f"{field}={interface[field]}") + + # Now lets handle our different states + if interface["state"] == "present": + if existing: + # If the interface already exists, we update it + for field in ( + "channel", "default", "ip", "module", "name", + "network", "options","vlan" + ): + if interface[field] is not None and interface[field] != existing[field]: + run_stack_command( + f"set.host.interface.{field}", + args + [f"{field}={interface[field]}"] + ) + changed = True + else: + # None existing, so we add a new one + for field in ( + "interface", "channel", "default", "ip", "mac", + "module", "name", "network", "options", "vlan" + ): + if interface[field]: + args.append(f"{field}={interface[field]}") + + run_stack_command("add.host.interface", args) + changed = True + + elif interface["state"] == "absent": + if existing: + run_stack_command("remove.host.interface", args) + changed = True + + elif interface["state"] == "update_mac": + if existing and existing["mac"] != interface["mac"]: + run_stack_command("set.host.interface.mac", args) + changed = True + + elif interface["state"] == "update_interface": + if existing and existing["interface"] != interface["interface"]: + run_stack_command("set.host.interface.interface", args) + changed = True + + return changed + + +def main(): + # Define the arguments for this module + argument_spec = dict( + name=dict(type="str", required=True), + appliance=dict(type="str", required=False), + box=dict(type="str", required=False), + comment=dict(type="str", required=False), + environment=dict(type="str", required=False), + groups=dict(type="list", required=False, elements="dict", options=dict( + name=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["absent", "present"]) + )), + installaction=dict(type="str", required=False), + interfaces=dict(type="list", required=False, elements="dict", options=dict( + channel=dict(type="str", required=False), + default=dict(type="bool", required=False), + interface=dict(type="str", required=True), + ip=dict(type="str", required=False), + mac=dict(type="str", required=False), + module=dict(type="str", required=False), + name=dict(type="str", required=False), + network=dict(type="str", required=False), + options=dict(type="str", required=False), + vlan=dict(type="str", required=False), + state=dict(type="str", default="present", choices=[ + "absent", "present", "update_mac", "update_interface" + ]) + )), + osaction=dict(type="str", required=False), + rack=dict(type="str", required=False), + rank=dict(type="str", required=False), + state=dict(type="str", default="present", choices=["absent", "present"]) + ) + + # Create our module object + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True + ) + + # Initialize a blank result + result = { + "changed": False + } + + # Bail if the user is just checking syntax of their playbook + if module.check_mode: + module.exit_json(**result) + + # Fetch our host info from Stacki + try: + hosts = run_stack_command("list.host", [module.params["name"]]) + except StackCommandError as e: + # If the host doesn't exist, it will raise an error + hosts = [] + + if len(hosts) > 1: + # No more than one host should match + module.fail_json(msg="error - more than one host matches name", **result) + + try: + # Are we adding or removing? + if module.params["state"] == "present": + if len(hosts) == 0: + _add_host(module) + result["changed"] = True + + else: + result["changed"] = _update_host(hosts[0], module) + else: + # Only remove a host that actually exists + if len(hosts): + run_stack_command("remove.host", [module.params["name"]]) + result["changed"] = True + + except StackCommandError as e: + # Fetching the data failed + module.fail_json(msg=e.message, **result) + + # Return our data + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/test-framework/test-suites/integration/files/ansible/add_host.yaml b/test-framework/test-suites/integration/files/ansible/add_host.yaml new file mode 100644 index 000000000..6c3e9875a --- /dev/null +++ b/test-framework/test-suites/integration/files/ansible/add_host.yaml @@ -0,0 +1,19 @@ +--- +- hosts: localhost + tasks: + - name: Add a host + stacki_host: + name: test-backend + appliance: backend + box: default + comment: "test host" + groups: + - name: test + installaction: console + interfaces: + - default: true + interface: eth0 + ip: "10.10.10.10" + mac: "00:11:22:33:44:55" + rack: "10" + rank: "4" diff --git a/test-framework/test-suites/integration/files/ansible/edit_host.yaml b/test-framework/test-suites/integration/files/ansible/edit_host.yaml new file mode 100644 index 000000000..04599d5bb --- /dev/null +++ b/test-framework/test-suites/integration/files/ansible/edit_host.yaml @@ -0,0 +1,22 @@ +--- +- hosts: localhost + tasks: + - name: Modify a host + stacki_host: + name: test-backend + appliance: backend + box: default + comment: "test host" + groups: + - name: foo + state: absent + - name: bar + installaction: console + interfaces: + - interface: eth1 + state: absent + - interface: eth2 + ip: "10.10.2.1" + mac: "00:11:22:33:44:55" + rack: "10" + rank: "4" diff --git a/test-framework/test-suites/integration/files/ansible/remove_host.yaml b/test-framework/test-suites/integration/files/ansible/remove_host.yaml new file mode 100644 index 000000000..eaf5720ea --- /dev/null +++ b/test-framework/test-suites/integration/files/ansible/remove_host.yaml @@ -0,0 +1,7 @@ +--- +- hosts: localhost + tasks: + - name: Remove a host + stacki_host: + name: test-backend + state: absent \ No newline at end of file diff --git a/test-framework/test-suites/integration/files/ansible/update_host_interface.yaml b/test-framework/test-suites/integration/files/ansible/update_host_interface.yaml new file mode 100644 index 000000000..a508bfc7b --- /dev/null +++ b/test-framework/test-suites/integration/files/ansible/update_host_interface.yaml @@ -0,0 +1,10 @@ +--- +- hosts: localhost + tasks: + - name: Update a host interface device + stacki_host: + name: test-backend + interfaces: + - interface: eth1 + mac: "00:11:22:33:44:55" + state: update_interface diff --git a/test-framework/test-suites/integration/files/ansible/update_host_mac.yaml b/test-framework/test-suites/integration/files/ansible/update_host_mac.yaml new file mode 100644 index 000000000..1aa8490be --- /dev/null +++ b/test-framework/test-suites/integration/files/ansible/update_host_mac.yaml @@ -0,0 +1,10 @@ +--- +- hosts: localhost + tasks: + - name: Update a host interface mac + stacki_host: + name: test-backend + interfaces: + - interface: eth0 + mac: "11:22:33:44:55:66" + state: update_mac diff --git a/test-framework/test-suites/integration/tests/ansible/test_stacki_host.py b/test-framework/test-suites/integration/tests/ansible/test_stacki_host.py new file mode 100644 index 000000000..bb10c574a --- /dev/null +++ b/test-framework/test-suites/integration/tests/ansible/test_stacki_host.py @@ -0,0 +1,240 @@ +import json + + +class TestStackiHost: + def test_add_host(self, host, host_os, add_group, test_file): + # Run the ansible playbook to add our test host + result = host.run(f"ansible-playbook {test_file('ansible/add_host.yaml')}") + assert result.rc == 0 + assert "changed=1" in result.stdout + + # Check that it is there now + result = host.run("stack list host test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'appliance': 'backend', + 'box': 'default', + 'comment': 'test host', + 'environment': None, + 'host': 'test-backend', + 'installaction': 'console', + 'os': host_os, + 'osaction': 'default', + 'rack': '10', + 'rank': '4' + }] + + # Check that the interface is there too + result = host.run("stack list host interface test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'channel': None, + 'default': True, + 'host': 'test-backend', + 'interface': 'eth0', + 'ip': '10.10.10.10', + 'mac': '00:11:22:33:44:55', + 'module': None, + 'name': None, + 'network': None, + 'options': None, + 'vlan': None + }] + + # And the host group + result = host.run("stack list host group test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'groups': 'test', + 'host': 'test-backend' + }] + + # Test idempotency by running it again + result = host.run(f"ansible-playbook {test_file('ansible/add_host.yaml')}") + assert result.rc == 0 + assert "changed=0" in result.stdout + + def test_edit_host(self, host, add_group, host_os, test_file): + # Add a few extra groups + add_group("foo") + add_group("bar") + + # Add a test backend + result = host.run("stack add host test-backend appliance=frontend rack=0 rank=1") + assert result.rc == 0 + + # Give it a few groups: + # test: not touched, foo: removed, bar: added by the playbook + + result = host.run("stack add host group test-backend group=test") + assert result.rc == 0 + + result = host.run("stack add host group test-backend group=foo") + assert result.rc == 0 + + # Give it a bunch of interfaces to modify by the playbook: + # eth0: not touched, eth1: removed, eth2: added by the playbook + + result = host.run("stack add host interface test-backend interface=eth0 ip=10.10.0.1") + assert result.rc == 0 + + result = host.run("stack add host interface test-backend interface=eth1 ip=10.10.1.1") + assert result.rc == 0 + + # Run the ansible playbook to modify our test host + result = host.run(f"ansible-playbook {test_file('ansible/edit_host.yaml')}") + assert result.rc == 0 + assert "changed=1" in result.stdout + + # Check that it is there now + result = host.run("stack list host test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'appliance': 'backend', + 'box': 'default', + 'comment': 'test host', + 'environment': None, + 'host': 'test-backend', + 'installaction': 'console', + 'os': host_os, + 'osaction': 'default', + 'rack': '10', + 'rank': '4' + }] + + # Check that the interfaces are there too + result = host.run("stack list host interface test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [ + { + 'channel': None, + 'default': None, + 'host': 'test-backend', + 'interface': 'eth0', + 'ip': '10.10.0.1', + 'mac': None, + 'module': None, + 'name': None, + 'network': None, + 'options': None, + 'vlan': None + }, + { + 'channel': None, + 'default': None, + 'host': 'test-backend', + 'interface': 'eth2', + 'ip': '10.10.2.1', + 'mac': '00:11:22:33:44:55', + 'module': None, + 'name': None, + 'network': None, + 'options': None, + 'vlan': None + } + ] + + # And the host group + result = host.run("stack list host group test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'groups': 'bar test', + 'host': 'test-backend' + }] + + # Test idempotency by running it again + result = host.run(f"ansible-playbook {test_file('ansible/edit_host.yaml')}") + assert result.rc == 0 + assert "changed=0" in result.stdout + + def test_update_host_mac(self, host, test_file): + # Add a test backend + result = host.run("stack add host test-backend appliance=frontend rack=0 rank=1") + assert result.rc == 0 + + # Give it an interface with a mac to update + result = host.run("stack add host interface test-backend interface=eth0 mac=00:11:22:33:44:55") + assert result.rc == 0 + + # Run the ansible playbook to modify our test host + result = host.run(f"ansible-playbook {test_file('ansible/update_host_mac.yaml')}") + assert result.rc == 0 + assert "changed=1" in result.stdout + + # Check that the interface is updated + result = host.run("stack list host interface test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'channel': None, + 'default': None, + 'host': 'test-backend', + 'interface': 'eth0', + 'ip': None, + 'mac': '11:22:33:44:55:66', + 'module': None, + 'name': None, + 'network': None, + 'options': None, + 'vlan': None + }] + + # Test idempotency by running it again + result = host.run(f"ansible-playbook {test_file('ansible/update_host_mac.yaml')}") + assert result.rc == 0 + assert "changed=0" in result.stdout + + def test_update_host_interface(self, host, test_file): + # Add a test backend + result = host.run("stack add host test-backend appliance=frontend rack=0 rank=1") + assert result.rc == 0 + + # Give it an interface with a mac to update + result = host.run("stack add host interface test-backend interface=eth0 mac=00:11:22:33:44:55") + assert result.rc == 0 + + # Run the ansible playbook to modify our test host + result = host.run(f"ansible-playbook {test_file('ansible/update_host_interface.yaml')}") + assert result.rc == 0 + assert "changed=1" in result.stdout + + # Check that the interface is updated + result = host.run("stack list host interface test-backend output-format=json") + assert result.rc == 0 + assert json.loads(result.stdout) == [{ + 'channel': None, + 'default': None, + 'host': 'test-backend', + 'interface': 'eth1', + 'ip': None, + 'mac': '00:11:22:33:44:55', + 'module': None, + 'name': None, + 'network': None, + 'options': None, + 'vlan': None + }] + + # Test idempotency by running it again + result = host.run(f"ansible-playbook {test_file('ansible/update_host_interface.yaml')}") + assert result.rc == 0 + assert "changed=0" in result.stdout + + def test_remove_host(self, host, test_file): + # Add a test backend + result = host.run("stack add host test-backend appliance=backend rack=0 rank=1") + assert result.rc == 0 + + # Run the ansible playbook to remove our test host + result = host.run(f"ansible-playbook {test_file('ansible/remove_host.yaml')}") + assert result.rc == 0 + assert "changed=1" in result.stdout + + # Check that the host is gone + result = host.run("stack list host test-backend") + assert result.rc == 255 + assert result.stderr == 'error - cannot resolve host "test-backend"\n' + + # Test idempotency by running it again + result = host.run(f"ansible-playbook {test_file('ansible/remove_host.yaml')}") + assert result.rc == 0 + assert "changed=0" in result.stdout