Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[wip] facade charm #152

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ dist/
.ruff_cache
.pytest_cache
.idea/
/facade-charm/mocks/provide/
/facade-charm/mocks/require/
/facade-charm/charmcraft.yaml

venv
*.charm
*.charm
26 changes: 26 additions & 0 deletions facade-charm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Facade charm

Usage:
`tox -e pack`
`juju deploy facade`
`juju relate facade:provide-your-interface <your charm>`
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved

## update databags from jhack eval

`jhack eval facade/0 "self.set('provide-your-interface', app_data={'hello': 'world'}, unit_data={'lets foo': 'those bars')"`

## update databags from mock files

`cd facade-charm`
`jhack sync -S facade/0 --include-files=".*\.yaml" --source mocks`

`cd facade-charm/mocks/provide`

edit `provide-your-interface.yaml`

`juju run facade update`

# using custom interfaces

Your interface isn't in charm-relation-interfaces (yet)? No problem.
Add it to `custom_interfaces.yaml`, and it shall be picked up when you `tox -e pack`.
3 changes: 3 additions & 0 deletions facade-charm/custom_interfaces.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interfaces:
# - your-interface-here
- prova-prova
1 change: 1 addition & 0 deletions facade-charm/mocks/__DO_NOT_TOUCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
the contents of this directory may be overridden by the update_endpoints script.
1 change: 1 addition & 0 deletions facade-charm/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ops ~= 2.5
135 changes: 135 additions & 0 deletions facade-charm/src/charm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# Copyright 2024 pietro
# See LICENSE file for licensing details.

import logging
from itertools import chain
from pathlib import Path
from typing import Dict

import ops
import yaml
from ops import ActiveStatus

logger = logging.getLogger(__name__)


class FacadeCharm(ops.CharmBase):
"""Charming facade."""

def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status)
self.framework.observe(self.on.update_action, self._on_update_action)

for relation in self.meta.relations:
on_relation = self.on[relation]
for evt in ['changed', 'created', 'joined']:
self.framework.observe(getattr(on_relation, "relation_" + evt), self._on_update_relation)

def _on_update_action(self, e: ops.ActionEvent):
updated = []
if endpoint := e.params.get("endpoint"):
e.log(f"updating endpoint {endpoint}")
if relations := self.model.relations[endpoint]:
updated = self._update(*relations)
else:
e.log(f"no bindings on {endpoint}")

else:
e.log(f"updating all endpoints")
updated = self._update()
e.set_results({"updated": updated})

def _on_clear_action(self, e: ops.ActionEvent):
updated = []
if endpoint := e.params.get("endpoint"):
e.log(f"clearing endpoint {endpoint}")
if relations := self.model.relations[endpoint]:
updated = self._update(*relations, clear=True)
else:
e.log(f"no bindings on {endpoint}")
else:
e.log(f"clearing all endpoints")
updated = self._update(clear=True)
e.set_results({"cleared": updated})

def _on_update_relation(self, e: ops.RelationEvent):
self._update(e.relation)

def _update(self, *relation: ops.Relation,
clear=False):
to_update = list(relation) if relation else list(chain(*self.model.relations.values()))
updated = []
for relation in to_update:
if self._update_relation(relation, clear=clear):
updated.append(relation.name)
return updated

def _update_relation(self, relation: ops.Relation, clear=False, replace=False):
changed = False

app_databag = relation.data[self.app]
unit_databag = relation.data[self.unit]
if replace or clear:
if app_databag.keys():
app_databag.clear()
changed = True
if unit_databag.keys():
unit_databag.clear()
changed = True

if not clear:
app_data, unit_data = self._load_mock(relation.name)
if dict(app_databag) != app_data:
app_databag.update(app_data)
changed = True
if dict(unit_databag) != unit_data:
unit_databag.update(unit_data)
changed = True
return changed

def _load_mock(self, endpoint: str):
mocks_root = Path(__file__).parent.parent / 'mocks'

if endpoint.startswith("provide"):
pth = mocks_root / "provide" / (endpoint + ".yaml")
else:
pth = mocks_root / "provide" / (endpoint + ".yaml")

if not pth.exists():
logger.error(f"mock not found for {endpoint}")
return {}, {}

yml = yaml.safe_load(pth.read_text())
app_data = yml.get("app_data", {})
unit_data = yml.get("unit_data", {})
return app_data, unit_data

def _on_collect_unit_status(self, e: ops.CollectStatusEvent):
e.add_status(ActiveStatus("facade ready"))

# target for jhack eval
def set(self,
endpoint: str,
relation_id: int = None,
app_data: Dict[str, str] = None,
unit_data: Dict[str, str] = None,
):

rel = self.model.get_relation(endpoint, relation_id)

