root/tags/v1.0/change.py

Revision 28, 17.7 KB (checked in by daedalus, 4 years ago)

* Refactored code to permit more flexible templating of changes. You can now

define a <changetemplate/> in 95% the same way as a <change/>, and then
refer to a <changetemplate/> when defining a <change/> to allow you to
combine change templates as a change flow, using namespaces and iterators
for each one.

* Some work on manual bailout in authoritarian mode, but it isn't completely

bailing out immediately yet, due to some complexities in the deferred chains
that I haven't quite unravelled.

Line 
1"""
2A changeset is a sequence of changes that can be applied to a managed element.
3A changeset will move the element from one state, A, to a destination state, B.
4A changeset may contain other changesets. These changesets will be implemented
5in a specific order.
6The successful implementation of a changeset must be testable.
7If the test fails, implementation of the changeset has failed, and a backout can
8be executed.
9A changeset contains the necessary backouts.
10
11If a sub-changeset fails implementation, the containing changeset also fails.
12
13Imagine the following changeset:
14
15useradd justin
16
17This adds a user to the system. If something goes wrong, how do we back out?
18Default backout behaviour is to alert a human that something went wrong.
19
20Backout may depend on the exact failure. The 'did it work' test should permit
21selecting from a list of possible backout procedures, depending on the failure
22that was detected.
23
24This example would be a "Add User" changeset, and could be different on different
25destination systems.
26
27Note that a "Remove User" changeset would be different from the backout procedure
28for a failed "Add User" changeset.
29
30For a changeset that runs a sequence of commands (a CommandChangeset, perhaps?),
31the default behaviour should be to detect if an individual command in the sequence
32failed, and report the error to the backout method detection code. This will
33provide the most generic way of testing for failure without having to run a mini-audit
34after each command is executed. However, if such paranoid auditing is required, you
35could implement the changeset as a series of changesets, rather than a single,
36multi-command changeset. This would provide much finer grained error detection and
37possible correction capabilities.
38
39A changeset is a set of changes to be applied to a set of devices.
40A change can be applied to multiple devices, eg: loading the same configuration file onto all 70 hosts.
41Does the 'load configuration file to host list' change happen all at once, or as part of a sequence
42of changes? How do the changes inter-relate? Contrast "Change all 70 hosts, then do the next thing", to
43"Change the first host, then do this other thing, then change the next host".
44
45A Change is the minimum transaction size. This is the smallest element that can be applied,
46or backed out. So, to implement the above scenarios, you could:
47
48- Define a Change that should be applied to a device list. The provisioning method may be different
49  (ssh vs rsh) but only to a certain degree. A ZAPI commandset is fundamentally different to an ssh/rsh
50  commandset, for example.
51- Define a Change that should be applied to a device list of length 1.
52- Define the order that changes should occur in.
53
54So.. first we define a list of devices and their attributes (ssh key locations, etc). This would be
55a single, global area for all devices/entities under change management.
56
57Then, we define a changeset, which is a list of changes. Each change defines the device(s) it applies to,
58and how to apply the change to it.
59"""
60import logging
61import debug
62
63log = logging.getLogger('modipy')
64
65from zope.interface import Interface, implements
66
67from twisted.internet import defer
68
69CHANGE_STATE = {
70    'success': 0,
71    'pending': 1,
72    'partial_failure': 2,
73    'total_failure': 3,
74    'backout_ok': 4,
75    'backout_failed': 5,
76    'retry': 6,
77    }
78
79class ChangeConditionFailure(Exception):
80    """
81    Raised if an expectset condition fails eval()
82    """
83
84class IChange(Interface):
85    """
86    How to implement a specific change.
87    """
88
89    def __init__(self, name, devices=[], serial_mode=True, backout_all=False, on_fail_continue=False, **kwargs):
90        """
91        @param devices: a list of devices that this change should be applied to.
92        @param serial_mode: Boolean, defines whether the change should be applied
93        to each device one after the other, or if all devices can have the change
94        applied to them in parallel.
95        @param backout_all: Boolean. If set to True, if the application of a
96        L{Change} to a device fails, the L{Change} will be backed out for all other
97        devices that have so far succeeded. This makes a L{Change} atomic.
98        @param on_fail_continue: Boolean. If set to True, if application of a
99        L{Change} to a device fails, the change will be backed out from that device,
100        but the system will continue attempting to apply the change to the rest
101        of the devices that have not yet been processed. Overrides backout_all.
102
103        When you create a L{Change} object, you can supply a list of parameters
104        that can be used within the L{Change} object to parameterise the
105        change itself.
106
107        This is handy for substituting the username, for example, for
108        a standard user adding mechanism.
109
110        If a L{Change} should be applied to some devices in parallel, and other
111        devices in series, this should be broken up into 2 separate L{Change}s.
112        """
113        self.devices = devices
114        self.serial_mode = serial_mode
115        self.backout_all = backout_all
116        self.namespace = kwargs
117
118    def pre_apply_check(self, provisioner, namespace):
119        """
120        Perform any pre-implementation checks you may need to perform
121        before attempting to implement the change.
122        """
123   
124    def apply(self, provisioner, namespace):
125        """
126        Implement the change via the provisioner.
127
128        @param provisioner: A L{Provisioner} instance that this change
129        should be applied via.
130        """
131
132    def test_apply_success(self, provisioner, result, namespace):
133        """
134        Test that the change was successfully implemented.
135        @param result: Holds a result structure that was
136        returned from the apply().
137
138        @returns: True on success, False otherwise
139        """
140
141    def backout(self, provisioner, result, namespace):
142        """
143        Back out this change via the provisioner.
144
145        @param result: The result of a change apply() that failed.
146        """
147
148    def test_backout_success(self, provisioner, namespace):
149        """
150        Test that the backout worked.
151
152        @returns: True on success, False otherwise
153        """
154
155class ChangeFailed(Exception):
156    """
157    Execution of a Change failed for some reason.
158    """
159
160class CommandChange:
161    """
162    A CommandChange implements a change by using a supplied
163    CommandProvisioner to execute the change by running a
164    sequence of commands on the Provisioner target.
165
166    There are several phases to change processing:
167    * Pre-change checking. This phase runs checks to see if
168      the change should even start.
169    * Change implementation. This runs the actual change.
170    * Post-change verification. This makes sure the change
171      did what it was supposed to do.
172    * Backout. This backs out the change if it fails to get
173      implemented correctly, or if post-change verification fails.
174   
175    """
176    implements( IChange, )
177
178    def __init__(self, name, devices=[], serial_mode=True, backout_all=False,
179                 on_fail_continue=False,
180                 on_fail_retry=False,
181                 max_retries=3,
182                 pre_impl=None,
183                 impl=None,
184                 post_impl=None,
185                 pre_backout=None,
186                 backoutset=None,
187                 post_backout=None,
188                 pre_requisites=[],
189                 **kwargs):
190
191        self.name = name
192
193        # must use a copy or weird things happen
194        self.devices = devices[:]
195        self.serial_mode = serial_mode
196        self.backout_all = backout_all
197        self.on_fail_continue = on_fail_continue
198        self.on_fail_retry = on_fail_retry
199        self.max_retries = max_retries
200        self.retries = 0
201        self.pre_impl = pre_impl
202        self.impl = impl
203        self.post_impl = post_impl
204        self.pre_backout = pre_backout
205        self.backoutset = backoutset
206        self.post_backout = post_backout
207        self.pre_requisites = pre_requisites[:]
208
209        self.namespace = kwargs
210
211        self.state = CHANGE_STATE['pending']
212        pass
213
214    def __repr__(self):
215        return '<%s: %s>' % ( self.__class__, self.name )
216
217    def copy(self):
218        """
219        Create a copy of myself.
220        """
221        newchange = CommandChange('Copy of %s' % self.name,
222                                  self.devices,
223                                  self.serial_mode,
224                                  self.backout_all,
225                                  self.on_fail_continue,
226                                  self.on_fail_retry,
227                                  self.max_retries,
228                                  self.pre_impl,
229                                  self.impl,
230                                  self.post_impl,
231                                  self.pre_backout,
232                                  self.backoutset,
233                                  self.post_backout,
234                                  self.pre_requisites,
235                                  )
236
237        # set the namespace as a copy of mine
238        if self.namespace is not None:
239            newchange.namespace = self.namespace.copy()
240            pass
241
242        return newchange
243       
244    def set_state(self, state_string):
245        self.state = CHANGE_STATE[state_string]
246
247    def run_expectset(self, provisioner, expectset, namespace):
248        """
249        Execute an expectset and verify it against the conditions, if any.
250        """
251        log.debug("running expectset: %s", expectset)
252        if expectset is not None:
253            commands, conditions = expectset
254
255            # If this change has an iterator, configure the iteration values
256            # into the namespace and set up a callback chain to run the
257            # commands in order.
258            if getattr(self, 'iterator', None) is not None:
259                log.debug("I am iterating some commands!")
260
261                d = defer.succeed(None)
262
263                for ns in self.iterator:
264                    log.debug("Adding namespace entry of: %s", ns)
265
266                    # Make sure we only update a copy, so that this
267                    # update isn't permanent
268                    newnamespace = ns.copy()
269                    newnamespace.update(namespace)
270                    log.debug("Namespace is now: %s", newnamespace)
271
272                    d.addCallback(provisioner.run_commands, commands, newnamespace)
273                    d.addCallback(self.check_conditions, conditions, newnamespace)
274                    pass
275
276                return d
277                pass
278
279            else:
280                d = provisioner.run_commands(None, commands, namespace)
281                d.addCallback(self.check_conditions, conditions, namespace)
282                return d
283        else:
284            return defer.succeed(None)
285
286    def check_conditions(self, result, conditions, namespace={}):
287        """
288        Check the result of running expectset commands against a set
289        of conditions, if any.
290        """
291        log.debug("condition checking result: %s against conditions: %s", result, conditions)
292
293        # FIXME: include all namespaces when building this namespace
294        namespace['exitcode'] = result[0]
295        namespace['cmdoutput'] = result[1]
296
297        # Default to failure if exitCode is != 0
298        if len(conditions) == 0:
299            conditions.append('exitcode == 0')
300
301        for cond in conditions:
302            # perform a string substitution on the condition first
303            cond = cond % namespace
304
305            # create a real dict out of what may be a Namespace object
306            ns = {}
307            ns.update(namespace)
308            ok = eval( cond, ns, {} )
309            if not ok:
310                log.error("%s: condition failed: %s", self.name, cond)
311                raise ChangeConditionFailure("change failed condition check: %s" % cond)
312            else:
313                log.debug("condition check passed")
314
315    def run_commands_failure(self, failure):
316        """
317        Called if the command fails utterly to run, such as with connection problems.
318        """
319        log.error("Command failure: %s: %s")
320        return failure
321       
322    def pre_apply_check(self, provisioner, namespace):
323        log.debug("running pre_apply_check")
324        return self.run_expectset(provisioner, self.pre_impl, namespace)
325
326    def apply(self, provisioner, namespace):
327        """
328        Use provisioner to implement me.
329        """
330        return self.run_expectset(provisioner, self.impl, namespace)
331   
332    def test_apply_success(self, provisioner, results, namespace):
333        log.debug("testing success of change. results: %s", results)
334        return self.run_expectset(provisioner, self.post_impl, namespace)
335
336    def backout(self, provisioner, namespace):
337        """
338        Back out a change from a device.
339        """
340        log.debug("Running backout")
341        return self.run_expectset(provisioner, self.backoutset, namespace)
342
343    def test_backout_success(self, results, provisioner, namespace):
344        log.debug("testing success of backout. results: %s", results)
345        return self.run_expectset(provisioner, self.post_backout, namespace)
346
347    def parse_node_preimpl(self, node):
348        """
349        When passed a 'preimpl' node, parse it and add it to my commands.
350        """
351        expectset, conditions = self.build_expectset(node)
352        log.debug("set pre_impl node to: %s", (expectset, conditions))
353        self.pre_impl = (expectset, conditions)
354
355    def parse_node_impl(self, node):
356        """
357        When passed a 'impl' node, parse it and add it to my commands.
358        """
359        expectset, conditions = self.build_expectset(node)
360        log.debug("set post_impl node to: %s", (expectset, conditions))
361        self.impl = (expectset, conditions)
362
363    def parse_node_postimpl(self, node):
364        """
365        When passed a 'postimpl' node, parse it and add it to my commands.
366        """
367        expectset, conditions = self.build_expectset(node)
368        self.post_impl = (expectset, conditions)
369
370    def parse_node_prebackout(self, node):
371        """
372        When passed a 'prebackout' node, parse it and add it to my commands.
373        """
374        expectset, conditions = self.build_expectset(node)
375        self.pre_backout = (expectset, conditions)
376
377    def parse_node_backout(self, node):
378        """
379        When passed a 'backout' node, parse it and add it to my commands.
380        """
381        expectset, conditions = self.build_expectset(node)
382        self.backoutset = (expectset, conditions)
383
384    def parse_node_postbackout(self, node):
385        """
386        When passed a 'postbackout' node, parse it and add it to my commands.
387        """
388        expectset, conditions = self.build_expectset(node)
389        self.post_backout = (expectset, conditions)
390
391    def build_expectset(self, node):
392        """
393        Build an expectset from a node that contains a set of expectset commands.
394        """
395        known_children = [ 'command', 'condition' ]
396
397        expectset = []
398        conditions = []
399       
400        for child in node.xpath('*'):
401            if child.tag not in known_children:
402                raise ValueError("'%s' is an unknown child nodename of '%s'" % (child.tag, node.tag))
403            else:
404                if child.tag == 'command':
405                    expect_tuple = self.parse_command(child)
406                    log.debug("adding command to expectset: %s", expect_tuple)
407                    expectset.append(expect_tuple)
408                elif child.tag == 'condition':
409                    conditions.append( self.parse_condition(child) )
410                pass
411            pass
412
413        log.debug("built expectset: %s", (expectset, conditions))
414        return expectset, conditions
415
416    def parse_command(self, element):
417        """
418        Parse a command element to extract the expect/send pairs.
419        """
420        if element.tag != 'command':
421            raise ValueError("element is not a command node")
422
423        # find the expect string, if it exists
424        expectNodes = element.findall('expect')
425        if len(expectNodes) == 1:
426            expect_str = expectNodes[0].text
427
428        elif len(expectNodes) > 1:
429            log.error(">0 expectNodes found: %s", expectNodes)
430            raise ValueError("More than one 'expect' tag found!")
431
432        else:
433            expect_str = None
434
435        sendNodes = element.findall('send')
436        if len(sendNodes) == 1:
437            send_str = sendNodes[0].text
438
439        elif len(sendNodes) > 1:
440            log.error(">0 sendNodes found: %s", sendNodes)
441            raise ValueError("More than one 'send' tag found!")
442
443        else:
444            send_str = None
445
446        log.debug("returning: %s, %s", expect_str, send_str)
447        return (expect_str, send_str)
448
449    def parse_condition(self, node):
450        """
451        Parse a condition node, and add it
452        """
453        return node.text
454
455    def fetch_namespace(self, provisioner):
456        """
457        Build a unified namespace merging my namespace with the
458        device, provisioner and global namespaces.
459        """
460        log.info("Fetching namespace...")
461
462        namespace = self.namespace
463
464    def can_retry(self):
465        """
466        Check to see if this change is allowed to retry after failing.
467        """
468        if self.on_fail_retry:
469            log.debug("Onfail-retry is enabled")
470            if self.retries < self.max_retries:
471                log.debug("Retries: %d is less than retry max: %s. Will retry change.", self.retries, self.max_retries)
472                self.retries += 1
473                return True
474            else:
475                log.debug("Too many retries for change. Will not retry again.")
476            pass
477        return False
478       
479if __name__ == '__main__':
480    log.debug('Testing base provisioner')
481
482    # Define a changeset
483    changeset = []
484    commands = [ "echo 'hello there!'", ]
485    change = CommandChange(commandlist=commands)
486
487    changeset.append(change)
488
489    # Add a provisioner
490    prov = CommandProvisioner()
491
492    for change in changeset:
493        prov.perform_change(change)
Note: See TracBrowser for help on using the browser.