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

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 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
11 changes: 11 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
options:
django_debug:
type: boolean
description: "Set the backed by Django API DEBUG setting"
default: false
django_allowed_hosts:
nrobinaubertin marked this conversation as resolved.
Show resolved Hide resolved
type: string
description: "Set the backed by Django API ALLOWED_HOSTS setting"
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")})
65 changes: 60 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():
# 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,40 @@ 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
"""
# 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"
)
29 changes: 26 additions & 3 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import ops
from charms.bind.v0 import dns_record

import actions_mixin
import constants
import dns_data
import events
Expand All @@ -31,7 +32,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):
"""Charm the service."""

def __init__(self, *args: typing.Any):
Expand Down Expand Up @@ -66,6 +67,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:
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 +159,18 @@ 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": self.config["django_allowed_hosts"],
nrobinaubertin marked this conversation as resolved.
Show resolved Hide resolved
}
)

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 +183,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