Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions charms/garm-configurator/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ config:
A Python dict-like string containing key-value pairs of script name
to bash script to be run prior to runner installation.

provides:
garm-configurator:
interface: garm_configurator_v0

requires:
image:
interface: github_runner_image_v0
Expand Down
31 changes: 31 additions & 0 deletions charms/garm-configurator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

"""Charm entrypoint for the GARM configurator charm."""

import json
import typing

import ops
Expand All @@ -15,6 +16,8 @@
CharmState,
)

GARM_CONFIGURATOR_RELATION_NAME = "garm-configurator"


class GarmConfiguratorCharm(ops.CharmBase):
"""GARM configurator charm."""
Expand All @@ -29,6 +32,7 @@ def __init__(self, *args: typing.Any) -> None:
for event in [
self.on.config_changed,
self.on.secret_changed,
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_changed,
self.on[IMAGE_RELATION_NAME].relation_joined,
self.on[IMAGE_RELATION_NAME].relation_changed,
self.on[IMAGE_RELATION_NAME].relation_broken,
Expand Down Expand Up @@ -65,6 +69,33 @@ def _reconcile(self, event: ops.EventBase) -> None:
}
)

pre_install = state.scaleset_config.pre_install_scripts
relation_data = {
"name": state.scaleset_config.name,
"provider_name": state.provider_name,
"credentials_name": state.credentials_name,
"image_id": str(state.image_id),
"flavor": state.scaleset_config.flavor,
"os_arch": state.scaleset_config.os_arch,
"min_idle_runner": str(state.scaleset_config.min_idle_runner),
"max_runner": str(state.scaleset_config.max_runner),
"labels": (
state.scaleset_config.labels
if isinstance(state.scaleset_config.labels, str)
else ",".join(state.scaleset_config.labels)
),
"runner_group": state.scaleset_config.runner_group,
"pre_install_scripts": json.dumps({"pre_install.sh": pre_install})
if pre_install
else "",
}
if state.scaleset_config.org:
relation_data["org"] = state.scaleset_config.org
if state.scaleset_config.repo:
relation_data["repo"] = state.scaleset_config.repo
for garm_relation in self.model.relations[GARM_CONFIGURATOR_RELATION_NAME]:
garm_relation.data[self.unit].update(relation_data)

if relation is None:
self.unit.status = ops.WaitingStatus("Waiting for image builder relation")
return
Expand Down
10 changes: 10 additions & 0 deletions charms/garm-configurator/src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,16 @@ def from_charm(cls, charm: ops.CharmBase) -> "CharmState":
image_id=image_id,
)

@property
def provider_name(self) -> str:
"""Derived GARM provider name for scalesets."""
return f"openstack-{self.provider_config.project_name}"

@property
def credentials_name(self) -> str:
"""Derived GARM credentials name for scalesets."""
return f"github-app-{self.github_app_config.client_id}"


def _get_image_id_from_relation(charm: ops.CharmBase) -> str | None:
"""Return the OpenStack image UUID from the image builder relation, if available.
Expand Down
110 changes: 109 additions & 1 deletion charms/garm-configurator/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def _make_private_key_secret():
return Secret(tracked_content={"value": "random-secret"})


def _make_garm_configurator_relation():
return Relation(endpoint="garm-configurator")


def _valid_config(secret: Secret, private_key_secret: Secret) -> dict:
return {
"openstack-auth-url": "https://keystone.example.com:5000/v3",
Expand Down Expand Up @@ -432,13 +436,117 @@ def test_status_waiting_on_relation_broken():
assert out.unit_status == ops.WaitingStatus("Waiting for image builder relation")


def test_garm_configurator_relation_data_written_on_reconcile():
"""
arrange: Valid config and an active garm-configurator relation.
act: Run config-changed.
assert: The charm writes all expected scaleset fields to local unit relation data.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
config["labels"] = "self-hosted,linux"
config["pre-install-scripts"] = "echo hello"
garm_relation = _make_garm_configurator_relation()
state = State(
config=config,
secrets=[secret, pk_secret],
relations=[garm_relation],
)

out = ctx.run(ctx.on.config_changed(), state)

