|
18 | 18 | This is so that :code:`import ops` followed by :code:`ops.main(MyCharm)` works
|
19 | 19 | as expected.
|
20 | 20 | """
|
21 |
| - |
22 | 21 | import logging
|
23 | 22 | import os
|
24 | 23 | import shutil
|
@@ -294,10 +293,10 @@ def run_any_legacy_hook(self):
|
294 | 293 | subprocess.run(argv, check=True)
|
295 | 294 | except subprocess.CalledProcessError as e:
|
296 | 295 | logger.warning("Legacy %s exited with status %d.", self._dispatch_path, e.returncode)
|
297 |
| - sys.exit(e.returncode) |
| 296 | + raise _Abort(e.returncode) from e |
298 | 297 | except OSError as e:
|
299 | 298 | logger.warning("Unable to run legacy %s: %s", self._dispatch_path, e)
|
300 |
| - sys.exit(1) |
| 299 | + raise _Abort(1) from e |
301 | 300 | else:
|
302 | 301 | logger.debug("Legacy %s exited with status 0.", self._dispatch_path)
|
303 | 302 |
|
@@ -330,7 +329,7 @@ def _init_dispatch(self):
|
330 | 329 |
|
331 | 330 | if 'OPERATOR_DISPATCH' in os.environ:
|
332 | 331 | logger.debug("Charm called itself via %s.", self._dispatch_path)
|
333 |
| - sys.exit(0) |
| 332 | + raise _Abort(0) |
334 | 333 | os.environ['OPERATOR_DISPATCH'] = '1'
|
335 | 334 |
|
336 | 335 | self.is_dispatch_aware = True
|
@@ -369,97 +368,182 @@ def _should_use_controller_storage(db_path: Path, meta: CharmMeta) -> bool:
|
369 | 368 | return False
|
370 | 369 |
|
371 | 370 |
|
372 |
| -def main(charm_class: Type[ops.charm.CharmBase], |
373 |
| - use_juju_for_storage: Optional[bool] = None): |
374 |
| - """Setup the charm and dispatch the observed event. |
| 371 | +class _Abort(Exception): # noqa: N818 |
| 372 | + """Raised when something happens that should interrupt ops execution.""" |
375 | 373 |
|
376 |
| - The event name is based on the way this executable was called (argv[0]). |
| 374 | + def __init__(self, exit_code: int): |
| 375 | + super().__init__() |
| 376 | + self.exit_code = exit_code |
377 | 377 |
|
378 |
| - Args: |
379 |
| - charm_class: the charm class to instantiate and receive the event. |
380 |
| - use_juju_for_storage: whether to use controller-side storage. If not specified |
381 |
| - then kubernetes charms that haven't previously used local storage and that |
382 |
| - are running on a new enough Juju default to controller-side storage, |
383 |
| - otherwise local storage is used. |
| 378 | + |
| 379 | +class _Manager: |
| 380 | + """Initialises the Framework and manages the lifecycle of a charm. |
| 381 | +
|
| 382 | + Running _Manager consists of three main steps: |
| 383 | + - setup: initialise the following from JUJU_* environment variables: |
| 384 | + - the Framework (hook tool wrappers) |
| 385 | + - the storage backend |
| 386 | + - the event that Juju is emitting on us |
| 387 | + - the charm instance (user-facing) |
| 388 | + - emit: core user-facing lifecycle step. Consists of: |
| 389 | + - re-emit any deferred events found in the storage |
| 390 | + - emit the Juju event to the charm |
| 391 | + - emit any custom events emitted by the charm during this phase |
| 392 | + - emit the ``collect-status`` events |
| 393 | + - commit: responsible for: |
| 394 | + - store any events deferred throughout this execution |
| 395 | + - graceful teardown of the storage |
384 | 396 | """
|
385 |
| - charm_dir = _get_charm_dir() |
386 |
| - |
387 |
| - model_backend = ops.model._ModelBackend() |
388 |
| - debug = ('JUJU_DEBUG' in os.environ) |
389 |
| - # For actions, there is a communication channel with the user running the |
390 |
| - # action, so we want to send exception details through stderr, rather than |
391 |
| - # only to juju-log as normal. |
392 |
| - handling_action = ('JUJU_ACTION_NAME' in os.environ) |
393 |
| - setup_root_logging(model_backend, debug=debug, exc_stderr=handling_action) |
394 |
| - logger.debug("ops %s up and running.", ops.__version__) # type:ignore |
395 |
| - |
396 |
| - dispatcher = _Dispatcher(charm_dir) |
397 |
| - dispatcher.run_any_legacy_hook() |
398 |
| - |
399 |
| - metadata = (charm_dir / 'metadata.yaml').read_text() |
400 |
| - actions_meta = charm_dir / 'actions.yaml' |
401 |
| - actions_metadata = actions_meta.read_text() if actions_meta.exists() else None |
402 |
| - |
403 |
| - # If we are in a RelationBroken event, we want to know which relation is |
404 |
| - # broken within the model, not only in the event's `.relation` attribute. |
405 |
| - if os.environ.get('JUJU_DISPATCH_PATH', '').endswith('-relation-broken'): |
406 |
| - broken_relation_id = _get_juju_relation_id() |
407 |
| - else: |
408 |
| - broken_relation_id = None |
409 |
| - meta = CharmMeta.from_yaml(metadata, actions_metadata) |
410 |
| - model = ops.model.Model(meta, model_backend, broken_relation_id=broken_relation_id) |
411 |
| - |
412 |
| - charm_state_path = charm_dir / CHARM_STATE_FILE |
413 |
| - |
414 |
| - if use_juju_for_storage and not ops.storage.juju_backend_available(): |
415 |
| - # raise an exception; the charm is broken and needs fixing. |
416 |
| - msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it' |
417 |
| - raise RuntimeError(msg.format(JujuVersion.from_environ())) |
418 |
| - |
419 |
| - if use_juju_for_storage is None: |
420 |
| - use_juju_for_storage = _should_use_controller_storage(charm_state_path, meta) |
421 |
| - elif use_juju_for_storage: |
422 |
| - warnings.warn("Controller storage is deprecated; it's intended for " |
423 |
| - "podspec charms and will be removed in a future release.", |
424 |
| - category=DeprecationWarning) |
425 |
| - |
426 |
| - if use_juju_for_storage: |
427 |
| - if dispatcher.is_restricted_context(): |
| 397 | + |
| 398 | + def __init__( |
| 399 | + self, |
| 400 | + charm_class: Type["ops.charm.CharmBase"], |
| 401 | + model_backend: Optional[ops.model._ModelBackend] = None, |
| 402 | + use_juju_for_storage: Optional[bool] = None, |
| 403 | + charm_state_path: str = CHARM_STATE_FILE |
| 404 | + ): |
| 405 | + |
| 406 | + self._charm_state_path = charm_state_path |
| 407 | + self._charm_class = charm_class |
| 408 | + if model_backend is None: |
| 409 | + model_backend = ops.model._ModelBackend() |
| 410 | + self._model_backend = model_backend |
| 411 | + |
| 412 | + # Do this as early as possible to be sure to catch the most logs. |
| 413 | + self._setup_root_logging() |
| 414 | + |
| 415 | + self._charm_root = _get_charm_dir() |
| 416 | + self._charm_meta = CharmMeta.from_charm_root(self._charm_root) |
| 417 | + self._use_juju_for_storage = use_juju_for_storage |
| 418 | + |
| 419 | + # Set up dispatcher, framework and charm objects. |
| 420 | + self.dispatcher = _Dispatcher(self._charm_root) |
| 421 | + self.dispatcher.run_any_legacy_hook() |
| 422 | + |
| 423 | + self.framework = self._make_framework(self.dispatcher) |
| 424 | + self.charm = self._make_charm(self.framework, self.dispatcher) |
| 425 | + |
| 426 | + def _make_charm(self, framework: "ops.framework.Framework", dispatcher: _Dispatcher): |
| 427 | + charm = self._charm_class(framework) |
| 428 | + dispatcher.ensure_event_links(charm) |
| 429 | + return charm |
| 430 | + |
| 431 | + def _setup_root_logging(self): |
| 432 | + debug = "JUJU_DEBUG" in os.environ |
| 433 | + # For actions, there is a communication channel with the user running the |
| 434 | + # action, so we want to send exception details through stderr, rather than |
| 435 | + # only to juju-log as normal. |
| 436 | + handling_action = 'JUJU_ACTION_NAME' in os.environ |
| 437 | + setup_root_logging(self._model_backend, debug=debug, exc_stderr=handling_action) |
| 438 | + |
| 439 | + logger.debug("ops %s up and running.", ops.__version__) # type:ignore |
| 440 | + |
| 441 | + def _make_storage(self, dispatcher: _Dispatcher): |
| 442 | + charm_state_path = self._charm_root / self._charm_state_path |
| 443 | + |
| 444 | + use_juju_for_storage = self._use_juju_for_storage |
| 445 | + if use_juju_for_storage and not ops.storage.juju_backend_available(): |
| 446 | + # raise an exception; the charm is broken and needs fixing. |
| 447 | + msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it' |
| 448 | + raise RuntimeError(msg.format(JujuVersion.from_environ())) |
| 449 | + |
| 450 | + if use_juju_for_storage is None: |
| 451 | + use_juju_for_storage = _should_use_controller_storage( |
| 452 | + charm_state_path, |
| 453 | + self._charm_meta |
| 454 | + ) |
| 455 | + elif use_juju_for_storage: |
| 456 | + warnings.warn("Controller storage is deprecated; it's intended for " |
| 457 | + "podspec charms and will be removed in a future release.", |
| 458 | + category=DeprecationWarning) |
| 459 | + |
| 460 | + if use_juju_for_storage and dispatcher.is_restricted_context(): |
428 | 461 | # TODO: jam 2020-06-30 This unconditionally avoids running a collect metrics event
|
429 | 462 | # Though we eventually expect that Juju will run collect-metrics in a
|
430 |
| - # non-restricted context. Once we can determine that we are running collect-metrics |
431 |
| - # in a non-restricted context, we should fire the event as normal. |
| 463 | + # non-restricted context. Once we can determine that we are running |
| 464 | + # collect-metrics in a non-restricted context, we should fire the event as normal. |
432 | 465 | logger.debug('"%s" is not supported when using Juju for storage\n'
|
433 | 466 | 'see: https://github.com/canonical/operator/issues/348',
|
434 | 467 | dispatcher.event_name)
|
435 | 468 | # Note that we don't exit nonzero, because that would cause Juju to rerun the hook
|
436 |
| - return |
437 |
| - store = ops.storage.JujuStorage() |
438 |
| - else: |
439 |
| - store = ops.storage.SQLiteStorage(charm_state_path) |
440 |
| - framework = ops.framework.Framework(store, charm_dir, meta, model, |
441 |
| - event_name=dispatcher.event_name) |
442 |
| - framework.set_breakpointhook() |
443 |
| - try: |
444 |
| - charm = charm_class(framework) |
445 |
| - dispatcher.ensure_event_links(charm) |
| 469 | + raise _Abort(0) |
446 | 470 |
|
| 471 | + if self._use_juju_for_storage: |
| 472 | + store = ops.storage.JujuStorage() |
| 473 | + else: |
| 474 | + store = ops.storage.SQLiteStorage(charm_state_path) |
| 475 | + return store |
| 476 | + |
| 477 | + def _make_framework( |
| 478 | + self, |
| 479 | + dispatcher: _Dispatcher |
| 480 | + ): |
| 481 | + # If we are in a RelationBroken event, we want to know which relation is |
| 482 | + # broken within the model, not only in the event's `.relation` attribute. |
| 483 | + if os.environ.get('JUJU_DISPATCH_PATH', '').endswith('-relation-broken'): |
| 484 | + broken_relation_id = _get_juju_relation_id() |
| 485 | + else: |
| 486 | + broken_relation_id = None |
| 487 | + |
| 488 | + model = ops.model.Model(self._charm_meta, self._model_backend, |
| 489 | + broken_relation_id=broken_relation_id) |
| 490 | + store = self._make_storage(dispatcher) |
| 491 | + framework = ops.framework.Framework(store, self._charm_root, self._charm_meta, model, |
| 492 | + event_name=dispatcher.event_name) |
| 493 | + framework.set_breakpointhook() |
| 494 | + return framework |
| 495 | + |
| 496 | + def _emit(self): |
| 497 | + """Emit the event on the charm.""" |
447 | 498 | # TODO: Remove the collect_metrics check below as soon as the relevant
|
448 | 499 | # Juju changes are made. Also adjust the docstring on
|
449 | 500 | # EventBase.defer().
|
450 | 501 | #
|
451 | 502 | # Skip reemission of deferred events for collect-metrics events because
|
452 | 503 | # they do not have the full access to all hook tools.
|
453 |
| - if not dispatcher.is_restricted_context(): |
454 |
| - framework.reemit() |
| 504 | + if not self.dispatcher.is_restricted_context(): |
| 505 | + # Re-emit any deferred events from the previous run. |
| 506 | + self.framework.reemit() |
| 507 | + |
| 508 | + # Emit the Juju event. |
| 509 | + _emit_charm_event(self.charm, self.dispatcher.event_name) |
| 510 | + # Emit collect-status events. |
| 511 | + ops.charm._evaluate_status(self.charm) |
455 | 512 |
|
456 |
| - _emit_charm_event(charm, dispatcher.event_name) |
| 513 | + def _commit(self): |
| 514 | + """Commit the framework and gracefully teardown.""" |
| 515 | + self.framework.commit() |
457 | 516 |
|
458 |
| - ops.charm._evaluate_status(charm) |
| 517 | + def run(self): |
| 518 | + """Emit and then commit the framework.""" |
| 519 | + try: |
| 520 | + self._emit() |
| 521 | + self._commit() |
| 522 | + finally: |
| 523 | + self.framework.close() |
| 524 | + |
| 525 | + |
| 526 | +def main(charm_class: Type[ops.charm.CharmBase], |
| 527 | + use_juju_for_storage: Optional[bool] = None): |
| 528 | + """Set up the charm and dispatch the observed event. |
| 529 | +
|
| 530 | + The event name is based on the way this executable was called (argv[0]). |
| 531 | +
|
| 532 | + Args: |
| 533 | + charm_class: the charm class to instantiate and receive the event. |
| 534 | + use_juju_for_storage: whether to use controller-side storage. If not specified |
| 535 | + then Kubernetes charms that haven't previously used local storage and that |
| 536 | + are running on a new enough Juju default to controller-side storage, |
| 537 | + otherwise local storage is used. |
| 538 | + """ |
| 539 | + try: |
| 540 | + manager = _Manager( |
| 541 | + charm_class, |
| 542 | + use_juju_for_storage=use_juju_for_storage) |
459 | 543 |
|
460 |
| - framework.commit() |
461 |
| - finally: |
462 |
| - framework.close() |
| 544 | + manager.run() |
| 545 | + except _Abort as e: |
| 546 | + sys.exit(e.exit_code) |
463 | 547 |
|
464 | 548 |
|
465 | 549 | # Make this module callable and call main(), so that "import ops" and then
|
|
0 commit comments