| 1 | """ |
|---|
| 2 | A changeset is a sequence of changes that can be applied to a managed element. |
|---|
| 3 | A changeset will move the element from one state, A, to a destination state, B. |
|---|
| 4 | A changeset may contain other changesets. These changesets will be implemented |
|---|
| 5 | in a specific order. |
|---|
| 6 | The successful implementation of a changeset must be testable. |
|---|
| 7 | If the test fails, implementation of the changeset has failed, and a backout can |
|---|
| 8 | be executed. |
|---|
| 9 | A changeset contains the necessary backouts. |
|---|
| 10 | |
|---|
| 11 | If a sub-changeset fails implementation, the containing changeset also fails. |
|---|
| 12 | |
|---|
| 13 | Imagine the following changeset: |
|---|
| 14 | |
|---|
| 15 | useradd justin |
|---|
| 16 | |
|---|
| 17 | This adds a user to the system. If something goes wrong, how do we back out? |
|---|
| 18 | Default backout behaviour is to alert a human that something went wrong. |
|---|
| 19 | |
|---|
| 20 | Backout may depend on the exact failure. The 'did it work' test should permit |
|---|
| 21 | selecting from a list of possible backout procedures, depending on the failure |
|---|
| 22 | that was detected. |
|---|
| 23 | |
|---|
| 24 | This example would be a "Add User" changeset, and could be different on different |
|---|
| 25 | destination systems. |
|---|
| 26 | |
|---|
| 27 | Note that a "Remove User" changeset would be different from the backout procedure |
|---|
| 28 | for a failed "Add User" changeset. |
|---|
| 29 | |
|---|
| 30 | For a changeset that runs a sequence of commands (a CommandChangeset, perhaps?), |
|---|
| 31 | the default behaviour should be to detect if an individual command in the sequence |
|---|
| 32 | failed, and report the error to the backout method detection code. This will |
|---|
| 33 | provide the most generic way of testing for failure without having to run a mini-audit |
|---|
| 34 | after each command is executed. However, if such paranoid auditing is required, you |
|---|
| 35 | could implement the changeset as a series of changesets, rather than a single, |
|---|
| 36 | multi-command changeset. This would provide much finer grained error detection and |
|---|
| 37 | possible correction capabilities. |
|---|
| 38 | |
|---|
| 39 | A changeset is a set of changes to be applied to a set of devices. |
|---|
| 40 | A change can be applied to multiple devices, eg: loading the same configuration file onto all 70 hosts. |
|---|
| 41 | Does the 'load configuration file to host list' change happen all at once, or as part of a sequence |
|---|
| 42 | of 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 | |
|---|
| 45 | A Change is the minimum transaction size. This is the smallest element that can be applied, |
|---|
| 46 | or 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 | |
|---|
| 54 | So.. first we define a list of devices and their attributes (ssh key locations, etc). This would be |
|---|
| 55 | a single, global area for all devices/entities under change management. |
|---|
| 56 | |
|---|
| 57 | Then, we define a changeset, which is a list of changes. Each change defines the device(s) it applies to, |
|---|
| 58 | and how to apply the change to it. |
|---|
| 59 | """ |
|---|
| 60 | import logging |
|---|
| 61 | import debug |
|---|
| 62 | |
|---|
| 63 | log = logging.getLogger('modipy') |
|---|
| 64 | |
|---|
| 65 | from zope.interface import Interface, implements |
|---|
| 66 | |
|---|
| 67 | from twisted.internet import defer |
|---|
| 68 | |
|---|
| 69 | CHANGE_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 | |
|---|
| 79 | class ChangeConditionFailure(Exception): |
|---|
| 80 | """ |
|---|
| 81 | Raised if an expectset condition fails eval() |
|---|
| 82 | """ |
|---|
| 83 | |
|---|
| 84 | class 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 | |
|---|
| 155 | class ChangeFailed(Exception): |
|---|
| 156 | """ |
|---|
| 157 | Execution of a Change failed for some reason. |
|---|
| 158 | """ |
|---|
| 159 | |
|---|
| 160 | class 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 | |
|---|
| 479 | if __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) |
|---|