Skip to content

Feat/app integration #530

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

Merged
merged 24 commits into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
26 changes: 25 additions & 1 deletion src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus

import logrotate
import manager_service
from charm_state import (
DEBUG_SSH_INTEGRATION_NAME,
IMAGE_INTEGRATION_NAME,
Expand All @@ -56,6 +57,8 @@
ConfigurationError,
LogrotateSetupError,
MissingMongoDBError,
RunnerManagerApplicationError,
RunnerManagerApplicationInstallError,
SubprocessError,
TokenError,
)
Expand Down Expand Up @@ -247,6 +250,13 @@ def _common_install_code(self) -> bool:
logger.error("Failed to setup runner manager user")
raise

try:
manager_service.install_package()
except RunnerManagerApplicationInstallError:
logger.error("Failed to install github runner manager package")
# Not re-raising error for until the github-runner-manager service replaces the
# library.

try:
logrotate.setup()
except LogrotateSetupError:
Expand Down Expand Up @@ -304,6 +314,7 @@ def _on_config_changed(self, _: ConfigChangedEvent) -> None:
"""Handle the configuration change."""
state = self._setup_state()
self._set_reconcile_timer()
self._setup_service(state)

flush_and_reconcile = False
if state.charm_config.token != self._stored.token:
Expand Down Expand Up @@ -434,6 +445,19 @@ def _on_update_status(self, _: UpdateStatusEvent) -> None:
self._ensure_reconcile_timer_is_active()
self._log_juju_processes()

def _setup_service(self, state: CharmState) -> None:
"""Set up services.

Args:
state: The charm state.
"""
try:
manager_service.setup(state, self.app.name, self.unit.name)
except RunnerManagerApplicationError:
logging.exception("Unable to setup the github-runner-manager service")
# Not re-raising error for until the github-runner-manager service replaces the
# library.

