summaryrefslogtreecommitdiffstats
path: root/src/lib/Bcfg2/Client/Tools/__init__.py
blob: aaadc14282b4f0d47ece3e6072b476a939612611 (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
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
"""This contains all Bcfg2 Tool modules"""

import os
import sys
import stat
import logging
import Bcfg2.Options
import Bcfg2.Client
import Bcfg2.Client.XML
from Bcfg2.Utils import Executor, ClassName


class ToolInstantiationError(Exception):
    """ This error is raised if the toolset cannot be instantiated. """
    pass


class Tool(object):
    """ The base tool class.  All tools subclass this.

    .. private-include: _entry_is_complete
    .. autoattribute:: Bcfg2.Client.Tools.Tool.__execs__
    .. autoattribute:: Bcfg2.Client.Tools.Tool.__handles__
    .. autoattribute:: Bcfg2.Client.Tools.Tool.__req__
    .. autoattribute:: Bcfg2.Client.Tools.Tool.__important__
    """

    options = [
        Bcfg2.Options.Option(
            cf=('client', 'command_timeout'),
            help="Timeout when running external commands other than probes",
            type=Bcfg2.Options.Types.timeout)]

    #: The name of the tool.  By default this uses
    #: :class:`Bcfg2.Client.Tools.ClassName` to ensure that it is the
    #: same as the name of the class.
    name = ClassName()

    #: Full paths to all executables the tool uses.  When the tool is
    #: instantiated it will check to ensure that all of these files
    #: exist and are executable.
    __execs__ = []

    #: A list of 2-tuples of entries handled by this tool.  Each
    #: 2-tuple should contain ``(<tag>, <type>)``, where ``<type>`` is
    #: the ``type`` attribute of the entry.  If this tool handles
    #: entries with no ``type`` attribute, specify None.
    __handles__ = []

    #: A dict that describes the required attributes for entries
    #: handled by this tool.  The keys are the names of tags.  The
    #: values may either be lists of attribute names (if the same
    #: attributes are required by all tags of that name), or dicts
    #: whose keys are the ``type`` attribute and whose values are
    #: lists of attributes required by tags with that ``type``
    #: attribute.  In that case, the ``type`` attribute will also be
    #: required.
    __req__ = {}

    #: A list of entry names that will be treated as important and
    #: installed before other entries.
    __important__ = []

    #: This tool is deprecated, and a warning will be produced if it
    #: is used.
    deprecated = False

    #: This tool is experimental, and a warning will be produced if it
    #: is used.
    experimental = False

    #: List of other tools (by name) that this tool conflicts with.
    #: If any of the listed tools are loaded, they will be removed at
    #: runtime with a warning.
    conflicts = []

    def __init__(self, config):
        """
        :param config: The XML configuration for this client
        :type config: lxml.etree._Element
        :raises: :exc:`Bcfg2.Client.Tools.ToolInstantiationError`
        """
        #: A :class:`logging.Logger` object that will be used by this
        #: tool for logging
        self.logger = logging.getLogger(self.name)

        #: The XML configuration for this client
        self.config = config

        #: An :class:`Bcfg2.Utils.Executor` object for
        #: running external commands.
        self.cmd = Executor(timeout=Bcfg2.Options.setup.command_timeout)

        #: A list of entries that have been modified by this tool
        self.modified = []

        #: A list of extra entries that are not listed in the
        #: configuration
        self.extra = []

        #: A list of all entries handled by this tool
        self.handled = []

        self._analyze_config()
        self._check_execs()

    def _analyze_config(self):
        """ Analyze the config at tool initialization-time for
        important and handled entries """
        for struct in self.config:
            for entry in struct:
                if (entry.tag == 'Path' and
                        entry.get('important', 'false').lower() == 'true'):
                    self.__important__.append(entry.get('name'))
        self.handled = self.getSupportedEntries()

    def _check_execs(self):
        """ Check all executables used by this tool to ensure that
        they exist and are executable """
        for filename in self.__execs__:
            try:
                mode = stat.S_IMODE(os.stat(filename)[stat.ST_MODE])
            except OSError:
                raise ToolInstantiationError(sys.exc_info()[1])
            except:
                raise ToolInstantiationError("%s: Failed to stat %s" %
                                             (self.name, filename))
            if not mode & stat.S_IEXEC:
                raise ToolInstantiationError("%s: %s not executable" %
                                             (self.name, filename))

    def _install_allowed(self, entry):
        """ Return true if the given entry is allowed to be installed by
        the whitelist or blacklist """
        if (Bcfg2.Options.setup.decision == 'whitelist' and
                not Bcfg2.Client.matches_white_list(
                    entry, Bcfg2.Options.setup.decision_list)):
            self.logger.info("In whitelist mode: suppressing Action: %s" %
                             entry.get('name'))
            return False
        if (Bcfg2.Options.setup.decision == 'blacklist' and
                not Bcfg2.Client.passes_black_list(
                    entry, Bcfg2.Options.setup.decision_list)):
            self.logger.info("In blacklist mode: suppressing Action: %s" %
                             entry.get('name'))
            return False
        return True

    def BundleUpdated(self, bundle):  # pylint: disable=W0613
        """ Callback that is invoked when a bundle has been updated.

        :param bundle: The bundle that has been updated
        :type bundle: lxml.etree._Element
        :returns: dict - A dict of the state of entries suitable for
                  updating :attr:`Bcfg2.Client.Client.states`
        """
        return dict()

    def BundleNotUpdated(self, bundle):  # pylint: disable=W0613
        """ Callback that is invoked when a bundle has been updated.

        :param bundle: The bundle that has been updated
        :type bundle: lxml.etree._Element
        :returns: dict - A dict of the state of entries suitable for
                  updating :attr:`Bcfg2.Client.Client.states`
        """
        return dict()

    def Inventory(self, structures=None):
        """ Take an inventory of the system as it exists.  This
        involves two steps:

        * Call the appropriate entry-specific Verify method for each
          entry this tool verifies;
        * Call :func:`Bcfg2.Client.Tools.Tool.FindExtra` to populate
          :attr:`Bcfg2.Client.Tools.Tool.extra` with extra entries.

        This implementation of
        :func:`Bcfg2.Client.Tools.Tool.Inventory` calls a
        ``Verify<tag>`` method to verify each entry, where ``<tag>``
        is the entry tag.  E.g., a Path entry would be verified by
        calling :func:`VerifyPath`.

        :param structures: The list of structures (i.e., bundles) to
                           get entries from.  If this is not given,
                           all children of
                           :attr:`Bcfg2.Client.Tools.Tool.config` will
                           be used.
        :type structures: list of lxml.etree._Element
        :returns: dict - A dict of the state of entries suitable for
                  updating :attr:`Bcfg2.Client.Client.states`
        """
        if not structures:
            structures = self.config.getchildren()
        mods = self.buildModlist()
        states = dict()
        for struct in structures:
            for entry in struct.getchildren():
                if self.canVerify(entry):
                    try:
                        func = getattr(self, "Verify%s" % entry.tag)
                    except AttributeError:
                        self.logger.error("%s: Cannot verify %s entries" %
                                          (self.name, entry.tag))
                        continue
                    try:
                        states[entry] = func(entry, mods)
                    except KeyboardInterrupt:
                        raise
                    except:  # pylint: disable=W0702
                        self.logger.error("%s: Unexpected failure verifying %s"
                                          % (self.name,
                                             self.primarykey(entry)),
                                          exc_info=1)
        self.extra = self.FindExtra()
        return states

    def Install(self, entries):
        """ Install entries.  'Install' in this sense means either
        initially install, or update as necessary to match the
        specification.

        This implementation of :func:`Bcfg2.Client.Tools.Tool.Install`
        calls a ``Install<tag>`` method to install each entry, where
        ``<tag>`` is the entry tag.  E.g., a Path entry would be
        installed by calling :func:`InstallPath`.

        :param entries: The entries to install
        :type entries: list of lxml.etree._Element
        :returns: dict - A dict of the state of entries suitable for
                  updating :attr:`Bcfg2.Client.Client.states`
        """
        states = dict()
        for entry in entries:
            try:
                func = getattr(self, "Install%s" % entry.tag)
            except AttributeError:
                self.logger.error("%s: Cannot install %s entries" %
                                  (self.name, entry.tag))
                continue
            try:
                states[entry] = func(entry)
                if states[entry]:
                    self.modified.append(entry)
            except:  # pylint: disable=W0702
                self.logger.error("%s: Unexpected failure installing %s" %
                                  (self.name, self.primarykey(entry)),
                                  exc_info=1)
        return states

    def Remove(self, entries):
        """ Remove specified extra entries.

        :param entries: The entries to remove
        :type entries: list of lxml.etree._Element
        :returns: None """
        pass

    def getSupportedEntries(self):
        """ Get all entries that are handled by this tool.

        :returns: list of lxml.etree._Element """
        rv = []
        for struct in self.config.getchildren():
            rv.extend([entry for entry in struct.getchildren()
                       if self.handlesEntry(entry)])
        return rv

    def handlesEntry(self, entry):
        """ Return True if the entry is handled by this tool.

        :param entry: Determine if this entry is handled.
        :type entry: lxml.etree._Element
        :returns: bool
        """
        return (entry.tag, entry.get('type')) in self.__handles__

    def buildModlist(self):
        """ Build a list of all Path entries in the configuration.
        (This can be used to determine which paths might be modified
        from their original state, useful for verifying packages)

        :returns: list of lxml.etree._Element """
        rv = []
        for struct in self.config.getchildren():
            rv.extend([entry.get('name') for entry in struct.getchildren()
                       if entry.tag == 'Path'])
        return rv

    def missing_attrs(self, entry):
        """ Return a list of attributes that were expected on an entry
        (from :attr:`Bcfg2.Client.Tools.Tool.__req__`), but not found.

        :param entry: The entry to find missing attributes on
        :type entry: lxml.etree._Element
        :returns: list of strings """
        required = self.__req__[entry.tag]
        if isinstance(required, dict):
            required = ["type"]
            try:
                required.extend(self.__req__[entry.tag][entry.get("type")])
            except KeyError:
                pass

        return [attr for attr in required
                if attr not in entry.attrib or not entry.attrib[attr]]

    def canVerify(self, entry):
        """ Test if entry can be verified by calling
        :func:`Bcfg2.Client.Tools.Tool._entry_is_complete`.

        :param entry: The entry to evaluate
        :type entry: lxml.etree._Element
        :returns: bool - True if the entry can be verified, False
                  otherwise.
        """
        return self._entry_is_complete(entry, action="verify")

    def FindExtra(self):
        """ Return a list of extra entries, i.e., entries that exist
        on the client but are not in the configuration.

        :returns: list of lxml.etree._Element """
        return []

    def primarykey(self, entry):
        """ Return a string that describes the entry uniquely amongst
        all entries in the configuration.

        :param entry: The entry to describe
        :type entry: lxml.etree._Element
        :returns: string """
        return "%s:%s" % (entry.tag, entry.get("name"))

    def canInstall(self, entry):
        """ Test if entry can be installed by calling
        :func:`Bcfg2.Client.Tools.Tool._entry_is_complete`.

        :param entry: The entry to evaluate
        :type entry: lxml.etree._Element
        :returns: bool - True if the entry can be installed, False
                  otherwise.
        """
        return self._entry_is_complete(entry, action="install")

    def _entry_is_complete(self, entry, action=None):
        """ Test if the entry is complete.  This involves three
        things:

        * The entry is handled by this tool (as reported by
          :func:`Bcfg2.Client.Tools.Tool.handlesEntry`;
        * The entry does not report a bind failure;
        * The entry is not missing any attributes (as reported by
          :func:`Bcfg2.Client.Tools.Tool.missing_attrs`).

        :param entry: The entry to evaluate
        :type entry: lxml.etree._Element
        :param action: The action being performed on the entry (e.g.,
                      "install", "verify").  This is used to produce
                      error messages; if not provided, generic error
                      messages will be used.
        :type action: string
        :returns: bool - True if the entry can be verified, False
                  otherwise.
        """
        if not self.handlesEntry(entry):
            return False

        if 'failure' in entry.attrib:
            if action is None:
                msg = "%s: %s reports bind failure"
            else:
                msg = "%%s: Cannot %s entry %%s with bind failure" % action
            self.logger.error(msg % (self.name, self.primarykey(entry)))
            return False

        missing = self.missing_attrs(entry)
        if missing:
            if action is None:
                desc = "%s is" % self.primarykey(entry)
            else:
                desc = "Cannot %s %s due to" % (action, self.primarykey(entry))
            self.logger.error("%s: %s missing required attribute(s): %s" %
                              (self.name, desc, ", ".join(missing)))
            return False
        return True


class PkgTool(Tool):
    """ PkgTool provides a one-pass install with fallback for use with
    packaging systems.  PkgTool makes a number of assumptions that may
    need to be overridden by a subclass.  For instance, it assumes
    that packages are installed by a shell command; that only one
    version of a given package can be installed; etc.  Nonetheless, it
    offers a strong base for writing simple package tools. """

    #: A tuple describing the format of the command to run to install
    #: a single package.  The first element of the tuple is a string
    #: giving the format of the command, with a single '%s' for the
    #: name of the package or packages to be installed.  The second
    #: element is a tuple whose first element is the format of the
    #: name of the package, and whose second element is a list whose
    #: members are the names of attributes that will be used when
    #: formatting the package name format string.
    pkgtool = ('echo %s', ('%s', ['name']))

    #: The ``type`` attribute of Packages handled by this tool.
    pkgtype = 'echo'

    def __init__(self, config):
        Tool.__init__(self, config)

        #: A dict of installed packages; the keys should be package
        #: names and the values should be simple strings giving the
        #: installed version.
        self.installed = {}
        self.RefreshPackages()

    def VerifyPackage(self, entry, modlist):
        """ Verify the given Package entry.

        :param entry: The Package entry to verify
        :type entry: lxml.etree._Element
        :param modlist: A list of all Path entries in the
                        configuration, which may be considered when
                        verifying a package.  For instance, a package
                        should verify successfully if paths in
                        ``modlist`` have been modified outside the
                        package.
        :type modlist: list of strings
        :returns: bool - True if the package verifies, false otherwise.
        """
        raise NotImplementedError

    def _get_package_command(self, packages):
        """ Get the command to install the given list of packages.

        :param packages: The Package entries to install
        :type packages: list of lxml.etree._Element
        :returns: string - the command to run
        """
        pkgargs = " ".join(self.pkgtool[1][0] %
                           tuple(pkg.get(field)
                                 for field in self.pkgtool[1][1])
                           for pkg in packages)
        return self.pkgtool[0] % pkgargs

    def Install(self, packages):
        """ Run a one-pass install where all required packages are
        installed with a single command, followed by single package
        installs in case of failure.

        :param entries: The entries to install
        :type entries: list of lxml.etree._Element
        :returns: dict - A dict of the state of entries suitable for
                  updating :attr:`Bcfg2.Client.Client.states`
        """
        self.logger.info("Trying single pass package install for pkgtype %s" %
                         self.pkgtype)

        states = dict()
        if self.cmd.run(self._get_package_command(packages)):
            self.logger.info("Single Pass Succeded")
            # set all package states to true and flush workqueues
            for entry in packages:
                self.logger.debug('Setting state to true for %s' %
                                  self.primarykey(entry))
                states[entry] = True
            self.RefreshPackages()
        else:
            self.logger.error("Single Pass Failed")
            # do single pass installs
            self.RefreshPackages()
            for pkg in packages:
                # handle state tracking updates
                if self.VerifyPackage(pkg, []):
                    self.logger.info("Forcing state to true for pkg %s" %
                                     (pkg.get('name')))
                    states[pkg] = True
                else:
                    self.logger.info("Installing pkg %s version %s" %
                                     (pkg.get('name'), pkg.get('version')))
                    if self.cmd.run(self._get_package_command([pkg])):
                        states[pkg] = True
                    else:
                        states[pkg] = False
                        self.logger.error("Failed to install package %s" %
                                          pkg.get('name'))
            self.RefreshPackages()
        self.modified.extend(entry for entry in packages
                             if entry in states and states[entry])
        return states

    def RefreshPackages(self):
        """ Refresh the internal representation of the package
        database (:attr:`Bcfg2.Client.Tools.PkgTool.installed`).

        :returns: None"""
        raise NotImplementedError

    def FindExtra(self):
        packages = [entry.get('name') for entry in self.getSupportedEntries()]
        extras = [data for data in list(self.installed.items())
                  if data[0] not in packages]
        return [Bcfg2.Client.XML.Element('Package', name=name,
                                         type=self.pkgtype, version=version)
                for (name, version) in extras]
    FindExtra.__doc__ = Tool.FindExtra.__doc__


class SvcTool(Tool):
    """ Base class for tools that handle Service entries """

    options = Tool.options + [
        Bcfg2.Options.Option(
            '-s', '--service-mode', default='default',
            choices=['default', 'disabled', 'build'],
            help='Set client service mode')]

    def __init__(self, config):
        Tool.__init__(self, config)
        #: List of services that have been restarted
        self.restarted = []
    __init__.__doc__ = Tool.__init__.__doc__

    def get_svc_command(self, service, action):
        """ Return a command that can be run to start or stop a service.

        :param service: The service entry to modify
        :type service: lxml.etree._Element
        :param action: The action to take (e.g., "stop", "start")
        :type action: string
        :returns: string - The command to run
        """
        return '/etc/init.d/%s %s' % (service.get('name'), action)

    def get_bootstatus(self, service):
        """ Return the bootstatus attribute if it exists.

        :param service: The service entry
        :type service: lxml.etree._Element
        :returns: string or None - Value of bootstatus if it exists. If
                  bootstatus is unspecified and status is not *ignore*,
                  return value of status. If bootstatus is unspecified
                  and status is *ignore*, return None.
        """
        if service.get('bootstatus') is not None:
            return service.get('bootstatus')
        elif service.get('status') != 'ignore':
            return service.get('status')
        return None

    def start_service(self, service):
        """ Start a service.

        :param service: The service entry to modify
        :type service: lxml.etree._Element
        :returns: Bcfg2.Utils.ExecutorResult - The return value from
                  :class:`Bcfg2.Utils.Executor.run`
        """
        self.logger.debug('Starting service %s' % service.get('name'))
        return self.cmd.run(self.get_svc_command(service, 'start'))

    def stop_service(self, service):
        """ Stop a service.

        :param service: The service entry to modify
        :type service: lxml.etree._Element
        :returns: Bcfg2.Utils.ExecutorResult - The return value from
                  :class:`Bcfg2.Utils.Executor.run`
        """
        self.logger.debug('Stopping service %s' % service.get('name'))
        return self.cmd.run(self.get_svc_command(service, 'stop'))

    def restart_service(self, service):
        """Restart a service.

        :param service: The service entry to modify
        :type service: lxml.etree._Element
        :returns: Bcfg2.Utils.ExecutorResult - The return value from
                  :class:`Bcfg2.Utils.Executor.run`
        """
        self.logger.debug('Restarting service %s' % service.get('name'))
        restart_target = service.get('target', 'restart')
        return self.cmd.run(self.get_svc_command(service, restart_target))

    def check_service(self, service):
        """ Check the status a service.

        :param service: The service entry to modify
        :type service: lxml.etree._Element
        :returns: bool - True if the status command returned 0, False
                  otherwise
        """
        return bool(self.cmd.run(self.get_svc_command(service, 'status')))

    def Remove(self, services):
        if Bcfg2.Options.setup.service_mode != 'disabled':
            for entry in services:
                entry.set("status", "off")
                self.InstallService(entry)
    Remove.__doc__ = Tool.Remove.__doc__

    def BundleUpdated(self, bundle):
        if Bcfg2.Options.setup.service_mode == 'disabled':
            return

        for entry in bundle:
            if (not self.handlesEntry(entry) or
                    not self._install_allowed(entry)):
                continue

            estatus = entry.get('status')
            restart = entry.get("restart", "true").lower()
            if (restart == "false" or estatus == 'ignore' or
                    (restart == "interactive" and
                     not Bcfg2.Options.setup.interactive)):
                continue

            success = False
            if estatus == 'on':
                if Bcfg2.Options.setup.service_mode == 'build':
                    success = self.stop_service(entry)
                elif entry.get('name') not in self.restarted:
                    if Bcfg2.Options.setup.interactive:
                        if not Bcfg2.Client.prompt('Restart service %s? (y/N) '
                                                   % entry.get('name')):
                            continue
                    success = self.restart_service(entry)
                    if success:
                        self.restarted.append(entry.get('name'))
            else:
                success = self.stop_service(entry)
            if not success:
                self.logger.error("Failed to manipulate service %s" %
                                  (entry.get('name')))
        return dict()
    BundleUpdated.__doc__ = Tool.BundleUpdated.__doc__

    def Install(self, entries):
        install_entries = []
        for entry in entries:
            if entry.get('install', 'true').lower() == 'false':
                self.logger.info("Installation is false for %s:%s, skipping" %
                                 (entry.tag, entry.get('name')))
            else:
                install_entries.append(entry)
        return Tool.Install(self, install_entries)
    Install.__doc__ = Tool.Install.__doc__

    def InstallService(self, entry):
        """ Install a single service entry.  See
        :func:`Bcfg2.Client.Tools.Tool.Install`.

        :param entry: The Service entry to install
        :type entry: lxml.etree._Element
        :returns: bool - True if installation was successful, False
                  otherwise
        """
        raise NotImplementedError