summaryrefslogtreecommitdiff
path: root/lib/ansible/modules/web_infrastructure/deploy_helper.py
blob: 38594dde36219677db11295253d19c926298d3cb (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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
#!/usr/bin/python
# -*- coding: utf-8 -*-

# (c) 2014, Jasper N. Brouwer <jasper@nerdsweide.nl>
# (c) 2014, Ramon de la Fuente <ramon@delafuente.nl>
#
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type


ANSIBLE_METADATA = {'metadata_version': '1.1',
                    'status': ['preview'],
                    'supported_by': 'community'}


DOCUMENTATION = '''
---
module: deploy_helper
version_added: "2.0"
author: "Ramon de la Fuente (@ramondelafuente)"
short_description: Manages some of the steps common in deploying projects.
description:
  - The Deploy Helper manages some of the steps common in deploying software.
    It creates a folder structure, manages a symlink for the current release
    and cleans up old releases.
  - "Running it with the C(state=query) or C(state=present) will return the C(deploy_helper) fact.
    C(project_path), whatever you set in the path parameter,
    C(current_path), the path to the symlink that points to the active release,
    C(releases_path), the path to the folder to keep releases in,
    C(shared_path), the path to the folder to keep shared resources in,
    C(unfinished_filename), the file to check for to recognize unfinished builds,
    C(previous_release), the release the 'current' symlink is pointing to,
    C(previous_release_path), the full path to the 'current' symlink target,
    C(new_release), either the 'release' parameter or a generated timestamp,
    C(new_release_path), the path to the new release folder (not created by the module)."

options:
  path:
    required: True
    aliases: ['dest']
    description:
      - the root path of the project. Alias I(dest).
        Returned in the C(deploy_helper.project_path) fact.

  state:
    description:
      - the state of the project.
        C(query) will only gather facts,
        C(present) will create the project I(root) folder, and in it the I(releases) and I(shared) folders,
        C(finalize) will remove the unfinished_filename file, create a symlink to the newly
          deployed release and optionally clean old releases,
        C(clean) will remove failed & old releases,
        C(absent) will remove the project folder (synonymous to the M(file) module with C(state=absent))
    choices: [ present, finalize, absent, clean, query ]
    default: present

  release:
    description:
      - the release version that is being deployed. Defaults to a timestamp format %Y%m%d%H%M%S (i.e. '20141119223359').
        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:
    description:
      - the name of the folder that will hold the releases. This can be relative to C(path) or absolute.
        Returned in the C(deploy_helper.releases_path) fact.
    default: releases

  shared_path:
    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.
        Returned in the C(deploy_helper.shared_path) fact.
    default: shared

  current_path:
    description:
      - the name of the symlink that is created when the deploy is finalized. Used in C(finalize) and C(clean).
        Returned in the C(deploy_helper.current_path) fact.
    default: current

  unfinished_filename:
    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).
    default: DEPLOY_UNFINISHED

  clean:
    description:
      - Whether to run the clean procedure in case of C(state=finalize).
    type: bool
    default: 'yes'

  keep_releases:
    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. The current version will not count.
    default: 5

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 I(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 = '''

# General explanation, starting with an example folder structure for a project:

# root:
#     releases:
#         - 20140415234508
#         - 20140415235146
#         - 20140416082818
#
#     shared:
#         - sessions
#         - uploads
#
#     current: releases/20140416082818


# The 'releases' folder holds all the available releases. A release is a complete build of the application being
# deployed. This can be a clone of a repository for example, or a sync of a local folder on your filesystem.
# Having timestamped folders is one way of having distinct releases, but you could choose your own strategy like
# git tags or commit hashes.
#
# During a deploy, a new folder should be created in the releases folder and any build steps required should be
# performed. Once the new build is ready, the deploy procedure is 'finalized' by replacing the 'current' symlink
# with a link to this build.
#
# The 'shared' folder holds any resource that is shared between releases. Examples of this are web-server
# session files, or files uploaded by users of your application. It's quite common to have symlinks from a release
# folder pointing to a shared/subfolder, and creating these links would be automated as part of the build steps.
#
# The 'current' symlink points to one of the releases. Probably the latest one, unless a deploy is in progress.
# The web-server's root for the project will go through this symlink, so the 'downtime' when switching to a new
# release is reduced to the time it takes to switch the link.
#
# To distinguish between successful builds and unfinished ones, a file can be placed in the folder of the release
# that is currently in progress. The existence of this file will mark it as unfinished, and allow an automated
# procedure to remove it during cleanup.


# Typical usage
- name: Initialize the deploy root and gather facts
  deploy_helper:
    path: /path/to/root
- name: Clone the project to the new release folder
  git:
    repo: git://foosball.example.org/path/to/repo.git
    dest: '{{ deploy_helper.new_release_path }}'
    version: v1.1.1
- name: Add an unfinished file, to allow cleanup on successful finalize
  file:
    path: '{{ deploy_helper.new_release_path }}/{{ deploy_helper.unfinished_filename }}'
    state: touch
- name: Perform some build steps, like running your dependency manager for example
  composer:
    command: install
    working_dir: '{{ deploy_helper.new_release_path }}'
- name: Create some folders in the shared folder
  file:
    path: '{{ deploy_helper.shared_path }}/{{ item }}'
    state: directory
  with_items:
    - sessions
    - uploads
- name: Add symlinks from the new release to the shared folder
  file:
    path: '{{ deploy_helper.new_release_path }}/{{ item.path }}'
    src: '{{ deploy_helper.shared_path }}/{{ item.src }}'
    state: link
  with_items:
      - path: app/sessions
        src: sessions
      - path: web/uploads
        src: uploads
- name: Finalize the deploy, removing the unfinished file and switching the symlink
  deploy_helper:
    path: /path/to/root
    release: '{{ deploy_helper.new_release }}'
    state: finalize

# Retrieving facts before running a deploy
- name: Run 'state=query' to gather facts without changing anything
  deploy_helper:
    path: /path/to/root
    state: query
# Remember to set the 'release' parameter when you actually call 'state=present' later
- name: Initialize the deploy root
  deploy_helper:
    path: /path/to/root
    release: '{{ deploy_helper.new_release }}'
    state: present

# all paths can be absolute or relative (to the 'path' parameter)
- 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 for releases (a version tag in this case):
- 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

# Using a different unfinished_filename:
- deploy_helper:
    path: /path/to/root
    unfinished_filename: README.md
    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
- deploy_helper:
    path: /path/to/root
    state: clean
# Or running the cleanup ahead of the new deploy
- deploy_helper:
    path: /path/to/root
    state: clean
- deploy_helper:
    path: /path/to/root
    state: present

# Keeping more old releases:
- deploy_helper:
    path: /path/to/root
    release: '{{ deploy_helper.new_release }}'
    state: finalize
    keep_releases: 10
# Or, if you use 'clean=false' on finalize:
- deploy_helper:
    path: /path/to/root
    state: clean
    keep_releases: 10

# Removing the entire project root folder
- deploy_helper:
    path: /path/to/root
    state: absent

# Debugging the facts returned by the module
- deploy_helper:
    path: /path/to/root
- debug:
    var: deploy_helper
'''
import os
import shutil
import time
import traceback

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native


class DeployHelper(object):

    def __init__(self, module):
        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")

        if self.release:
            new_release_path = os.path.join(releases_path, self.release)
        else:
            new_release_path = None

        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 as e:
                self.module.fail_json(msg="rmtree failed: %s" % to_native(e), exception=traceback.format_exc())

        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):
        changed = False

        if os.path.islink(link_name):
            norm_link = os.path.normpath(os.path.realpath(link_name))
            norm_source = os.path.normpath(os.path.realpath(source))
            if norm_link == norm_source:
                changed = False
            else:
                changed = True
                if not self.module.check_mode:
                    if not os.path.lexists(source):
                        self.module.fail_json(msg="the symlink target %s doesn't exists" % source)
                    tmp_link_name = link_name + '.' + self.unfinished_filename
                    if os.path.islink(tmp_link_name):
                        os.unlink(tmp_link_name)
                    os.symlink(source, tmp_link_name)
                    os.rename(tmp_link_name, link_name)
        else:
            changed = True
            if not self.module.check_mode:
                os.symlink(source, link_name)

        return changed

    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 remove_unfinished_link(self, path):
        changed = False

        tmp_link_name = os.path.join(path, self.release + '.' + self.unfinished_filename)
        if not self.module.check_mode and os.path.exists(tmp_link_name):
            changed = True
            os.remove(tmp_link_name)

        return changed

    def cleanup(self, releases_path, reserve_version):
        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))]
            try:
                releases.remove(reserve_version)
            except ValueError:
                pass

            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='path'),
            release=dict(required=False, type='str', default=None),
            releases_path=dict(required=False, type='str', default='releases'),
            shared_path=dict(required=False, type='path', default='shared'),
            current_path=dict(required=False, type='path', 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_link(facts['project_path'])
            changes += deploy_helper.remove_unfinished_builds(facts['releases_path'])
            changes += deploy_helper.cleanup(facts['releases_path'], facts['new_release'])

    elif deploy_helper.state == 'clean':
        changes += deploy_helper.remove_unfinished_link(facts['project_path'])
        changes += deploy_helper.remove_unfinished_builds(facts['releases_path'])
        changes += deploy_helper.cleanup(facts['releases_path'], facts['new_release'])

    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)


if __name__ == '__main__':
    main()