if app_data:
rel.data[self.app].update(app_data)
elif app_data is not None: # user passed {}
rel.data[self.app].clear()

if unit_data:
rel.data[self.unit].update(unit_data)
elif unit_data is not None: # user passed {}
rel.data[self.unit].clear()



if __name__ == "__main__": # pragma: nocover
ops.main(FacadeCharm) # type: ignore
35 changes: 35 additions & 0 deletions facade-charm/tests/integration/test_charm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# Copyright 2024 pietro
# See LICENSE file for licensing details.

import asyncio
import logging
from pathlib import Path

import pytest
import yaml
from pytest_operator.plugin import OpsTest

logger = logging.getLogger(__name__)

METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
APP_NAME = METADATA["name"]


@pytest.mark.abort_on_fail
async def test_build_and_deploy(ops_test: OpsTest):
"""Build the charm-under-test and deploy it together with related charms.

Assert on the unit status before any relations/configurations take place.
"""
# Build and deploy charm from local source folder
charm = await ops_test.build_charm(".")
resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]}

# Deploy the charm and wait for active/idle status
await asyncio.gather(
ops_test.model.deploy(charm, resources=resources, application_name=APP_NAME),
ops_test.model.wait_for_idle(
apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000
),
)
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
68 changes: 68 additions & 0 deletions facade-charm/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Copyright 2024 pietro
# See LICENSE file for licensing details.
#
# Learn more about testing at: https://juju.is/docs/sdk/testing

import unittest

import ops
import ops.testing
from charm import FacadeCharmCharm


class TestCharm(unittest.TestCase):
def setUp(self):
self.harness = ops.testing.Harness(FacadeCharmCharm)
self.addCleanup(self.harness.cleanup)
self.harness.begin()

def test_httpbin_pebble_ready(self):
# Expected plan after Pebble ready with default config
expected_plan = {
"services": {
"httpbin": {
"override": "replace",
"summary": "httpbin",
"command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent",
"startup": "enabled",
"environment": {"GUNICORN_CMD_ARGS": "--log-level info"},
}
},
}
# Simulate the container coming up and emission of pebble-ready event
self.harness.container_pebble_ready("httpbin")
# Get the plan now we've run PebbleReady
updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict()
# Check we've got the plan we expected
self.assertEqual(expected_plan, updated_plan)
# Check the service was started
service = self.harness.model.unit.get_container("httpbin").get_service("httpbin")
self.assertTrue(service.is_running())
# Ensure we set an ActiveStatus with no message
self.assertEqual(self.harness.model.unit.status, ops.ActiveStatus())
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved

def test_config_changed_valid_can_connect(self):
# Ensure the simulated Pebble API is reachable
self.harness.set_can_connect("httpbin", True)
# Trigger a config-changed event with an updated value
self.harness.update_config({"log-level": "debug"})
# Get the plan now we've run PebbleReady
updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict()
updated_env = updated_plan["services"]["httpbin"]["environment"]
# Check the config change was effective
self.assertEqual(updated_env, {"GUNICORN_CMD_ARGS": "--log-level debug"})
self.assertEqual(self.harness.model.unit.status, ops.ActiveStatus())

def test_config_changed_valid_cannot_connect(self):
# Trigger a config-changed event with an updated value
self.harness.update_config({"log-level": "debug"})
# Check the charm is in WaitingStatus
self.assertIsInstance(self.harness.model.unit.status, ops.WaitingStatus)

def test_config_changed_invalid(self):
# Ensure the simulated Pebble API is reachable
self.harness.set_can_connect("httpbin", True)
# Trigger a config-changed event with an updated value
self.harness.update_config({"log-level": "foobar"})
# Check the charm is in BlockedStatus
self.assertIsInstance(self.harness.model.unit.status, ops.BlockedStatus)
36 changes: 36 additions & 0 deletions facade-charm/tox.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2024 canonical
# See LICENSE file for licensing details.

[tox]
no_package = True
skip_missing_interpreters = True
env_list = format, lint, static, unit
min_version = 4.0.0

[vars]
src_path = {tox_root}/src
tests_path = {tox_root}/tests
all_path = {[vars]src_path} {[vars]tests_path}

[testenv]
set_env =
PYTHONPATH = {tox_root}/lib:{[vars]src_path}
PYTHONBREAKPOINT=pdb.set_trace
PY_COLORS=1
pass_env =
PYTHONPATH
CHARM_BUILD_DIR
MODEL_SETTINGS

[testenv:pack]
description = Pack the facade charm.
deps =
-r {tox_root}/requirements.txt
allowlist_externals =
charmcraft
rm
commands =
python ./update_endpoints.py
charmcraft pack
rm charmcraft.yaml

Loading
Loading