Skip to content
Merged
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
26 changes: 26 additions & 0 deletions charms/garm/src/scaleset_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@
# GitHub's own limit on the System label GARM registers the scale set under.
MAX_SCALESET_NAME_LENGTH = 64

# GARM names each runner `<runner_prefix>-<id>` and never validates the prefix length.
# The name becomes the OpenStack server name and so the runner's hostname, bounded to a
# 63-character DNS label. The id is the base62 form of a shortid, or of a UUID when that
# fails — at most 22 characters — which leaves 63 - 1 - 22 for the prefix.
MAX_RUNNER_PREFIX_LENGTH = 40
Comment thread
weiiwang01 marked this conversation as resolved.
DEFAULT_RUNNER_PREFIX = "garm"

# Past this, a runner a retired scaleset still reports cannot be doing a legitimate
# job: these are self-hosted runners, bound by MAX_JOB_RUNTIME, not the six-hour cap
# GitHub enforces on the runners it hosts itself. The margin absorbs reconcile
Expand Down Expand Up @@ -1047,6 +1054,7 @@ def _to_create_params(spec: ScalesetSpec, scaleset_name: str) -> CreateScaleSetP
"enable_shell": spec.enable_shell,
"extra_specs": _effective_extra_specs(spec) or None,
"template_id": spec.template_id,
"runner_prefix": runner_prefix(spec.name),
}
)

Expand Down Expand Up @@ -1103,6 +1111,7 @@ def _maybe_update(self, observed: ScaleSet, spec: ScalesetSpec, template_id: int
enable_shell=spec.enable_shell,
extra_specs=extra_specs,
template_id=spec.template_id,
runner_prefix=runner_prefix(spec.name),
)
# Send template_id when the scaleset has, or had, a custom template — a 0
# value detaches it (reverts to the default); omit it otherwise so an
Expand Down Expand Up @@ -1135,6 +1144,7 @@ def _needs_update(observed: ScaleSet, spec: ScalesetSpec, template_id: int) -> b
!= bool(desired_extra.get("disable_updates"))
or observed_extra.get("image_visibility") != desired_extra.get("image_visibility")
or (observed.template_id or 0) != template_id
or observed.runner_prefix != runner_prefix(spec.name)
)


Expand Down Expand Up @@ -1171,6 +1181,22 @@ def _name_base(logical_name: str) -> str:
return f"{logical_name[: limit - LABEL_HASH_LENGTH - 1]}-{digest}"


def runner_prefix(logical_name: str) -> str:
"""Return the prefix GARM names a scaleset's runners with.

Args:
logical_name: The scaleset name the operator configured.

Returns:
The name reduced to a hostname-safe form — lowercase alphanumerics joined by
single hyphens — truncated to ``MAX_RUNNER_PREFIX_LENGTH``. GARM's own default
prefix when nothing usable remains. Truncation needs no disambiguating hash:
GARM appends a random id to every runner name.
"""
prefix = re.sub(r"[^a-z0-9]+", "-", logical_name.lower()).strip("-")
return prefix[:MAX_RUNNER_PREFIX_LENGTH].rstrip("-") or DEFAULT_RUNNER_PREFIX


