From b9c79e6778239a7782e6c00cf0b659862f55089c Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Fri, 16 Oct 2026 02:17:16 +0800 Subject: [PATCH] Use scaleset name as runner name prefix --- charms/garm/src/scaleset_reconciler.py | 26 ++++++++ .../tests/unit/test_scaleset_reconciler.py | 65 +++++++++++++++++++ docs/changelog.md | 4 ++ 3 files changed, 95 insertions(+) diff --git a/charms/garm/src/scaleset_reconciler.py b/charms/garm/src/scaleset_reconciler.py index c5e6b766..2ad9399b 100644 --- a/charms/garm/src/scaleset_reconciler.py +++ b/charms/garm/src/scaleset_reconciler.py @@ -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 `-` 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 +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 @@ -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), } ) @@ -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 @@ -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) ) @@ -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*. diff --git a/charms/garm/tests/unit/test_scaleset_reconciler.py b/charms/garm/tests/unit/test_scaleset_reconciler.py index cc5dacb4..99912ba5 100644 --- a/charms/garm/tests/unit/test_scaleset_reconciler.py +++ b/charms/garm/tests/unit/test_scaleset_reconciler.py @@ -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, ) @@ -51,6 +53,7 @@ def __init__( template_id=None, enabled=True, updated_at=None, + runner_prefix=None, ): self.name = name self.id = sid @@ -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: @@ -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 []) ] @@ -357,6 +362,7 @@ def _existing_scaleset(**overrides): extra_specs={}, tags=[], enabled=True, + runner_prefix="my-scaleset", ) base.update(overrides) return base @@ -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-``. + """ + 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 `-` 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. diff --git a/docs/changelog.md b/docs/changelog.md index 9b89d43d..eb236167 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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-`. 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. + ## 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.