Skip to content

Commit d7a111e

Browse files
authored
Merge branch 'main' into chore/auto-copilot-collections
2 parents c1241b3 + a9f0cb3 commit d7a111e

10 files changed

Lines changed: 239 additions & 5 deletions

File tree

charmcraft.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ links:
2525
provides:
2626
cos-agent:
2727
interface: cos_agent
28+
planner:
29+
interface: github_runner_planner_v0
2830

2931
requires:
3032
debug-ssh:

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
This changelog documents user-relevant changes to the GitHub runner charm.
44

5+
## 2026-02-12
6+
7+
- Add support to integrate with GitHub Runner planner charm with the `github_runner_planner_v0` interface.
8+
59
## 2026-02-11
610

711
- Fixed charm hook errors caused by `ghapi`'s `pages()` leaving a stuck multiprocessing process that held the HTTP port.

github-runner-manager/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[project]
55
name = "github-runner-manager"
6-
version = "0.11.1"
6+
version = "0.12.0"
77
authors = [
88
{ name = "Canonical IS DevOps", email = "is-devops-team@canonical.com" },
99
]

src/charm.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@
5252
LABELS_CONFIG_NAME,
5353
MONGO_DB_INTEGRATION_NAME,
5454
PATH_CONFIG_NAME,
55+
PLANNER_INTEGRATION_NAME,
5556
TOKEN_CONFIG_NAME,
5657
CharmConfigInvalidError,
5758
CharmState,
5859
OpenstackImage,
60+
PlannerRelationData,
5961
build_proxy_config_from_charm,
6062
)
6163
from errors import (
@@ -234,6 +236,13 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
234236
self.on[IMAGE_INTEGRATION_NAME].relation_changed,
235237
self._on_image_relation_changed,
236238
)
239+
self.framework.observe(
240+
self.on[PLANNER_INTEGRATION_NAME].relation_changed,
241+
self._on_planner_relation_changed,
242+
)
243+
self.framework.observe(
244+
self.on[PLANNER_INTEGRATION_NAME].relation_broken, self._on_planner_relation_broken
245+
)
237246
self.framework.observe(self.on.check_runners_action, self._on_check_runners_action)
238247
self.framework.observe(self.on.flush_runners_action, self._on_flush_runners_action)
239248
self.framework.observe(self.on.update_status, self._on_update_status)
@@ -506,6 +515,30 @@ def _on_image_relation_changed(self, _: ops.RelationChangedEvent) -> None:
506515
self._manager_client.flush_runner()
507516
self.unit.status = ActiveStatus()
508517

518+
@catch_charm_errors
519+
def _on_planner_relation_changed(self, _: ops.RelationChangedEvent) -> None:
520+
"""Handle planner relation changed event."""
521+
self.unit.status = MaintenanceStatus("Setup planner")
522+
state = self._setup_state()
523+
self._setup_service(state)
524+
if self.unit.is_leader():
525+
flavor_data = PlannerRelationData(
526+
flavor=self.app.name,
527+
labels=state.charm_config.labels,
528+
minimum_pressure=state.runner_config.base_virtual_machines,
529+
)
530+
for relation in self.model.relations[PLANNER_INTEGRATION_NAME]:
531+
relation.data[self.app].update(flavor_data.to_relation_data())
532+
self.unit.status = ActiveStatus()
533+
534+
@catch_charm_errors
535+
def _on_planner_relation_broken(self, _: ops.RelationBrokenEvent) -> None:
536+
"""Handle planner relation broken event."""
537+
self.unit.status = MaintenanceStatus("Cleanup planner data")
538+
state = self._setup_state()
539+
self._setup_service(state)
540+
self.unit.status = ActiveStatus()
541+
509542
@catch_charm_errors
510543
def _on_database_created(self, _: ops.RelationEvent) -> None:
511544
"""Handle the MongoDB database created event."""

src/charm_state.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@
99
import logging
1010
import re
1111
from pathlib import Path
12-
from typing import Literal, cast
12+
from typing import Final, Literal, cast
1313
from urllib.parse import urlsplit
1414

1515
import yaml
1616
from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires
1717
from github_runner_manager.configuration import ProxyConfig, SSHDebugConnection
1818
from github_runner_manager.configuration.github import GitHubPath, parse_github_path
1919
from ops import CharmBase
20-
from pydantic import BaseModel, MongoDsn, ValidationError, create_model_from_typeddict, validator
20+
from pydantic import (
21+
BaseModel,
22+
MongoDsn,
23+
ValidationError,
24+
create_model_from_typeddict,
25+
validator,
26+
)
2127

