Skip to content

Commit e4e0901

Browse files
authored
Feat/app integration (#530)
1 parent 5776ff5 commit e4e0901

File tree

9 files changed

+495
-123
lines changed

9 files changed

+495
-123
lines changed

src/charm.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus
4242

4343
import logrotate
44+
import manager_service
4445
from charm_state import (
4546
DEBUG_SSH_INTEGRATION_NAME,
4647
IMAGE_INTEGRATION_NAME,
@@ -56,6 +57,8 @@
5657
ConfigurationError,
5758
LogrotateSetupError,
5859
MissingMongoDBError,
60+
RunnerManagerApplicationError,
61+
RunnerManagerApplicationInstallError,
5962
SubprocessError,
6063
TokenError,
6164
)
@@ -247,6 +250,13 @@ def _common_install_code(self) -> bool:
247250
logger.error("Failed to setup runner manager user")
248251
raise
249252

253+
try:
254+
manager_service.install_package()
255+
except RunnerManagerApplicationInstallError:
256+
logger.error("Failed to install github runner manager package")
257+
# Not re-raising error for until the github-runner-manager service replaces the
258+
# library.
259+
250260
try:
251261
logrotate.setup()
252262
except LogrotateSetupError:
@@ -304,6 +314,7 @@ def _on_config_changed(self, _: ConfigChangedEvent) -> None:
304314
"""Handle the configuration change."""
305315
state = self._setup_state()
306316
self._set_reconcile_timer()
317+
self._setup_service(state)
307318

308319
flush_and_reconcile = False
309320
if state.charm_config.token != self._stored.token:
@@ -434,6 +445,19 @@ def _on_update_status(self, _: UpdateStatusEvent) -> None:
434445
self._ensure_reconcile_timer_is_active()
435446
self._log_juju_processes()
436447

448+
def _setup_service(self, state: CharmState) -> None:
449+
"""Set up services.
450+
451+
Args:
452+
state: The charm state.
453+
"""
454+
try:
455+
manager_service.setup(state, self.app.name, self.unit.name)
456+
except RunnerManagerApplicationError:
457+
logging.exception("Unable to setup the github-runner-manager service")
458+
# Not re-raising error for until the github-runner-manager service replaces the
459+
# library.
460+
437461
@staticmethod
438462
def _log_juju_processes() -> None:
439463
"""Log the running Juju processes.
@@ -495,7 +519,7 @@ def _reconcile_openstack_runners(self, runner_scaler: RunnerScaler) -> None:
495519
def _install_deps(self) -> None:
496520
"""Install dependences for the charm."""
497521
logger.info("Installing charm dependencies.")
498-
self._apt_install(["run-one"])
522+
self._apt_install(["run-one", "python3-pip"])
499523

500524
def _apt_install(self, packages: Sequence[str]) -> None:
501525
"""Execute apt install command.

src/errors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,15 @@ def __init__(
5858

5959
class LogrotateSetupError(Exception):
6060
"""Represents an error raised when logrotate cannot be setup."""
61+
62+
63+
class RunnerManagerApplicationError(Exception):
64+
"""Represents an error raised with github-runner-manager application."""
65+
66+
67+
class RunnerManagerApplicationInstallError(RunnerManagerApplicationError):
68+
"""Represents an error raised when github-runner-manager application installation failed."""
69+
70+
71+
class RunnerManagerApplicationStartError(RunnerManagerApplicationError):
72+
"""Represents an error raised when github-runner-manager application start failed."""

src/manager_service.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""Manage the service of github-runner-manager."""
5+
6+
import json
7+
import logging
8+
import textwrap
9+
from pathlib import Path
10+
11+
from charms.operator_libs_linux.v1 import systemd
12+
from charms.operator_libs_linux.v1.systemd import SystemdError
13+
from github_runner_manager import constants
14+
from github_runner_manager.configuration.base import ApplicationConfiguration
15+
from yaml import safe_dump as yaml_safe_dump
16+
17+
from charm_state import CharmState
18+
from errors import (
19+
RunnerManagerApplicationInstallError,
20+
RunnerManagerApplicationStartError,
21+
SubprocessError,
22+
)
23+
from factories import create_application_configuration
24+
from utilities import execute_command
25+
26+
GITHUB_RUNNER_MANAGER_ADDRESS = "127.0.0.1"
27+
GITHUB_RUNNER_MANAGER_PORT = "55555"
28+
SYSTEMD_SERVICE_PATH = Path("/etc/systemd/system")
29+
GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE = "github-runner-manager.service"
30+
GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE_PATH = (
31+
SYSTEMD_SERVICE_PATH / GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE
32+
)
33+
GITHUB_RUNNER_MANAGER_PACKAGE = "github_runner_manager"
34+
JOB_MANAGER_PACKAGE = "jobmanager_client"
35+
GITHUB_RUNNER_MANAGER_PACKAGE_PATH = "./github-runner-manager"
36+
JOB_MANAGER_PACKAGE_PATH = "./jobmanager/client"
37+
GITHUB_RUNNER_MANAGER_SERVICE_NAME = "github-runner-manager"
38+
39+
_INSTALL_ERROR_MESSAGE = "Unable to install github-runner-manager package from source"
40+
_SERVICE_SETUP_ERROR_MESSAGE = "Unable to enable or start the github-runner-manager application"
41+
_SERVICE_STOP_ERROR_MESSAGE = "Unable to stop the github-runner-manager application"
42+
43+
logger = logging.getLogger(__name__)
44+
45+
46+
def setup(state: CharmState, app_name: str, unit_name: str) -> None:
47+
"""Set up the github-runner-manager service.
48+
49+
Args:
50+
state: The state of the charm.
51+
app_name: The Juju application name.
52+
unit_name: The Juju unit.
53+
"""
54+
config = create_application_configuration(state, app_name, unit_name)
55+
config_file = _setup_config_file(config)
56+
_setup_service_file(config_file)
57+
_enable_service()
58+
59+
60+
# TODO: Use pipx over pip once the version that supports `pipx install --global` lands on apt.
61+
def install_package() -> None:
62+
"""Install the GitHub runner manager package.
63+
64+
Raises:
65+
RunnerManagerApplicationInstallError: Unable to install the application.
66+
"""
67+
try:
68+
if systemd.service_running(GITHUB_RUNNER_MANAGER_SERVICE_NAME):
69+
systemd.service_stop(GITHUB_RUNNER_MANAGER_SERVICE_NAME)
70+
except SystemdError as err:
71+
raise RunnerManagerApplicationInstallError(_SERVICE_STOP_ERROR_MESSAGE) from err
72+
73+
logger.info("Upgrading pip")
74+
try:
75+
execute_command(["python3", "-m", "pip", "install", "--upgrade", "pip"])
76+
except SubprocessError as err:
77+
raise RunnerManagerApplicationInstallError(_INSTALL_ERROR_MESSAGE) from err
78+
79+
logger.info("Uninstalling previous version of packages")
80+
try:
81+
execute_command(["python3", "-m", "pip", "uninstall", GITHUB_RUNNER_MANAGER_PACKAGE])
82+
execute_command(["python3", "-m", "pip", "uninstall", JOB_MANAGER_PACKAGE])
83+
except SubprocessError:
84+
logger.info(
85+
"Unable to uninstall existing packages, likely due to previous version not installed"
86+
)
87+
88+
try:
89+
# Use `--prefix` to install the package in a location (/usr) all user can use and
90+
# `--ignore-installed` to force all dependencies be to installed under /usr.
91+
execute_command(
92+
[
93+
"python3",
94+
"-m",
95+
"pip",
96+
"install",
97+
"--prefix",
98+
"/usr",
99+
"--ignore-installed",
100+
GITHUB_RUNNER_MANAGER_PACKAGE_PATH,
101+
JOB_MANAGER_PACKAGE_PATH,
102+
]
103+
)
104+
except SubprocessError as err:
105+
raise RunnerManagerApplicationInstallError(_INSTALL_ERROR_MESSAGE) from err
106+
107+
108+
def _enable_service() -> None:
109+
"""Enable the github runner manager service.
110+
111+
Raises:
112+
RunnerManagerApplicationStartError: Unable to startup the service.
113+
"""
114+
try:
115+
systemd.service_enable(GITHUB_RUNNER_MANAGER_SERVICE_NAME)
116+
if not systemd.service_running(GITHUB_RUNNER_MANAGER_SERVICE_NAME):
117+
systemd.service_start(GITHUB_RUNNER_MANAGER_SERVICE_NAME)
118+
except SystemdError as err:
119+
raise RunnerManagerApplicationStartError(_SERVICE_SETUP_ERROR_MESSAGE) from err
120+
121+
122+
def _setup_config_file(config: ApplicationConfiguration) -> Path:
123+
"""Write the configuration to file.
124+
125+
Args:
126+
config: The application configuration.
127+
"""
128+
# Directly converting to `dict` will have the value be Python objects rather than string
129+
# representations. The values needs to be string representations to be converted to YAML file.
130+
# No easy way to directly convert to YAML file, so json module is used.
131+
config_dict = json.loads(config.json())
132+
path = Path(f"~{constants.RUNNER_MANAGER_USER}").expanduser() / "config.yaml"
133+
with open(path, "w+", encoding="utf-8") as file:
134+
yaml_safe_dump(config_dict, file)
135+
return path
136+
137+
138+
def _setup_service_file(config_file: Path) -> None:
139+
"""Configure the systemd service.
140+
141+
Args:
142+
config_file: The configuration file for the service.
143+
"""
144+
service_file_content = textwrap.dedent(
145+
f"""\
146+
[Unit]
147+
Description=Runs the github-runner-manager service
148+
149+
[Service]
150+
Type=simple
151+
User={constants.RUNNER_MANAGER_USER}
152+
Group={constants.RUNNER_MANAGER_GROUP}
153+
ExecStart=github-runner-manager --config-file {str(config_file)} --host \
154+
{GITHUB_RUNNER_MANAGER_ADDRESS} --port {GITHUB_RUNNER_MANAGER_PORT}
155+
Restart=on-failure
156+
157+
[Install]
158+
WantedBy=multi-user.target
159+
"""
160+
)
161+
GITHUB_RUNNER_MANAGER_SYSTEMD_SERVICE_PATH.write_text(service_file_content, "utf-8")

tests/integration/test_charm_no_runner.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from juju.model import Model
1010

1111
from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME
12-
from tests.integration.helpers.common import reconcile, wait_for
12+
from manager_service import GITHUB_RUNNER_MANAGER_SERVICE_NAME
13+
from tests.integration.helpers.common import reconcile, run_in_unit, wait_for
1314
from tests.integration.helpers.openstack import OpenStackInstanceHelper
1415

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

8687
await wait_for(lambda: _runners_number(0), timeout=10 * 60, check_interval=10)
88+
89+
90+
@pytest.mark.asyncio
91+
@pytest.mark.abort_on_fail
92+
async def test_manager_service_started(
93+
app_no_runner: Application,
94+
) -> None:
95+
"""
96+
arrange: A working application with no runners.
97+
act: Check the github runner manager service.
98+
assert: The service should be running.
99+
"""
100+
app = app_no_runner
101+
unit = app.units[0]
102+
103+
await run_in_unit(
104+
unit,
105+
f"sudo systemctl status {GITHUB_RUNNER_MANAGER_SERVICE_NAME}",
106+
timeout=60,
107+
assert_on_failure=True,
108+
assert_msg="GitHub runner manager service not healthy",
109+
)

tests/integration/test_jobmanager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@
2222
from pytest_operator.plugin import OpsTest
2323

2424
from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME, MAX_TOTAL_VIRTUAL_MACHINES_CONFIG_NAME
25-
from tests.integration.helpers.charm_metrics import (
26-
clear_metrics_log,
27-
)
25+
from tests.integration.helpers.charm_metrics import clear_metrics_log
2826
from tests.integration.helpers.common import reconcile, wait_for
2927
from tests.integration.helpers.openstack import OpenStackInstanceHelper, PrivateEndpointConfigs
3028
from tests.integration.utils_reactive import (

tests/unit/conftest.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from pathlib import Path
99

1010
import pytest
11+
from github_runner_manager.configuration.github import GitHubOrg
1112
from github_runner_manager.manager.runner_scaler import RunnerScaler
1213

14+
import charm_state
1315
import utilities
1416
from tests.unit.mock import MockGhapiClient
1517

@@ -124,3 +126,77 @@ def patched_retry_decorator(func: typing.Callable):
124126
return patched_retry_decorator
125127

126128
monkeypatch.setattr(utilities, "retry", patched_retry)
129+
130+
131+
@pytest.fixture(name="complete_charm_state")
132+
def complete_charm_state_fixture():
133+
"""Returns a fixture with a fully populated CharmState."""
134+
return charm_state.CharmState(
135+
arch="arm64",
136+
is_metrics_logging_available=False,
137+
proxy_config=charm_state.ProxyConfig(
138+
http="http://httpproxy.example.com:3128",
139+
https="http://httpsproxy.example.com:3128",
140+
no_proxy="127.0.0.1",
141+
),
142+
runner_proxy_config=charm_state.ProxyConfig(
143+
http="http://runnerhttpproxy.example.com:3128",
144+
https="http://runnerhttpsproxy.example.com:3128",
145+
no_proxy="10.0.0.1",
146+
),
147+
charm_config=charm_state.CharmConfig(
148+
dockerhub_mirror="https://docker.example.com",
149+
labels=("label1", "label2"),
150+
openstack_clouds_yaml=charm_state.OpenStackCloudsYAML(
151+
clouds={
152+
"microstack": {
153+
"auth": {
154+
"auth_url": "auth_url",
155+
"project_name": "project_name",
156+
"project_domain_name": "project_domain_name",
157+
"username": "username",
158+
"user_domain_name": "user_domain_name",
159+
"password": "password",
160+
},
161+
"region_name": "region",
162+
}
163+
},
164+
),
165+
path=GitHubOrg(org="canonical", group="group"),
166+
reconcile_interval=5,
167+
repo_policy_compliance=charm_state.RepoPolicyComplianceConfig(
168+
token="token",
169+
url="https://compliance.example.com",
170+
),
171+
token="githubtoken",
172+
manager_proxy_command="ssh -W %h:%p example.com",
173+
use_aproxy=True,
174+
),
175+
runner_config=charm_state.OpenstackRunnerConfig(
176+
base_virtual_machines=1,
177+
max_total_virtual_machines=2,
178+
flavor_label_combinations=[
179+
charm_state.FlavorLabel(
180+
flavor="flavor",
181+
label="flavorlabel",
182+
)
183+
],
184+
openstack_network="network",
185+
openstack_image=charm_state.OpenstackImage(
186+
id="image_id",
187+
tags=["arm64", "noble"],
188+
),
189+
),
190+
reactive_config=charm_state.ReactiveConfig(
191+
mq_uri="mongodb://user:password@localhost:27017",
192+
),
193+
ssh_debug_connections=[
194+
charm_state.SSHDebugConnection(
195+
host="10.10.10.10",
196+
port=3000,
197+
# Not very realistic
198+
rsa_fingerprint="SHA256:rsa",
199+
ed25519_fingerprint="SHA256:ed25519",
200+
),
201+
],
202+
)

0 commit comments

Comments
 (0)