@staticmethod
def _log_juju_processes() -> None:
"""Log the running Juju processes.
Expand Down Expand Up @@ -495,7 +519,7 @@ def _reconcile_openstack_runners(self, runner_scaler: RunnerScaler) -> None:
def _install_deps(self) -> None:
"""Install dependences for the charm."""
logger.info("Installing charm dependencies.")
self._apt_install(["run-one"])
self._apt_install(["run-one", "python3-pip"])

def _apt_install(self, packages: Sequence[str]) -> None:
"""Execute apt install command.
Expand Down
12 changes: 12 additions & 0 deletions src/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,15 @@ def __init__(

class LogrotateSetupError(Exception):
"""Represents an error raised when logrotate cannot be setup."""


class RunnerManagerApplicationError(Exception):
"""Represents an error raised with github-runner-manager application."""


class RunnerManagerApplicationInstallError(RunnerManagerApplicationError):
"""Represents an error raised when github-runner-manager application installation failed."""


class RunnerManagerApplicationStartError(RunnerManagerApplicationError):
"""Represents an error raised when github-runner-manager application start failed."""
161 changes: 161 additions & 0 deletions src/manager_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.

"""Manage the service of github-runner-manager."""

import json
import logging
import textwrap
from pathlib import Path

from charms.operator_libs_linux.v1 import systemd
from charms.operator_libs_linux.v1.systemd import SystemdError
from github_runner_manager import constants
from github_runner_manager.configuration.base import ApplicationConfiguration
from yaml import safe_dump as yaml_safe_dump

from charm_state import CharmState
from errors import (
RunnerManagerApplicationInstallError,
RunnerManagerApplicationStartError,
SubprocessError,
)
from factories import create_application_configuration
from utilities import execute_command

GITHUB_RUNNER_MANAGER_ADDRESS = "127.0.0.1"
GITHUB_RUNNER_MANAGER_PORT = "55555"
SYSTEMD_SERVICE_PATH = Path("/etc/systemd/system")
GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE = "github-runner-manager.service"
GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE_PATH = (
SYSTEMD_SERVICE_PATH / GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE
)
GITHUB_RUNNER_MANAGER_PACKAGE = "github_runner_manager"
JOB_MANAGER_PACKAGE = "jobmanager_client"
GITHUB_RUNNER_MANAGER_PACKAGE_PATH = "./github-runner-manager"
JOB_MANAGER_PACKAGE_PATH = "./jobmanager/client"
GITHUB_RUNNER_MANAGER_SERVICE_NAME = "github-runner-manager"

_INSTALL_ERROR_MESSAGE = "Unable to install github-runner-manager package from source"
_SERVICE_SETUP_ERROR_MESSAGE = "Unable to enable or start the github-runner-manager application"
_SERVICE_STOP_ERROR_MESSAGE = "Unable to stop the github-runner-manager application"

logger = logging.getLogger(__name__)


def setup(state: CharmState, app_name: str, unit_name: str) -> None:
"""Set up the github-runner-manager service.

Args:
state: The state of the charm.
app_name: The Juju application name.
unit_name: The Juju unit.
"""
config = create_application_configuration(state, app_name, unit_name)
config_file = _setup_config_file(config)
_setup_service_file(config_file)
_enable_service()


# TODO: Use pipx over pip once the version that supports `pipx install --global` lands on apt.
def install_package() -> None:
"""Install the GitHub runner manager package.

Raises:
RunnerManagerApplicationInstallError: Unable to install the application.
"""
try:
if systemd.service_running(GITHUB_RUNNER_MANAGER_SERVICE_NAME):
systemd.service_stop(GITHUB_RUNNER_MANAGER_SERVICE_NAME)
except SystemdError as err:
raise RunnerManagerApplicationInstallError(_SERVICE_STOP_ERROR_MESSAGE) from err

logger.info("Upgrading pip")
try:
execute_command(["python3", "-m", "pip", "install", "--upgrade", "pip"])
except SubprocessError as err:
raise RunnerManagerApplicationInstallError(_INSTALL_ERROR_MESSAGE) from err

logger.info("Uninstalling previous version of packages")
try:
execute_command(["python3", "-m", "pip", "uninstall", GITHUB_RUNNER_MANAGER_PACKAGE])
execute_command(["python3", "-m", "pip", "uninstall", JOB_MANAGER_PACKAGE])
except SubprocessError:
logger.info(
"Unable to uninstall existing packages, likely due to previous version not installed"
)

try:
# Use `--prefix` to install the package in a location (/usr) all user can use and
# `--ignore-installed` to force all dependencies be to installed under /usr.
execute_command(
[
"python3",
"-m",
"pip",
"install",
"--prefix",
"/usr",
"--ignore-installed",
GITHUB_RUNNER_MANAGER_PACKAGE_PATH,
JOB_MANAGER_PACKAGE_PATH,
]
)
except SubprocessError as err:
raise RunnerManagerApplicationInstallError(_INSTALL_ERROR_MESSAGE) from err


def _enable_service() -> None:
"""Enable the github runner manager service.

Raises:
RunnerManagerApplicationStartError: Unable to startup the service.
"""
try:
systemd.service_enable(GITHUB_RUNNER_MANAGER_SERVICE_NAME)
if not systemd.service_running(GITHUB_RUNNER_MANAGER_SERVICE_NAME):
systemd.service_start(GITHUB_RUNNER_MANAGER_SERVICE_NAME)
except SystemdError as err:
raise RunnerManagerApplicationStartError(_SERVICE_SETUP_ERROR_MESSAGE) from err


def _setup_config_file(config: ApplicationConfiguration) -> Path:
"""Write the configuration to file.

Args:
config: The application configuration.
"""
# Directly converting to `dict` will have the value be Python objects rather than string
# representations. The values needs to be string representations to be converted to YAML file.
# No easy way to directly convert to YAML file, so json module is used.
config_dict = json.loads(config.json())
path = Path(f"~{constants.RUNNER_MANAGER_USER}").expanduser() / "config.yaml"
with open(path, "w+", encoding="utf-8") as file:
yaml_safe_dump(config_dict, file)
return path


def _setup_service_file(config_file: Path) -> None:
"""Configure the systemd service.

Args:
config_file: The configuration file for the service.
"""
service_file_content = textwrap.dedent(
f"""\
[Unit]
Description=Runs the github-runner-manager service

[Service]
Type=simple
User={constants.RUNNER_MANAGER_USER}
Group={constants.RUNNER_MANAGER_GROUP}
ExecStart=github-runner-manager --config-file {str(config_file)} --host \
{GITHUB_RUNNER_MANAGER_ADDRESS} --port {GITHUB_RUNNER_MANAGER_PORT}
Restart=on-failure

[Install]
WantedBy=multi-user.target
"""
)
GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE_PATH.write_text(service_file_content, "utf-8")
25 changes: 24 additions & 1 deletion tests/integration/test_charm_no_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from juju.model import Model

from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME
from tests.integration.helpers.common import reconcile, wait_for
from manager_service import GITHUB_RUNNER_MANAGER_SERVICE_NAME
from tests.integration.helpers.common import reconcile, run_in_unit, wait_for
from tests.integration.helpers.openstack import OpenStackInstanceHelper

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -84,3 +85,25 @@ async def _runners_number(number) -> bool:
await reconcile(app=app, model=model)

await wait_for(lambda: _runners_number(0), timeout=10 * 60, check_interval=10)


@pytest.mark.asyncio
@pytest.mark.abort_on_fail
async def test_manager_service_started(
app_no_runner: Application,
) -> None:
"""
arrange: A working application with no runners.
act: Check the github runner manager service.
assert: The service should be running.
"""
app = app_no_runner
unit = app.units[0]

await run_in_unit(
unit,
f"sudo systemctl status {GITHUB_RUNNER_MANAGER_SERVICE_NAME}",
timeout=60,
assert_on_failure=True,
assert_msg="GitHub runner manager service not healthy",
)
4 changes: 1 addition & 3 deletions tests/integration/test_jobmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
from pytest_operator.plugin import OpsTest

from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME, MAX_TOTAL_VIRTUAL_MACHINES_CONFIG_NAME
from tests.integration.helpers.charm_metrics import (
clear_metrics_log,
)
from tests.integration.helpers.charm_metrics import clear_metrics_log
from tests.integration.helpers.common import reconcile, wait_for
from tests.integration.helpers.openstack import OpenStackInstanceHelper, PrivateEndpointConfigs
from tests.integration.utils_reactive import (
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from pathlib import Path

import pytest
from github_runner_manager.configuration.github import GitHubOrg
from github_runner_manager.manager.runner_scaler import RunnerScaler

import charm_state
import utilities
from tests.unit.mock import MockGhapiClient

Expand Down Expand Up @@ -124,3 +126,77 @@ def patched_retry_decorator(func: typing.Callable):
return patched_retry_decorator

monkeypatch.setattr(utilities, "retry", patched_retry)


@pytest.fixture(name="complete_charm_state")
def complete_charm_state_fixture():
"""Returns a fixture with a fully populated CharmState."""
return charm_state.CharmState(
arch="arm64",
is_metrics_logging_available=False,
proxy_config=charm_state.ProxyConfig(
http="http://httpproxy.example.com:3128",
https="http://httpsproxy.example.com:3128",
no_proxy="127.0.0.1",
),
runner_proxy_config=charm_state.ProxyConfig(
http="http://runnerhttpproxy.example.com:3128",
https="http://runnerhttpsproxy.example.com:3128",
no_proxy="10.0.0.1",
),
charm_config=charm_state.CharmConfig(
dockerhub_mirror="https://docker.example.com",
labels=("label1", "label2"),
openstack_clouds_yaml=charm_state.OpenStackCloudsYAML(
clouds={
"microstack": {
"auth": {
"auth_url": "auth_url",
"project_name": "project_name",
"project_domain_name": "project_domain_name",
"username": "username",
"user_domain_name": "user_domain_name",
"password": "password",
},
"region_name": "region",
}
},
),
path=GitHubOrg(org="canonical", group="group"),
reconcile_interval=5,
repo_policy_compliance=charm_state.RepoPolicyComplianceConfig(
token="token",
url="https://compliance.example.com",
),
token="githubtoken",
manager_proxy_command="ssh -W %h:%p example.com",
use_aproxy=True,
),
runner_config=charm_state.OpenstackRunnerConfig(
base_virtual_machines=1,
max_total_virtual_machines=2,
flavor_label_combinations=[
charm_state.FlavorLabel(
flavor="flavor",
label="flavorlabel",
)
],
openstack_network="network",
openstack_image=charm_state.OpenstackImage(
id="image_id",
tags=["arm64", "noble"],
),
),
reactive_config=charm_state.ReactiveConfig(
mq_uri="mongodb://user:password@localhost:27017",
),
ssh_debug_connections=[
charm_state.SSHDebugConnection(
host="10.10.10.10",
port=3000,
# Not very realistic
rsa_fingerprint="SHA256:rsa",
ed25519_fingerprint="SHA256:ed25519",
),
],
)
Loading
Loading