From 7793424b1b8f705cd62e1f954953e926c572c7db Mon Sep 17 00:00:00 2001 From: Jasper Lievisse Adriaanse Date: Thu, 5 Jan 2017 21:30:31 +0100 Subject: [PATCH] Add new module to manage SmartOS images through imgadm(1M) (#19696) * Add new module to manage SmartOS images through imgadm(1M) * Explain why check_mode is not supported * Add imgadm module * Incorporate feedback from abadger --- CHANGELOG.md | 2 + lib/ansible/modules/cloud/smartos/imgadm.py | 304 ++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 lib/ansible/modules/cloud/smartos/imgadm.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 863e7adee14..8ff55b43c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,8 @@ Ansible Changes By Release - packet: * packet_device * packet_sshkey +- smartos: + * imgadm - windows: * win_say diff --git a/lib/ansible/modules/cloud/smartos/imgadm.py b/lib/ansible/modules/cloud/smartos/imgadm.py new file mode 100644 index 00000000000..fbf660d45fc --- /dev/null +++ b/lib/ansible/modules/cloud/smartos/imgadm.py @@ -0,0 +1,304 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2016, Jasper Lievisse Adriaanse +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'version': '1.0'} + +DOCUMENTATION = ''' +--- +module: imgadm +short_description: Manage SmartOS images +description: + - Manage SmartOS virtual machine images through imgadm(1M) +version_added: "2.3" +author: Jasper Lievisse Adriaanse (@jasperla) +options: + force: + required: false + choices: [ yes, no ] + description: + - Force a given operation (where supported by imgadm(1M)). + pool: + required: false + default: zones + description: + - zpool to import to or delete images from. + source: + required: false + description: + - URI for the image source. + state: + required: true + choices: [ present, absent, deleted, imported, updated, vacuumed ] + description: + - State the object operated on should be in. C(imported) is an alias for + for C(present) and C(deleted) for C(absent). When set to C(vacuumed) + and C(uuid) to C(*), it will remove all unused images. + type: + required: false + choices: [ imgapi, docker, dsapi ] + default: imgapi + description: + - Type for image sources. + uuid: + required: false + description: + - Image UUID. Can either be a full UUID or C(*) for all images. +requirements: + - python >= 2.6 +''' + +EXAMPLES = ''' +- name: Import an image + imgadm: + uuid: '70e3ae72-96b6-11e6-9056-9737fd4d0764' + state: imported + +- name: Delete an image + imgadm: + uuid: '70e3ae72-96b6-11e6-9056-9737fd4d0764' + state: deleted + +- name: Update all images + imgadm: + uuid: '*' + state: updated + +- name: Update a single image + imgadm: + uuid: '70e3ae72-96b6-11e6-9056-9737fd4d0764' + state: updated + +- name: Add a source + imgadm: + source: 'https://datasets.project-fifo.net' + state: present + +- name: Add a Docker source + imgadm: + source: 'https://docker.io' + type: docker + state: present + +- name: Remove a source + imgadm: + source: 'https://docker.io' + state: absent +''' + +import re + +# Shortcut for the imgadm(1M) command. While imgadm(1M) supports a +# -E option to return any errors in JSON, the generated JSON does not play well +# with the JSON parsers of Python. The returned message contains '\n' as part of +# the stacktrace, which breaks the parsers. +IMGADM = 'imgadm' + +# Helper method to massage stderr +def errmsg(stderr): + match = re.match('^imgadm .*?: error \(\w+\): (.*): .*', stderr) + if match: + return match.groups()[0] + else: + return 'Unexpected failure' + +def update_images(module): + uuid = module.params['uuid'] + cmd = IMGADM + ' update' + + if uuid != '*': + cmd = '{0} {1}'.format(cmd, uuid) + + (rc, stdout, stderr) = module.run_command(cmd) + + # There is no feedback from imgadm(1M) to determine if anything + # was actually changed. So treat this as an 'always-changes' operation. + # Note that 'imgadm -v' produces unparseable JSON... + return rc, stdout, errmsg(stderr), True + +def manage_sources(module, present): + force = module.params['force'] + source = module.params['source'] + imgtype = module.params['type'] + + cmd = IMGADM + ' sources' + + if force: + cmd += ' -f' + + if present: + cmd = '{0} -a {1} -t {2}'.format(cmd, source, imgtype) + (rc, stdout, stderr) = module.run_command(cmd) + + # Check the various responses. + # Note that trying to add a source with the wrong type is handled + # above as it results in a non-zero status. + changed = True + + regex = 'Already have "{0}" image source "{1}", no change'.format(imgtype, source) + if re.match(regex, stdout): + changed = False + + regex = 'Added "%s" image source "%s"' % (imgtype, source) + if re.match(regex, stdout): + changed = True + + # Fallthrough, assume changes + return (rc, stdout, errmsg(stderr), changed) + else: + # Type is ignored by imgadm(1M) here + cmd += ' -d %s' % (source) + (rc, stdout, stderr) = module.run_command(cmd) + + changed = True + + regex = 'Do not have image source "%s", no change' % (source) + if re.match(regex, stdout): + changed = False + + regex = 'Deleted ".*" image source "%s"' % (source) + if re.match(regex, stdout): + changed = True + + return (rc, stdout, errmsg(stderr), changed) + +def manage_images(module, present): + uuid = module.params['uuid'] + pool = module.params['pool'] + state = module.params['state'] + + if state == 'vacuumed': + # Unconditionally pass '--force', otherwise we're prompted with 'y/N' + cmd = '{0} vacuum -f'.format(IMGADM) + + (rc, stdout, stderr) = module.run_command(cmd) + + if rc == 0: + if stdout == '': + changed = False + else: + changed = True + + return (rc, stdout, errmsg(stderr), changed) + + if present: + cmd = '{0} import -P {1} -q {2}'.format(IMGADM, pool, uuid) + + changed = False + (rc, stdout, stderr) = module.run_command(cmd) + + regex = 'Image {0} \(.*\) is already installed, skipping'.format(uuid) + if re.match(regex, stdout): + changed = False + + regex = '.*ActiveImageNotFound.*' + if re.match(regex, stderr): + changed = False + + regex = 'Imported image {0}'.format(uuid) + if re.match(regex, stdout): + changed = True + else: + cmd = '{0} delete -P {1} {2}'.format(IMGADM, pool, uuid) + + changed = False + (rc, stdout, stderr) = module.run_command(cmd) + + regex = '.*ImageNotInstalled.*' + if re.match(regex, stderr): + # Even if the 'rc' was non-zero (3), we handled the situation + # in order to determine if there was a change, so set rc to success. + rc = 0 + changed = False + + regex = 'Deleted image {0}'.format(uuid) + if re.match(regex, stdout): + changed = True + + return (rc, stdout, errmsg(stderr), changed) + +def main(): + module = AnsibleModule( + argument_spec=dict( + force=dict(default=None, type='bool'), + pool=dict(default='zones'), + source=dict(default=None), + state=dict(default=None, required=True, choices=['present', 'absent', 'deleted', 'imported', 'updated', 'vacuumed']), + type=dict(default='imgapi', choices=['imgapi', 'docker', 'dsapi']), + uuid=dict(default=None) + ), + # This module relies largely on imgadm(1M) to enforce idempotency, which does not + # provide a "noop" (or equivalent) mode to do a dry-run. + supports_check_mode=False, + ) + + uuid = module.params['uuid'] + source = module.params['source'] + state = module.params['state'] + + # Since there are a number of (natural) aliases, prevent having to look + # them up everytime we operate on `state`. + if state in ['present', 'imported', 'updated']: + present = True + else: + present = False + + stderr = stdout = '' + rc = 0 + result = { 'state': state } + changed = False + + # Perform basic UUID validation upfront. + if uuid and uuid != '*': + if not re.match('^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$', uuid, re.IGNORECASE): + module.fail_json(msg='Provided value for uuid option is not a valid UUID.') + + # Either manage sources or images. + if module.params['source']: + (rc, stdout, stderr, changed) = manage_sources(module, present) + result['source'] = source + else: + result['uuid'] = uuid + + if state == 'updated': + (rc, stdout, stderr, changed) = update_images(module) + else: + # Make sure operate on a single image for the following actions + if (uuid == '*') and (state != 'vacuumed'): + module.fail_json(msg='Can only specify uuid as "*" when updating image(s)') + + (rc, stdout, stderr, changed) = manage_images(module, present) + + if rc != 0: + if stderr: + module.fail_json(msg=stderr) + else: + module.fail_json(msg=stdout) + + result['changed'] = changed + + module.exit_json(**result) + +from ansible.module_utils.basic import AnsibleModule + +if __name__ == '__main__': + main()