Commit 17583c8b authored by Mark Beierl's avatar Mark Beierl
Browse files

Merge branch 'master' into 'master'

Update squid charm

See merge request !105
parents e2197769 9c012576
Pipeline #143 passed with stage
in 1 minute and 34 seconds
Welcome to The Operator Framework's documentation!
==================================================
.. toctree::
:maxdepth: 2
:caption: Contents:
ops package
===========
.. automodule:: ops
Submodules
----------
ops.charm module
----------------
.. automodule:: ops.charm
ops.framework module
--------------------
.. automodule:: ops.framework
ops.jujuversion module
----------------------
.. automodule:: ops.jujuversion
ops.log module
--------------
.. automodule:: ops.log
ops.main module
---------------
.. automodule:: ops.main
ops.model module
----------------
.. automodule:: ops.model
ops.testing module
------------------
.. automodule:: ops.testing
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
# Copyright 2020 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.
import re
from functools import total_ordering
@total_ordering
class JujuVersion:
PATTERN = r'''^
(?P<major>\d{1,9})\.(?P<minor>\d{1,9}) # <major> and <minor> numbers are always there
((?:\.|-(?P<tag>[a-z]+))(?P<patch>\d{1,9}))? # sometimes with .<patch> or -<tag><patch>
(\.(?P<build>\d{1,9}))?$ # and sometimes with a <build> number.
'''
def __init__(self, version):
m = re.match(self.PATTERN, version, re.VERBOSE)
if not m:
raise RuntimeError('"{}" is not a valid Juju version string'.format(version))
d = m.groupdict()
self.major = int(m.group('major'))
self.minor = int(m.group('minor'))
self.tag = d['tag'] or ''
self.patch = int(d['patch'] or 0)
self.build = int(d['build'] or 0)
def __repr__(self):
if self.tag:
s = '{}.{}-{}{}'.format(self.major, self.minor, self.tag, self.patch)
else:
s = '{}.{}.{}'.format(self.major, self.minor, self.patch)
if self.build > 0:
s += '.{}'.format(self.build)
return s
def __eq__(self, other):
if self is other:
return True
if isinstance(other, str):
other = type(self)(other)
elif not isinstance(other, JujuVersion):
raise RuntimeError('cannot compare Juju version "{}" with "{}"'.format(self, other))
return (
self.major == other.major
and self.minor == other.minor
and self.tag == other.tag
and self.build == other.build
and self.patch == other.patch)
def __lt__(self, other):
if self is other:
return False
if isinstance(other, str):
other = type(self)(other)
elif not isinstance(other, JujuVersion):
raise RuntimeError('cannot compare Juju version "{}" with "{}"'.format(self, other))
if self.major != other.major:
return self.major < other.major
elif self.minor != other.minor:
return self.minor < other.minor
elif self.tag != other.tag:
if not self.tag:
return False
elif not other.tag:
return True
return self.tag < other.tag
elif self.patch != other.patch:
return self.patch < other.patch
elif self.build != other.build:
return self.build < other.build
return False
#!/usr/bin/env python3
# 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.
import os
import subprocess
import sys
from pathlib import Path
import yaml
import ops.charm
import ops.framework
import ops.model
import logging
from ops.log import setup_root_logging
CHARM_STATE_FILE = '.unit-state.db'
logger = logging.getLogger()
def _get_charm_dir():
charm_dir = os.environ.get("JUJU_CHARM_DIR")
if charm_dir is None:
# Assume $JUJU_CHARM_DIR/lib/op/main.py structure.
charm_dir = Path('{}/../../..'.format(__file__)).resolve()
else:
charm_dir = Path(charm_dir).resolve()
return charm_dir
def _load_metadata(charm_dir):
metadata = yaml.safe_load((charm_dir / 'metadata.yaml').read_text())
actions_meta = charm_dir / 'actions.yaml'
if actions_meta.exists():
actions_metadata = yaml.safe_load(actions_meta.read_text())
else:
actions_metadata = {}
return metadata, actions_metadata
def _create_event_link(charm, bound_event):
"""Create a symlink for a particular event.
charm -- A charm object.
bound_event -- An event for which to create a symlink.
"""
if issubclass(bound_event.event_type, ops.charm.HookEvent):
event_dir = charm.framework.charm_dir / 'hooks'
event_path = event_dir / bound_event.event_kind.replace('_', '-')
elif issubclass(bound_event.event_type, ops.charm.ActionEvent):
if not bound_event.event_kind.endswith("_action"):
raise RuntimeError(
'action event name {} needs _action suffix'.format(bound_event.event_kind))
event_dir = charm.framework.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(
'cannot create a symlink: unsupported event type {}'.format(bound_event.event_type))
event_dir.mkdir(exist_ok=True)
if not event_path.exists():
# CPython has different implementations for populating sys.argv[0] for Linux and Windows.
# For Windows it is always an absolute path (any symlinks are resolved)
# while for Linux it can be a relative path.
target_path = os.path.relpath(os.path.realpath(sys.argv[0]), 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, charm):
"""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.
charm_dir -- A root directory of the charm.
charm -- An instance of the Charm class.
"""
for bound_event in charm.on.events().values():
# Only events that originate from Juju need symlinks.
if issubclass(bound_event.event_type, (ops.charm.HookEvent, ops.charm.ActionEvent)):
_create_event_link(charm, bound_event)
def _emit_charm_event(charm, event_name):
"""Emits a charm event based on a Juju event name.
charm -- A charm instance to emit an event from.
event_name -- A Juju event name to emit on a charm.
"""
event_to_emit = None
try:
event_to_emit = getattr(charm.on, event_name)
except AttributeError:
logger.debug("event %s not defined for %s", event_name, charm)
# 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 not None:
args, kwargs = _get_event_args(charm, event_to_emit)
logger.debug('Emitting Juju event %s', event_name)
event_to_emit.emit(*args, **kwargs)
def _get_event_args(charm, bound_event):
event_type = bound_event.event_type
model = charm.framework.model
if issubclass(event_type, ops.charm.RelationEvent):
relation_name = os.environ['JUJU_RELATION']
relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
relation = model.get_relation(relation_name, relation_id)
else:
relation = None
remote_app_name = os.environ.get('JUJU_REMOTE_APP', '')
remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '')
if remote_app_name or remote_unit_name:
if not remote_app_name:
if '/' not in remote_unit_name:
raise RuntimeError('invalid remote unit name: {}'.format(remote_unit_name))
remote_app_name = remote_unit_name.split('/')[0]
args = [relation, model.get_app(remote_app_name)]
if remote_unit_name:
args.append(model.get_unit(remote_unit_name))
return args, {}
elif relation:
return [relation], {}
return [], {}
def main(charm_class):
"""Setup the charm and dispatch the observed event.
The event name is based on the way this executable was called (argv[0]).
"""
charm_dir = _get_charm_dir()
model_backend = ops.model.ModelBackend()
debug = ('JUJU_DEBUG' in os.environ)
setup_root_logging(model_backend, debug=debug)
# Process the Juju event relevant to the current hook execution
# JUJU_HOOK_NAME, JUJU_FUNCTION_NAME, and JUJU_ACTION_NAME are not used
# in order to support simulation of events from debugging sessions.
#
# TODO: For Windows, when symlinks are used, this is not a valid
# method of getting an event name (see LP: #1854505).
juju_exec_path = Path(sys.argv[0])
has_dispatch = juju_exec_path.name == 'dispatch'
if has_dispatch:
# The executable was 'dispatch', which means the actual hook we want to
# run needs to be looked up in the JUJU_DISPATCH_PATH env var, where it
# should be a path relative to the charm directory (the directory that
# holds `dispatch`). If that path actually exists, we want to run that
# before continuing.
dispatch_path = juju_exec_path.parent / Path(os.environ['JUJU_DISPATCH_PATH'])
if dispatch_path.exists() and dispatch_path.resolve() != juju_exec_path.resolve():
argv = sys.argv.copy()
argv[0] = str(dispatch_path)
try:
subprocess.run(argv, check=True)
except subprocess.CalledProcessError as e:
logger.warning("hook %s exited with status %d", dispatch_path, e.returncode)
sys.exit(e.returncode)
juju_exec_path = dispatch_path
juju_event_name = juju_exec_path.name.replace('-', '_')
if juju_exec_path.parent.name == 'actions':
juju_event_name = '{}_action'.format(juju_event_name)
metadata, actions_metadata = _load_metadata(charm_dir)
meta = ops.charm.CharmMeta(metadata, actions_metadata)
unit_name = os.environ['JUJU_UNIT_NAME']
model = ops.model.Model(unit_name, meta, model_backend)
# TODO: If Juju unit agent crashes after exit(0) from the charm code
# the framework will commit the snapshot but Juju will not commit its
# operation.
charm_state_path = charm_dir / CHARM_STATE_FILE
framework = ops.framework.Framework(charm_state_path, charm_dir, meta, model)
try:
charm = charm_class(framework, None)
if not has_dispatch:
# 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 (see LP: #1854635).
if (juju_event_name in ('install', 'start', 'upgrade_charm')
or juju_event_name.endswith('_storage_attached')):
_setup_event_links(charm_dir, charm)
# TODO: Remove the collect_metrics check below as soon as the relevant
# Juju changes are made.
#
# Skip reemission of deferred events for collect-metrics events because
# they do not have the full access to all hook tools.
if juju_event_name != 'collect_metrics':
framework.reemit()
_emit_charm_event(charm, juju_event_name)
framework.commit()
finally:
framework.close()
# Copyright 2020 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.
import inspect
import pathlib
from textwrap import dedent
import typing
from ops import charm, framework, model
# OptionalYAML is something like metadata.yaml or actions.yaml. You can
# pass in a file-like object or the string directly.
OptionalYAML = typing.Optional[typing.Union[str, typing.TextIO]]
# noinspection PyProtectedMember
class Harness:
"""This class represents a way to build up the model that will drive a test suite.
The model that is created is from the viewpoint of the charm that you are testing.
Example::
harness = Harness(MyCharm)
# Do initial setup here
relation_id = harness.add_relation('db', 'postgresql')
# Now instantiate the charm to see events as the model changes
harness.begin()
harness.add_relation_unit(relation_id, 'postgresql/0')
harness.update_relation_data(relation_id, 'postgresql/0', {'key': 'val'})
# Check that charm has properly handled the relation_joined event for postgresql/0
self.assertEqual(harness.charm. ...)
Args:
charm_cls: The Charm class that you'll be testing.
meta: charm.CharmBase is a A string or file-like object containing the contents of
metadata.yaml. If not supplied, we will look for a 'metadata.yaml' file in the
parent directory of the Charm, and if not found fall back to a trivial
'name: test-charm' metadata.
actions: A string or file-like object containing the contents of
actions.yaml. If not supplied, we will look for a 'actions.yaml' file in the
parent directory of the Charm.
"""
def __init__(
self,
charm_cls: typing.Type[charm.CharmBase],
*,
meta: OptionalYAML = None,
actions: OptionalYAML = None):
# TODO: jam 2020-03-05 We probably want to take config as a parameter as well, since
# it would define the default values of config that the charm would see.
self._charm_cls = charm_cls
self._charm = None
self._charm_dir = 'no-disk-path' # this may be updated by _create_meta
self._meta = self._create_meta(meta, actions)
self._unit_name = self._meta.name + '/0'
self._framework = None
self._hooks_enabled = True
self._relation_id_counter = 0
self._backend = _TestingModelBackend(self._unit_name, self._meta)
self._model = model.Model(self._unit_name, self._meta, self._backend)
self._framework = framework.Framework(":memory:", self._charm_dir, self._meta, self._model)
@property
def charm(self) -> charm.CharmBase:
"""Return the instance of the charm class that was passed to __init__.
Note that the Charm is not instantiated until you have called
:meth:`.begin()`.
"""
return self._charm
@property
def model(self) -> model.Model:
"""Return the :class:`~ops.model.Model` that is being driven by this Harness."""
return self._model
@property
def framework(self) -> framework.Framework:
"""Return the Framework that is being driven by this Harness."""
return self._framework
def begin(self) -> None:
"""Instantiate the Charm and start handling events.
Before calling begin(), there is no Charm instance, so changes to the Model won't emit
events. You must call begin before :attr:`.charm` is valid.
"""
if self._charm is not None:
raise RuntimeError('cannot call the begin method on the harness more than once')
# The Framework adds attributes to class objects for events, etc. As such, we can't re-use
# the original class against multiple Frameworks. So create a locally defined class
# and register it.
# TODO: jam 2020-03-16 We are looking to changes this to Instance attributes instead of
# Class attributes which should clean up this ugliness. The API can stay the same
class TestEvents(self._charm_cls.on.__class__):
pass
TestEvents.__name__ = self._charm_cls.on.__class__.__name__
class TestCharm(self._charm_cls):
on = TestEvents()
# Note: jam 2020-03-01 This is so that errors in testing say MyCharm has no attribute foo,
# rather than TestCharm has no attribute foo.
TestCharm.__name__ = self._charm_cls.__name__
self._charm = TestCharm(self._framework, self._framework.meta.name)
def _create_meta(self, charm_metadata, action_metadata):
"""Create a CharmMeta object.
Handle the cases where a user doesn't supply explicit metadata snippets.
"""
filename = inspect.getfile(self._charm_cls)
charm_dir = pathlib.Path(filename).parents[1]
if charm_metadata is None:
metadata_path = charm_dir / 'metadata.yaml'
if metadata_path.is_file():
charm_metadata = metadata_path.read_text()
self._charm_dir = charm_dir
else:
# The simplest of metadata that the framework can support
charm_metadata = 'name: test-charm'
elif isinstance(charm_metadata, str):
charm_metadata = dedent(charm_metadata)
if action_metadata is None:
actions_path = charm_dir / 'actions.yaml'
if actions_path.is_file():
action_metadata = actions_path.read_text()
self._charm_dir = charm_dir
elif isinstance(action_metadata, str):
action_metadata = dedent(action_metadata)
return charm.CharmMeta.from_yaml(charm_metadata, action_metadata)
def disable_hooks(self) -> None:
"""Stop emitting hook events when the model changes.
This can be used by developers to stop changes to the model from emitting events that
the charm will react to. Call :meth:`.enable_hooks`
to re-enable them.
"""
self._hooks_enabled = False
def enable_hooks(self) -> None:
"""Re-enable hook events from charm.on when the model is changed.
By default hook events are enabled once you call :meth:`.begin`,
but if you have used :meth:`.disable_hooks`, this can be used to
enable them again.
"""
self._hooks_enabled = True
def _next_relation_id(self):
rel_id = self._relation_id_counter
self._relation_id_counter += 1
return rel_id
def add_relation(self, relation_name: str, remote_app: str) -> int:
"""Declare that there is a new relation between this app and `remote_app`.
Args:
relation_name: The relation on Charm that is being related to
remote_app: The name of the application that is being related to
Return:
The relation_id created by this add_relation.
"""
rel_id = self._next_relation_id()
self._backend._relation_ids_map.setdefault(relation_name, []).append(rel_id)
self._backend._relation_names[rel_id] = relation_name
self._backend._relation_list_map[rel_id] = []
self._backend._relation_data[rel_id] = {
remote_app: {},
self._backend.unit_name: {},
self._backend.app_name: {},
}
# Reload the relation_ids list
if self._model is not None:
self._model.relations._invalidate(relation_name)
if self._charm is None or not self._hooks_enabled:
return rel_id
relation = self._model.get_relation(relation_name, rel_id)
app = self._model.get_app(remote_app)
self._charm.on[relation_name].relation_created.emit(
relation, app)
return rel_id
def add_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
"""Add a new unit to a relation.
Example::
rel_id = harness.add_relation('db', 'postgresql')
harness.add_relation_unit(rel_id, 'postgresql/0')
This will trigger a `relation_joined` event and a `relation_changed` event.
Args:
relation_id: The integer relation identifier (as returned by add_relation).
remote_unit_name: A string representing the remote unit that is being added.
Return:
None
"""
self._backend._relation_list_map[relation_id].append(remote_unit_name)
self._backend._relation_data[relation_id][remote_unit_name] = {}
relation_name = self._backend._relation_names[relation_id]
# Make sure that the Model reloads the relation_list for this relation_id, as well as
# reloading the relation data for this unit.
if self._model is not None:
self._model.relations._invalidate(relation_name)
remote_unit = self._model.get_unit(remote_unit_name)
relation = self._model.get_relation(relation_name, relation_id)
relation.data[remote_unit]._invalidate()
if self._charm is None or not self._hooks_enabled:
return
self._charm.on[relation_name].relation_joined.emit(
relation, remote_unit.app, remote_unit)
def get_relation_data(self, relation_id: int, app_or_unit: str) -> typing.Mapping:
"""Get the relation data bucket for a single app or unit in a given relation.
This ignores all of the safety checks of who can and can't see data in relations (eg,
non-leaders can't read their own application's relation data because there are no events
that keep that data up-to-date for the unit).
Args:
relation_id: The relation whose content we want to look at.
app_or_unit: The name of the application or unit whose data we want to read
Return:
a dict containing the relation data for `app_or_unit` or None.
Raises:
KeyError: if relation_id doesn't exist
"""
return self._backend._relation_data[relation_id].get(app_or_unit, None)
def get_workload_version(self) -> str:
"""Read the workload version that was set by the unit."""
return self._backend._workload_version
def update_relation_data(
self,
relation_id: int,
app_or_unit: str,
key_values: typing.Mapping,
) -> None:
"""Update the relation data for a given unit or application in a given relation.
This also triggers the `relation_changed` event for this relation_id.
Args:
relation_id: The integer relation_id representing this relation.
app_or_unit: The unit or application name that is being updated.
This can be the local or remote application.
key_values: Each key/value will be updated in the relation data.
"""
relation_name = self._backend._relation_names[relation_id]
relation = self._model.get_relation(relation_name, relation_id)
if '/' in app_or_unit:
entity = self._model.get_unit(app_or_unit)
else:
entity = self._model.get_app(app_or_unit)
rel_data = relation.data.get(entity, None)
if rel_data is not None:
# rel_data may have cached now-stale data, so _invalidate() it.
# Note, this won't cause the data to be loaded if it wasn't already.
rel_data._invalidate()
new_values = self._backend._relation_data[relation_id][app_or_unit].copy()
for k, v in key_values.items():
if v == '':
new_values.pop(k, None)
else:
new_values[k] = v
self._backend._relation_data[relation_id][app_or_unit] = new_values
if app_or_unit == self._model.unit.name:
# No events for our own unit
return
if app_or_unit == self._model.app.name:
# updating our own app only generates an event if it is a peer relation and we
# aren't the leader
is_peer = self._meta.relations[relation_name].role == 'peers'
if not is_peer:
return
if self._model.unit.is_leader():
return
self._emit_relation_changed(relation_id, app_or_unit)
def _emit_relation_changed(self, relation_id, app_or_unit):
if self._charm is None or not self._hooks_enabled:
return
rel_name = self._backend._relation_names[relation_id]
relation = self.model.get_relation(rel_name, relation_id)
if '/' in app_or_unit:
app_name = app_or_unit.split('/')[0]
unit_name = app_or_unit
app = self.model.get_app(app_name)
unit = self.model.get_unit(unit_name)
args = (relation, app, unit)
else:
app_name = app_or_unit
app = self.model.get_app(app_name)
args = (relation, app)
self._charm.on[rel_name].relation_changed.emit(*args)
def update_config(
self,
key_values: typing.Mapping[str, str] = None,
unset: typing.Iterable[str] = (),
) -> None:
"""Update the config as seen by the charm.
This will trigger a `config_changed` event.
Args:
key_values: A Mapping of key:value pairs to update in config.
unset: An iterable of keys to remove from Config. (Note that this does
not currently reset the config values to the default defined in config.yaml.)
"""
config = self._backend._config
if key_values is not None:
for key, value in key_values.items():
config[key] = value
for key in unset:
config.pop(key, None)
# NOTE: jam 2020-03-01 Note that this sort of works "by accident". Config
# is a LazyMapping, but its _load returns a dict and this method mutates
# the dict that Config is caching. Arguably we should be doing some sort
# of charm.framework.model.config._invalidate()
if self._charm is None or not self._hooks_enabled:
return
self._charm.on.config_changed.emit()
def set_leader(self, is_leader: bool = True) -> None:
"""Set whether this unit is the leader or not.
If this charm becomes a leader then `leader_elected` will be triggered.
Args:
is_leader: True/False as to whether this unit is the leader.
"""
was_leader = self._backend._is_leader
self._backend._is_leader = is_leader
# Note: jam 2020-03-01 currently is_leader is cached at the ModelBackend level, not in
# the Model objects, so this automatically gets noticed.
if is_leader and not was_leader and self._charm is not None and self._hooks_enabled:
self._charm.on.leader_elected.emit()
class _TestingModelBackend:
"""This conforms to the interface for ModelBackend but provides canned data.
DO NOT use this class directly, it is used by `Harness`_ to drive the model.
`Harness`_ is responsible for maintaining the internal consistency of the values here,
as the only public methods of this type are for implementing ModelBackend.
"""
def __init__(self, unit_name, meta):
self.unit_name = unit_name
self.app_name = self.unit_name.split('/')[0]
self._calls = []
self._meta = meta
self._is_leader = None
self._relation_ids_map = {} # relation name to [relation_ids,...]
self._relation_names = {} # reverse map from relation_id to relation_name
self._relation_list_map = {} # relation_id: [unit_name,...]
self._relation_data = {} # {relation_id: {name: data}}
self._config = {}
self._is_leader = False
self._resources_map = {}
self._pod_spec = None
self._app_status = None
self._unit_status = None
self._workload_version = None
def relation_ids(self, relation_name):
try:
return self._relation_ids_map[relation_name]
except KeyError as e:
if relation_name not in self._meta.relations:
raise model.ModelError('{} is not a known relation'.format(relation_name)) from e
return []
def relation_list(self, relation_id):
try:
return self._relation_list_map[relation_id]
except KeyError as e:
raise model.RelationNotFoundError from e
def relation_get(self, relation_id, member_name, is_app):
if is_app and '/' in member_name:
member_name = member_name.split('/')[0]
if relation_id not in self._relation_data:
raise model.RelationNotFoundError()
return self._relation_data[relation_id][member_name].copy()
def relation_set(self, relation_id, key, value, is_app):
relation = self._relation_data[relation_id]
if is_app:
bucket_key = self.app_name
else:
bucket_key = self.unit_name
if bucket_key not in relation:
relation[bucket_key] = {}
bucket = relation[bucket_key]
if value == '':
bucket.pop(key, None)
else:
bucket[key] = value
def config_get(self):
return self._config
def is_leader(self):
return self._is_leader
def application_version_set(self, version):
self._workload_version = version
def resource_get(self, resource_name):
return self._resources_map[resource_name]
def pod_spec_set(self, spec, k8s_resources):
self._pod_spec = (spec, k8s_resources)
def status_get(self, *, is_app=False):
if is_app:
return self._app_status
else:
return self._unit_status
def status_set(self, status, message='', *, is_app=False):
if is_app:
self._app_status = (status, message)
else:
self._unit_status = (status, message)
def storage_list(self, name):
raise NotImplementedError(self.storage_list)
def storage_get(self, storage_name_id, attribute):
raise NotImplementedError(self.storage_get)
def storage_add(self, name, count=1):
raise NotImplementedError(self.storage_add)
def action_get(self):
raise NotImplementedError(self.action_get)
def action_set(self, results):
raise NotImplementedError(self.action_set)
def action_log(self, message):
raise NotImplementedError(self.action_log)
def action_fail(self, message=''):
raise NotImplementedError(self.action_fail)
def network_get(self, endpoint_name, relation_id=None):
raise NotImplementedError(self.network_get)
# 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.
from setuptools import setup
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name="ops",
version="0.0.1",
description="The Python library behind great charms",
long_description=long_description,
long_description_content_type="text/markdown",
license="Apache-2.0",
url="https://github.com/canonical/operator",
packages=["ops"],
classifiers=[
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"License :: OSI Approved :: Apache Software License",
],
)
#!/bin/bash
case $1 in
db) echo '["db:1"]' ;;
mon) echo '["mon:2"]' ;;
ha) echo '[]' ;;
db0) echo '[]' ;;
db1) echo '["db1:4"]' ;;
db2) echo '["db2:5", "db2:6"]' ;;
*) echo '[]' ;;
esac
#!/bin/bash
fail_not_found() {
1>&2 echo "ERROR invalid value \"$1\" for option -r: relation not found"
exit 2
}
case $2 in
1) echo '["remote/0"]' ;;
2) echo '["remote/0"]' ;;
3) fail_not_found $2 ;;
4) echo '["remoteapp1/0"]' ;;
5) echo '["remoteapp1/0"]' ;;
6) echo '["remoteapp2/0"]' ;;
*) fail_not_found $2 ;;
esac
name: main
summary: A charm used for testing the basic operation of the entrypoint code.
maintainer: Dmitrii Shcherbakov <dmitrii.shcherbakov@canonical.com>
description: A charm used for testing the basic operation of the entrypoint code.
tags:
- misc
series:
- bionic
- cosmic
- disco
min-juju-version: 2.7.1
provides:
db:
interface: db
requires:
mon:
interface: monitoring
peers:
ha:
interface: cluster
subordinate: false
storage:
disks:
type: block
multiple:
range: 0-
#!/usr/bin/env python3
# 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.
import os
import base64
import pickle
import sys
import logging
sys.path.append('lib')
from ops.charm import CharmBase # noqa: E402 (module-level import after non-import code)
from ops.main import main # noqa: E402 (ditto)
logger = logging.getLogger()
class Charm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
# This environment variable controls the test charm behavior.
charm_config = os.environ.get('CHARM_CONFIG')
if charm_config is not None:
self._charm_config = pickle.loads(base64.b64decode(charm_config))
else:
self._charm_config = {}
# TODO: refactor to use StoredState
# (this implies refactoring most of test_main.py)
self._state_file = self._charm_config.get('STATE_FILE')
try:
with open(str(self._state_file), 'rb') as f:
self._state = pickle.load(f)
except (FileNotFoundError, EOFError):
self._state = {
'on_install': [],
'on_start': [],
'on_config_changed': [],
'on_update_status': [],
'on_leader_settings_changed': [],
'on_db_relation_joined': [],
'on_mon_relation_changed': [],
'on_mon_relation_departed': [],
'on_ha_relation_broken': [],
'on_foo_bar_action': [],
'on_start_action': [],
'on_collect_metrics': [],
'on_log_critical_action': [],
'on_log_error_action': [],
'on_log_warning_action': [],
'on_log_info_action': [],
'on_log_debug_action': [],
# Observed event types per invocation. A list is used to preserve the
# order in which charm handlers have observed the events.
'observed_event_types': [],
}
self.framework.observe(self.on.install, self)
self.framework.observe(self.on.start, self)
self.framework.observe(self.on.config_changed, self)
self.framework.observe(self.on.update_status, self)
self.framework.observe(self.on.leader_settings_changed, self)
# Test relation events with endpoints from different
# sections (provides, requires, peers) as well.
self.framework.observe(self.on.db_relation_joined, self)
self.framework.observe(self.on.mon_relation_changed, self)
self.framework.observe(self.on.mon_relation_departed, self)
self.framework.observe(self.on.ha_relation_broken, self)
if self._charm_config.get('USE_ACTIONS'):
self.framework.observe(self.on.start_action, self)
self.framework.observe(self.on.foo_bar_action, self)
self.framework.observe(self.on.collect_metrics, self)
if self._charm_config.get('USE_LOG_ACTIONS'):
self.framework.observe(self.on.log_critical_action, self)
self.framework.observe(self.on.log_error_action, self)
self.framework.observe(self.on.log_warning_action, self)
self.framework.observe(self.on.log_info_action, self)
self.framework.observe(self.on.log_debug_action, self)
def _write_state(self):
"""Write state variables so that the parent process can read them.
Each invocation will override the previous state which is intentional.
"""
if self._state_file is not None:
with self._state_file.open('wb') as f:
pickle.dump(self._state, f)
def on_install(self, event):
self._state['on_install'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._write_state()
def on_start(self, event):
self._state['on_start'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._write_state()
def on_config_changed(self, event):
self._state['on_config_changed'].append(type(event))
self._state['observed_event_types'].append(type(event))
event.defer()
self._write_state()
def on_update_status(self, event):
self._state['on_update_status'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._write_state()
def on_leader_settings_changed(self, event):
self._state['on_leader_settings_changed'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._write_state()
def on_db_relation_joined(self, event):
assert event.app is not None, 'application name cannot be None for a relation-joined event'
self._state['on_db_relation_joined'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._state['db_relation_joined_data'] = event.snapshot()
self._write_state()
def on_mon_relation_changed(self, event):
assert event.app is not None, (
'application name cannot be None for a relation-changed event')
if os.environ.get('JUJU_REMOTE_UNIT'):
assert event.unit is not None, (
'a unit name cannot be None for a relation-changed event'
' associated with a remote unit')
self._state['on_mon_relation_changed'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._state['mon_relation_changed_data'] = event.snapshot()
self._write_state()
def on_mon_relation_departed(self, event):
assert event.app is not None, (
'application name cannot be None for a relation-departed event')
self._state['on_mon_relation_departed'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._state['mon_relation_departed_data'] = event.snapshot()
self._write_state()
def on_ha_relation_broken(self, event):
assert event.app is None, (
'relation-broken events cannot have a reference to a remote application')
assert event.unit is None, (
'relation broken events cannot have a reference to a remote unit')
self._state['on_ha_relation_broken'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._state['ha_relation_broken_data'] = event.snapshot()
self._write_state()
def on_start_action(self, event):
assert event.handle.kind == 'start_action', (
'event action name cannot be different from the one being handled')
self._state['on_start_action'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._write_state()
def on_foo_bar_action(self, event):
assert event.handle.kind == 'foo_bar_action', (
'event action name cannot be different from the one being handled')
self._state['on_foo_bar_action'].append(type(event))
self._state['observed_event_types'].append(type(event))
self._write_state()
def on_collect_metrics(self, event):
self._state['on_collect_metrics'].append(type(event))
self._state['observed_event_types'].append(type(event))
event.add_metrics({'foo': 42}, {'bar': 4.2})
self._write_state()
def on_log_critical_action(self, event):
logger.critical('super critical')
def on_log_error_action(self, event):
logger.error('grave error')
def on_log_warning_action(self, event):
logger.warning('wise warning')
def on_log_info_action(self, event):
logger.info('useful info')
def on_log_debug_action(self, event):
logger.debug('insightful debug')
if __name__ == '__main__':
main(Charm)
#!/usr/bin/python3
# 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.
import os
import unittest
import tempfile
import shutil
from pathlib import Path
from ops.charm import (
CharmBase,
CharmMeta,
CharmEvents,
)
from ops.framework import Framework, EventSource, EventBase
from ops.model import Model, ModelBackend
from .test_helpers import fake_script, fake_script_calls
class TestCharm(unittest.TestCase):
def setUp(self):
def restore_env(env):
os.environ.clear()
os.environ.update(env)
self.addCleanup(restore_env, os.environ.copy())
os.environ['PATH'] = "{}:{}".format(Path(__file__).parent / 'bin', os.environ['PATH'])
os.environ['JUJU_UNIT_NAME'] = 'local/0'
self.tmpdir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, str(self.tmpdir))
self.meta = CharmMeta()
class CustomEvent(EventBase):
pass
class TestCharmEvents(CharmEvents):
custom = EventSource(CustomEvent)
# Relations events are defined dynamically and modify the class attributes.
# We use a subclass temporarily to prevent these side effects from leaking.
CharmBase.on = TestCharmEvents()
def cleanup():
CharmBase.on = CharmEvents()
self.addCleanup(cleanup)
def create_framework(self):
model = Model('local/0', self.meta, ModelBackend())
framework = Framework(self.tmpdir / "framework.data", self.tmpdir, self.meta, model)
self.addCleanup(framework.close)
return framework
def test_basic(self):
class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.started = False
framework.observe(self.on.start, self)
def on_start(self, event):
self.started = True
events = list(MyCharm.on.events())
self.assertIn('install', events)
self.assertIn('custom', events)
framework = self.create_framework()
charm = MyCharm(framework, None)
charm.on.start.emit()
self.assertEqual(charm.started, True)
def test_helper_properties(self):
framework = self.create_framework()
class MyCharm(CharmBase):
pass
charm = MyCharm(framework, None)
self.assertEqual(charm.app, framework.model.app)
self.assertEqual(charm.unit, framework.model.unit)
self.assertEqual(charm.meta, framework.meta)
self.assertEqual(charm.charm_dir, framework.charm_dir)
def test_relation_events(self):
class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.seen = []
for rel in ('req1', 'req-2', 'pro1', 'pro-2', 'peer1', 'peer-2'):
# Hook up relation events to generic handler.
self.framework.observe(self.on[rel].relation_joined, self.on_any_relation)
self.framework.observe(self.on[rel].relation_changed, self.on_any_relation)
self.framework.observe(self.on[rel].relation_departed, self.on_any_relation)
self.framework.observe(self.on[rel].relation_broken, self.on_any_relation)
def on_any_relation(self, event):
assert event.relation.name == 'req1'
assert event.relation.app.name == 'remote'
self.seen.append(type(event).__name__)
# language=YAML
self.meta = CharmMeta.from_yaml(metadata='''
name: my-charm
requires:
req1:
interface: req1
req-2:
interface: req2
provides:
pro1:
interface: pro1
pro-2:
interface: pro2
peers:
peer1:
interface: peer1
peer-2:
interface: peer2
''')
charm = MyCharm(self.create_framework(), None)
rel = charm.framework.model.get_relation('req1', 1)
unit = charm.framework.model.get_unit('remote/0')
charm.on['req1'].relation_joined.emit(rel, unit)
charm.on['req1'].relation_changed.emit(rel, unit)
charm.on['req-2'].relation_changed.emit(rel, unit)
charm.on['pro1'].relation_departed.emit(rel, unit)
charm.on['pro-2'].relation_departed.emit(rel, unit)
charm.on['peer1'].relation_broken.emit(rel)
charm.on['peer-2'].relation_broken.emit(rel)
self.assertEqual(charm.seen, [
'RelationJoinedEvent',
'RelationChangedEvent',
'RelationChangedEvent',
'RelationDepartedEvent',
'RelationDepartedEvent',
'RelationBrokenEvent',
'RelationBrokenEvent',
])
def test_storage_events(self):
class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.seen = []
self.framework.observe(self.on['stor1'].storage_attached, self)
self.framework.observe(self.on['stor2'].storage_detaching, self)
self.framework.observe(self.on['stor3'].storage_attached, self)
self.framework.observe(self.on['stor-4'].storage_attached, self)
def on_stor1_storage_attached(self, event):
self.seen.append(type(event).__name__)
def on_stor2_storage_detaching(self, event):
self.seen.append(type(event).__name__)
def on_stor3_storage_attached(self, event):
self.seen.append(type(event).__name__)
def on_stor_4_storage_attached(self, event):
self.seen.append(type(event).__name__)
# language=YAML
self.meta = CharmMeta.from_yaml('''
name: my-charm
storage:
stor-4:
multiple:
range: 2-4
type: filesystem
stor1:
type: filesystem
stor2:
multiple:
range: "2"
type: filesystem
stor3:
multiple:
range: 2-
type: filesystem
''')
self.assertIsNone(self.meta.storages['stor1'].multiple_range)
self.assertEqual(self.meta.storages['stor2'].multiple_range, (2, 2))
self.assertEqual(self.meta.storages['stor3'].multiple_range, (2, None))
self.assertEqual(self.meta.storages['stor-4'].multiple_range, (2, 4))
charm = MyCharm(self.create_framework(), None)
charm.on['stor1'].storage_attached.emit()
charm.on['stor2'].storage_detaching.emit()
charm.on['stor3'].storage_attached.emit()
charm.on['stor-4'].storage_attached.emit()
self.assertEqual(charm.seen, [
'StorageAttachedEvent',
'StorageDetachingEvent',
'StorageAttachedEvent',
'StorageAttachedEvent',
])
@classmethod
def _get_action_test_meta(cls):
# language=YAML
return CharmMeta.from_yaml(metadata='''
name: my-charm
''', actions='''
foo-bar:
description: "Foos the bar."
params:
foo-name:
description: "A foo name to bar"
type: string
silent:
default: false
description: ""
type: boolean
required: foo-bar
title: foo-bar
start:
description: "Start the unit."
''')
def _test_action_events(self, cmd_type):
class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
framework.observe(self.on.foo_bar_action, self)
framework.observe(self.on.start_action, self)
def on_foo_bar_action(self, event):
self.seen_action_params = event.params
event.log('test-log')
event.set_results({'res': 'val with spaces'})
event.fail('test-fail')
def on_start_action(self, event):
pass
fake_script(self, cmd_type + '-get', """echo '{"foo-name": "name", "silent": true}'""")
fake_script(self, cmd_type + '-set', "")
fake_script(self, cmd_type + '-log', "")
fake_script(self, cmd_type + '-fail', "")
self.meta = self._get_action_test_meta()
os.environ['JUJU_{}_NAME'.format(cmd_type.upper())] = 'foo-bar'
framework = self.create_framework()
charm = MyCharm(framework, None)
events = list(MyCharm.on.events())
self.assertIn('foo_bar_action', events)
self.assertIn('start_action', events)
charm.on.foo_bar_action.emit()
self.assertEqual(charm.seen_action_params, {"foo-name": "name", "silent": True})
self.assertEqual(fake_script_calls(self), [
[cmd_type + '-get', '--format=json'],
[cmd_type + '-log', "test-log"],
[cmd_type + '-set', "res=val with spaces"],
[cmd_type + '-fail', "test-fail"],
])
# Make sure that action events that do not match the current context are
# not possible to emit by hand.
with self.assertRaises(RuntimeError):
charm.on.start_action.emit()
def test_action_events(self):
self._test_action_events('action')
def _test_action_event_defer_fails(self, cmd_type):
class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
framework.observe(self.on.start_action, self)
def on_start_action(self, event):
event.defer()
fake_script(self, cmd_type + '-get', """echo '{"foo-name": "name", "silent": true}'""")
self.meta = self._get_action_test_meta()
os.environ['JUJU_{}_NAME'.format(cmd_type.upper())] = 'start'
framework = self.create_framework()
charm = MyCharm(framework, None)
with self.assertRaises(RuntimeError):
charm.on.start_action.emit()
def test_action_event_defer_fails(self):
self._test_action_event_defer_fails('action')
if __name__ == "__main__":
unittest.main()
# 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.
import os
import pathlib
import subprocess
import shutil
import tempfile
import unittest
def fake_script(test_case, name, content):
if not hasattr(test_case, 'fake_script_path'):
fake_script_path = tempfile.mkdtemp('-fake_script')
os.environ['PATH'] = '{}:{}'.format(fake_script_path, os.environ["PATH"])
def cleanup():
shutil.rmtree(fake_script_path)
os.environ['PATH'] = os.environ['PATH'].replace(fake_script_path + ':', '')
test_case.addCleanup(cleanup)
test_case.fake_script_path = pathlib.Path(fake_script_path)
with (test_case.fake_script_path / name).open('wt') as f:
# Before executing the provided script, dump the provided arguments in calls.txt.
f.write('''#!/bin/bash
{ echo -n $(basename $0); printf ";%s" "$@"; echo; } >> $(dirname $0)/calls.txt
''' + content)
os.chmod(str(test_case.fake_script_path / name), 0o755)
def fake_script_calls(test_case, clear=False):
try:
with (test_case.fake_script_path / 'calls.txt').open('r+t') as f:
calls = [line.split(';') for line in f.read().splitlines()]
if clear:
f.truncate(0)
return calls
except FileNotFoundError:
return []
class FakeScriptTest(unittest.TestCase):
def test_fake_script_works(self):
fake_script(self, 'foo', 'echo foo runs')
fake_script(self, 'bar', 'echo bar runs')
output = subprocess.getoutput('foo a "b c "; bar "d e" f')
self.assertEqual(output, 'foo runs\nbar runs')
self.assertEqual(fake_script_calls(self), [
['foo', 'a', 'b c '],
['bar', 'd e', 'f'],
])
def test_fake_script_clear(self):
fake_script(self, 'foo', 'echo foo runs')
output = subprocess.getoutput('foo a "b c"')
self.assertEqual(output, 'foo runs')
self.assertEqual(fake_script_calls(self, clear=True), [['foo', 'a', 'b c']])
fake_script(self, 'bar', 'echo bar runs')
output = subprocess.getoutput('bar "d e" f')
self.assertEqual(output, 'bar runs')
self.assertEqual(fake_script_calls(self, clear=True), [['bar', 'd e', 'f']])
self.assertEqual(fake_script_calls(self, clear=True), [])
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment