diff --git a/lib/ansible/modules/extras/web_infrastructure/deploy_helper.py b/lib/ansible/modules/extras/web_infrastructure/deploy_helper.py new file mode 100644 index 00000000000..b7bf9a3eba9 --- /dev/null +++ b/lib/ansible/modules/extras/web_infrastructure/deploy_helper.py @@ -0,0 +1,341 @@ +#!/usr/bin/python + +DOCUMENTATION = ''' +--- +module: deploy_helper +version_added: "1.8" +author: Ramon de la Fuente, Jasper N. Brouwer +short_description: Manages the folders for deploy of a project +description: + - Manages some of the steps common in deploying projects. + It creates a folder structure, cleans up old releases and manages a symlink for the current release. + + For more information, see the :doc:`guide_deploy_helper` + +options: + path: + required: true + aliases: ['dest'] + description: + - the root path of the project. Alias I(dest). + + state: + required: false + choices: [ present, finalize, absent, clean, query ] + default: present + description: + - the state of the project. + C(query) will only gather facts, + C(present) will create the project, + C(finalize) will create a symlink to the newly deployed release, + C(clean) will remove failed & old releases, + C(absent) will remove the project folder (synonymous to M(file) with state=absent) + + release: + required: false + description: + - the release version that is being deployed (defaults to a timestamp %Y%m%d%H%M%S). This parameter is + optional during C(state=present), but needs to be set explicitly for C(state=finalize). You can use the + generated fact C(release={{ deploy_helper.new_release }}) + + releases_path: + required: false + default: releases + description: + - the name of the folder that will hold the releases. This can be relative to C(path) or absolute. + + shared_path: + required: false + default: shared + description: + - the name of the folder that will hold the shared resources. This can be relative to C(path) or absolute. + If this is set to an empty string, no shared folder will be created. + + current_path: + required: false + default: current + description: + - the name of the symlink that is created when the deploy is finalized. Used in C(finalize) and C(clean). + + unfinished_filename: + required: false + default: DEPLOY_UNFINISHED + description: + - the name of the file that indicates a deploy has not finished. All folders in the releases_path that + contain this file will be deleted on C(state=finalize) with clean=True, or C(state=clean). This file is + automatically deleted from the I(new_release_path) during C(state=finalize). + + clean: + required: false + default: True + description: + - Whether to run the clean procedure in case of C(state=finalize). + + keep_releases: + required: false + default: 5 + description: + - the number of old releases to keep when cleaning. Used in C(finalize) and C(clean). Any unfinished builds + will be deleted first, so only correct releases will count. + +notes: + - Facts are only returned for C(state=query) and C(state=present). If you use both, you should pass any overridden + parameters to both calls, otherwise the second call will overwrite the facts of the first one. + - When using C(state=clean), the releases are ordered by creation date. You should be able to switch to a + new naming strategy without problems. + - Because of the default behaviour of generating the I(new_release) fact, this module will not be idempotent + unless you pass your own release name with C(release). Due to the nature of deploying software, this should not + be much of a problem. +''' + +EXAMPLES = ''' +Example usage for the deploy_helper module. + + tasks: + # Typical usage: + - deploy_helper: path=/path/to/root state=present + ...some_build_steps_here, like a git clone to {{ deploy_helper.new_release_path }} for example... + - deploy_helper: path=/path/to/root release={{ deploy_helper.new_release }} state=finalize + + # Gather information only + - deploy_helper: path=/path/to/root state=query + # Remember to set the 'release=' when you actually call state=present later + - deploy_helper: path=/path/to/root release={{ deploy_helper.new_release }} state=present + + # all paths can be absolute or relative (to 'path') + - deploy_helper: path=/path/to/root + releases_path=/var/www/project/releases + shared_path=/var/www/shared + current_path=/var/www/active + + # Using your own naming strategy: + - deploy_helper: path=/path/to/root release=v1.1.1 state=present + - deploy_helper: path=/path/to/root release={{ deploy_helper.new_release }} state=finalize + + # Postponing the cleanup of older builds: + - deploy_helper: path=/path/to/root release={{ deploy_helper.new_release }} state=finalize clean=False + ...anything you do before actually deleting older releases... + - deploy_helper: path=/path/to/root state=clean + + # Keeping more old releases: + - deploy_helper: path=/path/to/root release={{ deploy_helper.new_release }} state=finalize keep_releases=10 + # Or: + - deploy_helper: path=/path/to/root state=clean keep_releases=10 + + # Using a different unfinished_filename: + - deploy_helper: path=/path/to/root unfinished_filename=README.md release={{ deploy_helper.new_release }} state=finalize + +''' + +class DeployHelper(object): + + def __init__(self, module): + module.params['path'] = os.path.expanduser(module.params['path']) + + self.module = module + self.file_args = module.load_file_common_arguments(module.params) + + self.clean = module.params['clean'] + self.current_path = module.params['current_path'] + self.keep_releases = module.params['keep_releases'] + self.path = module.params['path'] + self.release = module.params['release'] + self.releases_path = module.params['releases_path'] + self.shared_path = module.params['shared_path'] + self.state = module.params['state'] + self.unfinished_filename = module.params['unfinished_filename'] + + def gather_facts(self): + current_path = os.path.join(self.path, self.current_path) + releases_path = os.path.join(self.path, self.releases_path) + if self.shared_path: + shared_path = os.path.join(self.path, self.shared_path) + else: + shared_path = None + + previous_release, previous_release_path = self._get_last_release(current_path) + + if not self.release and (self.state == 'query' or self.state == 'present'): + self.release = time.strftime("%Y%m%d%H%M%S") + + new_release_path = os.path.join(releases_path, self.release) + + return { + 'project_path': self.path, + 'current_path': current_path, + 'releases_path': releases_path, + 'shared_path': shared_path, + 'previous_release': previous_release, + 'previous_release_path': previous_release_path, + 'new_release': self.release, + 'new_release_path': new_release_path, + 'unfinished_filename': self.unfinished_filename + } + + def delete_path(self, path): + if not os.path.lexists(path): + return False + + if not os.path.isdir(path): + self.module.fail_json(msg="%s exists but is not a directory" % path) + + if not self.module.check_mode: + try: + shutil.rmtree(path, ignore_errors=False) + except Exception, e: + self.module.fail_json(msg="rmtree failed: %s" % str(e)) + + return True + + def create_path(self, path): + changed = False + + if not os.path.lexists(path): + changed = True + if not self.module.check_mode: + os.makedirs(path) + + elif not os.path.isdir(path): + self.module.fail_json(msg="%s exists but is not a directory" % path) + + changed += self.module.set_directory_attributes_if_different(self._get_file_args(path), changed) + + return changed + + def check_link(self, path): + if os.path.lexists(path): + if not os.path.islink(path): + self.module.fail_json(msg="%s exists but is not a symbolic link" % path) + + def create_link(self, source, link_name): + if not self.module.check_mode: + if os.path.islink(link_name): + os.unlink(link_name) + os.symlink(source, link_name) + + return True + + def remove_unfinished_file(self, new_release_path): + changed = False + unfinished_file_path = os.path.join(new_release_path, self.unfinished_filename) + if os.path.lexists(unfinished_file_path): + changed = True + if not self.module.check_mode: + os.remove(unfinished_file_path) + + return changed + + def remove_unfinished_builds(self, releases_path): + changes = 0 + + for release in os.listdir(releases_path): + if (os.path.isfile(os.path.join(releases_path, release, self.unfinished_filename))): + if self.module.check_mode: + changes += 1 + else: + changes += self.delete_path(os.path.join(releases_path, release)) + + return changes + + def cleanup(self, releases_path): + changes = 0 + + if os.path.lexists(releases_path): + releases = [ f for f in os.listdir(releases_path) if os.path.isdir(os.path.join(releases_path,f)) ] + + if not self.module.check_mode: + releases.sort( key=lambda x: os.path.getctime(os.path.join(releases_path,x)), reverse=True) + for release in releases[self.keep_releases:]: + changes += self.delete_path(os.path.join(releases_path, release)) + elif len(releases) > self.keep_releases: + changes += (len(releases) - self.keep_releases) + + return changes + + def _get_file_args(self, path): + file_args = self.file_args.copy() + file_args['path'] = path + return file_args + + def _get_last_release(self, current_path): + previous_release = None + previous_release_path = None + + if os.path.lexists(current_path): + previous_release_path = os.path.realpath(current_path) + previous_release = os.path.basename(previous_release_path) + + return previous_release, previous_release_path + +def main(): + + module = AnsibleModule( + argument_spec = dict( + path = dict(aliases=['dest'], required=True, type='str'), + release = dict(required=False, type='str', default=''), + releases_path = dict(required=False, type='str', default='releases'), + shared_path = dict(required=False, type='str', default='shared'), + current_path = dict(required=False, type='str', default='current'), + keep_releases = dict(required=False, type='int', default=5), + clean = dict(required=False, type='bool', default=True), + unfinished_filename = dict(required=False, type='str', default='DEPLOY_UNFINISHED'), + state = dict(required=False, choices=['present', 'absent', 'clean', 'finalize', 'query'], default='present') + ), + add_file_common_args = True, + supports_check_mode = True + ) + + deploy_helper = DeployHelper(module) + facts = deploy_helper.gather_facts() + + result = { + 'state': deploy_helper.state + } + + changes = 0 + + if deploy_helper.state == 'query': + result['ansible_facts'] = { 'deploy_helper': facts } + + elif deploy_helper.state == 'present': + deploy_helper.check_link(facts['current_path']) + changes += deploy_helper.create_path(facts['project_path']) + changes += deploy_helper.create_path(facts['releases_path']) + if deploy_helper.shared_path: + changes += deploy_helper.create_path(facts['shared_path']) + + result['ansible_facts'] = { 'deploy_helper': facts } + + elif deploy_helper.state == 'finalize': + if not deploy_helper.release: + module.fail_json(msg="'release' is a required parameter for state=finalize (try the 'deploy_helper.new_release' fact)") + if deploy_helper.keep_releases <= 0: + module.fail_json(msg="'keep_releases' should be at least 1") + + changes += deploy_helper.remove_unfinished_file(facts['new_release_path']) + changes += deploy_helper.create_link(facts['new_release_path'], facts['current_path']) + if deploy_helper.clean: + changes += deploy_helper.remove_unfinished_builds(facts['releases_path']) + changes += deploy_helper.cleanup(facts['releases_path']) + + elif deploy_helper.state == 'clean': + changes += deploy_helper.remove_unfinished_builds(facts['releases_path']) + changes += deploy_helper.cleanup(facts['releases_path']) + + elif deploy_helper.state == 'absent': + # destroy the facts + result['ansible_facts'] = { 'deploy_helper': [] } + changes += deploy_helper.delete_path(facts['project_path']) + + if changes > 0: + result['changed'] = True + else: + result['changed'] = False + + module.exit_json(**result) + + +# import module snippets +from ansible.module_utils.basic import * + +main()