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

feat: enable juju-systemd-notices to observe snap services #128

Merged
merged 15 commits into from
Jul 21, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
132 changes: 84 additions & 48 deletions lib/charms/operator_libs_linux/v0/juju_systemd_notices.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/python3
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -28,6 +28,7 @@

```python
from charms.operator_libs_linux.v0.juju_systemd_notices import (
Service,
ServiceStartedEvent,
ServiceStoppedEvent,
SystemdNotices,
Expand All @@ -41,7 +42,7 @@ def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

# Register services with charm. This adds the events to observe.
self._systemd_notices = SystemdNotices(self, ["slurmd"])
self._systemd_notices = SystemdNotices(self, Service("snap.slurm.slurmd", alias="slurmd"))
self.framework.observe(self.on.install, self._on_install)
self.framework.observe(self.on.stop, self._on_stop)
self.framework.observe(self.on.service_slurmd_started, self._on_slurmd_started)
Expand All @@ -58,7 +59,7 @@ def _on_install(self, _: InstallEvent) -> None:
def _on_start(self, _: StartEvent) -> None:
# This will trigger the juju-systemd-notices daemon to
# emit a `service-slurmd-started` event.
systemd.service_start("slurmd")
snap.slurmd.enable()

def _on_stop(self, _: StopEvent) -> None:
# To stop the juju-systemd-notices service running in the background.
Expand All @@ -72,26 +73,27 @@ def _on_slurmd_started(self, _: ServiceStartedEvent) -> None:

# This will trigger the juju-systemd-notices daemon to
# emit a `service-slurmd-stopped` event.
systemd.service_stop("slurmd")
snap.slurmd.stop()

def _on_slurmd_stopped(self, _: ServiceStoppedEvent) -> None:
self.unit.status = BlockedStatus("slurmd not running")
```
"""

__all__ = ["ServiceStartedEvent", "ServiceStoppedEvent", "SystemdNotices"]
__all__ = ["Service", "ServiceStartedEvent", "ServiceStoppedEvent", "SystemdNotices"]

import argparse
import asyncio
import functools
import logging
import re
import signal
import subprocess
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import List
from typing import Mapping, Optional

import yaml
from dbus_fast.aio import MessageBus
from dbus_fast.constants import BusType, MessageType
from dbus_fast.errors import DBusError
Expand All @@ -111,12 +113,11 @@ def _on_slurmd_stopped(self, _: ServiceStoppedEvent) -> None:

# juju-systemd-notices charm library dependencies.
# Charm library dependencies are installed when the consuming charm is packed.
PYDEPS = ["dbus-fast>=1.90.2"]
PYDEPS = ["dbus-fast>=1.90.2", "pyyaml>=6.0.1"]
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved

_logger = logging.getLogger(__name__)
_juju_unit = None
_service_states = {}
_service_hook_regex_filter = re.compile(r"service-(?P<service>[\w\\:-]*)-(?:started|stopped)")
_DBUS_CHAR_MAPPINGS = {
"_5f": "_", # _ must be first since char mappings contain _.
"_40": "@",
Expand Down Expand Up @@ -148,6 +149,22 @@ def _systemctl(*args) -> None:
_disable_service = functools.partial(_systemctl, "disable")


@dataclass
class Service:
"""Systemd service to observe.

Args:
name: Name of systemd service to observe on dbus.
alias: Event name alias for service.
"""

name: str
alias: Optional[str] = None

def __post_init__(self) -> None: # noqa D105
self.alias = self.alias or self.name
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved


class ServiceStartedEvent(EventBase):
"""Event emitted when service has started."""

Expand All @@ -159,7 +176,7 @@ class ServiceStoppedEvent(EventBase):
class SystemdNotices:
"""Observe systemd services on your machine base."""

def __init__(self, charm: CharmBase, services: List[str]) -> None:
def __init__(self, charm: CharmBase, *services: Service) -> None:
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
"""Instantiate systemd notices service."""
self._charm = charm
self._services = services
Expand All @@ -170,39 +187,64 @@ def __init__(self, charm: CharmBase, services: List[str]) -> None:
"Attaching systemd notice events to charm %s", self._charm.__class__.__name__
)
for service in self._services:
self._charm.on.define_event(f"service_{service}_started", ServiceStartedEvent)
self._charm.on.define_event(f"service_{service}_stopped", ServiceStoppedEvent)
self._charm.on.define_event(f"service_{service.alias}_started", ServiceStartedEvent)
self._charm.on.define_event(f"service_{service.alias}_stopped", ServiceStoppedEvent)

def subscribe(self) -> None:
"""Subscribe charmed operator to observe status of systemd services."""
self._generate_hooks()
self._generate_config()
self._start()

def stop(self) -> None:
"""Stop charmed operator from observing the status of subscribed services."""
_stop_service(self._service_file.name)
# Notices daemon is disabled so that the service will not restart after machine reboot.
_disable_service(self._service_file.name)

def _generate_hooks(self) -> None:
"""Generate legacy event hooks for observed systemd services."""
_logger.debug("Generating systemd notice hooks for %s", self._services)
start_hooks = [Path(f"hooks/service-{service}-started") for service in self._services]
stop_hooks = [Path(f"hooks/service-{service}-stopped") for service in self._services]
start_hooks = [Path(f"hooks/service-{s.alias}-started") for s in self._services]
stop_hooks = [Path(f"hooks/service-{s.alias}-stopped") for s in self._services]
for hook in start_hooks + stop_hooks:
if hook.exists():
_logger.debug("Hook %s already exists. Skipping...", hook.name)
else:
hook.symlink_to(self._charm.framework.charm_dir / "dispatch")

def _generate_config(self) -> None:
"""Generate watch file for systemd notices daemon."""
_logger.debug("Generating watch file for %s", self._services)
config = {"services": {s.name: s.alias for s in self._services}}

config_file = self._charm.framework.charm_dir / "watch.yaml"
if config_file.exists():
_logger.debug("Overwriting existing watch file %s", config_file.name)
config_file.write_text(yaml.dump(config))
config_file.chmod(0o600)

def _start(self) -> None:
"""Start systemd notices daemon to observe subscribed services."""
_logger.debug("Starting %s daemon", self._service_file.name)
if self._service_file.exists():
_logger.debug("Overwriting existing service file %s", self._service_file.name)
self._service_file.write_text(
textwrap.dedent(
f"""
[Unit]
Description=Juju systemd notices daemon
After=multi-user.target