2228
from errors import MissingMongoDBError
2329
from models import AnyHttpsUrl, FlavorLabel, OpenStackCloudsYAML
@@ -61,10 +67,53 @@
6167
DEBUG_SSH_INTEGRATION_NAME = "debug-ssh"
6268
IMAGE_INTEGRATION_NAME = "image"
6369
MONGO_DB_INTEGRATION_NAME = "mongodb"
70+
PLANNER_INTEGRATION_NAME = "planner"
71+
72+
# Keys and defaults for planner relation app data bag
73+
PLANNER_FLAVOR_RELATION_KEY: Final[str] = "flavor"
74+
PLANNER_LABELS_RELATION_KEY: Final[str] = "labels"
75+
PLANNER_PLATFORM_RELATION_KEY: Final[str] = "platform"
76+
PLANNER_PRIORITY_RELATION_KEY: Final[str] = "priority"
77+
PLANNER_MINIMUM_PRESSURE_RELATION_KEY: Final[str] = "minimum-pressure"
78+
PLANNER_DEFAULT_PLATFORM: Final[str] = "github"
79+
PLANNER_DEFAULT_PRIORITY: Final[int] = 50
6480

6581
LogLevel = Literal["CRITICAL", "FATAL", "ERROR", "WARNING", "INFO", "DEBUG"]
6682

6783

