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
5 changes: 5 additions & 0 deletions charms/garm-configurator/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ config:
type: string
description: |
OpenStack network name or ID to use for instances.
security-group:
type: string
description: |
OpenStack security group name or ID to attach to runner instances. When
unset, the provider attaches the project's default security group.
github-app-id:
type: int
description: |
Expand Down
8 changes: 5 additions & 3 deletions charms/garm-configurator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ def _configure_garm_relation(self, state: CharmState) -> None:
"""Publish scaleset configuration to the garm-configurator relation.

Writes non-secret scaleset fields (name, provider, credentials, image,
image visibility, flavor, arch, runner counts, labels, runner group,
remote-shell toggle, and pre-install scripts) to the relation. The ``image_id`` wire field
carries either an OpenStack image name or ID for compatibility.
image visibility, security group, flavor, arch, runner counts, labels,
runner group, remote-shell toggle, and pre-install scripts) to the relation.
The ``image_id`` wire field carries either an OpenStack image name or ID
for compatibility.
The optional ``org`` and ``repo`` fields are included only when set.

When the image reference is present and this unit holds leadership, also
Expand All @@ -161,6 +162,7 @@ def _configure_garm_relation(self, state: CharmState) -> None:
"provider_name": state.provider_config.provider_name,
"image_id": state.image or "",
"image_visibility": state.scaleset_config.image_visibility or "",
"security_group": state.scaleset_config.security_group,
"flavor": state.scaleset_config.flavor,
"os_arch": state.scaleset_config.os_arch,
"min_idle_runner": str(state.scaleset_config.min_idle_runner),
Expand Down
4 changes: 4 additions & 0 deletions charms/garm-configurator/src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
OPENSTACK_PROJECT_DOMAIN_NAME_CONFIG_NAME = "openstack-project-domain-name"
OPENSTACK_REGION_NAME_CONFIG_NAME = "openstack-region-name"
OPENSTACK_NETWORK_CONFIG_NAME = "openstack-network"
SECURITY_GROUP_CONFIG_NAME = "security-group"

GITHUB_APP_ID_CONFIG_NAME = "github-app-id"
GITHUB_APP_INSTALLATION_ID_CONFIG_NAME = "github-app-installation-id"
Expand Down Expand Up @@ -234,6 +235,7 @@ class ScalesetConfig(BaseModel):
enable_shell: Whether GARM's remote shell is enabled on the scaleset.
pre_install_scripts: Script name to bash script pairs for pre-installation.
image_visibility: Glance visibility the provider searches when resolving an image name.
security_group: OpenStack security group name or ID to attach to runners.
"""

name: str
Expand All @@ -248,6 +250,7 @@ class ScalesetConfig(BaseModel):
enable_shell: bool = False
pre_install_scripts: str | None = None
image_visibility: str | None = None
security_group: str = ""

@classmethod
def from_charm(cls, charm: ops.CharmBase) -> "ScalesetConfig":
Expand Down Expand Up @@ -329,6 +332,7 @@ def from_charm(cls, charm: ops.CharmBase) -> "ScalesetConfig":
enable_shell=bool(charm.config.get(SCALESET_ENABLE_SHELL_CONFIG_NAME, False)),
pre_install_scripts=pre_install_scripts,
image_visibility=image_visibility,
security_group=str(charm.config.get(SECURITY_GROUP_CONFIG_NAME, "")).strip(),
)


