From a4a6e6d4ec11a81255526b46256b0e10d6ff3587 Mon Sep 17 00:00:00 2001 From: Jakob Meng Date: Wed, 11 Jan 2023 14:09:44 +0100 Subject: [PATCH] Added resource{,s} modules Change-Id: I0b04d43d5095ee74ec5af27013b6159a6a4d0f13 --- ci/roles/resource/defaults/main.yml | 3 + ci/roles/resource/tasks/check_mode.yml | 194 ++++++ ci/roles/resource/tasks/main.yml | 861 +++++++++++++++++++++++++ ci/roles/resources/defaults/main.yml | 3 + ci/roles/resources/tasks/main.yml | 53 ++ ci/run-collection.yml | 2 + meta/runtime.yml | 2 + plugins/module_utils/resource.py | 237 +++++++ plugins/modules/resource.py | 425 ++++++++++++ plugins/modules/resources.py | 141 ++++ 10 files changed, 1921 insertions(+) create mode 100644 ci/roles/resource/defaults/main.yml create mode 100644 ci/roles/resource/tasks/check_mode.yml create mode 100644 ci/roles/resource/tasks/main.yml create mode 100644 ci/roles/resources/defaults/main.yml create mode 100644 ci/roles/resources/tasks/main.yml create mode 100644 plugins/module_utils/resource.py create mode 100644 plugins/modules/resource.py create mode 100644 plugins/modules/resources.py diff --git a/ci/roles/resource/defaults/main.yml b/ci/roles/resource/defaults/main.yml new file mode 100644 index 00000000..e194c527 --- /dev/null +++ b/ci/roles/resource/defaults/main.yml @@ -0,0 +1,3 @@ +--- +expected_fields: + - resource diff --git a/ci/roles/resource/tasks/check_mode.yml b/ci/roles/resource/tasks/check_mode.yml new file mode 100644 index 00000000..2185fe5b --- /dev/null +++ b/ci/roles/resource/tasks/check_mode.yml @@ -0,0 +1,194 @@ +--- +- module_defaults: + group/openstack.cloud.openstack: + cloud: "{{ cloud }}" + # Listing modules individually is required for + # backward compatibility with Ansible 2.9 only + openstack.cloud.resource: + cloud: "{{ cloud }}" + openstack.cloud.resources: + cloud: "{{ cloud }}" + block: + - name: Create security group + check_mode: false + openstack.cloud.resource: + service: network + type: security_group + attributes: + name: ansible_security_group + description: 'ansible security group' + register: security_group + + - name: Assert created security group + assert: + that: + - security_group.resource.name == 'ansible_security_group' + - security_group.resource.description == 'ansible security group' + + - name: Find created security group + openstack.cloud.resources: + service: network + type: security_group + parameters: + name: ansible_security_group + register: security_groups + + - name: Assert created security group independently + assert: + that: + - security_groups.resources | length == 1 + - security_groups.resources.0.name == 'ansible_security_group' + - security_groups.resources.0.description == 'ansible security group' + + - name: Update security group simulation + check_mode: true + openstack.cloud.resource: + service: network + type: security_group + attributes: + name: ansible_security_group + description: 'ansible neutron security group' + register: security_group + + - name: Assert security group update simulation + assert: + that: + - security_group is changed + - security_group.resource.description == 'ansible neutron security group' + + - name: Find non-updated security group + openstack.cloud.resources: + service: network + type: security_group + parameters: + name: ansible_security_group + register: security_groups + + - name: Assert security group non-update + assert: + that: + - security_groups.resources | length == 1 + - security_groups.resources.0.name == 'ansible_security_group' + - security_groups.resources.0.description == 'ansible security group' + + - name: Update security group + check_mode: false + openstack.cloud.resource: + service: network + type: security_group + attributes: + name: ansible_security_group + description: 'ansible neutron security group' + register: security_group + + - name: Assert security group update + assert: + that: + - security_group is changed + - security_group.resource.description == 'ansible neutron security group' + + - name: Find updated security group + openstack.cloud.resources: + service: network + type: security_group + parameters: + name: ansible_security_group + register: security_groups + + - name: Assert security group update + assert: + that: + - security_groups.resources | length == 1 + - security_groups.resources.0.name == 'ansible_security_group' + - security_groups.resources.0.description == 'ansible neutron security group' + + - name: Delete security group simulation + check_mode: true + openstack.cloud.resource: + service: network + type: security_group + attributes: + name: ansible_security_group + state: absent + register: security_group + + - name: Assert security group delete simulation + assert: + that: + - security_group is changed + - "'security_group' not in security_group" + + - name: Find non-deleted security group + openstack.cloud.resources: + service: network + type: security_group + parameters: + name: ansible_security_group + register: security_groups + + - name: Assert security group non-deletion + assert: + that: + - security_groups.resources | length == 1 + - security_groups.resources.0.name == 'ansible_security_group' + - security_groups.resources.0.description == 'ansible neutron security group' + + - name: Delete security group + check_mode: false + openstack.cloud.resource: + service: network + type: security_group + attributes: + name: ansible_security_group + state: absent + register: security_group + + - name: Assert security group deletion + assert: + that: + - security_group is changed + - "'resource' not in security_group" + + - name: Find deleted security group + openstack.cloud.resources: + service: network + type: security_group + parameters: + name: ansible_security_group + register: security_groups + + - name: Assert security group deletion + assert: + that: + - security_groups.resources | length == 0 + + - name: Create security group simulation + check_mode: true + openstack.cloud.resource: + service: network + type: security_group + attributes: + name: ansible_security_group + description: 'ansible security group' + register: security_group + + - name: Assert security group creation simulation + assert: + that: + - security_group is changed + - security_group.resource.name == 'ansible_security_group' + - security_group.resource.description == 'ansible security group' + - security_group.resource.keys() | sort == ['description', 'name'] | sort + + - name: Find non-created security group + openstack.cloud.resources: + service: network + type: security_group + parameters: + name: ansible_security_group + register: security_groups + + - name: Assert security group non-creation + assert: + that: + - security_groups.resources | length == 0 diff --git a/ci/roles/resource/tasks/main.yml b/ci/roles/resource/tasks/main.yml new file mode 100644 index 00000000..f043cd84 --- /dev/null +++ b/ci/roles/resource/tasks/main.yml @@ -0,0 +1,861 @@ +--- +- module_defaults: + group/openstack.cloud.openstack: + cloud: "{{ cloud }}" + # Listing modules individually is required for + # backward compatibility with Ansible 2.9 only + openstack.cloud.resource: + cloud: "{{ cloud }}" + openstack.cloud.resources: + cloud: "{{ cloud }}" + openstack.cloud.router: + cloud: "{{ cloud }}" + block: + - name: Create external network + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_external + is_router_external: true + wait: true + register: network_external + + - name: Assert return values of resource module + assert: + that: + # allow new fields to be introduced but prevent fields from being removed + - expected_fields|difference(network_external.keys())|length == 0 + + - name: Assert external network + assert: + that: + - network_external is changed + + - name: Create external network again + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_external + is_router_external: true + wait: true + register: network_external + + - name: Assert external network again + assert: + that: + - network_external is not changed + + - name: Create external subnet + openstack.cloud.resource: + service: network + type: subnet + attributes: + cidr: 10.6.6.0/24 + ip_version: 4 + name: ansible_external_subnet + network_id: "{{ network_external.resource.id }}" + register: subnet_external + + - name: Assert external subnet + assert: + that: + - subnet_external is changed + + - name: Create external subnet again + openstack.cloud.resource: + service: network + type: subnet + attributes: + cidr: 10.6.6.0/24 + ip_version: 4 + name: ansible_external_subnet + network_id: "{{ network_external.resource.id }}" + register: subnet_external + + - name: Assert external subnet again + assert: + that: + - subnet_external is not changed + + - name: Create external port + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_external + network_id: "{{ network_external.resource.id }}" + fixed_ips: + - ip_address: 10.6.6.50 + non_updateable_attributes: + - fixed_ips + register: port_external + + - name: Assert external port + assert: + that: + - port_external is changed + + - name: Create external port again + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_external + network_id: "{{ network_external.resource.id }}" + fixed_ips: + - ip_address: 10.6.6.50 + non_updateable_attributes: + - fixed_ips + register: port_external + + - name: Assert external port again + assert: + that: + - port_external is not changed + + - name: Create internal network + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_internal + is_router_external: false + wait: true + register: network_internal + + - name: Assert internal network + assert: + that: + - network_internal is changed + + - name: Create internal network again + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_internal + is_router_external: false + wait: true + register: network_internal + + - name: Assert internal network again + assert: + that: + - network_internal is not changed + + - name: Create internal subnet + openstack.cloud.resource: + service: network + type: subnet + attributes: + cidr: 10.7.7.0/24 + ip_version: 4 + name: ansible_internal_subnet + network_id: "{{ network_internal.resource.id }}" + register: subnet_internal + + - name: Assert internal subnet + assert: + that: + - subnet_internal is changed + + - name: Create internal subnet again + openstack.cloud.resource: + service: network + type: subnet + attributes: + cidr: 10.7.7.0/24 + ip_version: 4 + name: ansible_internal_subnet + network_id: "{{ network_internal.resource.id }}" + register: subnet_internal + + - name: Assert internal subnet again + assert: + that: + - subnet_internal is not changed + + - name: Create internal port 1 + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal1 + network_id: "{{ network_internal.resource.id }}" + fixed_ips: + - ip_address: 10.7.7.100 + subnet_id: "{{ subnet_internal.resource.id }}" + register: port_internal1 + + - name: Assert internal port 1 + assert: + that: + - port_internal1 is changed + + - name: Create internal port 1 again + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal1 + network_id: "{{ network_internal.resource.id }}" + fixed_ips: + - ip_address: 10.7.7.100 + subnet_id: "{{ subnet_internal.resource.id }}" + register: port_internal1 + + - name: Assert internal port 1 again + assert: + that: + - port_internal1 is not changed + + - name: Create internal port 2 + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal2 + network_id: "{{ network_internal.resource.id }}" + fixed_ips: + - ip_address: 10.7.7.101 + subnet_id: "{{ subnet_internal.resource.id }}" + register: port_internal2 + + - name: Assert internal port 2 + assert: + that: + - port_internal2 is changed + + - name: Create internal port 2 again + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal2 + network_id: "{{ network_internal.resource.id }}" + fixed_ips: + - ip_address: 10.7.7.101 + subnet_id: "{{ subnet_internal.resource.id }}" + register: port_internal2 + + - name: Assert internal port 2 again + assert: + that: + - port_internal2 is not changed + + - name: Create router + openstack.cloud.resource: + service: network + type: router + attributes: + name: ansible_router + external_gateway_info: + enable_snat: true + external_fixed_ips: + - ip_address: 10.6.6.10 + subnet_id: "{{ subnet_external.resource.id }}" + network_id: "{{ network_external.resource.id }}" + wait: true + register: router + + - name: Assert router + assert: + that: + - router is changed + + - name: Create router again + openstack.cloud.resource: + service: network + type: router + attributes: + name: ansible_router + external_gateway_info: + enable_snat: true + external_fixed_ips: + - ip_address: 10.6.6.10 + subnet_id: "{{ subnet_external.resource.id }}" + network_id: "{{ network_external.resource.id }}" + wait: true + register: router + + - name: Assert router again + assert: + that: + - router is not changed + + - name: Attach router to internal subnet + openstack.cloud.router: + name: ansible_router + network: "{{ network_external.resource.id }}" + external_fixed_ips: + - ip: 10.6.6.10 + subnet: "{{ subnet_external.resource.id }}" + interfaces: + - net: "{{ network_internal.resource.id }}" + subnet: "{{ subnet_internal.resource.id }}" + portip: 10.7.7.1 + + - name: Create floating ip address 1 + openstack.cloud.resource: + service: network + type: ip + attributes: + name: 10.6.6.150 + floating_ip_address: 10.6.6.150 + floating_network_id: "{{ network_external.resource.id }}" + register: ip1 + + - name: Assert floating ip address 1 + assert: + that: + - ip1 is changed + + - name: Create floating ip address 1 again + openstack.cloud.resource: + service: network + type: ip + attributes: + name: 10.6.6.150 + floating_ip_address: 10.6.6.150 + floating_network_id: "{{ network_external.resource.id }}" + register: ip1 + + - name: Assert floating ip address 1 again + assert: + that: + - ip1 is not changed + + - name: Create floating ip address 2 + openstack.cloud.resource: + service: network + type: ip + attributes: + name: 10.6.6.151 + floating_ip_address: 10.6.6.151 + floating_network_id: "{{ network_external.resource.id }}" + port_id: "{{ port_internal1.resource.id }}" + register: ip2 + + - name: Assert floating ip address 2 + assert: + that: + - ip2 is changed + + - name: Create floating ip address 2 again + openstack.cloud.resource: + service: network + type: ip + attributes: + name: 10.6.6.151 + floating_ip_address: 10.6.6.151 + floating_network_id: "{{ network_external.resource.id }}" + port_id: "{{ port_internal1.resource.id }}" + register: ip2 + + - name: Assert floating ip address 2 again + assert: + that: + - ip2 is not changed + + - name: Create floating ip address 3 + openstack.cloud.resource: + service: network + type: ip + attributes: + name: 10.6.6.152 + floating_ip_address: 10.6.6.152 + floating_network_id: "{{ network_external.resource.id }}" + fixed_ip_address: 10.7.7.101 + port_id: "{{ port_internal2.resource.id }}" + updateable_attributes: + - fixed_ip_address + - port_id + register: ip3 + + - name: Assert floating ip address 3 + assert: + that: + - ip3 is changed + + - name: Create floating ip address 3 again + openstack.cloud.resource: + service: network + type: ip + attributes: + name: 10.6.6.152 + floating_ip_address: 10.6.6.152 + floating_network_id: "{{ network_external.resource.id }}" + fixed_ip_address: 10.7.7.101 + port_id: "{{ port_internal2.resource.id }}" + updateable_attributes: + - fixed_ip_address + - port_id + register: ip3 + + - name: Assert floating ip address 3 again + assert: + that: + - ip3 is not changed + + - name: List images + openstack.cloud.resources: + service: image + type: image + register: images + + - name: Identify CirrOS image id + set_fact: + image_id: "{{ images.resources|community.general.json_query(query)|first }}" + vars: + query: "[?starts_with(name, 'cirros')].id" + + - name: List compute flavors + openstack.cloud.resources: + service: compute + type: flavor + register: flavors + + - name: Identify m1.tiny flavor id + set_fact: + flavor_id: "{{ flavors.resources|community.general.json_query(query)|first }}" + vars: + query: "[?name == 'm1.tiny'].id" + + - name: Create server + openstack.cloud.resource: + service: compute + type: server + attributes: + name: ansible_server + image_id: "{{ image_id }}" + flavor_id: "{{ flavor_id }}" + networks: + - uuid: "{{ network_internal.resource.id }}" + port: "{{ port_internal1.resource.id }}" + - uuid: "{{ network_internal.resource.id }}" + port: "{{ port_internal2.resource.id }}" + non_updateable_attributes: + - name + - image_id + - flavor_id + - networks + wait: true + register: server + + - name: Assert server + assert: + that: + - server is changed + + - name: Create server again + openstack.cloud.resource: + service: compute + type: server + attributes: + name: ansible_server + image_id: "{{ image_id }}" + flavor_id: "{{ flavor_id }}" + networks: + - uuid: "{{ network_internal.resource.id }}" + port: "{{ port_internal1.resource.id }}" + - uuid: "{{ network_internal.resource.id }}" + port: "{{ port_internal2.resource.id }}" + non_updateable_attributes: + - name + - image_id + - flavor_id + - networks + wait: true + register: server + + - name: Assert server again + assert: + that: + - server is not changed + + - name: Detach floating ip address 3 + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.152 + port_id: !!null + register: ip3 + + - name: Assert floating ip address 3 + assert: + that: + - ip3 is changed + + - name: Detach floating ip address 3 again + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.152 + port_id: !!null + register: ip3 + + - name: Assert floating ip address 3 again + assert: + that: + - ip3 is not changed + + - name: Delete server + openstack.cloud.resource: + service: compute + type: server + attributes: + name: ansible_server + state: absent + wait: true + register: server + + - name: Assert server + assert: + that: + - server is changed + + - name: Delete server again + openstack.cloud.resource: + service: compute + type: server + attributes: + name: ansible_server + state: absent + wait: true + register: server + + - name: Assert server + assert: + that: + - server is not changed + + - name: Delete floating ip address 3 + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.152 + state: absent + register: ip3 + + - name: Assert floating ip address 3 + assert: + that: + - ip3 is changed + + - name: Delete floating ip address 3 again + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.152 + state: absent + register: ip3 + + - name: Assert floating ip address 3 again + assert: + that: + - ip3 is not changed + + - name: Delete floating ip address 2 + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.151 + state: absent + register: ip2 + + - name: Assert floating ip address 2 + assert: + that: + - ip2 is changed + + - name: Delete floating ip address 2 again + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.151 + state: absent + register: ip2 + + - name: Assert floating ip address 2 again + assert: + that: + - ip2 is not changed + + - name: Delete floating ip address 1 + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.150 + state: absent + register: ip1 + + - name: Assert floating ip address 1 + assert: + that: + - ip1 is changed + + - name: Delete floating ip address 1 again + openstack.cloud.resource: + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.150 + state: absent + register: ip1 + + - name: Assert floating ip address 1 again + assert: + that: + - ip1 is not changed + + - name: Detach router from internal subnet + openstack.cloud.router: + name: ansible_router + network: "{{ network_external.resource.id }}" + external_fixed_ips: + - ip: 10.6.6.10 + subnet: "{{ subnet_external.resource.id }}" + interfaces: [] + register: router + + - name: Delete router + openstack.cloud.resource: + service: network + type: router + attributes: + name: ansible_router + state: absent + wait: true + register: router + + - name: Assert router + assert: + that: + - router is changed + + - name: Delete router again + openstack.cloud.resource: + service: network + type: router + attributes: + name: ansible_router + state: absent + wait: true + register: router + + - name: Assert router again + assert: + that: + - router is not changed + + - name: Delete internal port 2 + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal2 + state: absent + register: port_internal2 + + - name: Assert internal port 2 + assert: + that: + - port_internal2 is changed + + - name: Delete internal port 2 again + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal2 + state: absent + register: port_internal2 + + - name: Assert internal port 2 again + assert: + that: + - port_internal2 is not changed + + - name: Delete internal port 1 + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal1 + state: absent + register: port_internal1 + + - name: Assert internal port 1 + assert: + that: + - port_internal1 is changed + + - name: Delete internal port 1 again + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_internal1 + state: absent + register: port_internal1 + + - name: Assert internal port 1 again + assert: + that: + - port_internal1 is not changed + + - name: Delete internal subnet + openstack.cloud.resource: + service: network + type: subnet + attributes: + name: ansible_internal_subnet + state: absent + register: subnet_internal + + - name: Assert internal subnet + assert: + that: + - subnet_internal is changed + + - name: Delete internal subnet again + openstack.cloud.resource: + service: network + type: subnet + attributes: + name: ansible_internal_subnet + state: absent + register: subnet_internal + + - name: Assert internal subnet again + assert: + that: + - subnet_internal is not changed + + - name: Delete internal network + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_internal + state: absent + wait: true + register: network_internal + + - name: Assert internal network + assert: + that: + - network_internal is changed + + - name: Delete internal network again + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_internal + state: absent + wait: true + register: network_internal + + - name: Assert internal network again + assert: + that: + - network_internal is not changed + + - name: Delete external port + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_external + state: absent + register: port_external + + - name: Assert external port + assert: + that: + - port_external is changed + + - name: Delete external port again + openstack.cloud.resource: + service: network + type: port + attributes: + name: ansible_port_external + state: absent + register: port_external + + - name: Assert external port again + assert: + that: + - port_external is not changed + + - name: Delete external subnet + openstack.cloud.resource: + service: network + type: subnet + attributes: + name: ansible_external_subnet + state: absent + register: subnet_external + + - name: Assert external subnet + assert: + that: + - subnet_external is changed + + - name: Delete external subnet again + openstack.cloud.resource: + service: network + type: subnet + attributes: + name: ansible_external_subnet + state: absent + register: subnet_external + + - name: Assert external subnet again + assert: + that: + - subnet_external is not changed + + - name: Delete external network + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_external + state: absent + wait: true + register: network_external + + - name: Assert external network + assert: + that: + - network_external is changed + + - name: Delete external network again + openstack.cloud.resource: + service: network + type: network + attributes: + name: ansible_network_external + state: absent + wait: true + register: network_external + + - name: Assert external network again + assert: + that: + - network_external is not changed + +- name: Verify resource's check mode + import_tasks: check_mode.yml diff --git a/ci/roles/resources/defaults/main.yml b/ci/roles/resources/defaults/main.yml new file mode 100644 index 00000000..89d32ca5 --- /dev/null +++ b/ci/roles/resources/defaults/main.yml @@ -0,0 +1,3 @@ +--- +expected_fields: + - resources diff --git a/ci/roles/resources/tasks/main.yml b/ci/roles/resources/tasks/main.yml new file mode 100644 index 00000000..0e52485b --- /dev/null +++ b/ci/roles/resources/tasks/main.yml @@ -0,0 +1,53 @@ +--- +- module_defaults: + group/openstack.cloud.openstack: + cloud: "{{ cloud }}" + # Listing modules individually is required for + # backward compatibility with Ansible 2.9 only + openstack.cloud.resources: + cloud: "{{ cloud }}" + block: + - name: List images + openstack.cloud.resources: + service: image + type: image + register: images + + - name: Identify CirrOS image id + set_fact: + image_id: "{{ images.resources|community.general.json_query(query)|first }}" + vars: + query: "[?starts_with(name, 'cirros')].id" + + - name: Assert return values of resources module + assert: + that: + - images is not changed + # allow new fields to be introduced but prevent fields from being removed + - expected_fields|difference(images.keys())|length == 0 + + - name: List compute flavors + openstack.cloud.resources: + service: compute + type: flavor + register: flavors + + - name: Identify m1.tiny flavor id + set_fact: + flavor_id: "{{ flavors.resources|community.general.json_query(query)|first }}" + vars: + query: "[?name == 'm1.tiny'].id" + + - name: List public network + openstack.cloud.resources: + service: network + type: network + parameters: + name: public + register: networks + + - name: Assert public network + assert: + that: + - networks.resources|length == 1 + - networks.resources.0.name == 'public' diff --git a/ci/run-collection.yml b/ci/run-collection.yml index 80e830e5..6d307727 100644 --- a/ci/run-collection.yml +++ b/ci/run-collection.yml @@ -42,6 +42,8 @@ - { role: port, tags: port } - { role: project, tags: project } - { role: recordset, tags: recordset } + - { role: resource, tags: resource } + - { role: resources, tags: resources } - { role: role_assignment, tags: role_assignment } - { role: router, tags: router } - { role: security_group, tags: security_group } diff --git a/meta/runtime.yml b/meta/runtime.yml index 25c4f2ef..5a28e45b 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -60,6 +60,8 @@ action_groups: - project_info - quota - recordset + - resource + - resources - role_assignment - router - routers_info diff --git a/plugins/module_utils/resource.py b/plugins/module_utils/resource.py new file mode 100644 index 00000000..7f40de38 --- /dev/null +++ b/plugins/module_utils/resource.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Jakob Meng, +# Copyright (c) 2023 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +class StateMachine: + + @staticmethod + def default_crud_functions(connection, service_name, type_name): + session = getattr(connection, service_name) + + create_function = getattr(session, 'create_{0}'.format(type_name)) + delete_function = getattr(session, 'delete_{0}'.format(type_name)) + find_function = getattr(session, 'find_{0}'.format(type_name)) + get_function = getattr(session, 'get_{0}'.format(type_name)) + list_function = getattr(session, '{0}s'.format(type_name)) + update_function = getattr(session, 'update_{0}'.format(type_name)) + + return dict( + create=create_function, + delete=delete_function, + find=find_function, + get=get_function, + list=list_function, + update=update_function, + ) + + def __init__(self, + connection, + sdk, + type_name, + service_name, + crud_functions=None, + **kwargs): + for k in ['connection', 'sdk', 'service_name', 'type_name']: + setattr(self, k, locals()[k]) + + self.session = getattr(connection, service_name) + + if not crud_functions: + crud_functions = StateMachine.default_crud_functions( + connection, service_name, type_name) + + for k in ['create', 'delete', 'find', 'get', 'list', 'update']: + setattr(self, '{0}_function'.format(k), crud_functions[k]) + + # kwargs is for passing arguments to subclasses + for k, v in kwargs.items(): + setattr(self, k, v) + + def __call__(self, attributes, check_mode, state, timeout, wait, + updateable_attributes, non_updateable_attributes, **kwargs): + # kwargs is for passing arguments to subclasses + + resource = self._find(attributes, **kwargs) + + if check_mode: + return self._simulate(state, resource, attributes, timeout, wait, + updateable_attributes, + non_updateable_attributes, **kwargs) + + if state == 'present' and not resource: + # Create resource + resource = self._create(attributes, timeout, wait, **kwargs) + return resource, True + + elif state == 'present' and resource: + # Update resource + update = self._build_update(resource, attributes, + updateable_attributes, + non_updateable_attributes, **kwargs) + if update: + resource = self._update(resource, timeout, update, wait, + **kwargs) + + return resource, bool(update) + + elif state == 'absent' and resource: + # Delete resource + self._delete(resource, attributes, timeout, wait, **kwargs) + return None, True + + elif state == 'absent' and not resource: + # Do nothing + return None, False + + def _build_update(self, resource, attributes, updateable_attributes, + non_updateable_attributes, **kwargs): + update = {} + + # Fetch details to populate all resource attributes + resource = self.get_function(resource['id']) + + comparison_attributes = ( + set(updateable_attributes + if updateable_attributes is not None + else attributes.keys()) + - set(non_updateable_attributes + if non_updateable_attributes is not None + else [])) + + resource_attributes = dict( + (k, attributes[k]) + for k in comparison_attributes + if not self._is_equal(attributes[k], resource[k])) + + if resource_attributes: + update['resource_attributes'] = resource_attributes + + return update + + def _create(self, attributes, timeout, wait, **kwargs): + resource = self.create_function(**attributes) + + if wait: + resource = self.sdk.resource.wait_for_status(self.session, + resource, + status='active', + failures=['error'], + wait=timeout, + attribute='status') + + return resource + + def _delete(self, resource, attributes, timeout, wait, **kwargs): + self.delete_function(resource['id']) + + if wait: + for count in self.sdk.utils.iterate_timeout( + timeout=timeout, + message="Timeout waiting for resource to be absent" + ): + if self._find(attributes) is None: + break + + def _freeze(self, o): + if isinstance(o, dict): + return frozenset((k, self._freeze(v)) for k, v in o.items()) + + if isinstance(o, list): + return tuple(self._freeze(v) for v in o) + + return o + + def _is_equal(self, a, b): + if any([a is None and b is not None, + a is not None and b is None]): + return False + + if a is None and b is None: + return True + + if isinstance(a, list) and isinstance(b, list): + return self._freeze(a) == self._freeze(b) + + if isinstance(a, dict) and isinstance(b, dict): + if set(a.keys()) != set(b.keys()): + return False + return self._freeze(a) == self._freeze(b) + + # else + return a == b + + def _find(self, attributes, **kwargs): + # use find_* functions for id instead of get_* functions because + # get_* functions raise exceptions when resources cannot be found + for k in ['id', 'name']: + if k in attributes: + return self.find_function(attributes[k]) + + matches = list(self._find_matches(attributes, **kwargs)) + if len(matches) > 1: + self.fail_json(msg='Found more than a single resource' + ' which matches the given attributes.') + elif len(matches) == 1: + return matches[0] + else: # len(matches) == 0 + return None + + def _find_matches(self, attributes, **kwargs): + return self.list_function(**attributes) + + def _update(self, resource, timeout, update, wait, **kwargs): + resource_attributes = update.get('resource_attributes') + if resource_attributes: + resource = self.update_function(resource['id'], + **resource_attributes) + + if wait: + resource = self.sdk.resource.wait_for_status(self.session, + resource, + status='active', + failures=['error'], + wait=timeout, + attribute='status') + + return resource + + def _simulate(self, state, resource, attributes, timeout, wait, + updateable_attributes, + non_updateable_attributes, **kwargs): + if state == 'present' and not resource: + resource = self._simulate_create(attributes, timeout, wait, + **kwargs) + return resource, True + elif state == 'present' and resource: + update = self._build_update(resource, attributes, + updateable_attributes, + non_updateable_attributes, + **kwargs) + if update: + resource = self._simulate_update(resource, timeout, update, + wait, **kwargs) + + return resource, bool(update) + elif state == 'absent' and resource: + return None, True + else: + # state == 'absent' and not resource: + return None, False + + def _simulate_create(self, attributes, timeout, wait, **kwargs): + class Resource(dict): + def to_dict(self, *args, **kwargs): + return self + + return Resource(attributes) + + def _simulate_update(self, resource, timeout, update, wait, **kwargs): + resource_attributes = update.get('resource_attributes') + if resource_attributes: + for k, v in resource_attributes.items(): + resource[k] = v + + return resource diff --git a/plugins/modules/resource.py b/plugins/modules/resource.py new file mode 100644 index 00000000..b2f5e6a2 --- /dev/null +++ b/plugins/modules/resource.py @@ -0,0 +1,425 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Jakob Meng, +# Copyright (c) 2023 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: resource +short_description: Manage a OpenStack cloud resource +author: OpenStack Ansible SIG +description: + - Create, update and delete a OpenStack cloud resource. +options: + attributes: + description: + - "Resource attributes which are defined in openstacksdk's resource + classes." + - I(attributes) is a set of key-value pairs where each key is a attribute + name such as C(id) and value holds its corresponding attribute value + such C(ddad2d86-02a6-444d-80ae-1cc2fb023784). + - Define attribute keys C(id) or C(name) or any set of attribute keys + which uniquely identify a resource. This module fails if multiple + resources match the given set of attributes. + - For a complete list of attributes open any resource class inside + openstacksdk such as file C(openstack/compute/v2/server.py) in + U(https://opendev.org/openstack/openstacksdk/) for server attributes. + required: true + type: dict + non_updateable_attributes: + description: + - List of attribute names which cannot be updated. + - When I(non_updateable_attributes) is not specified, then all attributes + in I(attributes) will be compared to an existing resource during + updates. + - When both I(updateable_attributes) and I(non_updateable_attributes) are + specified, then only attributes which are listed in + I(updateable_attributes) but not in I(non_updateable_attributes) will + will be considered during updates. + type: list + elements: str + service: + description: + - OpenStack service which this resource is part of. + - Examples are C(block_storage), C(compute) or C(network). + - "I(service) must be a C(lowercase) name of a OpenStack service as + used in openstacksdk. For a list of available services visit + U(https://opendev.org/openstack/openstacksdk): Most subdirectories + in the C(openstack) directory correspond to a OpenStack service, + except C(cloud), C(common) and other auxiliary directories." + required: true + type: str + state: + description: + - Whether the resource should be C(present) or C(absent). + choices: ['present', 'absent'] + default: present + type: str + type: + description: + - Typename of the resource. + - Examples are C(ip), C(network), C(router) or C(server). + - "I(type) must be a C(lowercase) name of a openstacksdk resource class. + Resource classes are defined in openstacksdk's service folders. For + example, visit U(https://opendev.org/openstack/openstacksdk), change + to C(openstack) directory, change to any service directory such as + C(compute), choose a api version directory such as C(v2) and find all + available resource classes such as C(Server) inside C(*.py) files." + required: true + type: str + updateable_attributes: + description: + - List of attribute names which can be updated. + - When I(updateable_attributes) is not specified, then all attributes + in I(attributes) will be compared to an existing resource during + updates. + - When both I(updateable_attributes) and I(non_updateable_attributes) are + specified, then only attributes which are listed in + I(updateable_attributes) but not in I(non_updateable_attributes) will + will be considered during updates. + type: list + elements: str + wait: + description: + - Whether Ansible should wait until the resource has reached its target + I(state). + - Only a subset of OpenStack resources report a status. Resources which + do not support status processing will block indefinitely if I(wait) is + set to C(true). + type: bool + default: false +notes: + - "This module does not support all OpenStack cloud resources. Resource + handling must follow openstacksdk's CRUD structure using and providing + C(.s), C(.find_), + C(.create_), C(.update_) and + C(.delete_) functions. The module will fail before + applying any changes if these functions cannot be found." +extends_documentation_fragment: + - openstack.cloud.openstack +''' + +RETURN = r''' +resource: + description: Dictionary describing the identified (and possibly modified) + OpenStack cloud resource. + returned: On success when I(state) is C(present). + type: dict +''' + +EXAMPLES = r''' +- name: Create external network + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: network + attributes: + name: ansible_network_external + is_router_external: true + wait: true + register: network_external + +- name: Create external subnet + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: subnet + attributes: + cidr: 10.6.6.0/24 + ip_version: 4 + name: ansible_external_subnet + network_id: "{{ network_external.resource.id }}" + register: subnet_external + +- name: Create external port + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: port + attributes: + name: ansible_port_external + network_id: "{{ network_external.resource.id }}" + fixed_ips: + - ip_address: 10.6.6.50 + non_updateable_attributes: + - fixed_ips + +- name: Create internal network + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: network + attributes: + name: ansible_network_internal + is_router_external: false + wait: true + register: network_internal + +- name: Create internal subnet + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: subnet + attributes: + cidr: 10.7.7.0/24 + ip_version: 4 + name: ansible_internal_subnet + network_id: "{{ network_internal.resource.id }}" + register: subnet_internal + +- name: Create internal port + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: port + attributes: + name: ansible_port_internal + network_id: "{{ network_internal.resource.id }}" + fixed_ips: + - ip_address: 10.7.7.100 + subnet_id: "{{ subnet_internal.resource.id }}" + register: port_internal + +- name: Create router + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: router + attributes: + name: ansible_router + external_gateway_info: + enable_snat: true + external_fixed_ips: + - ip_address: 10.6.6.10 + subnet_id: "{{ subnet_external.resource.id }}" + network_id: "{{ network_external.resource.id }}" + wait: true + +- name: Attach router to internal subnet + openstack.cloud.router: + cloud: devstack-admin + name: ansible_router + network: "{{ network_external.resource.id }}" + external_fixed_ips: + - ip: 10.6.6.10 + subnet: "{{ subnet_external.resource.id }}" + interfaces: + - net: "{{ network_internal.resource.id }}" + subnet: "{{ subnet_internal.resource.id }}" + portip: 10.7.7.1 + +- name: Create floating ip address + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: ip + attributes: + name: 10.6.6.150 + floating_ip_address: 10.6.6.150 + floating_network_id: "{{ network_external.resource.id }}" + port_id: "{{ port_internal.resource.id }}" + register: ip + +- name: List images + openstack.cloud.resources: + cloud: devstack-admin + service: image + type: image + register: images + +- name: Identify CirrOS image id + set_fact: + image_id: "{{ + images.resources|community.general.json_query(query)|first }}" + vars: + query: "[?starts_with(name, 'cirros')].id" + +- name: List compute flavors + openstack.cloud.resources: + cloud: devstack-admin + service: compute + type: flavor + register: flavors + +- name: Identify m1.tiny flavor id + set_fact: + flavor_id: "{{ + flavors.resources|community.general.json_query(query)|first }}" + vars: + query: "[?name == 'm1.tiny'].id" + +- name: Create server + openstack.cloud.resource: + cloud: devstack-admin + service: compute + type: server + attributes: + name: ansible_server + image_id: "{{ image_id }}" + flavor_id: "{{ flavor_id }}" + networks: + - uuid: "{{ network_internal.resource.id }}" + port: "{{ port_internal.resource.id }}" + non_updateable_attributes: + - name + - image_id + - flavor_id + - networks + wait: true + +- name: Detach floating ip address + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.150 + port_id: !!null + +- name: Delete server + openstack.cloud.resource: + cloud: devstack-admin + service: compute + type: server + attributes: + name: ansible_server + state: absent + wait: true + +- name: Delete floating ip address + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: ip + attributes: + floating_ip_address: 10.6.6.150 + state: absent + +- name: Detach router from internal subnet + openstack.cloud.router: + cloud: devstack-admin + name: ansible_router + network: "{{ network_external.resource.id }}" + external_fixed_ips: + - ip: 10.6.6.10 + subnet: "{{ subnet_external.resource.id }}" + interfaces: [] + +- name: Delete router + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: router + attributes: + name: ansible_router + state: absent + wait: true + +- name: Delete internal port + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: port + attributes: + name: ansible_port_internal + state: absent + +- name: Delete internal subnet + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: subnet + attributes: + name: ansible_internal_subnet + state: absent + +- name: Delete internal network + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: network + attributes: + name: ansible_network_internal + state: absent + wait: true + +- name: Delete external port + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: port + attributes: + name: ansible_port_external + state: absent + +- name: Delete external subnet + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: subnet + attributes: + name: ansible_external_subnet + state: absent + +- name: Delete external network + openstack.cloud.resource: + cloud: devstack-admin + service: network + type: network + attributes: + name: ansible_network_external + state: absent + wait: true +''' + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule +from ansible_collections.openstack.cloud.plugins.module_utils.resource import StateMachine + + +class ResourceModule(OpenStackModule): + argument_spec = dict( + attributes=dict(required=True, type='dict'), + non_updateable_attributes=dict(type='list', elements='str'), + service=dict(required=True), + state=dict(default='present', choices=['absent', 'present']), + type=dict(required=True), + updateable_attributes=dict(type='list', elements='str'), + wait=dict(default=False, type='bool'), + ) + + module_kwargs = dict( + supports_check_mode=True + ) + + def run(self): + service_name = self.params['service'] + type_name = self.params['type'] + + sm = StateMachine(connection=self.conn, + service_name=service_name, + type_name=type_name, + sdk=self.sdk) + + kwargs = dict((k, self.params[k]) + for k in ['attributes', 'non_updateable_attributes', + 'state', 'timeout', 'wait', + 'updateable_attributes']) + + resource, is_changed = sm(check_mode=self.ansible.check_mode, **kwargs) + + if resource is None: + self.exit_json(changed=is_changed) + else: + self.exit_json(changed=is_changed, + resource=resource.to_dict(computed=False)) + + +def main(): + module = ResourceModule() + module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/resources.py b/plugins/modules/resources.py new file mode 100644 index 00000000..f6845daa --- /dev/null +++ b/plugins/modules/resources.py @@ -0,0 +1,141 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Jakob Meng, +# Copyright (c) 2023 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: resources +short_description: List OpenStack cloud resources +author: OpenStack Ansible SIG +description: + - List OpenStack cloud resources. +options: + service: + description: + - OpenStack service which this resource is part of. + - Examples are C(block_storage), C(compute) or C(network). + - "I(service) must be a C(lowercase) name of a OpenStack service as + used in openstacksdk. For a list of available services visit + U(https://opendev.org/openstack/openstacksdk): Most subdirectories + in the C(openstack) directory correspond to a OpenStack service, + except C(cloud), C(common) and other auxiliary directories." + required: true + type: str + parameters: + description: + - Query parameters passed to OpenStack API for results filtering. + - I(attributes) is a set of key-value pairs where each key is a attribute + name such as C(id) and value holds its corresponding attribute value + such C(ddad2d86-02a6-444d-80ae-1cc2fb023784). + - For a complete list of valid query parameters open any resource class + inside openstacksdk such as file C(openstack/compute/v2/server.py) in + U(https://opendev.org/openstack/openstacksdk/) and consult variable + C(_query_mapping). + type: dict + type: + description: + - Typename of the resource. + - Examples are C(ip), C(network), C(router) or C(server). + - "I(type) must be a C(lowercase) name of a openstacksdk resource class. + Resource classes are defined in openstacksdk's service folders. For + example, visit U(https://opendev.org/openstack/openstacksdk), change + to C(openstack) directory, change to any service directory such as + C(compute), choose a api version directory such as C(v2) and find all + available resource classes such as C(Server) inside C(*.py) files." + required: true + type: str +notes: + - "This module does not support all OpenStack cloud resources. Resource + handling must follow openstacksdk's CRUD structure using and providing + a C(.s) function. The module will fail if this function + cannot be found." +extends_documentation_fragment: + - openstack.cloud.openstack +''' + +RETURN = r''' +resources: + description: Dictionary describing the identified OpenStack cloud resources. + returned: always + type: list + elements: dict +''' + +EXAMPLES = r''' +- name: List images + openstack.cloud.resources: + cloud: devstack-admin + service: image + type: image + register: images + +- name: Identify CirrOS image id + set_fact: + image_id: "{{ + images.resources|community.general.json_query(query)|first }}" + vars: + query: "[?starts_with(name, 'cirros')].id" + +- name: List compute flavors + openstack.cloud.resources: + cloud: devstack-admin + service: compute + type: flavor + register: flavors + +- name: Identify m1.tiny flavor id + set_fact: + flavor_id: "{{ + flavors.resources|community.general.json_query(query)|first }}" + vars: + query: "[?name == 'm1.tiny'].id" + +- name: List public network + openstack.cloud.resources: + cloud: devstack-admin + service: network + type: network + parameters: + name: public +''' + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule + + +class ResourcesModule(OpenStackModule): + argument_spec = dict( + parameters=dict(type='dict'), + service=dict(required=True), + type=dict(required=True), + ) + + module_kwargs = dict( + supports_check_mode=True + ) + + def run(self): + service_name = self.params['service'] + type_name = self.params['type'] + + session = getattr(self.conn, service_name) + list_function = getattr(session, '{0}s'.format(type_name)) + + parameters = self.params['parameters'] + resources = \ + list_function(**parameters) if parameters else list_function() + + self.exit_json( + changed=False, + resources=[r.to_dict(computed=False) for r in resources]) + + +def main(): + module = ResourcesModule() + module() + + +if __name__ == '__main__': + main()