84+
@dataclasses.dataclass(frozen=True)
85+
class PlannerRelationData:
86+
"""Data written to the planner relation app databag.
87+
88+
Attributes:
89+
flavor: The flavor name (app name).
90+
labels: Runner labels for this flavor.
91+
platform: The platform identifier.
92+
priority: Scheduling priority.
93+
minimum_pressure: Minimum number of runners to maintain.
94+
"""
95+
96+
flavor: str
97+
labels: tuple[str, ...]
98+
platform: str = PLANNER_DEFAULT_PLATFORM
99+
priority: int = PLANNER_DEFAULT_PRIORITY
100+
minimum_pressure: int = 0
101+
102+
def to_relation_data(self) -> dict[str, str]:
103+
"""Serialize to relation databag format.
104+
105+
Returns:
106+
Dictionary of string key-value pairs for the Juju relation databag.
107+
"""
108+
return {
109+
PLANNER_FLAVOR_RELATION_KEY: self.flavor,
110+
PLANNER_LABELS_RELATION_KEY: json.dumps(list(self.labels)),
111+
PLANNER_PLATFORM_RELATION_KEY: self.platform,
112+
PLANNER_PRIORITY_RELATION_KEY: str(self.priority),
113+
PLANNER_MINIMUM_PRESSURE_RELATION_KEY: str(self.minimum_pressure),
114+
}
115+
116+
68117
@dataclasses.dataclass
69118
class GithubConfig:
70119
"""Charm configuration related to GitHub.
@@ -455,7 +504,6 @@ def from_charm(cls, charm: CharmBase) -> "CharmConfig":
455504
runner_manager_log_level = cast(
456505
LogLevel, charm.config.get(RUNNER_MANAGER_LOG_LEVEL_CONFIG_NAME, "INFO")
457506
)
458-
# pydantic allows to pass str as AnyHttpUrl, mypy complains about it
459507
return cls(
460508
allow_external_contributor=cast(
461509
bool, charm.config.get(ALLOW_EXTERNAL_CONTRIBUTOR_CONFIG_NAME, False)

tests/integration/conftest.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,3 +959,51 @@ def show_debug_log(juju: jubilant.Juju):
959959
yield juju
960960
show_debug_log(juju)
961961
return
962+
963+
964+
@pytest.fixture(scope="module")
965+
def planner_token_secret_name() -> str:
966+
"""Planner token secret name."""
967+
return "planner-token-secret"
968+
969+
970+
@pytest_asyncio.fixture(scope="module")
971+
async def planner_token_secret(model: Model, planner_token_secret_name: str) -> str:
972+
"""Create a planner token secret."""
973+
return await model.add_secret(
974+
name=planner_token_secret_name, data_args=["token=MOCK_PLANNER_TOKEN"]
975+
)
976+
977+
978+
@pytest_asyncio.fixture(scope="module")
979+
async def mock_planner_app(model: Model, planner_token_secret) -> AsyncIterator[Application]:
980+
"""Deploy a minimal any-charm that acts as the requires side of the planner relation."""
981+
planner_name = "planner"
982+
983+
any_charm_src_overwrite = {
984+
"any_charm.py": textwrap.dedent(f"""\
985+
from any_charm_base import AnyCharmBase
986+
987+
class AnyCharm(AnyCharmBase):
988+
def __init__(self, *args, **kwargs):
989+
super().__init__(*args, **kwargs)
990+
self.framework.observe(
991+
self.on["require-github-runner-planner-v0"].relation_changed,
992+
self._on_planner_relation_changed,
993+
)
994+
995+
def _on_planner_relation_changed(self, event):
996+
event.relation.data[self.unit]["endpoint"] = "http://mock:8080"
997+
event.relation.data[self.unit]["token"] = "{planner_token_secret}"
998+
"""),
999+
}
1000+
1001+
planner_app: Application = await model.deploy(
1002+
"any-charm",
1003+
planner_name,
1004+
channel="latest/beta",
1005+
config={"src-overwrite": json.dumps(any_charm_src_overwrite)},
1006+
)
1007+
1008+
await model.wait_for_idle(apps=[planner_app.name], status=ACTIVE, timeout=10 * 60)
1009+
yield planner_app

tests/integration/test_charm_no_runner.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33

44
"""Integration tests for github-runner charm with no runner."""
55

6+
import json
67
import logging
78

9+
import jubilant
810
import pytest
911
from github_runner_manager.reconcile_service import (
1012
RECONCILE_SERVICE_START_MSG,
1113
RECONCILE_START_MSG,
1214
)
1315
from juju.application import Application
16+
from juju.model import Model
17+
from ops import ActiveStatus
1418

1519
from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME
1620
from manager_service import GITHUB_RUNNER_MANAGER_SERVICE_NAME
@@ -109,7 +113,8 @@ async def test_manager_service_started(
109113
# 1.
110114
await run_in_unit(
111115
unit,
112-
f"sudo systemctl status {GITHUB_RUNNER_MANAGER_SERVICE_NAME}@{unit.name.replace('/', '-')}.service",
116+
f"sudo systemctl status {GITHUB_RUNNER_MANAGER_SERVICE_NAME}"
117+
f"@{unit.name.replace('/', '-')}.service",
113118
timeout=60,
114119
assert_on_failure=True,
115120
assert_msg="GitHub runner manager service not healthy",
@@ -134,3 +139,57 @@ async def test_manager_service_started(
134139
log = await get_github_runner_manager_service_log(unit)
135140
assert RECONCILE_SERVICE_START_MSG not in log
136141
assert RECONCILE_START_MSG in log
142+
143+
144+
@pytest.mark.asyncio
145+
@pytest.mark.abort_on_fail
146+
async def test_planner_integration(
147+
model: Model,
148+
juju: jubilant.Juju,
149+
app_no_runner: Application,
150+
mock_planner_app: Application,
151+
planner_token_secret_name: str,
152+
) -> None:
153+
"""
154+
arrange: A working application with no runners and a mock planner, and the secret granted.
155+
act:
156+
1. Integrate the application with the mock planner.
157+
2. Remove the integration.
158+
assert:
159+
1. The charm writes its flavor data to the planner relation app data bag.
160+
2. The charm returns to active status after the relation is removed.
161+
"""
162+
await model.grant_secret(planner_token_secret_name, app_no_runner.name)
163+
await model.grant_secret(planner_token_secret_name, mock_planner_app.name)
164+
165+
await model.relate(f"{app_no_runner.name}:planner", mock_planner_app.name)
166+
await model.wait_for_idle(
167+
apps=[app_no_runner.name, mock_planner_app.name],
168+
status=ActiveStatus.name,
169+
idle_period=30,
170+
timeout=10 * 60,
171+
)
172+
173+
# Verify the runner charm wrote flavor data to the relation app databag.
174+
# Query from the planner unit's perspective so "application-data" shows the
175+
# remote (runner) app's data rather than the planner's own app data.
176+
planner_unit_name = mock_planner_app.units[0].name
177+
raw = juju.cli("show-unit", planner_unit_name, "--format", "json")
178+
unit_data = json.loads(raw)[planner_unit_name]
179+
planner_rel = next(
180+
rel
181+
for rel in unit_data["relation-info"]
182+
if rel["endpoint"] == "require-github-runner-planner-v0"
183+
)
184+
app_data = planner_rel["application-data"]
185+
assert app_data["flavor"] == app_no_runner.name
186+
assert app_data["platform"] == "github"
187+
assert app_data["priority"] == "50"
188+
assert app_data["minimum-pressure"] == "0"
189+
190+
await mock_planner_app.remove_relation(
191+
"require-github-runner-planner-v0", f"{app_no_runner.name}:planner"
192+
)
193+
await model.wait_for_idle(
194+
apps=[app_no_runner.name], status=ActiveStatus.name, idle_period=30, timeout=10 * 60
195+
)

tests/unit/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ def complete_charm_state_fixture():
160160
manager_proxy_command="ssh -W %h:%p example.com",
161161
use_aproxy=True,
162162
runner_manager_log_level="INFO",
163+
planner=None,
163164
),
164165
runner_config=charm_state.OpenstackRunnerConfig(
165166
base_virtual_machines=1,

tests/unit/factories.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
OPENSTACK_FLAVOR_CONFIG_NAME,
3333
OPENSTACK_NETWORK_CONFIG_NAME,
3434
PATH_CONFIG_NAME,
35+
PLANNER_INTEGRATION_NAME,
3536
RECONCILE_INTERVAL_CONFIG_NAME,
3637
RUNNER_MANAGER_LOG_LEVEL_CONFIG_NAME,
3738
TEST_MODE_CONFIG_NAME,
@@ -111,6 +112,7 @@ class Meta:
111112
COS_AGENT_INTEGRATION_NAME: [],
112113
DEBUG_SSH_INTEGRATION_NAME: [],
113114
MONGO_DB_INTEGRATION_NAME: [],
115+
PLANNER_INTEGRATION_NAME: [],
114116
}
115117
)
116118

tests/unit/test_charm.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434
OPENSTACK_CLOUDS_YAML_CONFIG_NAME,
3535
OPENSTACK_FLAVOR_CONFIG_NAME,
3636
PATH_CONFIG_NAME,
37+
PLANNER_DEFAULT_PLATFORM,
38+
PLANNER_DEFAULT_PRIORITY,
39+
PLANNER_FLAVOR_RELATION_KEY,
40+
PLANNER_INTEGRATION_NAME,
41+
PLANNER_LABELS_RELATION_KEY,
42+
PLANNER_MINIMUM_PRESSURE_RELATION_KEY,
43+
PLANNER_PLATFORM_RELATION_KEY,
44+
PLANNER_PRIORITY_RELATION_KEY,
3745
TOKEN_CONFIG_NAME,
3846
USE_APROXY_CONFIG_NAME,
3947
OpenStackCloudsYAML,
@@ -703,3 +711,32 @@ def test_database_integration_events_setup_service(
703711
else:
704712
getattr(harness.charm.database.on, hook).emit(relation=relation_mock)
705713
setup_service_mock.assert_called_once()
714+
715+
716+
def test_planner_relation_changed_writes_flavor(monkeypatch: pytest.MonkeyPatch):
717+
"""
718+
arrange: Set up charm with mocked _setup_state and _setup_service.
719+
act: Fire planner relation_changed event.
720+
assert: The app data bag contains all flavor fields for the planner.
721+
"""
722+
harness = Harness(GithubRunnerCharm)
723+
harness.set_leader(True)
724+
relation_id = harness.add_relation(PLANNER_INTEGRATION_NAME, "planner-app")
725+
harness.add_relation_unit(relation_id, "planner-app/0")
726+
harness.begin()
727+
monkeypatch.setattr("charm.manager_service", MagicMock())
728+
state_mock = MagicMock()
729+
state_mock.charm_config.labels = ("label1", "label2")
730+
state_mock.runner_config.base_virtual_machines = 3
731+
harness.charm._setup_state = MagicMock(return_value=state_mock)
732+
harness.charm._setup_service = MagicMock()
733+
734+
harness.update_relation_data(relation_id, "planner-app/0", {"endpoint": "http://example.com"})
735+
736+
assert harness.get_relation_data(relation_id, harness.charm.app) == {
737+
PLANNER_FLAVOR_RELATION_KEY: harness.charm.app.name,
738+
PLANNER_LABELS_RELATION_KEY: '["label1", "label2"]',
739+
PLANNER_PLATFORM_RELATION_KEY: PLANNER_DEFAULT_PLATFORM,
740+
PLANNER_PRIORITY_RELATION_KEY: str(PLANNER_DEFAULT_PRIORITY),
741+
PLANNER_MINIMUM_PRESSURE_RELATION_KEY: "3",
742+
}

0 commit comments

Comments
 (0)