1# Copyright: (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
2# Copyright: (c) 2018, Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5from __future__ import (absolute_import, division, print_function)
6__metaclass__ = type
7
8import datetime
9import os
10import platform
11import random
12import shutil
13import socket
14import sys
15import time
16
17from ansible import constants as C
18from ansible import context
19from ansible.cli import CLI
20from ansible.cli.arguments import option_helpers as opt_help
21from ansible.errors import AnsibleOptionsError
22from ansible.module_utils._text import to_native, to_text
23from ansible.module_utils.six.moves import shlex_quote
24from ansible.plugins.loader import module_loader
25from ansible.utils.cmd_functions import run_cmd
26from ansible.utils.display import Display
27
28display = Display()
29
30
31class PullCLI(CLI):
32    ''' Used to pull a remote copy of ansible on each managed node,
33        each set to run via cron and update playbook source via a source repository.
34        This inverts the default *push* architecture of ansible into a *pull* architecture,
35        which has near-limitless scaling potential.
36
37        The setup playbook can be tuned to change the cron frequency, logging locations, and parameters to ansible-pull.
38        This is useful both for extreme scale-out as well as periodic remediation.
39        Usage of the 'fetch' module to retrieve logs from ansible-pull runs would be an
40        excellent way to gather and analyze remote logs from ansible-pull.
41    '''
42
43    DEFAULT_REPO_TYPE = 'git'
44    DEFAULT_PLAYBOOK = 'local.yml'
45    REPO_CHOICES = ('git', 'subversion', 'hg', 'bzr')
46    PLAYBOOK_ERRORS = {
47        1: 'File does not exist',
48        2: 'File is not readable',
49    }
50    SUPPORTED_REPO_MODULES = ['git']
51    ARGUMENTS = {'playbook.yml': 'The name of one the YAML format files to run as an Ansible playbook.'
52                                 'This can be a relative path within the checkout. By default, Ansible will'
53                                 "look for a playbook based on the host's fully-qualified domain name,"
54                                 'on the host hostname and finally a playbook named *local.yml*.', }
55
56    SKIP_INVENTORY_DEFAULTS = True
57
58    @staticmethod
59    def _get_inv_cli():
60        inv_opts = ''
61        if context.CLIARGS.get('inventory', False):
62            for inv in context.CLIARGS['inventory']:
63                if isinstance(inv, list):
64                    inv_opts += " -i '%s' " % ','.join(inv)
65                elif ',' in inv or os.path.exists(inv):
66                    inv_opts += ' -i %s ' % inv
67
68        return inv_opts
69
70    def init_parser(self):
71        ''' create an options parser for bin/ansible '''
72
73        super(PullCLI, self).init_parser(
74            usage='%prog -U <repository> [options] [<playbook.yml>]',
75            desc="pulls playbooks from a VCS repo and executes them for the local host")
76
77        # Do not add check_options as there's a conflict with --checkout/-C
78        opt_help.add_connect_options(self.parser)
79        opt_help.add_vault_options(self.parser)
80        opt_help.add_runtask_options(self.parser)
81        opt_help.add_subset_options(self.parser)
82        opt_help.add_inventory_options(self.parser)
83        opt_help.add_module_options(self.parser)
84        opt_help.add_runas_prompt_options(self.parser)
85
86        self.parser.add_argument('args', help='Playbook(s)', metavar='playbook.yml', nargs='*')
87
88        # options unique to pull
89        self.parser.add_argument('--purge', default=False, action='store_true', help='purge checkout after playbook run')
90        self.parser.add_argument('-o', '--only-if-changed', dest='ifchanged', default=False, action='store_true',
91                                 help='only run the playbook if the repository has been updated')
92        self.parser.add_argument('-s', '--sleep', dest='sleep', default=None,
93                                 help='sleep for random interval (between 0 and n number of seconds) before starting. '
94                                      'This is a useful way to disperse git requests')
95        self.parser.add_argument('-f', '--force', dest='force', default=False, action='store_true',
96                                 help='run the playbook even if the repository could not be updated')
97        self.parser.add_argument('-d', '--directory', dest='dest', default=None, help='directory to checkout repository to')
98        self.parser.add_argument('-U', '--url', dest='url', default=None, help='URL of the playbook repository')
99        self.parser.add_argument('--full', dest='fullclone', action='store_true', help='Do a full clone, instead of a shallow one.')
100        self.parser.add_argument('-C', '--checkout', dest='checkout',
101                                 help='branch/tag/commit to checkout. Defaults to behavior of repository module.')
102        self.parser.add_argument('--accept-host-key', default=False, dest='accept_host_key', action='store_true',
103                                 help='adds the hostkey for the repo url if not already added')
104        self.parser.add_argument('-m', '--module-name', dest='module_name', default=self.DEFAULT_REPO_TYPE,
105                                 help='Repository module name, which ansible will use to check out the repo. Choices are %s. Default is %s.'
106                                      % (self.REPO_CHOICES, self.DEFAULT_REPO_TYPE))
107        self.parser.add_argument('--verify-commit', dest='verify', default=False, action='store_true',
108                                 help='verify GPG signature of checked out commit, if it fails abort running the playbook. '
109                                      'This needs the corresponding VCS module to support such an operation')
110        self.parser.add_argument('--clean', dest='clean', default=False, action='store_true',
111                                 help='modified files in the working repository will be discarded')
112        self.parser.add_argument('--track-subs', dest='tracksubs', default=False, action='store_true',
113                                 help='submodules will track the latest changes. This is equivalent to specifying the --remote flag to git submodule update')
114        # add a subset of the check_opts flag group manually, as the full set's
115        # shortcodes conflict with above --checkout/-C
116        self.parser.add_argument("--check", default=False, dest='check', action='store_true',
117                                 help="don't make any changes; instead, try to predict some of the changes that may occur")
118        self.parser.add_argument("--diff", default=C.DIFF_ALWAYS, dest='diff', action='store_true',
119                                 help="when changing (small) files and templates, show the differences in those files; works great with --check")
120
121    def post_process_args(self, options):
122        options = super(PullCLI, self).post_process_args(options)
123
124        if not options.dest:
125            hostname = socket.getfqdn()
126            # use a hostname dependent directory, in case of $HOME on nfs
127            options.dest = os.path.join('~/.ansible/pull', hostname)
128        options.dest = os.path.expandvars(os.path.expanduser(options.dest))
129
130        if os.path.exists(options.dest) and not os.path.isdir(options.dest):
131            raise AnsibleOptionsError("%s is not a valid or accessible directory." % options.dest)
132
133        if options.sleep:
134            try:
135                secs = random.randint(0, int(options.sleep))
136                options.sleep = secs
137            except ValueError:
138                raise AnsibleOptionsError("%s is not a number." % options.sleep)
139
140        if not options.url:
141            raise AnsibleOptionsError("URL for repository not specified, use -h for help")
142
143        if options.module_name not in self.SUPPORTED_REPO_MODULES:
144            raise AnsibleOptionsError("Unsupported repo module %s, choices are %s" % (options.module_name, ','.join(self.SUPPORTED_REPO_MODULES)))
145
146        display.verbosity = options.verbosity
147        self.validate_conflicts(options)
148
149        return options
150
151    def run(self):
152        ''' use Runner lib to do SSH things '''
153
154        super(PullCLI, self).run()
155
156        # log command line
157        now = datetime.datetime.now()
158        display.display(now.strftime("Starting Ansible Pull at %F %T"))
159        display.display(' '.join(sys.argv))
160
161        # Build Checkout command
162        # Now construct the ansible command
163        node = platform.node()
164        host = socket.getfqdn()
165        limit_opts = 'localhost,%s,127.0.0.1' % ','.join(set([host, node, host.split('.')[0], node.split('.')[0]]))
166        base_opts = '-c local '
167        if context.CLIARGS['verbosity'] > 0:
168            base_opts += ' -%s' % ''.join(["v" for x in range(0, context.CLIARGS['verbosity'])])
169
170        # Attempt to use the inventory passed in as an argument
171        # It might not yet have been downloaded so use localhost as default
172        inv_opts = self._get_inv_cli()
173        if not inv_opts:
174            inv_opts = " -i localhost, "
175            # avoid interpreter discovery since we already know which interpreter to use on localhost
176            inv_opts += '-e %s ' % shlex_quote('ansible_python_interpreter=%s' % sys.executable)
177
178        # SCM specific options
179        if context.CLIARGS['module_name'] == 'git':
180            repo_opts = "name=%s dest=%s" % (context.CLIARGS['url'], context.CLIARGS['dest'])
181            if context.CLIARGS['checkout']:
182                repo_opts += ' version=%s' % context.CLIARGS['checkout']
183
184            if context.CLIARGS['accept_host_key']:
185                repo_opts += ' accept_hostkey=yes'
186
187            if context.CLIARGS['private_key_file']:
188                repo_opts += ' key_file=%s' % context.CLIARGS['private_key_file']
189
190            if context.CLIARGS['verify']:
191                repo_opts += ' verify_commit=yes'
192
193            if context.CLIARGS['tracksubs']:
194                repo_opts += ' track_submodules=yes'
195
196            if not context.CLIARGS['fullclone']:
197                repo_opts += ' depth=1'
198        elif context.CLIARGS['module_name'] == 'subversion':
199            repo_opts = "repo=%s dest=%s" % (context.CLIARGS['url'], context.CLIARGS['dest'])
200            if context.CLIARGS['checkout']:
201                repo_opts += ' revision=%s' % context.CLIARGS['checkout']
202            if not context.CLIARGS['fullclone']:
203                repo_opts += ' export=yes'
204        elif context.CLIARGS['module_name'] == 'hg':
205            repo_opts = "repo=%s dest=%s" % (context.CLIARGS['url'], context.CLIARGS['dest'])
206            if context.CLIARGS['checkout']:
207                repo_opts += ' revision=%s' % context.CLIARGS['checkout']
208        elif context.CLIARGS['module_name'] == 'bzr':
209            repo_opts = "name=%s dest=%s" % (context.CLIARGS['url'], context.CLIARGS['dest'])
210            if context.CLIARGS['checkout']:
211                repo_opts += ' version=%s' % context.CLIARGS['checkout']
212        else:
213            raise AnsibleOptionsError('Unsupported (%s) SCM module for pull, choices are: %s'
214                                      % (context.CLIARGS['module_name'],
215                                         ','.join(self.REPO_CHOICES)))
216
217        # options common to all supported SCMS
218        if context.CLIARGS['clean']:
219            repo_opts += ' force=yes'
220
221        path = module_loader.find_plugin(context.CLIARGS['module_name'])
222        if path is None:
223            raise AnsibleOptionsError(("module '%s' not found.\n" % context.CLIARGS['module_name']))
224
225        bin_path = os.path.dirname(os.path.abspath(sys.argv[0]))
226        # hardcode local and inventory/host as this is just meant to fetch the repo
227        cmd = '%s/ansible %s %s -m %s -a "%s" all -l "%s"' % (bin_path, inv_opts, base_opts,
228                                                              context.CLIARGS['module_name'],
229                                                              repo_opts, limit_opts)
230        for ev in context.CLIARGS['extra_vars']:
231            cmd += ' -e %s' % shlex_quote(ev)
232
233        # Nap?
234        if context.CLIARGS['sleep']:
235            display.display("Sleeping for %d seconds..." % context.CLIARGS['sleep'])
236            time.sleep(context.CLIARGS['sleep'])
237
238        # RUN the Checkout command
239        display.debug("running ansible with VCS module to checkout repo")
240        display.vvvv('EXEC: %s' % cmd)
241        rc, b_out, b_err = run_cmd(cmd, live=True)
242
243        if rc != 0:
244            if context.CLIARGS['force']:
245                display.warning("Unable to update repository. Continuing with (forced) run of playbook.")
246            else:
247                return rc
248        elif context.CLIARGS['ifchanged'] and b'"changed": true' not in b_out:
249            display.display("Repository has not changed, quitting.")
250            return 0
251
252        playbook = self.select_playbook(context.CLIARGS['dest'])
253        if playbook is None:
254            raise AnsibleOptionsError("Could not find a playbook to run.")
255
256        # Build playbook command
257        cmd = '%s/ansible-playbook %s %s' % (bin_path, base_opts, playbook)
258        if context.CLIARGS['vault_password_files']:
259            for vault_password_file in context.CLIARGS['vault_password_files']:
260                cmd += " --vault-password-file=%s" % vault_password_file
261        if context.CLIARGS['vault_ids']:
262            for vault_id in context.CLIARGS['vault_ids']:
263                cmd += " --vault-id=%s" % vault_id
264
265        for ev in context.CLIARGS['extra_vars']:
266            cmd += ' -e %s' % shlex_quote(ev)
267        if context.CLIARGS['become_ask_pass']:
268            cmd += ' --ask-become-pass'
269        if context.CLIARGS['skip_tags']:
270            cmd += ' --skip-tags "%s"' % to_native(u','.join(context.CLIARGS['skip_tags']))
271        if context.CLIARGS['tags']:
272            cmd += ' -t "%s"' % to_native(u','.join(context.CLIARGS['tags']))
273        if context.CLIARGS['subset']:
274            cmd += ' -l "%s"' % context.CLIARGS['subset']
275        else:
276            cmd += ' -l "%s"' % limit_opts
277        if context.CLIARGS['check']:
278            cmd += ' -C'
279        if context.CLIARGS['diff']:
280            cmd += ' -D'
281
282        os.chdir(context.CLIARGS['dest'])
283
284        # redo inventory options as new files might exist now
285        inv_opts = self._get_inv_cli()
286        if inv_opts:
287            cmd += inv_opts
288
289        # RUN THE PLAYBOOK COMMAND
290        display.debug("running ansible-playbook to do actual work")
291        display.debug('EXEC: %s' % cmd)
292        rc, b_out, b_err = run_cmd(cmd, live=True)
293
294        if context.CLIARGS['purge']:
295            os.chdir('/')
296            try:
297                shutil.rmtree(context.CLIARGS['dest'])
298            except Exception as e:
299                display.error(u"Failed to remove %s: %s" % (context.CLIARGS['dest'], to_text(e)))
300
301        return rc
302
303    @staticmethod
304    def try_playbook(path):
305        if not os.path.exists(path):
306            return 1
307        if not os.access(path, os.R_OK):
308            return 2
309        return 0
310
311    @staticmethod
312    def select_playbook(path):
313        playbook = None
314        if context.CLIARGS['args'] and context.CLIARGS['args'][0] is not None:
315            playbook = os.path.join(path, context.CLIARGS['args'][0])
316            rc = PullCLI.try_playbook(playbook)
317            if rc != 0:
318                display.warning("%s: %s" % (playbook, PullCLI.PLAYBOOK_ERRORS[rc]))
319                return None
320            return playbook
321        else:
322            fqdn = socket.getfqdn()
323            hostpb = os.path.join(path, fqdn + '.yml')
324            shorthostpb = os.path.join(path, fqdn.split('.')[0] + '.yml')
325            localpb = os.path.join(path, PullCLI.DEFAULT_PLAYBOOK)
326            errors = []
327            for pb in [hostpb, shorthostpb, localpb]:
328                rc = PullCLI.try_playbook(pb)
329                if rc == 0:
330                    playbook = pb
331                    break
332                else:
333                    errors.append("%s: %s" % (pb, PullCLI.PLAYBOOK_ERRORS[rc]))
334            if playbook is None:
335                display.warning("\n".join(errors))
336            return playbook
337