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

Add actions to control the new workload #54

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 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
40 changes: 40 additions & 0 deletions actions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
check-acl:
description: Check the existence of an ACL
params:
service-account:
type: string
description: The unique identifier of the service account.
zone:
type: string
description: The targeted zone.
required:
- service-account
- zone
create-acl:
description: Create an ACL
params:
service-account:
type: string
description: The unique identifier of the service account.
zone:
type: string
description: The targeted zone.
required:
- service-account
- zone
delete-acl:
description: Delete an ACL
params:
service-account:
type: string
description: The unique identifier of the service account.
zone:
type: string
description: The targeted zone.
required:
- service-account
- zone
list-acl:
description: List all ACLs
13 changes: 13 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
options:
django_debug:
type: boolean
description: "Whether Django debug mode is enabled."
default: false
django_allowed_hosts:
nrobinaubertin marked this conversation as resolved.
Show resolved Hide resolved
type: string
description: A comma-separated list of host/domain names that the Django API
can serve. This configuration will set the DJANGO_ALLOWED_HOSTS environment
variable with its content being a JSON encoded list.
default: "localhost, 127.0.0.1, 0.0.0.0"
6 changes: 5 additions & 1 deletion metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ issues: https://github.com/canonical/bind-operator/issues
maintainers:
- https://launchpad.net/~canonical-is-devops
source: https://github.com/canonical/bind-operator

description: |
A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators)
deploying and managing a DNS server Integrator on Kubernetes and bare metal.
Expand All @@ -32,3 +31,8 @@ provides:
peers:
bind-peers:
interface: bind-instance
resources:
charmed-bind-snap:
type: file
filename: charmed-bind.snap
description: charmed-bind snap file for debugging purposes
92 changes: 92 additions & 0 deletions src/actions_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""Actions mixin for BindCharm."""

# Ignore having too few public methods for a mixin
# pylint: disable=too-few-public-methods

import ops

import bind


class ActionsMixin:
"""Regroups actions of the BindCharm.

Attributes:
bind: attribute from BindCharm
framework: attribute from BindCharm
on: attribute from BindCharm
"""

bind: bind.BindService
framework: ops.Framework
on: ops.CharmEvents

def hooks(self) -> None:
"""Define hooks that BindCharm should observe."""
# We ignore the type of the `self` argument of each hook
# as mypy has trouble understanding it
self.framework.observe(
self.on.create_acl_action, self._on_create_acl_action # type: ignore[arg-type]
)
self.framework.observe(
self.on.delete_acl_action, self._on_delete_acl_action # type: ignore[arg-type]
)
self.framework.observe(
self.on.check_acl_action, self._on_check_acl_action # type: ignore[arg-type]
)
self.framework.observe(
self.on.list_acl_action, self._on_list_acl_action # type: ignore[arg-type]
)

def _on_create_acl_action(self, event: ops.charm.ActionEvent) -> None:
"""Handle the create ACL ActionEvent.

Args:
event: Event triggering this action handler.
"""
event.set_results(
nrobinaubertin marked this conversation as resolved.
Show resolved Hide resolved
{
"result": self.bind.command(
f"create_acl {event.params['service-account']} {event.params['zone']}"
)
}
)

def _on_delete_acl_action(self, event: ops.charm.ActionEvent) -> None:
"""Handle the create ACL ActionEvent.

Args:
event: Event triggering this action handler.
"""
event.set_results(
{
"result": self.bind.command(
f"delete_acl {event.params['service-account']} {event.params['zone']}"
)
}
)

def _on_check_acl_action(self, event: ops.charm.ActionEvent) -> None:
"""Handle the create ACL ActionEvent.

Args:
event: Event triggering this action handler.
"""
event.set_results(
{
"result": self.bind.command(
f"check_acl {event.params['service-account']} {event.params['zone']}"
)
}
)

def _on_list_acl_action(self, event: ops.charm.ActionEvent) -> None:
"""Handle the create ACL ActionEvent.

