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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
27 changes: 26 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,12 @@ def _common_install_code(self) -> bool:
logger.error("Failed to setup runner manager user")
raise

try:
manager_service.install_package()
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about upgrading? How is this handled?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It is a local python package.
Installing it with code changes would update the installation.

The _common_install_code function is called on charm upgrade as well. So it should cover the charm upgrade event.

Copy link
Collaborator

Choose a reason for hiding this comment

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

So due to the --ignore-installed flag it would just install without caring about a previous installation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have updated the install_package method to uninstall the packages prior to installing them.

except RunnerManagerApplicationInstallError:
logger.error("Failed to install github runner manager package")
raise

try:
logrotate.setup()
except LogrotateSetupError:
Expand Down Expand Up @@ -304,6 +313,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 +444,21 @@ 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.

Raise:
RunnerManagerApplicationError: Issue with the github-runner-manager service.
"""
try:
manager_service.setup(state, self.app.name, self.unit.name)
except RunnerManagerApplicationError:
logging.exception("Unable to setup the github-runner-manager service")
raise

@staticmethod
def _log_juju_processes() -> None:
"""Log the running Juju processes.
Expand Down Expand Up @@ -495,7 +520,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 RunnerManagerApplicationStartupError(RunnerManagerApplicationError):
"""Represents an error raised when github-runner-manager application start up failed."""
146 changes: 146 additions & 0 deletions src/manager_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.

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

import json
import logging
from pathlib import Path

import jinja2
from charms.operator_libs_linux.v1 import systemd
from charms.operator_libs_linux.v1.systemd import SystemdError
from github_runner_manager import constants
from yaml import safe_dump as yaml_safe_dump

from charm_state import CharmState
from errors import (
RunnerManagerApplicationInstallError,
RunnerManagerApplicationStartupError,
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"

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_file = _setup_config_file(state, app_name, unit_name)
_setup_service_file(config_file)
_enable_service()


def install_package() -> None:
"""Install the GitHub runner manager package.

Raises:
RunnerManagerApplicationInstallError: Unable to install the application.
"""
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])
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe keep in mind for adaptions later, I think having an abstraction to interact with pip (similar as in https://github.com/canonical/github-runner-image-builder-operator/blob/main/src/pipx.py ) would make it the code easier testable and less coupled.

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:
RunnerManagerApplicationStartupError: 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 RunnerManagerApplicationStartupError(_SERVICE_SETUP_ERROR_MESSAGE) from err


def _setup_config_file(state: CharmState, app_name: str, unit_name: str) -> Path:
"""Write the configuration to file.

Args:
state: The charm state.
app_name: The Juju application name.
unit_name: The Juju unit.
"""
config = create_application_configuration(state, app_name, unit_name)
# 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.
"""
jinja = jinja2.Environment(loader=jinja2.FileSystemLoader("templates"), autoescape=True)

service_file_content = jinja.get_template("github-runner-manager.service.j2").render(
user=constants.RUNNER_MANAGER_USER,
group=constants.RUNNER_MANAGER_GROUP,
config=str(config_file),
host=GITHUB_RUNNER_MANAGER_ADDRESS,
port=GITHUB_RUNNER_MANAGER_PORT,
)

GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE_PATH.write_text(service_file_content, "utf-8")
12 changes: 12 additions & 0 deletions templates/github-runner-manager.service.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[Unit]
Description=Runs the github-runner-manager service

[Service]
Type=simple
User={{user}}
Group={{group}}
ExecStart=github-runner-manager --config-file {{config}} --host {{host}} --port {{port}}
Restart=on-failure

[Install]
WantedBy=multi-user.target
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