[Service]
Type=simple
Restart=always
WorkingDirectory={self._charm.framework.charm_dir}
Environment="PYTHONPATH={self._charm.framework.charm_dir / "venv"}"
ExecStart=/usr/bin/python3 {__file__} {self._charm.unit.name}

[Install]
WantedBy=multi-user.target
[Unit]
Description=Juju systemd notices daemon
After=multi-user.target

[Service]
Type=simple
Restart=always
WorkingDirectory={self._charm.framework.charm_dir}
Environment="PYTHONPATH={self._charm.framework.charm_dir / "venv"}"
ExecStart=/usr/bin/python3 {__file__} {self._charm.unit.name}

[Install]
WantedBy=multi-user.target
"""
).strip()
)
Expand All @@ -214,12 +256,6 @@ def subscribe(self) -> None:
_start_service(self._service_file.name)
_logger.debug("Started %s daemon", self._service_file.name)

def stop(self) -> None:
"""Stop charmed operator from observing the status of subscribed services."""
_stop_service(self._service_file.name)
# Notices daemon is disabled so that the service will not restart after machine reboot.
_disable_service(self._service_file.name)


def _name_to_dbus_path(name: str) -> str:
"""Convert the specified name into an org.freedesktop.systemd1.Unit path handle.
Expand Down Expand Up @@ -256,6 +292,16 @@ def _dbus_path_to_name(path: str) -> str:
return name


@functools.lru_cache(maxsize=32)
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
def _read_config() -> Mapping[str, str]:
"""Read systemd notices daemon configuration to service names and aliases."""
config_file = Path.cwd() / "watch.yaml"
_logger.debug("Loading observed services from configuration file %s", config_file)

with config_file.open("rt") as fin:
return yaml.safe_load(fin)["services"]
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved


def _systemd_unit_changed(msg: Message) -> bool:
"""Send Juju notification if systemd unit state changes on the DBus bus.

Expand Down Expand Up @@ -310,8 +356,10 @@ async def _send_juju_notification(service: str, state: str) -> None:
if service.endswith(".service"):
service = service[0:-len(".service")] # fmt: skip

watched_services = _read_config()
alias = watched_services[service]
event_name = "started" if state == "active" else "stopped"
hook = f"service-{service}-{event_name}"
hook = f"service-{alias}-{event_name}"
cmd = ["/usr/bin/juju-exec", _juju_unit, f"hooks/{hook}"]

_logger.debug("Invoking hook %s with command: %s", hook, " ".join(cmd))
Expand Down Expand Up @@ -364,20 +412,8 @@ async def _async_load_services() -> None:
will be queried from systemd to determine it's initial state.
"""
global _juju_unit
hooks_dir = Path.cwd() / "hooks"
_logger.info("Loading services from hooks in %s", hooks_dir)

if not hooks_dir.exists():
_logger.warning("Hooks dir %s does not exist.", hooks_dir)
return

watched_services = []
# Get service-{service}-(started|stopped) hooks defined by the charm.
for hook in hooks_dir.iterdir():
match = _service_hook_regex_filter.match(hook.name)
if match:
watched_services.append(match.group("service"))

watched_services = _read_config()
_logger.info("Services from hooks are %s", watched_services)
if not watched_services:
return
Expand All @@ -386,7 +422,7 @@ async def _async_load_services() -> None:

# Loop through all the services and be sure that a new watcher is
# started for new ones.
for service in watched_services:
for service in watched_services.keys():
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
# The .service suffix is necessary and will cause lookup failures of the
# service unit when readying the watcher if absent from the service name.
service = f"{service}.service"
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
# See LICENSE file for licensing details.

name: juju-systemd-notices
description: |
Test charm used for the juju_systemd_notices charm library integration tests.
summary: |
A charm with a minimal daemon for testing the juju-systemd-notices charm library.

type: charm
bases:
- build-on:
Expand All @@ -9,3 +15,8 @@ bases:
run-on:
- name: ubuntu
channel: "22.04"

actions:
stop-service:
description: Stop internal test service inside charm

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""Minimal charm for testing the juju_systemd_notices charm library.
Expand All @@ -12,6 +12,7 @@
import charms.operator_libs_linux.v1.systemd as systemd
import daemon
from charms.operator_libs_linux.v0.juju_systemd_notices import (
Service,
ServiceStartedEvent,
ServiceStoppedEvent,
SystemdNotices,
Expand All @@ -29,7 +30,7 @@ class NoticesCharm(CharmBase):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

self._systemd_notices = SystemdNotices(self, ["test"])
self._systemd_notices = SystemdNotices(self, Service("test"))
event_handler_bindings = {
self.on.install: self._on_install,
self.on.start: self._on_start,
Expand Down
Loading
Loading