From ec29c9e710c0714c86df9b9f93ad84e21b146a88 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Thu, 8 Oct 2026 14:06:54 +0000 Subject: [PATCH] fix(garm): set XDG_RUNTIME_DIR for runner jobs GARM's agent mode starts the runner from a system service without a login session, so jobs have no XDG_RUNTIME_DIR and skopeo/podman/buildah fall back to the root-owned /run/containers. Enable lingering for the runner user and export XDG_RUNTIME_DIR via the runner .env from the shared charmed template. --- charms/garm/src/garm_template.py | 23 ++++++++++++------- charms/garm/tests/unit/test_garm_template.py | 24 ++++++++++++++++++++ docs/changelog.md | 4 ++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/charms/garm/src/garm_template.py b/charms/garm/src/garm_template.py index 84b1a7d6..fb6ee52c 100644 --- a/charms/garm/src/garm_template.py +++ b/charms/garm/src/garm_template.py @@ -9,13 +9,23 @@ from charm_state import SSHDebugInfo from garm_api import GarmApiError, GarmAuthenticatedClient -from runner_paths import RUNNER_ENV_PATH, prepend_after_shebang +from runner_paths import RUNNER_ENV_PATH, RUNNER_USER, prepend_after_shebang logger = logging.getLogger(__name__) GARM_BASE_TEMPLATE_NAME: typing.Final[str] = "github_linux" GARM_CHARMED_TEMPLATE_NAME: typing.Final[str] = "github_linux_charmed" +# GARM's agent mode starts the runner from a system service with no login session, +# so jobs get no XDG_RUNTIME_DIR and tools keeping per-user state there (skopeo, +# podman, buildah) fall back to root-owned /run/containers. Lingering keeps +# /run/user/ alive without a session; the .env entry exports it to every job. +_RUNNER_RUNTIME_DIR_SNIPPET: typing.Final[str] = f"""\ +sudo loginctl enable-linger {RUNNER_USER} || true +mkdir -p $(dirname {RUNNER_ENV_PATH}) +echo "XDG_RUNTIME_DIR=/run/user/$(id -u {RUNNER_USER})" >> {RUNNER_ENV_PATH} +""" + class CharmedTemplateError(Exception): """Raised when charmed template management fails; message is used as the unit status.""" @@ -28,10 +38,9 @@ def apply_charmed_template( """Create or update the charmed runner install template and return its id. Derives ``github_linux_charmed`` from the built-in ``github_linux`` base by - prepending shell snippets. The debug-ssh snippet (tmate env vars) is added - only when connections are present; with none the charmed template is still - maintained as a snippet-free copy of the base so scalesets can reference it - unconditionally (future snippets attach the same way). + prepending shell snippets. The runner runtime-directory setup is always added; + the debug-ssh snippet (tmate env vars) only when connections are present. + Scalesets reference the charmed template unconditionally. Args: client: Authenticated GARM API client. @@ -119,9 +128,7 @@ def _build_charmed_template_data( # GARM returns the template body as a base64-encoded string. base_script = base64.b64decode(base_template.data).decode("utf-8") - snippet = build_tmate_env_snippet(connections) - if not snippet: - return base_script.encode("utf-8") + snippet = _RUNNER_RUNTIME_DIR_SNIPPET + build_tmate_env_snippet(connections) return prepend_after_shebang(base_script, snippet).encode("utf-8") diff --git a/charms/garm/tests/unit/test_garm_template.py b/charms/garm/tests/unit/test_garm_template.py index d1315868..78e31a0d 100644 --- a/charms/garm/tests/unit/test_garm_template.py +++ b/charms/garm/tests/unit/test_garm_template.py @@ -11,6 +11,7 @@ import garm_template from charm_state import SSHDebugInfo from garm_api import GarmApiError +from runner_paths import RUNNER_ENV_PATH _CONN = SSHDebugInfo(host="h", port=10022, rsa_fingerprint="rsa-fp", ed25519_fingerprint="ed-fp") @@ -246,6 +247,29 @@ def test_build_charmed_template_data_prepends_snippet_after_shebang(): assert "echo hello" in decoded +def test_build_charmed_template_data_sets_up_runner_runtime_dir(): + """ + arrange: get_template returns a base script; no debug-ssh connections. + act: Call _build_charmed_template_data(). + assert: The runner user gets lingering and XDG_RUNTIME_DIR in the runner .env, so + jobs of a runner started without a login session can use tools that keep + per-user state there (skopeo/podman login), and it precedes the base body. + """ + client = MagicMock() + base_script = "#!/bin/bash\necho hello\n" + template = MagicMock() + template.data = base64.b64encode(base_script.encode()).decode() + client.get_template.return_value = template + + result = garm_template._build_charmed_template_data(client, 1, []) + + decoded = result.decode() + assert decoded.startswith("#!/bin/bash\n") + assert "sudo loginctl enable-linger runner" in decoded + assert f'XDG_RUNTIME_DIR=/run/user/$(id -u runner)" >> {RUNNER_ENV_PATH}' in decoded + assert decoded.index("enable-linger") < decoded.index("echo hello") + + _PATCHED = b"patched-data" diff --git a/docs/changelog.md b/docs/changelog.md index 80a9e528..8da2626c 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-08 + +- `garm`: set `XDG_RUNTIME_DIR` for runner jobs. Runners start without a login session, so tools that keep per-user state there, such as `skopeo login`, `podman` and `buildah`, failed with `mkdir /run/containers: permission denied`. + ## 2026-10-07 - `garm`: bump the GARM workload so that scale set labels longer than 64 characters work on PostgreSQL. GARM stored labels in a `varchar(64)` column, so such a label failed with `value too long for type character varying(64)`; the column is now `varchar(255)`, and GARM migrates existing databases on startup.