From 2419b5ab19ae1fa8899bdd4d7e6a17ff02b25889 Mon Sep 17 00:00:00 2001 From: Rafael Castillo Date: Thu, 9 Jun 2022 15:34:41 -0700 Subject: [PATCH] Update image for new sdk - Use proxy layer where possible - Image upload has some tricky logic so that stays in the cloud layer - Convert return value to dict - Document return values - Update visibility logic for glance v2 api - Increase test coverage - General refactoring to bring more in line with rest of collection - Deprecate is_public attribute which has been replaced with visibility. - Deprecate volume attribute which has been made obsolete with openstack.cloud.volume module. Removed examples showing the volume attribute since users are encouraged to use openstack.cloud.volume module. Change-Id: I1d8034a3b9a391444ea275b68b06ee3a291c73c3 --- ci/roles/image/defaults/main.yml | 65 ++++ ci/roles/image/tasks/main.yml | 107 ++++++- plugins/modules/image.py | 512 +++++++++++++++++++++++++------ 3 files changed, 586 insertions(+), 98 deletions(-) diff --git a/ci/roles/image/defaults/main.yml b/ci/roles/image/defaults/main.yml index 66ba71ab..c61b93be 100644 --- a/ci/roles/image/defaults/main.yml +++ b/ci/roles/image/defaults/main.yml @@ -2,3 +2,68 @@ image_name: ansible_image image_tags: - test - ansible +expected_fields: + - architecture + - checksum + - container_format + - created_at + - direct_url + - disk_format + - file + - has_auto_disk_config + - hash_algo + - hash_value + - hw_cpu_cores + - hw_cpu_policy + - hw_cpu_sockets + - hw_cpu_thread_policy + - hw_cpu_threads + - hw_disk_bus + - hw_machine_type + - hw_qemu_guest_agent + - hw_rng_model + - hw_scsi_model + - hw_serial_port_count + - hw_video_model + - hw_video_ram + - hw_vif_model + - hw_watchdog_action + - hypervisor_type + - id + - instance_type_rxtx_factor + - instance_uuid + - is_hidden + - is_hw_boot_menu_enabled + - is_hw_vif_multiqueue_enabled + - is_protected + - kernel_id + - locations + - metadata + - min_disk + - min_ram + - name + - needs_config_drive + - needs_secure_boot + - os_admin_user + - os_command_line + - os_distro + - os_require_quiesce + - os_shutdown_timeout + - os_type + - os_version + - owner + - owner_id + - properties + - ramdisk_id + - schema + - size + - status + - store + - tags + - updated_at + - url + - virtual_size + - visibility + - vm_mode + - vmware_adaptertype + - vmware_ostype diff --git a/ci/roles/image/tasks/main.yml b/ci/roles/image/tasks/main.yml index b8560027..acf93aba 100644 --- a/ci/roles/image/tasks/main.yml +++ b/ci/roles/image/tasks/main.yml @@ -1,4 +1,10 @@ --- +- name: Ensure image does not exist before tests + openstack.cloud.image: + cloud: "{{ cloud }}" + state: absent + name: "{{ image_name }}" + - name: Create a test image file shell: mktemp register: tmp_file @@ -6,15 +12,40 @@ - name: Fill test image file to 1MB shell: truncate -s 1048576 {{ tmp_file.stdout }} +- name: ensure mock kernel and ramdisk images (defaults) + openstack.cloud.image: + cloud: "{{ cloud }}" + state: present + name: "{{ item }}" + filename: "{{ tmp_file.stdout }}" + disk_format: raw + loop: + - cirros-vmlinuz + - cirros-initrd + - name: Create raw image (defaults) openstack.cloud.image: cloud: "{{ cloud }}" state: present name: "{{ image_name }}" filename: "{{ tmp_file.stdout }}" + is_protected: true disk_format: raw tags: "{{ image_tags }}" - register: image + register: returned_image + +- debug: + var: returned_image + +- name: Assert changed + assert: + that: returned_image is changed + +- name: Assert fields + assert: + that: + - item in returned_image.image + loop: "{{ expected_fields }}" - name: Get details of created image openstack.cloud.image_info: @@ -28,11 +59,68 @@ - "image_info_result.images[0].name == image_name" - "image_info_result.images[0].tags | sort == image_tags | sort" + +- name: Create raw image again (defaults) + openstack.cloud.image: + cloud: "{{ cloud }}" + state: present + name: "{{ image_name }}" + filename: "{{ tmp_file.stdout }}" + is_protected: true + disk_format: raw + tags: "{{ image_tags }}" + register: returned_image + +- name: Assert not changed + assert: + that: returned_image is not changed + +- name: Assert fields + assert: + that: + - item in returned_image.image + loop: "{{ expected_fields }}" + +- name: Update raw image (defaults) + openstack.cloud.image: + cloud: "{{ cloud }}" + state: present + name: "{{ image_name }}" + is_protected: false + register: returned_image + +- name: Assert changed + assert: + that: + - returned_image is changed + - returned_image.image.is_protected == false + +- name: Assert changed + assert: + that: + - returned_image is changed + - name: Delete raw image (defaults) openstack.cloud.image: cloud: "{{ cloud }}" state: absent name: "{{ image_name }}" + register: returned_image + +- name: assert image changed + assert: + that: returned_image is changed + +- name: Delete raw image again (defaults) + openstack.cloud.image: + cloud: "{{ cloud }}" + state: absent + name: "{{ image_name }}" + register: returned_image + +- name: assert image not changed + assert: + that: returned_image is not changed - name: Create raw image (complex) openstack.cloud.image: @@ -44,12 +132,23 @@ is_public: True min_disk: 10 min_ram: 1024 + # TODO(rcastillo): upload cirros-vmlinuz, cirros-initrd in test setup kernel: cirros-vmlinuz ramdisk: cirros-initrd properties: cpu_arch: x86_64 distro: ubuntu - register: image + register: returned_image + +- name: Assert visibility + assert: + that: returned_image.image.visibility == 'public' + +- name: Assert fields + assert: + that: + - item in returned_image.image + loop: "{{ expected_fields }}" - name: Delete raw image (complex) openstack.cloud.image: @@ -87,7 +186,7 @@ disk_format: raw tags: "{{ image_tags }}" project: image_owner_project - register: image + register: returned_image - name: Get details of created image (owner by project name) openstack.cloud.image_info: @@ -116,7 +215,7 @@ tags: "{{ image_tags }}" project: image_owner_project project_domain: default - register: image + register: returned_image - name: Get details of created image (owner by project name and domain name) openstack.cloud.image_info: diff --git a/plugins/modules/image.py b/plugins/modules/image.py index 6d7d3197..94f273a7 100644 --- a/plugins/modules/image.py +++ b/plugins/modules/image.py @@ -4,9 +4,6 @@ # Copyright (c) 2013, Benno Joy # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) - -# TODO(mordred): we need to support "location"(v1) and "locations"(v2) - DOCUMENTATION = ''' --- module: image @@ -40,16 +37,17 @@ options: default: bare choices: ['ami', 'aki', 'ari', 'bare', 'ovf', 'ova', 'docker'] type: str - project: + owner: description: - The name or ID of the project owning the image type: str - aliases: ['owner'] - project_domain: + aliases: ['project'] + owner_domain: description: - - The domain the project owning the image belongs to + - The name or id of the domain the project owning the image belongs to - May be used to identify a unique project when providing a name to the project argument and multiple projects with such name exist type: str + aliases: ['project_domain'] min_disk: description: - The minimum disk space (in GB) required to boot this image @@ -60,7 +58,10 @@ options: type: int is_public: description: - - Whether the image can be accessed publicly. Note that publicizing an image requires admin role by default. + - Whether the image can be accessed publicly. + Note that publicizing an image requires admin role by default. + - Use I(visibility) instead of I(is_public), + the latter has been deprecated. type: bool default: false is_protected: @@ -68,7 +69,6 @@ options: - Prevent image from being deleted aliases: ['protected'] type: bool - default: false filename: description: - The path to the file which has to be uploaded @@ -98,10 +98,17 @@ options: default: [] type: list elements: str + visibility: + description: + - The image visibility + type: str + choices: [public, private, shared, community] volume: description: - ID of a volume to create an image from. - The volume must be in AVAILABLE state. + - Switch to module M(openstack.cloud.volume) instead of using I(volume), + the latter has been deprecated. type: str requirements: - "python >= 3.6" @@ -133,33 +140,306 @@ EXAMPLES = ''' properties: cpu_arch: x86_64 distro: ubuntu +''' -# Create image from volume attached to an instance -- name: create volume snapshot - openstack.cloud.volume_snapshot: - auth: - "{{ auth }}" - display_name: myvol_snapshot - volume: myvol - force: yes - register: myvol_snapshot - -- name: create volume from snapshot - openstack.cloud.volume: - auth: - "{{ auth }}" - size: "{{ myvol_snapshot.snapshot.size }}" - snapshot_id: "{{ myvol_snapshot.snapshot.id }}" - display_name: myvol_snapshot_volume - wait: yes - register: myvol_snapshot_volume - -- name: create image from volume snapshot - openstack.cloud.image: - auth: - "{{ auth }}" - volume: "{{ myvol_snapshot_volume.volume.id }}" - name: myvol_image +RETURN = ''' +id: + description: ID of the image. + returned: On success when I(state) is 'present'. + type: str +image: + description: Dictionary describing the image. + type: dict + returned: On success when I(state) is 'present'. + contains: + id: + description: Unique UUID. + returned: success + type: str + name: + description: Name given to the image. + returned: success + type: str + status: + description: Image status. + returned: success + type: str + architecture: + description: | + The CPU architecture that must be supported by the hypervisor. + returned: success + type: str + created_at: + description: Image created at timestamp. + returned: success + type: str + container_format: + description: Container format of the image. + returned: success + type: str + direct_url: + description: URL to access the image file kept in external store. + returned: success + type: str + min_ram: + description: Min amount of RAM required for this image. + returned: success + type: int + disk_format: + description: Disk format of the image. + returned: success + type: str + file: + description: The URL for the virtual machine image file. + returned: success + type: str + has_auto_disk_config: + description: > + If root partition on disk is automatically resized before the instance + boots. + returned: success + type: bool + hash_algo: + description: | + The algorithm used to compute a secure hash of the image data. + returned: success + type: str + hash_value: + description: > + The hexdigest of the secure hash of the image data computed using the + algorithm whose name is the value of the os_hash_algo property. + returned: success + type: str + hw_cpu_cores: + description: > + Used to pin the virtual CPUs (vCPUs) of instances to the host's physical + CPU cores (pCPUs). + returned: success + type: str + hw_cpu_policy: + description: The hexdigest of the secure hash of the image data. + returned: success + type: str + hw_cpu_sockets: + description: Preferred number of sockets to expose to the guest. + returned: success + type: str + hw_cpu_thread_policy: + description: > + Defines how hardware CPU threads in a simultaneous multithreading-based + (SMT) architecture be used. + returned: success + type: str + hw_cpu_threads: + description: | + The preferred number of threads to expose to the guest. + returned: success + type: str + hw_disk_bus: + description: | + Specifies the type of disk controller to attach disk devices to. + returned: success + type: str + hw_machine_type: + description: | + Enables booting an ARM system using the specified machine type. + returned: success + type: str + hw_qemu_guest_agent: + description: > + A string boolean, which if "true", QEMU guest agent will be exposed to + the instance. + returned: success + type: str + hw_rng_model: + description: Adds a random-number generator device to the image's instances. + returned: success + type: str + hw_scsi_model: + description: > + Enables the use of VirtIO SCSI (virtio-scsi) to provide block device + access for compute instances. + returned: success + type: str + hw_video_model: + description: The video image driver used. + returned: success + type: str + hw_video_ram: + description: Maximum RAM for the video image. + returned: success + type: str + hw_vif_model: + description: Specifies the model of virtual network interface device to use. + returned: success + type: str + hw_watchdog_action: + description: > + Enables a virtual hardware watchdog device that carries out the + specified action if the server hangs. + returned: success + type: str + hypervisor_type: + description: The hypervisor type. + returned: success + type: str + instance_type_rxtx_factor: + description: > + Optional property allows created servers to have a different bandwidth + cap than that defined in the network they are attached to. + returned: success + type: str + instance_uuid: + description: > + For snapshot images, this is the UUID of the server used to create this + image. + returned: success + type: str + is_hidden: + description: >- + Controls whether an image is displayed in the default image-list + response + returned: success + type: bool + is_hw_boot_menu_enabled: + description: Enables the BIOS bootmenu. + returned: success + type: bool + is_hw_vif_multiqueue_enabled: + description: | + Enables the virtio-net multiqueue feature. + returned: success + type: bool + kernel_id: + description: > + The ID of an image stored in the Image service that should be used as + the kernel when booting an AMI-style image. + returned: success + type: str + locations: + description: A list of URLs to access the image file in external store. + returned: success + type: str + metadata: + description: The location metadata. + returned: success + type: str + needs_config_drive: + description: Specifies whether the image needs a config drive. + returned: success + type: bool + needs_secure_boot: + description: Whether Secure Boot is needed. + returned: success + type: bool + os_admin_user: + description: The operating system admin username. + returned: success + type: str + os_command_line: + description: The kernel command line to be used by libvirt driver. + returned: success + type: str + os_distro: + description: | + The common name of the operating system distribution in lowercase. + returned: success + type: str + os_require_quiesce: + description: | + If true, require quiesce on snapshot via QEMU guest agent. + returned: success + type: str + os_shutdown_timeout: + description: Time for graceful shutdown. + returned: success + type: str + os_type: + description: The operating system installed on the image. + returned: success + type: str + os_version: + description: | + The operating system version as specified by the distributor. + returned: success + type: str + owner_id: + description: 'The ID of the owner, or project, of the image.' + returned: success + type: str + ramdisk_id: + description: > + The ID of image stored in the Image service that should be used as the + ramdisk when booting an AMI-style image. + returned: success + type: str + schema: + description: URL for the schema describing a virtual machine image. + returned: success + type: str + store: + description: > + Glance will attempt to store the disk image data in the backing store + indicated by the value of the header. + returned: success + type: str + updated_at: + description: Image updated at timestamp. + returned: success + type: str + url: + description: URL to access the image file kept in external store. + returned: success + type: str + virtual_size: + description: The virtual size of the image. + returned: success + type: str + vm_mode: + description: The virtual machine mode. + returned: success + type: str + vmware_adaptertype: + description: | + The virtual SCSI or IDE controller used by the hypervisor. + returned: success + type: str + vmware_ostype: + description: Operating system installed in the image. + returned: success + type: str + filters: + description: Additional properties associated with the image. + returned: success + type: dict + min_disk: + description: Min amount of disk space required for this image. + returned: success + type: int + is_protected: + description: Image protected flag. + returned: success + type: bool + checksum: + description: Checksum for the image. + returned: success + type: str + owner: + description: Owner for the image. + returned: success + type: str + visibility: + description: Indicates who has access to the image. + returned: success + type: str + size: + description: Size of the image. + returned: success + type: int + tags: + description: List of tags assigned to the image + returned: success + type: list ''' from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule @@ -176,12 +456,12 @@ class ImageModule(OpenStackModule): disk_format=dict(default='qcow2', choices=['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi', 'iso', 'vhdx', 'ploop']), container_format=dict(default='bare', choices=['ami', 'aki', 'ari', 'bare', 'ovf', 'ova', 'docker']), - project=dict(type='str', aliases=['owner']), - project_domain=dict(type='str'), + owner=dict(type='str', aliases=['project']), + owner_domain=dict(type='str', aliases=['project_domain']), min_disk=dict(type='int', default=0), min_ram=dict(type='int', default=0), is_public=dict(type='bool', default=False), - is_protected=dict(type='bool', default=False, aliases=['protected']), + is_protected=dict(type='bool', aliases=['protected']), filename=dict(type='str'), ramdisk=dict(type='str'), kernel=dict(type='str'), @@ -189,77 +469,121 @@ class ImageModule(OpenStackModule): volume=dict(type='str'), tags=dict(type='list', default=[], elements='str'), state=dict(default='present', choices=['absent', 'present']), + visibility=dict(type='str', choices=['public', 'private', 'shared', 'community']), ) module_kwargs = dict( - mutually_exclusive=[['filename', 'volume']], + mutually_exclusive=[ + ('filename', 'volume'), + ('visibility', 'is_public'), + ], ) + # resource attributes obtainable directly from params + attr_params = ('id', 'name', 'filename', 'disk_format', + 'container_format', 'wait', 'timeout', 'is_public', + 'is_protected', 'min_disk', 'min_ram', 'volume', 'tags') + + def _resolve_visibility(self): + """resolve a visibility value to be compatible with older versions""" + if self.params['visibility']: + return self.params['visibility'] + if self.params['is_public'] is not None: + return 'public' if self.params['is_public'] else 'private' + return None + + def _build_params(self, owner): + params = {attr: self.params[attr] for attr in self.attr_params} + if owner: + params['owner_id'] = owner.id + params['visibility'] = self._resolve_visibility() + params = {k: v for k, v in params.items() if v is not None} + return params + + def _return_value(self, image_name_or_id): + image = self.conn.image.find_image(image_name_or_id) + if image: + image = image.to_dict(computed=False) + return image + + def _build_update(self, image): + update_payload = dict(is_protected=self.params['is_protected']) + for k in ('kernel', 'ramdisk'): + if not self.params[k]: + continue + k_id = '{0}_id'.format(k) + k_image = self.conn.image.find_image( + name_or_id=self.params[k], ignore_missing=False) + update_payload[k_id] = k_image.id + update_payload = {k: v for k, v in update_payload.items() + if v is not None and image[k] != v} + for p, v in self.params['properties'].items(): + if p not in image or image[p] != v: + update_payload[p] = v + if (self.params['tags'] + and set(image['tags']) != set(self.params['tags'])): + update_payload['tags'] = self.params['tags'] + return update_payload + def run(self): + changed = False + image_filters = {} + image_name_or_id = self.params['id'] or self.params['name'] + owner_name_or_id = self.params['owner'] + owner_domain_name_or_id = self.params['owner_domain'] + + if self.params['checksum']: + image_filters['checksum'] = image_filters + owner_filters = {} + if owner_domain_name_or_id: + owner_domain = self.conn.identity.find_domain( + owner_domain_name_or_id) + if owner_domain: + owner_filters['domain_id'] = owner_domain.id + else: + # else user may not be able to enumerate domains + owner_filters['domain_id'] = owner_domain_name_or_id + + owner = None + if owner_name_or_id: + owner = self.conn.identity.find_project( + owner_name_or_id, ignore_missing=False, **owner_filters) + + image = None + if image_name_or_id: + image = self.conn.image.find_image(image_name_or_id, **image_filters) changed = False - if self.params['id']: - image = self.conn.get_image(name_or_id=self.params['id']) - elif self.params['checksum']: - image = self.conn.get_image(name_or_id=self.params['name'], filters={'checksum': self.params['checksum']}) - else: - image = self.conn.get_image(name_or_id=self.params['name']) - if self.params['state'] == 'present': + attrs = self._build_params(owner) if not image: - kwargs = {} - if self.params['id'] is not None: - kwargs['id'] = self.params['id'] - if self.params['project']: - project_domain = {'id': None} - if self.params['project_domain']: - project_domain = self.conn.get_domain(name_or_id=self.params['project_domain']) - if not project_domain or project_domain['id'] is None: - self.fail(msg='Project domain %s could not be found' % self.params['project_domain']) - project = self.conn.get_project(name_or_id=self.params['project'], domain_id=project_domain['id']) - if not project: - self.fail(msg='Project %s could not be found' % self.params['project']) - kwargs['owner'] = project['id'] - image = self.conn.create_image( - name=self.params['name'], - filename=self.params['filename'], - disk_format=self.params['disk_format'], - container_format=self.params['container_format'], - wait=self.params['wait'], - timeout=self.params['timeout'], - is_public=self.params['is_public'], - is_protected=self.params['is_protected'], - min_disk=self.params['min_disk'], - min_ram=self.params['min_ram'], - volume=self.params['volume'], - tags=self.params['tags'], - **kwargs - ) + # self.conn.image.create_image cannot be used because + # self.conn.create_image provides a volume parameter + # Ref.: https://opendev.org/openstack/openstacksdk/src/commit/a41d04ea197439c2f134ce3554995693933a46ac/openstack/cloud/_image.py#L306 + image = self.conn.create_image(**attrs) changed = True if not self.params['wait']: - self.exit(changed=changed, image=image, id=image.id) + self.exit_json(changed=changed, + image=self._return_value(image.id), + id=image.id) - self.conn.update_image_properties( - image=image, - kernel=self.params['kernel'], - ramdisk=self.params['ramdisk'], - is_protected=self.params['is_protected'], - **self.params['properties']) - if self.params['tags']: - self.conn.image.update_image(image.id, tags=self.params['tags']) - image = self.conn.get_image(name_or_id=image.id) - self.exit(changed=changed, image=image, id=image.id) + update_payload = self._build_update(image) - elif self.params['state'] == 'absent': - if not image: - changed = False - else: - self.conn.delete_image( - name_or_id=self.params['name'], - wait=self.params['wait'], - timeout=self.params['timeout']) + if update_payload: + self.conn.image.update_image(image, **update_payload) changed = True - self.exit(changed=changed) + + self.exit_json(changed=changed, image=self._return_value(image.id), + id=image.id) + + elif self.params['state'] == 'absent' and image is not None: + # self.conn.image.delete_image() does not offer a wait parameter + self.conn.delete_image( + name_or_id=image['id'], + wait=self.params['wait'], + timeout=self.params['timeout']) + changed = True + self.exit_json(changed=changed) def main():