Expand Down
28 changes: 28 additions & 0 deletions charms/garm-configurator/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,3 +1042,31 @@ def test_image_visibility_written_to_garm_configurator_relation(configured, expe
assert out.get_relation(garm_relation.id).local_unit_data.get("image_visibility", "") == (
expected
)


@pytest.mark.parametrize(
"configured, expected",
[("sg-runner", "sg-runner"), (None, "")],
ids=["set", "unset"],
)
def test_security_group_written_to_garm_configurator_relation(configured, expected):
"""
arrange: Valid config with security-group set or unset.
act: Run config-changed with a garm-configurator relation present.
assert: The databag carries the group, or an empty string when unset so the provider
attaches the project's default group.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
if configured is not None:
config["security-group"] = configured
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)

assert out.get_relation(garm_relation.id).local_unit_data.get("security_group", "") == (
expected
)
1 change: 1 addition & 0 deletions charms/garm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ def _build_scaleset_spec(
template_id=template_id,
runner_config=RunnerConfig.from_databag(data),
image_visibility=data.get("image_visibility", "").strip(),
security_group=data.get("security_group", "").strip(),
)

def _build_desired_credentials(self) -> list[CredentialSpec]:
Expand Down
10 changes: 8 additions & 2 deletions charms/garm/src/scaleset_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ class ScalesetSpec:
template_id: int | None = None
runner_config: RunnerConfig = field(default_factory=RunnerConfig)
image_visibility: str = ""
security_group: str = ""


class ScalesetReconciler:
Expand Down Expand Up @@ -1143,6 +1144,7 @@ def _needs_update(observed: ScaleSet, spec: ScalesetSpec, template_id: int) -> b
or bool(observed_extra.get("disable_updates"))
!= bool(desired_extra.get("disable_updates"))
or observed_extra.get("image_visibility") != desired_extra.get("image_visibility")
or observed_extra.get("security_groups") != desired_extra.get("security_groups")
or (observed.template_id or 0) != template_id
or observed.runner_prefix != runner_prefix(spec.name)
)
Expand Down Expand Up @@ -1332,8 +1334,9 @@ def _effective_extra_specs(spec: ScalesetSpec) -> dict[str, object]:

Single source of truth for create, update, and drift detection: combines the
operator-supplied pre-install scripts with the charm's aproxy bootstrap, the
``disable_updates`` flag when a runner proxy is configured, and the image
visibility the provider searches when resolving an image name.
``disable_updates`` flag when a runner proxy is configured, the image
visibility the provider searches when resolving an image name, and the
security group a runner's port is attached to.

Args:
spec: The desired scaleset.
Expand All @@ -1360,4 +1363,7 @@ def _effective_extra_specs(spec: ScalesetSpec) -> dict[str, object]:
}
if spec.image_visibility:
extra_specs["image_visibility"] = spec.image_visibility
if spec.security_group:
# The provider's extra spec is a list; the configurator publishes one group.
extra_specs["security_groups"] = [spec.security_group]
return extra_specs
23 changes: 23 additions & 0 deletions charms/garm/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,29 @@ def test_image_visibility_follows_the_configurator_databag(
assert [spec.image_visibility for spec in specs] == [expected]


@pytest.mark.parametrize(
"databag_value, expected",
[("sg-runner", "sg-runner"), (None, "")],
ids=["set", "absent"],
)
def test_security_group_follows_the_configurator_databag(
ctx: Context, garm_api: _GarmApiMocks, databag_value: str | None, expected: str
):
"""
arrange: A configurator unit publishing security_group, or nothing — the last being an
older configurator that predates the option.
act: Run update-status.
assert: The scaleset spec carries the group, or none so the provider's default applies.
"""
data = {**_PROVIDER_UNIT_DATA, **_SCALESET_UNIT_DATA}
if databag_value is not None:
data["security_group"] = databag_value
ctx.run(ctx.on.update_status(), _state(configurator_units_data={0: data}))

specs = garm_api.scaleset.return_value.reconcile.call_args[0][0]
assert [spec.security_group for spec in specs] == [expected]


# --- Event wiring -------------------------------------------------------------------------


Expand Down
44 changes: 44 additions & 0 deletions charms/garm/tests/unit/test_scaleset_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ def _spec(
template_id=None,
runner_config=None,
image_visibility="",
security_group="",
):
return ScalesetSpec(
name=name,
Expand All @@ -276,6 +277,7 @@ def _spec(
template_id=template_id,
runner_config=runner_config or RunnerConfig(),
image_visibility=image_visibility,
security_group=security_group,
)


Expand Down Expand Up @@ -1338,6 +1340,48 @@ def test_create_sends_image_visibility_as_an_extra_spec():
assert params.extra_specs == {"image_visibility": "private"}


@pytest.mark.parametrize(
"observed_extra, desired, expected_extra",
[
({"security_groups": ["sg-old"]}, "sg-runner", {"security_groups": ["sg-runner"]}),
({"security_groups": ["sg-runner"]}, "", {}),
],
ids=["changed", "cleared"],
)
def test_update_converges_security_group(observed_extra, desired, expected_extra):
"""
arrange: An existing scaleset carrying a different security group, or one the spec no
longer sets.
act: Reconcile the spec.
assert: One update brings the extra spec to the desired group, clearing it with an
explicit empty dict when unset — GARM round-trips the value as sent, so a key it
rewrote would update the scaleset on every reconcile instead.
"""
client = FakeGarmClient(
providers=["openstack-demo"],
scalesets=[_existing_scaleset(extra_specs=observed_extra)],
)
_reconcile(client, [_spec(security_group=desired)])

assert len(client.updated) == 1
_, params = client.updated[0]
assert params.extra_specs == expected_extra


def test_create_sends_security_group_as_an_extra_spec():
"""
arrange: A spec naming a security group locked down for runner traffic.
act: Reconcile a create.
assert: The group reaches the provider, which attaches it to the runner's port; without
it runners share the project's default group and can reach each other.
"""
client = FakeGarmClient(providers=["openstack-demo"], scalesets=[])
_reconcile(client, [_spec(security_group="sg-runner")])

_, _, params = client.created[0]
assert params.extra_specs == {"security_groups": ["sg-runner"]}


@pytest.mark.parametrize(
"observed_extra, desired, expected_extra",
[
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Each revision is versioned by the date of the revision.
## 2026-10-21

- `garm`: job steps now see `USER` and `LOGNAME` set to the account their UID resolves to, so `usermod -aG <group> $USER` followed by `sg <group>` works on GARM runners. This works around the duplicate-UID account layout of the runner images; removal is tracked in [#438](https://github.com/canonical/github-runner-operators/issues/438).
- `garm-configurator`: add the optional `security-group` configuration, which the `garm` charm passes to the OpenStack provider as the scale set's `security_groups` extra spec. Runners in a project otherwise share its default security group, so they can reach each other and a compromised job can attack its peers; set `security-group` to a group that isolates them. The group applies to runners created after it is set; runners that already exist keep the group they were launched with.

## 2026-10-16

Expand Down
Loading