summaryrefslogtreecommitdiff
path: root/web_infrastructure/deploy_helper.py
blob: b7bf9a3eba9bfbe8decd05ef81163294f753527c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
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()