def _is_family_member(observed_name: str, logical_name: str) -> bool:
"""Return whether a live scaleset is a generation of *logical_name*.

Expand Down
65 changes: 65 additions & 0 deletions charms/garm/tests/unit/test_scaleset_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
from runner_template import build_template_data
from scaleset_reconciler import (
DRAIN_DEADLINE,
MAX_RUNNER_PREFIX_LENGTH,
MAX_SCALESET_NAME_LENGTH,
Handover,
ScalesetProgress,
ScalesetReconciler,
ScalesetSpec,
_effective_extra_specs,
runner_prefix,
target_scaleset_name,
)

Expand Down Expand Up @@ -51,6 +53,7 @@ def __init__(
template_id=None,
enabled=True,
updated_at=None,
runner_prefix=None,
):
self.name = name
self.id = sid
Expand All @@ -72,6 +75,7 @@ def __init__(
# GARM stamps this on every write, so for a retired scaleset it is the
# moment it was disabled — which is what the drain deadline measures from.
self.updated_at = updated_at or datetime.now(timezone.utc)
self.runner_prefix = runner_prefix


class _FakeJob:
Expand Down Expand Up @@ -151,6 +155,7 @@ def __init__(
template_id=ss.get("template_id", None),
enabled=ss.get("enabled", True),
updated_at=ss.get("updated_at", None),
runner_prefix=ss.get("runner_prefix", None),
)
for ss in (scalesets or [])
]
Expand Down Expand Up @@ -357,6 +362,7 @@ def _existing_scaleset(**overrides):
extra_specs={},
tags=[],
enabled=True,
runner_prefix="my-scaleset",
)
base.update(overrides)
return base
Expand Down Expand Up @@ -415,6 +421,65 @@ def test_no_update_when_scaleset_unchanged():
assert client.deleted == []


def test_create_names_runners_after_scaleset():
"""
arrange: FakeGarmClient with the provider registered and no existing scalesets.
act: Reconcile a desired spec.
assert: The scaleset is created with its logical name as runner prefix, so runners
are named after the scaleset rather than GARM's default ``garm-<id>``.
"""
client = FakeGarmClient(providers=["openstack-demo"], scalesets=[])

_reconcile(client, [_spec(name="my-scaleset")])

assert len(client.created) == 1
_, _, params = client.created[0]
assert params.runner_prefix == "my-scaleset"


def test_update_renames_runners_of_existing_scaleset():
"""
arrange: An existing scaleset still on GARM's default runner prefix.
act: Reconcile the matching desired spec.
assert: It is updated in place to the scaleset-derived prefix, so scalesets created
before this change also name new runners after themselves without a recreate.
"""
client = FakeGarmClient(
providers=["openstack-demo"],
scalesets=[_existing_scaleset(runner_prefix="garm")],
)

_reconcile(client, [_spec()])

assert client.created == []
assert len(client.updated) == 1
_, params = client.updated[0]
assert params.runner_prefix == "my-scaleset"


@pytest.mark.parametrize(
"logical_name, expected",
[
("my-scaleset", "my-scaleset"),
("My_Scale.Set", "my-scale-set"),
("--x86__jammy--", "x86-jammy"),
("a" * (MAX_RUNNER_PREFIX_LENGTH + 10), "a" * MAX_RUNNER_PREFIX_LENGTH),
("a" * (MAX_RUNNER_PREFIX_LENGTH - 1) + "-b", "a" * (MAX_RUNNER_PREFIX_LENGTH - 1)),
("___", "garm"),
],
ids=["kept", "sanitised", "trimmed", "truncated", "no-trailing-hyphen", "empty-fallback"],
)
def test_runner_prefix(logical_name, expected):
"""
arrange: A configured scaleset name.
act: Derive the runner prefix from it.
assert: The prefix is a lowercase hostname-safe form of the name, at most
MAX_RUNNER_PREFIX_LENGTH long so GARM's `<prefix>-<id>` runner name still fits a
63-character hostname, falling back to GARM's default when nothing usable is left.
"""
assert runner_prefix(logical_name) == expected


def test_update_when_scaleset_disabled():
"""
arrange: FakeGarmClient with one existing disabled scaleset that otherwise matches.
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

Each revision is versioned by the date of the revision.

## 2026-10-16

- `garm`: name runners after their scale set (for example `my-scaleset-dvnyp5dm3sm`) instead of GARM's default `garm-<id>`. The prefix is the scale set name in lowercase, with every run of characters other than letters and digits replaced by a hyphen, and truncated to 40 characters so the runner name still fits a 63-character hostname. Existing scale sets are updated in place; runners created before the update keep their names.
Comment thread
weiiwang01 marked this conversation as resolved.

## 2026-10-15

- `garm`: bump the GARM workload so that it starts on PostgreSQL databases created by older GARM versions. On such databases, the previous workload failed a startup migration with `operator does not exist: text = uuid` and GARM did not start; GARM now converts the affected columns to the expected types on startup.
Expand Down
Loading