forked from canonical/operator
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy path_main.py
613 lines (511 loc) · 24.2 KB
/
_main.py
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
# Copyright 2019 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implement the main entry point to the framework."""
import logging
import os
import shutil
import subprocess
import sys
import warnings
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast
from . import charm as _charm
from . import framework as _framework
from . import model as _model
from . import storage as _storage
from . import version as _version
from .jujucontext import _JujuContext
from .log import setup_root_logging
CHARM_STATE_FILE = '.unit-state.db'
logger = logging.getLogger()
def _exe_path(path: Path) -> Optional[Path]:
"""Find and return the full path to the given binary.
Here path is the absolute path to a binary, but might be missing an extension.
"""
p = shutil.which(path.name, mode=os.F_OK, path=str(path.parent))
if p is None:
return None
return Path(p)
def _create_event_link(
charm_dir: Path,
bound_event: '_framework.BoundEvent',
link_to: Union[str, Path],
):
"""Create a symlink for a particular event.
Args:
charm_dir: The root charm directory.
bound_event: An event for which to create a symlink.
link_to: What the event link should point to
"""
# type guard
assert bound_event.event_kind, f'unbound BoundEvent {bound_event}'
if issubclass(bound_event.event_type, _charm.HookEvent):
event_dir = charm_dir / 'hooks'
event_path = event_dir / bound_event.event_kind.replace('_', '-')
elif issubclass(bound_event.event_type, _charm.ActionEvent):
if not bound_event.event_kind.endswith('_action'):
raise RuntimeError(f'action event name {bound_event.event_kind} needs _action suffix')
event_dir = charm_dir / 'actions'
# The event_kind is suffixed with "_action" while the executable is not.
event_path = event_dir / bound_event.event_kind[: -len('_action')].replace('_', '-')
else:
raise RuntimeError(
f'cannot create a symlink: unsupported event type {bound_event.event_type}'
)
event_dir.mkdir(exist_ok=True)
if not event_path.exists():
target_path = os.path.relpath(link_to, str(event_dir))
# Ignore the non-symlink files or directories
# assuming the charm author knows what they are doing.
logger.debug(
'Creating a new relative symlink at %s pointing to %s', event_path, target_path
)
event_path.symlink_to(target_path)
def _setup_event_links(charm_dir: Path, charm: '_charm.CharmBase', juju_context: _JujuContext):
"""Set up links for supported events that originate from Juju.
Whether a charm can handle an event or not can be determined by
introspecting which events are defined on it.
Hooks or actions are created as symlinks to the charm code file
which is determined by inspecting symlinks provided by the charm
author at hooks/install or hooks/start.
Args:
charm_dir: A root directory of the charm.
charm: An instance of the Charm class.
juju_context: An instance of the _JujuContext class.
"""
link_to = os.path.realpath(juju_context.dispatch_path or sys.argv[0])
for bound_event in charm.on.events().values():
# Only events that originate from Juju need symlinks.
if issubclass(bound_event.event_type, (_charm.HookEvent, _charm.ActionEvent)):
_create_event_link(charm_dir, bound_event, link_to)
def _get_event_args(
charm: '_charm.CharmBase',
bound_event: '_framework.BoundEvent',
juju_context: _JujuContext,
) -> Tuple[List[Any], Dict[str, Any]]:
event_type = bound_event.event_type
model = charm.framework.model
relation = None
if issubclass(event_type, _charm.WorkloadEvent):
workload_name = juju_context.workload_name
assert workload_name is not None
container = model.unit.get_container(workload_name)
args: List[Any] = [container]
if issubclass(event_type, _charm.PebbleNoticeEvent):
notice_id = juju_context.notice_id
notice_type = juju_context.notice_type
notice_key = juju_context.notice_key
args.extend([notice_id, notice_type, notice_key])
elif issubclass(event_type, _charm.PebbleCheckEvent):
check_name = juju_context.pebble_check_name
args.append(check_name)
return args, {}
elif issubclass(event_type, _charm.SecretEvent):
args: List[Any] = [
juju_context.secret_id,
juju_context.secret_label,
]
if issubclass(event_type, (_charm.SecretRemoveEvent, _charm.SecretExpiredEvent)):
args.append(juju_context.secret_revision)
return args, {}
elif issubclass(event_type, _charm.StorageEvent):
# Before JUJU_STORAGE_ID exists, take the event name as
# <storage_name>_storage_<attached|detached> and replace it with <storage_name>
storage_name = juju_context.storage_name or '-'.join(
bound_event.event_kind.split('_')[:-2]
)
storages = model.storages[storage_name]
index, storage_location = model._backend._storage_event_details()
if len(storages) == 1:
storage = storages[0]
else:
# If there's more than one value, pick the right one. We'll realize the key on lookup
storage = next((s for s in storages if s.index == index), None)
storage = cast(Union[_storage.JujuStorage, _storage.SQLiteStorage], storage)
storage.location = storage_location # type: ignore
return [storage], {}
elif issubclass(event_type, _charm.ActionEvent):
args: List[Any] = [juju_context.action_uuid]
return args, {}
elif issubclass(event_type, _charm.RelationEvent):
relation_name = juju_context.relation_name
assert relation_name is not None
relation_id = juju_context.relation_id
relation: Optional[_model.Relation] = model.get_relation(relation_name, relation_id)
remote_app_name = juju_context.remote_app_name
remote_unit_name = juju_context.remote_unit_name
departing_unit_name = juju_context.relation_departing_unit_name
if not remote_app_name and remote_unit_name:
if '/' not in remote_unit_name:
raise RuntimeError(f'invalid remote unit name: {remote_unit_name}')
remote_app_name = remote_unit_name.split('/')[0]
kwargs: Dict[str, Any] = {}
if remote_app_name:
kwargs['app'] = model.get_app(remote_app_name)
if remote_unit_name:
kwargs['unit'] = model.get_unit(remote_unit_name)
if departing_unit_name:
kwargs['departing_unit_name'] = departing_unit_name
if relation:
return [relation], kwargs
return [], kwargs
class _Dispatcher:
"""Encapsulate how to figure out what event Juju wants us to run.
Juju 2.7.0 and later provide the JUJU_DISPATCH_PATH environment variable.
Earlier versions called individual hook scripts, and that are supported via
two separate mechanisms:
- Charmcraft 0.1.2 and produce `dispatch` shell script that fills this
environment variable if it's missing
- Ops 0.8.0 and later likewise take use ``sys.argv[0]`` if the environment
variable is missing
Args:
charm_dir: the toplevel directory of the charm
Attributes:
event_name: the name of the event to run
is_dispatch_aware: are we running under a Juju that knows about the
dispatch binary, and is that binary present?
"""
def __init__(self, charm_dir: Path, juju_context: _JujuContext):
self._juju_context = juju_context
self._charm_dir = charm_dir
self._exec_path = Path(self._juju_context.dispatch_path or sys.argv[0])
dispatch = charm_dir / 'dispatch'
if self._juju_context.version.is_dispatch_aware() and _exe_path(dispatch) is not None:
self._init_dispatch()
else:
self._init_legacy()
def ensure_event_links(self, charm: '_charm.CharmBase'):
"""Make sure necessary symlinks are present on disk."""
if self.is_dispatch_aware:
# links aren't needed
return
# When a charm is force-upgraded and a unit is in an error state Juju
# does not run upgrade-charm and instead runs the failed hook followed
# by config-changed. Given the nature of force-upgrading the hook setup
# code is not triggered on config-changed.
#
# 'start' event is included as Juju does not fire the install event for
# K8s charms https://bugs.launchpad.net/juju/+bug/1854635, fixed in juju 2.7.6 and 2.8
if self.event_name in ('install', 'start', 'upgrade_charm') or self.event_name.endswith(
'_storage_attached'
):
_setup_event_links(self._charm_dir, charm, self._juju_context)
def run_any_legacy_hook(self):
"""Run any extant legacy hook.
If there is both a dispatch file and a legacy hook for the
current event, run the wanted legacy hook.
"""
if not self.is_dispatch_aware:
# we *are* the legacy hook
return
dispatch_path = _exe_path(self._charm_dir / self._dispatch_path)
if dispatch_path is None:
return
# super strange that there isn't an is_executable
if not os.access(str(dispatch_path), os.X_OK):
logger.warning('Legacy %s exists but is not executable.', self._dispatch_path)
return
if dispatch_path.resolve() == Path(sys.argv[0]).resolve():
logger.debug('Legacy %s is just a link to ourselves.', self._dispatch_path)
return
argv = sys.argv.copy()
argv[0] = str(dispatch_path)
logger.info('Running legacy %s.', self._dispatch_path)
try:
subprocess.run(argv, check=True)
except subprocess.CalledProcessError as e:
logger.warning('Legacy %s exited with status %d.', self._dispatch_path, e.returncode)
raise _Abort(e.returncode) from e
except OSError as e:
logger.warning('Unable to run legacy %s: %s', self._dispatch_path, e)
raise _Abort(1) from e
else:
logger.debug('Legacy %s exited with status 0.', self._dispatch_path)
def _set_name_from_path(self, path: Path):
"""Sets the name attribute to that which can be inferred from the given path."""
name = path.name.replace('-', '_')
if path.parent.name == 'actions':
name = f'{name}_action'
self.event_name = name
def _init_legacy(self):
"""Set up the 'legacy' dispatcher.
The current Juju doesn't know about 'dispatch' and calls hooks
explicitly.
"""
self.is_dispatch_aware = False
self._set_name_from_path(self._exec_path)
def _init_dispatch(self):
"""Set up the new 'dispatch' dispatcher.
The current Juju will run 'dispatch' if it exists, and otherwise fall
back to the old behaviour.
JUJU_DISPATCH_PATH will be set to the wanted hook, e.g. hooks/install,
in both cases.
"""
self._dispatch_path = Path(self._juju_context.dispatch_path)
if 'OPERATOR_DISPATCH' in os.environ:
logger.debug('Charm called itself via %s.', self._dispatch_path)
raise _Abort(0)
os.environ['OPERATOR_DISPATCH'] = '1'
self.is_dispatch_aware = True
self._set_name_from_path(self._dispatch_path)
def is_restricted_context(self):
"""Return True if we are running in a restricted Juju context.
When in a restricted context, most commands (relation-get, config-get,
state-get) are not available. As such, we change how we interact with
Juju.
"""
return self.event_name in ('collect_metrics',)
def _should_use_controller_storage(
db_path: Path, meta: _charm.CharmMeta, juju_context: _JujuContext
) -> bool:
"""Figure out whether we want to use controller storage or not."""
# if local state has been used previously, carry on using that
if db_path.exists():
return False
# only use controller storage for Kubernetes podspec charms
is_podspec = 'kubernetes' in meta.series
if not is_podspec:
logger.debug('Using local storage: not a Kubernetes podspec charm')
return False
# are we in a new enough Juju?
if juju_context.version.has_controller_storage():
logger.debug('Using controller storage: JUJU_VERSION=%s', juju_context.version)
return True
else:
logger.debug('Using local storage: JUJU_VERSION=%s', juju_context.version)
return False
class _Abort(Exception): # noqa: N818
"""Raised when something happens that should interrupt ops execution."""
def __init__(self, exit_code: int):
super().__init__()
self.exit_code = exit_code
class _Manager:
"""Initialises the Framework and manages the lifecycle of a charm.
Running _Manager consists of three main steps:
- setup: initialise the following from JUJU_* environment variables:
- the Framework (hook tool wrappers)
- the storage backend
- the event that Juju is emitting on us
- the charm instance (user-facing)
- emit: core user-facing lifecycle step. Consists of:
- emit the Juju event to the charm
- emit any custom events emitted by the charm during this phase
- emit the ``collect-status`` events
- commit: responsible for:
- store any events deferred throughout this execution
- graceful teardown of the storage
The above steps are first run for any deferred notices found in the storage
(all three steps for each notice, except for emitting status collection
events), and then run a final time for the Juju event that triggered this
execution.
"""
def __init__(
self,
charm_class: Type['_charm.CharmBase'],
juju_context: _JujuContext,
use_juju_for_storage: Optional[bool] = None,
charm_state_path: str = CHARM_STATE_FILE,
):
# The context is shared across deferred events and the Juju event. Any
# data from the context that is event-specific must be included in the
# event object snapshot/restore rather than re-read from the context.
# Data not connected to the event (debug settings, the Juju version, the
# app and unit name, and so forth) will be the *current* data, not the
# data at the time the event was deferred -- this aligns with the data
# from hook tools.
self._juju_context = juju_context
self._charm_state_path = charm_state_path
self._charm_class = charm_class
# Do this as early as possible to be sure to catch the most logs.
self._setup_root_logging()
self._charm_root = self._juju_context.charm_dir
self._charm_meta = self._load_charm_meta()
self._use_juju_for_storage = use_juju_for_storage
# Handle legacy hooks - this is only done once, not with each deferred
# event.
self._dispatcher = _Dispatcher(self._charm_root, self._juju_context)
self._dispatcher.run_any_legacy_hook()
# Storage is shared across all events, so we create it once here.
self._storage = self._make_storage()
self.run_deferred()
# This is the charm for the Juju event. We create it here so that it's
# available for pre-emit adjustments when being used in testing.
self.charm = self._make_charm(self._dispatcher.event_name)
# This is with the charm used for the Juju event, but it's being removed
# later this cycle anyway, so we want minimum tweaking.
self._dispatcher.ensure_event_links(self.charm)
def _load_charm_meta(self):
return _charm.CharmMeta.from_charm_root(self._charm_root)
def _make_model_backend(self):
# model._ModelBackend is stateless and can be reused across events.
# However, in testing (both Harness and Scenario) the backend stores all
# the state that is normally in Juju. To be consistent, we create a new
# backend object even in production code.
return _model._ModelBackend(juju_context=self._juju_context)
def _make_charm(self, event_name: str):
framework = self._make_framework(event_name)
return self._charm_class(framework)
def _setup_root_logging(self):
# For actions, there is a communication channel with the user running the
# action, so we want to send exception details through stderr, rather than
# only to juju-log as normal.
handling_action = self._juju_context.action_name is not None
# We don't really want to have a different backend here than when
# running the event. However, we need to create a new backend for each
# event and want the logging set up before we are ready to emit an
# event. In practice, this isn't a problem:
# * for model._ModelBackend, `juju_log` calls out directly to the hook
# tool; it's effectively a staticmethod.
# * for _private.harness._TestingModelBackend, `juju_log` is not
# implemented, and the logging is never configured.
# * for scenario.mocking._MockModelBackend, `juju_log` sends the logging
# through to the `Context` object, which will be the same for all
# events.
# TODO: write tests to make sure that everything remains ok here.
setup_root_logging(
self._make_model_backend(), debug=self._juju_context.debug, exc_stderr=handling_action
)
logger.debug('ops %s up and running.', _version.version)
def _make_storage(self):
charm_state_path = self._charm_root / self._charm_state_path
use_juju_for_storage = self._use_juju_for_storage
if use_juju_for_storage and not _storage.juju_backend_available():
# raise an exception; the charm is broken and needs fixing.
msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it'
raise RuntimeError(msg.format(self._juju_context.version))
if use_juju_for_storage is None:
use_juju_for_storage = _should_use_controller_storage(
charm_state_path, self._charm_meta, self._juju_context
)
elif use_juju_for_storage:
warnings.warn(
"Controller storage is deprecated; it's intended for "
'podspec charms and will be removed in a future release.',
category=DeprecationWarning,
)
if use_juju_for_storage and self._dispatcher.is_restricted_context():
# collect-metrics is going away in Juju 4.0, and restricted context
# with it, so we don't need this to be particularly generic.
logger.debug(
'"collect_metrics" is not supported when using Juju for storage\n'
'see: https://github.com/canonical/operator/issues/348',
)
# Note that we don't exit nonzero, because that would cause Juju to rerun the hook
raise _Abort(0)
if self._use_juju_for_storage:
store = _storage.JujuStorage()
else:
store = _storage.SQLiteStorage(charm_state_path)
return store
def _make_framework(self, event_name: str):
# If we are in a RelationBroken event, we want to know which relation is
# broken within the model, not only in the event's `.relation` attribute.
if self._juju_context.dispatch_path.endswith(('-relation-broken', '_relation_broken')):
broken_relation_id = self._juju_context.relation_id
else:
broken_relation_id = None
model_backend = self._make_model_backend()
model = _model.Model(
self._charm_meta, model_backend, broken_relation_id=broken_relation_id
)
framework = _framework.Framework(
self._storage,
self._charm_root,
self._charm_meta,
model,
event_name=event_name,
juju_debug_at=self._juju_context.debug_at,
)
framework.set_breakpointhook()
return framework
def _emit(self, charm: _charm.CharmBase, event_name: str):
"""Emit the event on the charm."""
# Emit the Juju event.
self._emit_charm_event(charm, event_name)
# Emit collect-status events.
_charm._evaluate_status(charm)
def _get_event_to_emit(
self, charm: _charm.CharmBase, event_name: str
) -> Optional[_framework.BoundEvent]:
try:
return getattr(charm.on, event_name)
except AttributeError:
logger.debug('Event %s not defined for %s.', event_name, charm)
return None
def _emit_charm_event(self, charm: _charm.CharmBase, event_name: str):
"""Emits a charm event based on a Juju event name.
Args:
charm: A charm instance to emit an event from.
event_name: A Juju event name to emit on a charm.
juju_context: An instance of the _JujuContext class.
"""
event_to_emit = self._get_event_to_emit(charm, event_name)
# If the event is not supported by the charm implementation, do
# not error out or try to emit it. This is to support rollbacks.
if event_to_emit is None:
return
args, kwargs = _get_event_args(charm, event_to_emit, self._juju_context)
logger.debug('Emitting Juju event %s.', event_name)
event_to_emit.emit(*args, **kwargs)
def _commit(self, framework: _framework.Framework):
"""Commit the framework and gracefully teardown."""
framework.commit()
def _close(self):
"""Perform any necessary cleanup before the framework is closed."""
# Provided for child classes - nothing needs to be done in the base.
def run_deferred(self):
"""Emit and then commit the framework.
A framework and charm object are created for each notice in the storage
(an event and observer pair), the relevant deferred event is emitted,
and the framework is committed. Note that collect-status events are not
emitted.
"""
# TODO: Remove the restricted context check below once we no longer need
# to support Juju < 4 (collect-metrics and restricted context are
# being removed in Juju 4.0).
#
# Skip re-emission of deferred events for collect-metrics events because
# they do not have the full access to all hook tools.
if self._dispatcher.is_restricted_context():
logger.debug("Skipping re-emission of deferred events in restricted context.")
return
# Re-emit previously deferred events to the observers that deferred them.
for event_path, _, _ in self._storage.notices():
event_handle = _framework.Handle.from_path(event_path)
logger.debug('Re-emitting deferred event: %s', event_handle)
charm = self._make_charm(event_handle.kind)
charm.framework.reemit(event_path)
self._commit(charm.framework)
self._close()
charm._destroy_charm()
def run(self):
"""Emit and then commit the framework."""
try:
self._emit(self.charm, self._dispatcher.event_name)
self._commit(self.charm.framework)
self._close()
finally:
self.charm.framework.close()
def main(charm_class: Type[_charm.CharmBase], use_juju_for_storage: Optional[bool] = None):
"""Set up the charm and dispatch the observed event.
See `ops.main() <#ops-main-entry-point>`_ for details.
"""
try:
juju_context = _JujuContext.from_dict(os.environ)
manager = _Manager(
charm_class, use_juju_for_storage=use_juju_for_storage, juju_context=juju_context
)
manager.run()
except _Abort as e:
sys.exit(e.exit_code)