rel_out = out.get_relation(garm_relation.id)
expected_relation_data = {
"name": "my-scaleset",
"provider_name": "openstack-myproject",
"credentials_name": "github-app-12345",
"image_id": "None",
"flavor": "m1.large",
"os_arch": "amd64",
"min_idle_runner": "0",
"max_runner": "5",
"labels": "self-hosted,linux",
"runner_group": "default",
"pre_install_scripts": '{"pre_install.sh": "echo hello"}',
"repo": "myorg/myrepo",
}
for key, value in expected_relation_data.items():
assert rel_out.local_unit_data[key] == value


def test_garm_configurator_relation_data_reflects_charm_state():
"""
arrange: Valid config with project and client identifiers plus a garm-configurator relation.
act: Run config-changed.
assert: Derived provider and credentials names are written from CharmState.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
config["openstack-project-name"] = "demo-project"
config["github-app-client-id"] = "abc123"
garm_relation = _make_garm_configurator_relation()
state = State(
config=config,
secrets=[secret, pk_secret],
relations=[garm_relation],
)

out = ctx.run(ctx.on.config_changed(), state)

rel_out = out.get_relation(garm_relation.id)
assert rel_out.local_unit_data["provider_name"] == "openstack-demo-project"
assert rel_out.local_unit_data["credentials_name"] == "github-app-abc123"


def test_garm_configurator_no_error_when_no_relation():
"""
arrange: Valid config with no garm-configurator relation.
act: Run config-changed.
assert: Reconcile completes and preserves the existing waiting status behavior.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
state = State(config=_valid_config(secret, pk_secret), secrets=[secret, pk_secret])

out = ctx.run(ctx.on.config_changed(), state)

assert out.unit_status == ops.WaitingStatus("Waiting for image builder relation")


def test_garm_configurator_relation_changed_triggers_reconcile():
"""
arrange: Valid config and a garm-configurator relation with no existing local unit data.
act: Run garm-configurator relation-changed.
assert: Reconcile writes relation data for the local unit.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
garm_relation = _make_garm_configurator_relation()
state = State(
config=_valid_config(secret, pk_secret),
secrets=[secret, pk_secret],
relations=[garm_relation],
)

out = ctx.run(ctx.on.relation_changed(garm_relation), state)

rel_out = out.get_relation(garm_relation.id)
assert rel_out.local_unit_data["name"] == "my-scaleset"


def test_reconcile_writes_full_config_to_garm_relation():
"""
arrange: All configs valid, image relation has a UUID, garm relation is joined.
act: config-changed fires (holistic reconcile).
assert: The full config payload (provider, github, scaleset, image_id) is written
to the garm-configurator relation's local unit data.
"""
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
Expand Down
72 changes: 72 additions & 0 deletions charms/garm-configurator/tests/unit/test_charm_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

"""Unit tests for charm_state."""

from charm_state import CharmState, GithubAppConfig, ProviderConfig, ScalesetConfig


def test_provider_name_derived_from_project_name():
"""CharmState.provider_name is openstack-{project_name}."""
project_name = "myproject"
state = CharmState(
provider_config=ProviderConfig(
auth_url="https://keystone.example.com:5000/v3",
username="admin",
password="s3cr3t",
project_name=project_name,
user_domain_name="Default",
project_domain_name="Default",
region_name="RegionOne",
network="external-net",
),
github_app_config=GithubAppConfig(
client_id="12345",
installation_id="67890",
private_key="private-key",
),
scaleset_config=ScalesetConfig(
name="my-scaleset",
flavor="m1.large",
os_arch="amd64",
min_idle_runner=0,
max_runner=5,
repo="myorg/myrepo",
),
image_id=None,
)

assert state.provider_name == f"openstack-{project_name}"


def test_credentials_name_derived_from_client_id():
"""CharmState.credentials_name is github-app-{client_id}."""
client_id = "12345"
state = CharmState(
provider_config=ProviderConfig(
auth_url="https://keystone.example.com:5000/v3",
username="admin",
password="s3cr3t",
project_name="myproject",
user_domain_name="Default",
project_domain_name="Default",
region_name="RegionOne",
network="external-net",
),
github_app_config=GithubAppConfig(
client_id=client_id,
installation_id="67890",
private_key="private-key",
),
scaleset_config=ScalesetConfig(
name="my-scaleset",
flavor="m1.large",
os_arch="amd64",
min_idle_runner=0,
max_runner=5,
repo="myorg/myrepo",
),
image_id=None,
)

assert state.credentials_name == f"github-app-{client_id}"
1 change: 0 additions & 1 deletion charms/garm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,3 @@ requires:
garm-configurator:
interface: garm_configurator_v0
limit: 1

Loading
Loading