Args:
event: Event triggering this action handler.
"""
event.set_results({"result": self.bind.command("list_acl")})
68 changes: 63 additions & 5 deletions src/bind.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import pathlib
import shutil
import subprocess # nosec
import tempfile
import time

Expand Down Expand Up @@ -44,6 +45,10 @@ class InstallError(SnapError):
"""Exception raised when unable to install dependencies for the service."""


class ConfigureError(SnapError):
"""Exception raised when unable to configure the service."""


class BindService:
"""Bind service class."""

Expand Down Expand Up @@ -104,16 +109,29 @@ def stop(self) -> None:
logger.error(error_msg)
raise StopError(error_msg) from e

def setup(self, unit_name: str) -> None:
def setup(self, unit_name: str, snap_path: str) -> None:
"""Prepare the machine.

Args:
unit_name: The name of the current unit
snap_path: The path to the snap to install, can be blank.
"""
self._install_snap_package(
snap_name=constants.DNS_SNAP_NAME,
snap_channel=constants.SNAP_PACKAGES[constants.DNS_SNAP_NAME]["channel"],
)
# If a snap resource was not given, install the snap as published on snapcraft
if snap_path == "":
self._install_snap_package(
snap_name=constants.DNS_SNAP_NAME,
snap_channel=constants.SNAP_PACKAGES[constants.DNS_SNAP_NAME]["channel"],
)
elif pathlib.Path(snap_path).is_file():
nrobinaubertin marked this conversation as resolved.
Show resolved Hide resolved
# Installing the charm via subprocess.
# Calling subprocess here is not a security issue.
subprocess.check_output(["sudo", "snap", "install", snap_path, "--dangerous"]) # nosec
else:
nrobinaubertin marked this conversation as resolved.
Show resolved Hide resolved
logger.warning(
"Custom snap workload path defined but no file found at this location: %s",
snap_path,
)

self._install_bind_reload_service(unit_name)
# We need to put the service zone in place so we call
# the following with an empty relation and topology.
Expand Down Expand Up @@ -330,3 +348,43 @@ def _bind_config_ip_list(self, ips: list[pydantic.IPvAnyAddress]) -> str:
if not ips:
return ""
return f"{';'.join([str(ip) for ip in ips])};"

def configure(self, config: dict[str, str]) -> None:
"""Configure the charmed-bind service.

Args:
config: dict of configuration values

Raises:
ConfigureError: when encountering a SnapError
"""
try:
cache = snap.SnapCache()
charmed_bind = cache[constants.DNS_SNAP_NAME]
charmed_bind.set(config)
except snap.SnapError as e:
error_msg = (
f"An exception occurred when configuring {constants.DNS_SNAP_NAME}. Reason: {e}"
)
logger.error(error_msg)
raise ConfigureError(error_msg) from e

def command(self, cmd: str) -> str:
"""Run manage command of the charmed-bind service.

Args:
cmd: command to execute by django's manage script

