diff --git a/.ansible-lint-ignore b/.ansible-lint-ignore index 91bd2869..9e9fc2c1 100644 --- a/.ansible-lint-ignore +++ b/.ansible-lint-ignore @@ -18,3 +18,6 @@ test/roles/azureplugin/molecule/default/create.yml yaml[octal-values] test/roles/azureplugin/molecule/default/destroy.yml yaml[octal-values] test/roles/ec2plugin/molecule/default/destroy.yml risky-file-permissions + +test/roles/openstackplugin/molecule/default/create.yml yaml[octal-values] +test/roles/openstackplugin/molecule/default/destroy.yml yaml[octal-values] diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 9b93531d..6a7e686e 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-22.04 needs: pre env: - PYTEST_REQPASS: 13 + PYTEST_REQPASS: 14 strategy: fail-fast: false matrix: ${{ fromJson(needs.pre.outputs.matrix) }} diff --git a/README.md b/README.md index cce600d4..90ab2e3e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository contains the following molecule plugins: - docker - ec2 - gce +- openstack - podman - vagrant diff --git a/doc/openstack/README.rst b/doc/openstack/README.rst new file mode 100644 index 00000000..0c1216e7 --- /dev/null +++ b/doc/openstack/README.rst @@ -0,0 +1,78 @@ +************************* +Molecule Openstack Plugin +************************* + +Molecule Openstack is designed to allow use of Openstack +for provisioning of test resources. + +.. _quickstart: + +Quickstart +========== + +Installation +------------ + +.. code-block:: bash + + pip install molecule-plugins + +Create a scenario +----------------- + +In a pre-existing role +^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: bash + + molecule init scenario -d openstack + +This will create a default scenario with the openstack driver +in a molecule folder, located in the current working directory. + +Example +------- +This is a molecule.yml example file + +.. code-block:: yaml + + dependency: + name: galaxy + driver: + name: openstack + platforms: + - name: ubuntu2004 + flavor: m1.small + image: Ubuntu_22.04 + user: ubuntu + provisioner: + name: ansible + +Then run + +.. code-block:: bash + + molecule test + +.. note:: + You need to configure `openstack authentication ` using config file or environment variables. + +Documentation +============= + +Details on the parameters for the platforms section are detailed in +``__. + +.. _license: + +License +======= + +The `MIT`_ License. + +.. _`MIT`: https://github.com/ansible/molecule/blob/master/LICENSE + +The logo is licensed under the `Creative Commons NoDerivatives 4.0 License`_. + +If you have some other use in mind, contact us. + +.. _`Creative Commons NoDerivatives 4.0 License`: https://creativecommons.org/licenses/by-nd/4.0/ diff --git a/doc/openstack/platforms.rst b/doc/openstack/platforms.rst new file mode 100644 index 00000000..ae2b0bad --- /dev/null +++ b/doc/openstack/platforms.rst @@ -0,0 +1,151 @@ +********************* +Options documentation +********************* + +Authentication +============== + +See https://docs.openstack.org/openstacksdk/latest/user/config/configuration.html#config-environment-variables + +Platform Arguments +================== + +=============================== =============================================== + Variable Description +=============================== =============================================== +description Set description for instance, \ + default = 'Molecule test instance' +flavor Set flavor for instance +image Set instance image +network Mapping of network settings (optional) +network.name Name of network +network.create Create network, default = true +network.router Mapping of network router settings +network.router.name Name of router +network.router.ext_network External gateway network +network.router.snat Enable or disable snat, default = omit +network.subnet Mapping of network subnet settings +network.subnet.name Name of subnet +network.subnet.cidr CIDR of subnet +network.subnet.ipv IP Version, default = 4 +network.subnet.dns_nameservers List of dns nameservers, default = omit +network.subnet.host_routers List of host router (destination, nexthop), \ + default = omit +security_group Mapping of security_group settings (optional) +security_group.name Name of security_group +security_group.create Create security group, default = true +security_group.description Description of security_group +security_group.rules Ingress Rules (list) defined in security_group +security_group.rules[].proto Protocol for rule +security_group.rules[].port Port +security_group.rules[].cidr Source IP address(es) in CIDR notation +security_group.rules[].port_min Starting port (can't be used with port) +security_group.rules[].port_max Ending port (can't be used with port) +security_group.rules[].type IPv4 or IPv6, default 'IPv4' +user Default user of image +volume Mapping of volume settings (optional if \ + flavor provides volume) +volume.size Size of volume (GB) +=============================== =============================================== + + +Image User +========== + +More information: https://docs.openstack.org/image-guide/obtain-images.html + +Security Groups +=============== + +If you specifiy a security group, +the security group will be managed by create and destroy playbook. +You can define some rules (see example below). + +You can use unmanaged security groups by specifying the name of the group +and setting `create` to `false` (see debian11 example below). +In this case, the specified security group must exist. + +Networks +======== + +If you specify a network, +the network will be managed by create and destroy playbook. +You need to define a subnet and router (see example below). + +You can use unmanaged network by specifying the name of the network +and setting `create` to `false`. +In this case, the specified network must exist. + + +Volumes +======= + +If you specify a volume, +the volume will be managed by create and destroy playbook. +You need to define the size of the volume. + +This setting is required if your flavor doesn't provide a disk. + +Examples +======== + +.. code-block:: yaml + + platforms: + - name: debian10 + flavor: m1.small + image: Debian_10 + user: debian + network: + name: molecule + router: + name: router1 + ext_network: public + subnet: subnet1 + subnet: + name: subnet1 + cidr: 192.168.11.0/24 + ipv: 4 # default + dns_nameservers: # default omit + - 8.8.8.8 + host_routes: # default omit + - destination: 192.168.0.0/24 + nexthop: 192.168.0.1 + security_group: + name: molecule + description: Molecule test + rules: + - proto: tcp + port: 22 + cidr: 0.0.0.0/0 + - proto: tcp + port: 22 + cidr: '::/0' + type: IPv6 + - proto: icmp + port: -1 + cidr: 0.0.0.0/0 + - proto: tcp + port_min: 5000 + port_max: 5050 + cidr: 0.0.0.0/0 + - name: debian11 + flavor: m1.small + image: Debian_11 + user: debian + security_group: + name: existing-sec + create: false + network: + name: molecule # use network from debian10 instance + - name: ubuntu2004 + falvor: m1.tiny + image: Ubuntu_2004 + user: ubuntu + security_group: + name: molecule # use security group from debian10 instance + network: + name: existing-net # use existing network + create: false + volume: + size: 10 # GB diff --git a/pyproject.toml b/pyproject.toml index cf691827..39a407f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,9 @@ selinux = [ vagrant = [ "python-vagrant", ] +openstack = [ + "openstacksdk >= 1.1.0" +] [tool.ruff] ignore = [ @@ -132,6 +135,7 @@ ec2 = "molecule_plugins.ec2.driver:EC2" gce = "molecule_plugins.gce.driver:GCE" podman = "molecule_plugins.podman.driver:Podman" vagrant = "molecule_plugins.vagrant.driver:Vagrant" +openstack = "molecule_plugins.openstack.driver:Openstack" [tool.setuptools_scm] local_scheme = "no-local-version" diff --git a/requirements.yml b/requirements.yml index 49ee5e36..bebf76b2 100644 --- a/requirements.yml +++ b/requirements.yml @@ -11,3 +11,4 @@ collections: - name: community.crypto version: ">=1.8.0" - name: community.vagrant + - name: openstack.cloud diff --git a/src/molecule_plugins/openstack/__init__.py b/src/molecule_plugins/openstack/__init__.py new file mode 100644 index 00000000..e1df90f6 --- /dev/null +++ b/src/molecule_plugins/openstack/__init__.py @@ -0,0 +1 @@ +"""Molecule Openstack Driver.""" diff --git a/src/molecule_plugins/openstack/cookiecutter/cookiecutter.json b/src/molecule_plugins/openstack/cookiecutter/cookiecutter.json new file mode 100644 index 00000000..29f6b718 --- /dev/null +++ b/src/molecule_plugins/openstack/cookiecutter/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "molecule_directory": "molecule", + "role_name": "OVERRIDDEN", + "scenario_name": "OVERRIDDEN" +} diff --git a/src/molecule_plugins/openstack/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml b/src/molecule_plugins/openstack/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml new file mode 100644 index 00000000..cc63d86b --- /dev/null +++ b/src/molecule_plugins/openstack/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + tasks: + - name: "Include {{ cookiecutter.role_name }}" + ansible.builtin.include_role: + name: "{{ cookiecutter.role_name }}" diff --git a/src/molecule_plugins/openstack/driver.py b/src/molecule_plugins/openstack/driver.py new file mode 100644 index 00000000..4739b2f0 --- /dev/null +++ b/src/molecule_plugins/openstack/driver.py @@ -0,0 +1,193 @@ +"""Openstack Driver Module.""" + +import os +from importlib import import_module + +from molecule import logger, util +from molecule.api import Driver + +LOG = logger.get_logger(__name__) + + +class Openstack(Driver): + """ + Openstack Driver Class. + + The class responsible for managing `Openstack`_ instances. `Openstack`_ is + `not` the default driver used in Molecule. + + .. _`openstack_collection`: https://docs.ansible.com/ansible/latest/collections/openstack/cloud/index.html + + .. code-block:: yaml + + driver: + name: openstack + platforms: + - name: instance-1 + flavor: m1.small + image: Ubuntu_20.04 + user: ubuntu + security_group: + name: molecule-sec + description: Molecule test + rules: + - proto: tcp + port_min: 22 + port_max: 80 + cidr: 0.0.0.0/0 + - proto: icmp + port: -1 + cidr: 0.0.0.0/0 + - proto: tcp + port: 22 + type: IPv6 + cidr: ::/0 + network: + name: network1 + create: true # default + router: + name: router1 + ext_network: public + subnet: subnet1 # must match with network.subnet.name + subnet: + name: subnet1 # must match with network.router.subnet + cidr: 192.168.10.0/24 + ipv: 4 + dns_nameservers: + - 8.8.8.8 + host_routes: + - destination: 192.168.0.0/24 + nexthop: 192.168.0.1 + - name: instance-2 + flavor: m1.small + image: Ubuntu_20.04 + user: ubuntu + security_group: + name: molecule-sec # use security group from instance-1 + network: + name: network1 # use network from instance-1 + + If specifying the security_group in your platform configuration, the security group is created. + You can disable this behavior by specifying security_group.create = false. + In this case the security group must exist. + + .. code-block:: yaml + + driver: + name: openstack + platforms: + - name: instance-1 + flavor: m1.small + image: Ubuntu_20.04 + user: ubuntu + security_group: + name: molecule-sec + create: false + + .. code-block:: bash + + $ python3 -m pip install molecule-plugins[openstack] + + Change the options passed to the ssh client. + + .. code-block:: yaml + + driver: + name: openstack + ssh_connection_options: + - '-o ControlPath=~/.ansible/cp/%r@%h-%p' + + .. important:: + + Molecule does not merge lists, when overriding the developer must + provide all options. + """ + + def __init__(self, config=None) -> None: + super().__init__(config) + self._name = "openstack" + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + + @property + def login_cmd_template(self): + connection_options = " ".join(self.ssh_connection_options) + + return ( + "ssh {address} " + "-l {user} " + "-p {port} " + "-i {identity_file} " + f"{connection_options}" + ) + + @property + def default_safe_files(self): + return [self.instance_config] + + @property + def default_ssh_connection_options(self): + return self._get_ssh_connection_options() + + def login_options(self, instance_name): + d = {"instance": instance_name} + + return util.merge_dicts(d, self._get_instance_config(instance_name)) + + def ansible_connection_options(self, instance_name): + try: + d = self._get_instance_config(instance_name) + + return { + "ansible_user": d["user"], + "ansible_host": d["address"], + "ansible_port": d["port"], + "ansible_private_key_file": d["identity_file"], + "connection": "ssh", + "ansible_ssh_common_args": " ".join(self.ssh_connection_options), + } + except StopIteration: + return {} + except OSError: + # Instance has yet to be provisioned , therefore the + # instance_config is not on disk. + return {} + + def _get_instance_config(self, instance_name): + instance_config_dict = util.safe_load_file(self._config.driver.instance_config) + + return next( + item for item in instance_config_dict if item["instance"] == instance_name + ) + + def _is_module_installed(self, module_name): + try: + import_module(module_name) + return True + except ModuleNotFoundError: + return False + + def sanity_checks(self): + req_modules = {"openstack": "openstacksdk"} + for module, pkg in req_modules.items(): + if not self._is_module_installed(module): + util.sysexit_with_message( + f'"{module}" not installed: pip install {pkg} should fix it.', + ) + + def template_dir(self): + """Return path to its own cookiecutterm templates. It is used by init + command in order to figure out where to load the templates from. + """ + return os.path.join(os.path.dirname(__file__), "cookiecutter") + + @property + def required_collections(self) -> dict[str, str]: + """Return collections dict containing names and versions required.""" + return {"openstack.cloud": "2.1.0"} diff --git a/src/molecule_plugins/openstack/playbooks/create.yml b/src/molecule_plugins/openstack/playbooks/create.yml new file mode 100644 index 00000000..d9596952 --- /dev/null +++ b/src/molecule_plugins/openstack/playbooks/create.yml @@ -0,0 +1,173 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + + tasks: + - name: Include molecule tasks + ansible.builtin.include_tasks: + file: tasks/vars.yml + + - name: Create security group + openstack.cloud.security_group: + name: "molecule-test-{{ item.security_group.name }}-{{ uuid }}" + description: "{{ item.security_group.description | default('Molecule Test') }}" + register: security_group + when: + - item.security_group is defined + - item.security_group.name is defined + - item.security_group.create | default(true) + loop: "{{ molecule_yml.platforms }}" + + - name: Create security group rules + openstack.cloud.security_group_rule: + security_group: "molecule-test-{{ item.0.security_group.name }}-{{ uuid }}" + protocol: "{{ item.1.proto }}" + ethertype: "{{ item.1.type | default('IPv4', true) }}" + port_range_min: "{{ item.1.port_min | default(item.1.port, true) | int }}" + port_range_max: "{{ item.1.port_max | default(item.1.port, true) | int }}" + when: + - item.0.security_group is defined + - item.0.security_group.create | default(true) + - "'rules' in item.0.security_group" + loop: "{{ molecule_yml.platforms | subelements('security_group.rules', skip_missing=True) }}" + + - name: Create network + openstack.cloud.network: + name: "molecule-test-{{ item.network.name }}-{{ uuid }}" + state: present + external: "{{ item.network.external | default(omit) }}" + when: + - item.network is defined + - item.network.name is defined + - item.network.create | default(true) + loop: "{{ molecule_yml.platforms }}" + + - name: Create subnet in network + openstack.cloud.subnet: + name: "molecule-test-{{ item.network.subnet.name }}-{{ uuid }}" + state: present + network_name: "molecule-test-{{ item.network.name }}-{{ uuid }}" + cidr: "{{ item.network.subnet.cidr }}" + ip_version: "{{ iitem.network.subnet.ipv | default(4) | int }}" + dns_nameservers: "{{ item.network.subnet.dns_nameservers | default(omit) }}" + host_routes: "{{ item.network.subnet.host_routes | default(omit) }}" + when: + - item.network is defined + - item.network.name is defined + - item.network.create | default(true) + - item.network.subnet is defined + - item.network.subnet.name is defined + loop: "{{ molecule_yml.platforms }}" + + - name: Create Router + openstack.cloud.router: + name: "molecule-test-{{ item.network.router.name }}-{{ uuid }}" + state: present + network: "{{ item.network.router.ext_network | default(omit) }}" + enable_snat: "{{ item.network.router.snat | default(omit) }}" + interfaces: + - net: "molecule-test-{{ item.network.name }}-{{ uuid }}" + subnet: "molecule-test-{{ item.network.subnet.name }}-{{ uuid }}" + when: + - item.network is defined + - item.network.name is defined + - item.network.create | default(true) + - item.network.router is defined + - item.network.router.name is defined + - item.network.subnet is defined + - item.network.subnet.name is defined + loop: "{{ molecule_yml.platforms }}" + + - name: Create ssh key + openstack.cloud.keypair: + name: "{{ key_name }}" + state: present + register: key_pair + + - name: Persist identity file + ansible.builtin.copy: + dest: "{{ identity_file }}" + content: "{{ key_pair.keypair.private_key }}" + mode: "0600" + when: key_pair is changed # noqa no-handler + + - name: Create openstack instance + openstack.cloud.server: + state: present + name: "molecule-test-{{ item.name }}-{{ uuid }}" + description: "{{ item.description | default('Molecule test instance') }}" + image: "{{ item.image }}" + key_name: "{{ key_name }}" + flavor: "{{ item.flavor }}" + boot_from_volume: "{{ true if item.volume is defined and item.volume.size else false }}" + terminate_volume: "{{ true if item.volume is defined and item.volume.size else false }}" + volume_size: "{{ item.volume.size if item.volume is defined and item.volume.size else omit }}" + network: >- + {{ + 'molecule-test-' + item.network.name + '-' + uuid + if item.network is defined and item.network.name and (item.network.create | default(true)) + else item.network.name + if item.network is defined and item.network.name and not (item.network.create | default(true)) + else 'public' + }} + security_groups: + - >- + {{ + 'molecule-test-' + item.security_group.name + '-' + uuid + if item.security_group is defined and item.security_group.name and (item.security_group.create | default(true)) + else item.security_group.name + if item.security_group is defined and item.security_group.name and not (item.security_group.create | default(true)) + else 'default' + }} + meta: + user: "{{ item.user }}" + molecule_instance: "{{ item.name }}" + network: >- + {{ + 'molecule-test-' + item.network.name + '-' + uuid + if item.network is defined and item.network.name and (item.network.create | default(true)) + else item.network.name + if item.network is defined and item.network.name and not (item.network.create | default(true)) + else 'public' + }} + loop: "{{ molecule_yml.platforms }}" + + - name: Create molecule instances configuration + block: + - name: Initialize an empty list for storing all instances + ansible.builtin.set_fact: + all_instances: [] + + - name: Retrieve server information + openstack.cloud.server_info: + server: "molecule-test-*-{{ uuid }}" + register: server_info + + - name: Include server_addr tasks + ansible.builtin.include_tasks: "tasks/server_addr.yml" + loop: "{{ server_info.servers }}" + + - name: Wipe out instance config + ansible.builtin.set_fact: + instance_conf: {} + + - name: Convert instance config dict to a list + ansible.builtin.set_fact: + instance_conf: "{{ all_instances | map(attribute='ansible_facts.instance_conf_dict') | list }}" + + - name: Dump instance config + ansible.builtin.copy: + content: "{{ instance_conf }}" + dest: "{{ molecule_instance_config }}" + mode: "0600" + + - name: Wait for SSH + ansible.builtin.wait_for: + port: 22 + host: "{{ item.address }}" + search_regex: SSH + delay: 10 + loop: "{{ lookup('file', molecule_instance_config) | from_yaml }}" diff --git a/src/molecule_plugins/openstack/playbooks/destroy.yml b/src/molecule_plugins/openstack/playbooks/destroy.yml new file mode 100644 index 00000000..c8f0411a --- /dev/null +++ b/src/molecule_plugins/openstack/playbooks/destroy.yml @@ -0,0 +1,63 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + tags: + - always + tasks: + - name: Include molecule Tasks + ansible.builtin.include_tasks: + file: tasks/vars.yml + + - name: Delete SSH Keys + openstack.cloud.keypair: + name: "{{ key_name }}" + state: absent + + - name: Delete local SSH Key + ansible.builtin.file: + name: "{{ identity_file }}" + state: absent + + - name: Destroy openstack instance + openstack.cloud.server: + state: absent + name: "molecule-test-{{ item.name }}-{{ uuid }}" + delete_fip: true + loop: "{{ molecule_yml.platforms }}" + + - name: Delete security groups + openstack.cloud.security_group: + state: absent + name: "molecule-test-{{ item.security_group.name }}-{{ uuid }}" + when: + - item.security_group is defined + - item.security_group.create | default(true) + - item.security_group.name is defined + loop: "{{ molecule_yml.platforms }}" + + - name: Destroy Router + openstack.cloud.router: + name: "molecule-test-{{ item.network.router.name }}-{{ uuid }}" + state: absent + when: + - item.network is defined + - item.network.name is defined + - item.network.create | default(true) + - item.network.router is defined + - item.network.router.name is defined + - item.network.subnet is defined + - item.network.subnet.name is defined + loop: "{{ molecule_yml.platforms }}" + + - name: Delete network + openstack.cloud.network: + name: "molecule-test-{{ item.network.name }}-{{ uuid }}" + state: absent + when: + - item.network is defined + - item.network.create | default(true) + - item.network.name is defined + loop: "{{ molecule_yml.platforms }}" diff --git a/src/molecule_plugins/openstack/playbooks/prepare.yml b/src/molecule_plugins/openstack/playbooks/prepare.yml new file mode 100644 index 00000000..96288a55 --- /dev/null +++ b/src/molecule_plugins/openstack/playbooks/prepare.yml @@ -0,0 +1,46 @@ +--- +- name: Prepare + hosts: all + gather_facts: false + tasks: + - name: Gather system info + ansible.builtin.raw: uname + register: raw_uname + changed_when: false + failed_when: false + + - name: Bootstrap python for Ansible + ansible.builtin.raw: | + command -v python3 python || ( + command -v apk >/dev/null && sudo apk add --no-progress --update python3 || + (test -e /usr/bin/dnf && sudo dnf install -y python3) || + (test -e /usr/bin/apt && (apt -y update && apt install -y python3-minimal)) || + (test -e /usr/bin/yum && sudo yum -y -qq install python3) || + (test -e /usr/sbin/pkg && sudo env ASSUME_ALWAYS_YES=yes pkg update && sudo env ASSUME_ALWAYS_YES=yes pkg install python3) || + (test -e /usr/sbin/pkg_add && sudo /usr/sbin/pkg_add -U -I -x python%3.9) || + (test -e /usr/bin/pacman && sudo /usr/bin/pacman -Sy python3 --noconfirm --quiet) || + echo "Warning: Python not bootstrapped due to unknown platform." + ) + become: true + changed_when: false + when: raw_uname.rc == 0 and raw_uname.stdout | trim == "Linux" + + - name: Set molecule instances + ansible.builtin.set_fact: + instances: "{{ lookup('file', molecule_instance_config) }}" + when: raw_uname.rc == 0 and raw_uname.stdout | trim == "Linux" + + - name: Save molecule instances + ansible.builtin.copy: + content: "{{ instances | map(attribute='instance') | list }}" + mode: "0644" + dest: /tmp/instances + when: raw_uname.rc == 0 and raw_uname.stdout | trim == "Linux" + + - name: Setup /etc/hosts + ansible.builtin.lineinfile: + line: "{{ item.address }} {{ item.instance }}" + path: /etc/hosts + become: true + loop: "{{ instances }}" + when: raw_uname.rc == 0 and raw_uname.stdout | trim == "Linux" diff --git a/src/molecule_plugins/openstack/playbooks/tasks/server_addr.yml b/src/molecule_plugins/openstack/playbooks/tasks/server_addr.yml new file mode 100644 index 00000000..64596989 --- /dev/null +++ b/src/molecule_plugins/openstack/playbooks/tasks/server_addr.yml @@ -0,0 +1,36 @@ +--- +- name: Extract address + ansible.builtin.set_fact: + address: >- + {%- if item.access_ipv4 -%} + {{ item.access_ipv4 }} + {%- elif item.access_ipv6 -%} + {{ item.access_ipv6 }} + {%- else -%} + {%- for int in item.addresses[item.metadata.get('network', 'public')] -%} + {%- if int['OS-EXT-IPS:type'] == 'floating' -%} + {{ int['addr'] }} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + +- name: Set to first addr if no floating + ansible.builtin.set_fact: + address: "{{ item.addresses[item.metadata.get('network', 'public')][0].addr }}" + when: address == "" + +- name: Populate instance config dict + ansible.builtin.set_fact: + instance_conf_dict: + { + "instance": "{{ item.metadata.molecule_instance }}", + "address": "{{ address }}", + "user": "{{ item.metadata.user }}", + "port": 22, + "identity_file": "{{ identity_file }}", + } + register: instance_conf_dict + +- name: Add instance to all instances list + ansible.builtin.set_fact: + all_instances: "{{ all_instances + [instance_conf_dict] }}" diff --git a/src/molecule_plugins/openstack/playbooks/tasks/vars.yml b/src/molecule_plugins/openstack/playbooks/tasks/vars.yml new file mode 100644 index 00000000..5b2d5291 --- /dev/null +++ b/src/molecule_plugins/openstack/playbooks/tasks/vars.yml @@ -0,0 +1,17 @@ +--- +- name: Load molecule state file + ansible.builtin.include_vars: + file: "{{ lookup('env', 'MOLECULE_STATE_FILE') }}" + name: molecule_state + +- name: Set Molecule run UUID + ansible.builtin.set_fact: + uuid: "{{ molecule_state.run_uuid }}" + +- name: Set ssh key name + ansible.builtin.set_fact: + key_name: "molecule-test-{{ uuid }}" + +- name: Set local identity file + ansible.builtin.set_fact: + identity_file: "{{ lookup('env', 'HOME') }}/.ansible/tmp/{{ key_name }}" diff --git a/test/openstack/.ansible-lint b/test/openstack/.ansible-lint new file mode 100644 index 00000000..c54b8ec5 --- /dev/null +++ b/test/openstack/.ansible-lint @@ -0,0 +1,9 @@ +# ansible-lint config for functional testing, used to bypass expected metadata +# errors in molecule-generated roles. Loaded via the metadata_lint_update +# pytest helper. For reference, see "E7xx - metadata" in: +# https://docs.ansible.com/ansible-lint/rules/default_rules.html +skip_list: + # metadata/701 - Role info should contain platforms + - '701' + # metadata/703 - Should change default metadata: " + - '703' diff --git a/test/openstack/__init__.py b/test/openstack/__init__.py new file mode 100644 index 00000000..bd880bdf --- /dev/null +++ b/test/openstack/__init__.py @@ -0,0 +1 @@ +"""Driver tests.""" diff --git a/test/openstack/conftest.py b/test/openstack/conftest.py new file mode 100644 index 00000000..55f3af9f --- /dev/null +++ b/test/openstack/conftest.py @@ -0,0 +1,10 @@ +"""Pytest Fixtures.""" +from conftest import random_string, temp_dir # noqa + +import pytest + + +@pytest.fixture() +def DRIVER(): + """Return name of the driver to be tested.""" + return "openstack" diff --git a/test/openstack/scenarios/molecule/default/converge.yml b/test/openstack/scenarios/molecule/default/converge.yml new file mode 100644 index 00000000..a63f9e8d --- /dev/null +++ b/test/openstack/scenarios/molecule/default/converge.yml @@ -0,0 +1,10 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true + tasks: + - name: Sample task # noqa command-instead-of-shell + ansible.builtin.shell: + cmd: uname + changed_when: false diff --git a/test/openstack/scenarios/molecule/default/molecule.yml b/test/openstack/scenarios/molecule/default/molecule.yml new file mode 100644 index 00000000..fdef83ac --- /dev/null +++ b/test/openstack/scenarios/molecule/default/molecule.yml @@ -0,0 +1,12 @@ +--- +dependency: + name: galaxy +driver: + name: openstack +platforms: + - name: ubuntu2004 + flavor: m1.small + image: Ubuntu_22.04 + user: ubuntu +provisioner: + name: ansible diff --git a/test/openstack/scenarios/molecule/multiple/converge.yml b/test/openstack/scenarios/molecule/multiple/converge.yml new file mode 100644 index 00000000..a63f9e8d --- /dev/null +++ b/test/openstack/scenarios/molecule/multiple/converge.yml @@ -0,0 +1,10 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true + tasks: + - name: Sample task # noqa command-instead-of-shell + ansible.builtin.shell: + cmd: uname + changed_when: false diff --git a/test/openstack/scenarios/molecule/multiple/molecule.yml b/test/openstack/scenarios/molecule/multiple/molecule.yml new file mode 100644 index 00000000..ff52f0cb --- /dev/null +++ b/test/openstack/scenarios/molecule/multiple/molecule.yml @@ -0,0 +1,16 @@ +--- +dependency: + name: galaxy +driver: + name: openstack +platforms: + - name: ubuntu2004 + flavor: m1.small + image: Ubuntu_22.04 + user: ubuntu + - name: debian10 + flavor: m1.small + image: Debian_10 + user: debian +provisioner: + name: ansible diff --git a/test/openstack/scenarios/molecule/network/converge.yml b/test/openstack/scenarios/molecule/network/converge.yml new file mode 100644 index 00000000..f93d7246 --- /dev/null +++ b/test/openstack/scenarios/molecule/network/converge.yml @@ -0,0 +1,21 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true + tasks: + - name: Sample task # noqa command-instead-of-shell + ansible.builtin.shell: + cmd: uname + changed_when: false + + - name: Get all instances + ansible.builtin.slurp: + src: /tmp/instances + register: instances_base64 + + - name: Ping all # noqa command-instead-of-shell + ansible.builtin.shell: + cmd: "ping -c 2 {{ item }}" + changed_when: false + loop: "{{ instances_base64.content | b64decode }}" diff --git a/test/openstack/scenarios/molecule/network/molecule.yml b/test/openstack/scenarios/molecule/network/molecule.yml new file mode 100644 index 00000000..f0ab7447 --- /dev/null +++ b/test/openstack/scenarios/molecule/network/molecule.yml @@ -0,0 +1,66 @@ +--- +dependency: + name: galaxy +driver: + name: openstack +platforms: + - name: debian10 + flavor: m1.small + image: Debian_10 + user: debian + security_group: + name: molecule + description: Molecule test + rules: + - proto: tcp + port: 22 + cidr: 0.0.0.0/0 + - proto: icmp + port: -1 + cidr: 0.0.0.0/0 + network: + name: molecule-test + router: + name: router1 + ext_network: public + subnet: subnet1 + subnet: + name: subnet1 + cidr: 192.168.11.0/24 + ipv: 4 + dns_nameservers: + - 8.8.8.8 + + - name: ubuntu2004 + flavor: m1.small + image: Ubuntu_20.04 + user: ubuntu + network: + name: molecule + create: false + security_group: + name: molecule-sec + description: Molecule test 2 + rules: + - proto: tcp + port_min: 22 + port_max: 80 + cidr: 0.0.0.0/0 + - proto: icmp + port: -1 + cidr: 0.0.0.0/0 + - proto: tcp + port: 22 + type: IPv6 + cidr: ::/0 + + - name: ubuntu2204 + flavor: m1.small + image: Ubuntu_22.04 + user: ubuntu + security_group: + name: molecule + network: + name: molecule-test +provisioner: + name: ansible diff --git a/test/openstack/scenarios/molecule/security_group/converge.yml b/test/openstack/scenarios/molecule/security_group/converge.yml new file mode 100644 index 00000000..a63f9e8d --- /dev/null +++ b/test/openstack/scenarios/molecule/security_group/converge.yml @@ -0,0 +1,10 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true + tasks: + - name: Sample task # noqa command-instead-of-shell + ansible.builtin.shell: + cmd: uname + changed_when: false diff --git a/test/openstack/scenarios/molecule/security_group/molecule.yml b/test/openstack/scenarios/molecule/security_group/molecule.yml new file mode 100644 index 00000000..2fc048e0 --- /dev/null +++ b/test/openstack/scenarios/molecule/security_group/molecule.yml @@ -0,0 +1,48 @@ +--- +dependency: + name: galaxy +driver: + name: openstack +platforms: + - name: debian10 + flavor: m1.small + image: Debian_10 + user: debian + security_group: + name: molecule + description: Molecule test + rules: + - proto: tcp + port: 22 + cidr: 0.0.0.0/0 + - proto: icmp + port: -1 + cidr: 0.0.0.0/0 + - name: ubuntu2004 + flavor: m1.small + image: Ubuntu_20.04 + user: ubuntu + security_group: + name: molecule-sec + description: Molecule test 2 + rules: + - proto: tcp + port_min: 22 + port_max: 80 + cidr: 0.0.0.0/0 + - proto: icmp + port: -1 + cidr: 0.0.0.0/0 + - proto: tcp + port: 22 + type: IPv6 + cidr: ::/0 + - name: ubuntu2204 + flavor: m1.small + image: Ubuntu_22.04 + user: ubuntu + security_group: + name: test + create: false +provisioner: + name: ansible diff --git a/test/openstack/scenarios/molecule/volume/converge.yml b/test/openstack/scenarios/molecule/volume/converge.yml new file mode 100644 index 00000000..a63f9e8d --- /dev/null +++ b/test/openstack/scenarios/molecule/volume/converge.yml @@ -0,0 +1,10 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true + tasks: + - name: Sample task # noqa command-instead-of-shell + ansible.builtin.shell: + cmd: uname + changed_when: false diff --git a/test/openstack/scenarios/molecule/volume/molecule.yml b/test/openstack/scenarios/molecule/volume/molecule.yml new file mode 100644 index 00000000..c3678969 --- /dev/null +++ b/test/openstack/scenarios/molecule/volume/molecule.yml @@ -0,0 +1,22 @@ +--- +dependency: + name: galaxy +driver: + name: openstack +platforms: + - name: debian10 + flavor: m1.tiny + image: Debian_10 + user: debian + volume: + size: 10 + + - name: ubuntu2004 + flavor: m1.tiny + image: Ubuntu_20.04 + user: ubuntu + volume: + size: 15 + +provisioner: + name: ansible diff --git a/test/openstack/test_driver.py b/test/openstack/test_driver.py new file mode 100644 index 00000000..6b39c43e --- /dev/null +++ b/test/openstack/test_driver.py @@ -0,0 +1,7 @@ +"""Unit tests.""" +from molecule import api + + +def test_driver_is_detected(DRIVER): + """Asserts that molecule recognizes the driver.""" + assert DRIVER in [str(d) for d in api.drivers()] diff --git a/test/openstack/test_func.py b/test/openstack/test_func.py new file mode 100644 index 00000000..807cfa2f --- /dev/null +++ b/test/openstack/test_func.py @@ -0,0 +1,87 @@ +"""Functional tests.""" +import os +import pathlib +import shutil +import subprocess + +import pytest + +import openstack +from conftest import change_dir_to +from molecule import logger +from molecule.util import run_command + +LOG = logger.get_logger(__name__) + + +def is_openstack_auth() -> bool: + """Is the openstack authentication config in place?""" + + try: + conn = openstack.connect() + list(conn.compute.servers()) + return True + except Exception: + return False + + +def format_result(result: subprocess.CompletedProcess): + """Return friendly representation of completed process run.""" + return ( + f"RC: {result.returncode}\n" + + f"STDOUT: {result.stdout}\n" + + f"STDERR: {result.stderr}" + ) + + +@pytest.mark.skipif(not is_openstack_auth(), reason="Openstack authentication missing") +def test_openstack_init_and_test_scenario(tmp_path: pathlib.Path, DRIVER: str) -> None: + """Verify that init scenario works.""" + shutil.rmtree(tmp_path, ignore_errors=True) + tmp_path.mkdir(exist_ok=True) + + scenario_name = "default" + + with change_dir_to(tmp_path): + scenario_directory = tmp_path / "molecule" / scenario_name + cmd = [ + "molecule", + "init", + "scenario", + scenario_name, + "--driver-name", + DRIVER, + ] + result = run_command(cmd) + assert result.returncode == 0 + + assert scenario_directory.exists() + + confpath = os.path.join(scenario_directory, "molecule.yml") + testconf = os.path.join( + os.path.dirname(__file__), + "scenarios/molecule", + scenario_name, + "molecule.yml", + ) + + shutil.copyfile(testconf, confpath) + + cmd = ["molecule", "--debug", "test", "-s", scenario_name] + result = run_command(cmd) + assert result.returncode == 0 + + +@pytest.mark.skipif(not is_openstack_auth(), reason="Openstack authentication missing") +@pytest.mark.parametrize( + "scenario", + [("multiple"), ("security_group"), ("network"), ("volume")], +) +def test_specific_scenarios(temp_dir, scenario) -> None: + """Verify that specific scenarios work""" + scenario_directory = os.path.join(os.path.dirname(__file__), "scenarios") + + with change_dir_to(scenario_directory): + cmd = ["molecule", "test", "--scenario-name", scenario] + result = run_command(cmd) + assert result.returncode == 0 diff --git a/tox.ini b/tox.ini index fa395466..f6b0cd3f 100644 --- a/tox.ini +++ b/tox.ini @@ -25,6 +25,7 @@ extras = ec2 gce podman + openstack test vagrant deps = @@ -57,6 +58,7 @@ passenv = SSL_CERT_FILE TOXENV TWINE_* + OS_* allowlist_externals = bash twine