Returns:
The resulting output of the command's execution
"""
try:
# We ignore security issues with this subprocess call
# as it can only be done from the operator of the charm
return subprocess.check_output(
["sudo", "snap", "run", "charmed-bind.manage", cmd]
).decode( # nosec
"utf-8"
)
except subprocess.SubprocessError as e:
return f"Error: {e}"
32 changes: 29 additions & 3 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

"""Charm for bind."""

import json
import logging
import pathlib
import subprocess # nosec
Expand All @@ -13,6 +14,7 @@
import ops
from charms.bind.v0 import dns_record

import actions_mixin
import constants
import dns_data
import events
Expand All @@ -31,7 +33,7 @@ class PeerRelationNetworkUnavailableError(exceptions.BindCharmError):
"""Exception raised when the peer relation network is unavailable."""


class BindCharm(ops.CharmBase):
class BindCharm(actions_mixin.ActionsMixin, ops.CharmBase):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about the use of multiple inheritance here using a mixin, as it is not going to be reused in other places.

As ActionsMixin only uses three variables (charm.on, charm.bind and charm.framework) I think an instance of tha class could be created with those variables explicitly passed, and so BindCharm and ActionsMixin are a bit less coupled.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, if we pass and instance of the class, they are tightly coupled. I'm doing the mixin dance to acknowledge it but still split the code in two.
This is in contrast to the bind service who can be tested outside of the charm instance.

"""Charm the service."""

def __init__(self, *args: typing.Any):
Expand Down Expand Up @@ -66,6 +68,21 @@ def __init__(self, *args: typing.Any):
self.unit.open_port("tcp", 8080) # ACL API
self.unit.open_port("tcp", 53) # Bind DNS
self.unit.open_port("udp", 53) # Bind DNS
actions_mixin.ActionsMixin.hooks(self)

# Try to check if the `charmed-bind-snap` resource is defined.
# Using this can be useful when debugging locally
# More information about resources:
# https://juju.is/docs/sdk/resources#heading--add-a-resource-to-a-charm
self.snap_path: str = ""
try:
self.snap_path = str(self.model.resources.fetch("charmed-bind-snap"))
except ops.ModelError as e:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work without charmed-bind-snap?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the CI is working without it. I'm just using it locally

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, my bad. Doesn't work in the last CI anymore. Fixing...

self.unit.status = ops.BlockedStatus(
"Something went wrong when claiming resource 'charmed-bind-snap; "
"run `juju debug-log` for more info'"
)
logger.error(e)

def _on_reload_bind(self, _: events.ReloadBindEvent) -> None:
"""Handle periodic reload bind event.
Expand Down Expand Up @@ -143,11 +160,20 @@ def _on_collect_status(self, event: ops.CollectStatusEvent) -> None:

def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None:
"""Handle changed configuration event."""
self.unit.status = ops.MaintenanceStatus("Configuring workload")
self.bind.configure(
{
"django-debug": "true" if self.config["django_debug"] else "false",
"django-allowed-hosts": json.dumps(
str(self.config["django_allowed_hosts"]).split(",")
),
}
)

def _on_install(self, _: ops.InstallEvent) -> None:
"""Handle install."""
self.unit.status = ops.MaintenanceStatus("Preparing bind")
self.bind.setup(self.unit.name)
self.bind.setup(self.unit.name, self.snap_path)

def _on_start(self, _: ops.StartEvent) -> None:
"""Handle start."""
Expand All @@ -160,7 +186,7 @@ def _on_stop(self, _: ops.StopEvent) -> None:
def _on_upgrade_charm(self, _: ops.UpgradeCharmEvent) -> None:
"""Handle upgrade-charm."""
self.unit.status = ops.MaintenanceStatus("Upgrading dependencies")
self.bind.setup(self.unit.name)
self.bind.setup(self.unit.name, self.snap_path)

def _on_leader_elected(self, _: ops.LeaderElectedEvent) -> None:
"""Handle leader-elected event."""
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def pytest_addoption(parser):
parser: Pytest parser.
"""
parser.addoption("--charm-file", action="store", default=None)
parser.addoption("--charmed-bind-snap-file", action="store", default=None)
parser.addoption(
"--use-existing",
action="store_true",
Expand Down
11 changes: 9 additions & 2 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,18 @@ async def app_fixture(
yield model.applications[app_name]
return

resources = {}

if pytestconfig.getoption("--charmed-bind-snap-file"):
resources.update({"charmed-bind-snap": pytestconfig.getoption("--charmed-bind-snap-file")})

if charm := pytestconfig.getoption("--charm-file"):
application = await model.deploy(f"./{charm}", application_name=app_name)
application = await model.deploy(
f"./{charm}", application_name=app_name, resources=resources
)
else:
charm = await ops_test.build_charm(".")
application = await model.deploy(charm, application_name=app_name)
application = await model.deploy(charm, application_name=app_name, resources=resources)

await model.wait_for_idle(apps=[application.name], status="active")

Expand Down
Loading
Loading