From 0d0a7526ef2504b5a1798d7f6efbf390113b013d Mon Sep 17 00:00:00 2001 From: yhaliaw Date: Mon, 13 Jul 2026 16:30:04 +0800 Subject: [PATCH 1/2] Fix the path for tmate ssh-debug env var --- charms/garm/src/garm_template.py | 24 ++-------- charms/garm/src/runner_paths.py | 43 ++++++++++++++++++ charms/garm/src/runner_template.py | 22 ++++----- charms/garm/tests/unit/test_garm_template.py | 48 ++++++++++++++++++++ 4 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 charms/garm/src/runner_paths.py diff --git a/charms/garm/src/garm_template.py b/charms/garm/src/garm_template.py index d9deee41..84b1a7d6 100644 --- a/charms/garm/src/garm_template.py +++ b/charms/garm/src/garm_template.py @@ -9,6 +9,7 @@ from charm_state import SSHDebugInfo from garm_api import GarmApiError, GarmAuthenticatedClient +from runner_paths import RUNNER_ENV_PATH, prepend_after_shebang logger = logging.getLogger(__name__) @@ -73,7 +74,10 @@ def build_tmate_env_snippet(connections: list[SSHDebugInfo]) -> str: if not connections: return "" conn = connections[0] - runner_env = "/home/runner/.env" + # Global env write: injected into the shared github_linux_charmed template, so + # these tmate vars apply to every scaleset (a separate .env append from the + # per-scaleset one in runner_template.render_pre_job_hooks). + runner_env = RUNNER_ENV_PATH lines = [ f"mkdir -p $(dirname {runner_env})", f'cat >> {runner_env} << "EOF"', @@ -87,24 +91,6 @@ def build_tmate_env_snippet(connections: list[SSHDebugInfo]) -> str: return "\n".join(lines) -def prepend_after_shebang(script: str, snippet: str) -> str: - """Insert *snippet* immediately after the shebang line of *script*. - - If no shebang is present the snippet is prepended at the very start. - - Args: - script: The original shell script (may start with ``#!``). - snippet: The shell code to inject. - - Returns: - The modified script string. - """ - lines = script.split("\n") - if lines and lines[0].startswith("#!"): - return lines[0] + "\n" + snippet + "\n".join(lines[1:]) - return snippet + script - - def _build_charmed_template_data( client: GarmAuthenticatedClient, base_id: int, diff --git a/charms/garm/src/runner_paths.py b/charms/garm/src/runner_paths.py new file mode 100644 index 00000000..1507388c --- /dev/null +++ b/charms/garm/src/runner_paths.py @@ -0,0 +1,43 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Shared runner-install paths and template-splicing primitives. + +Both template-injection flows — the global ``github_linux_charmed`` template +(:mod:`garm_template`) and the per-scaleset templates (:mod:`runner_template`) — +prepend shell after the shebang and append to the same runner ``.env`` file. +Keeping the paths and the splice helper here gives them one source of truth so +the two flows cannot drift apart. +""" + +# GARM installs the runner under the `runner` user's home; the GitHub Actions +# runner sources a `.env` dotfile from that install directory. +RUNNER_USER = "runner" +RUNNER_HOME = "/home/runner/actions-runner" +PRE_JOB_HOOK_PATH = f"{RUNNER_HOME}/pre-job.sh" +RUNNER_ENV_PATH = f"{RUNNER_HOME}/.env" + + +def prepend_after_shebang(script: str, snippet: str) -> str: + """Insert *snippet* immediately after the shebang line of *script*. + + The runner install scripts these flows patch always start with a ``#!`` + shebang, so the snippet lands on its own line right after it. If no shebang + is present the snippet is prepended at the very start instead. Either way the + snippet is separated from the following body by a newline, so a snippet that + is not newline-terminated cannot run into the next line. + + Args: + script: The original shell script (may start with ``#!``). + snippet: The shell code to inject. + + Returns: + The modified script string. An empty *snippet* leaves *script* unchanged. + """ + if not snippet: + return script + body = snippet if snippet.endswith("\n") else snippet + "\n" + if script.startswith("#!"): + shebang, _, rest = script.partition("\n") + return f"{shebang}\n{body}{rest}" + return f"{body}{script}" diff --git a/charms/garm/src/runner_template.py b/charms/garm/src/runner_template.py index 66a18945..14aaadc0 100644 --- a/charms/garm/src/runner_template.py +++ b/charms/garm/src/runner_template.py @@ -42,13 +42,13 @@ import jinja2 from charm_state import RunnerConfig - -# GARM installs the runner under the `runner` user's home; the GitHub Actions -# runner sources a `.env` dotfile from that directory. -RUNNER_USER = "runner" -RUNNER_HOME = "/home/runner/actions-runner" -PRE_JOB_HOOK_PATH = f"{RUNNER_HOME}/pre-job.sh" -RUNNER_ENV_PATH = f"{RUNNER_HOME}/.env" +from runner_paths import ( + PRE_JOB_HOOK_PATH, + RUNNER_ENV_PATH, + RUNNER_HOME, + RUNNER_USER, + prepend_after_shebang, +) _TEMPLATES_DIR = pathlib.Path(__file__).parent / "templates" _JINJA_ENV = jinja2.Environment( @@ -96,10 +96,7 @@ def build_template_data(base: bytes, config: RunnerConfig) -> bytes: """ text = base.decode("utf-8") injection = render_pre_install(config) + render_pre_job_hooks(config) - if "\n" in text: - shebang, rest = text.split("\n", 1) - return f"{shebang}\n{injection}{rest}".encode("utf-8") - return f"{text}\n{injection}".encode("utf-8") + return prepend_after_shebang(text, injection).encode("utf-8") def render_pre_install(config: RunnerConfig) -> str: @@ -152,6 +149,9 @@ def render_pre_job_hooks(config: RunnerConfig) -> str: # since the delimiter depends on the already-rendered hook body. prejob_delim = _heredoc_delimiter(hook_body, "GARM_CHARM_PREJOB") env_delim = _heredoc_delimiter("\n".join(env_entries), "GARM_CHARM_ENV") + # Per-scaleset env write: these vars come from the scaleset's RunnerConfig, so + # they land in the per-scaleset template (a separate .env append from the + # global tmate one in garm_template.build_tmate_env_snippet). return _PRE_JOB_HOOKS_TEMPLATE.render( runner_home=RUNNER_HOME, hook_path=PRE_JOB_HOOK_PATH, diff --git a/charms/garm/tests/unit/test_garm_template.py b/charms/garm/tests/unit/test_garm_template.py index f5cecc13..d1315868 100644 --- a/charms/garm/tests/unit/test_garm_template.py +++ b/charms/garm/tests/unit/test_garm_template.py @@ -34,6 +34,7 @@ def test_ssh_debug_info_env_vars(): patched = garm_template.prepend_after_shebang(base_script, snippet) assert patched.startswith("#!/bin/bash\n") + assert "cat >> /home/runner/actions-runner/.env <<" in patched assert "TMATE_SERVER_HOST=10.10.0.5" in patched assert "TMATE_SERVER_PORT=2222" in patched assert "TMATE_SERVER_RSA_FINGERPRINT=SHA256:rsa" in patched @@ -41,6 +42,53 @@ def test_ssh_debug_info_env_vars(): assert "echo 'hello'" in patched +def test_prepend_after_shebang_no_shebang_prepends_at_start(): + """ + arrange: A script that does not start with a shebang and a snippet. + act: Call prepend_after_shebang(). + assert: The snippet is prepended at the very start, on its own line, with the + original script body preserved intact after it. + """ + patched = garm_template.prepend_after_shebang("echo original\n", "echo injected\n") + + assert patched == "echo injected\necho original\n" + + +def test_prepend_after_shebang_separates_snippet_without_trailing_newline(): + """ + arrange: A shebang script and a snippet that is not newline-terminated. + act: Call prepend_after_shebang(). + assert: The snippet does not run into the following body line — a newline is + inserted so the injected code and the original body stay separate. + """ + patched = garm_template.prepend_after_shebang("#!/bin/bash\necho body\n", "echo injected") + + assert patched == "#!/bin/bash\necho injected\necho body\n" + + +def test_prepend_after_shebang_no_shebang_unterminated_snippet(): + """ + arrange: A script with no shebang and a snippet that is not newline-terminated. + act: Call prepend_after_shebang(). + assert: The snippet is prepended at the start, separated from the body by a + newline so the two do not run together. + """ + patched = garm_template.prepend_after_shebang("echo original", "echo injected") + + assert patched == "echo injected\necho original" + + +def test_prepend_after_shebang_empty_snippet_leaves_script_unchanged(): + """ + arrange: A script and an empty snippet. + act: Call prepend_after_shebang(). + assert: The script is returned unchanged — no stray blank line is injected. + """ + script = "#!/bin/bash\necho body\n" + + assert garm_template.prepend_after_shebang(script, "") == script + + def test_apply_charmed_template_maintains_when_no_connections_and_charmed_exists(): """ arrange: list_templates returns base + charmed; no debug-ssh connections. From ad362f4813388078b239d96b77062dc8faa4294a Mon Sep 17 00:00:00 2001 From: yhaliaw Date: Mon, 13 Jul 2026 16:34:34 +0800 Subject: [PATCH 2/2] Add spelling lint fix and changelog entry --- docs/.custom_wordlist.txt | 1 + docs/changelog.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/.custom_wordlist.txt b/docs/.custom_wordlist.txt index 440cf0cf..b4c33970 100644 --- a/docs/.custom_wordlist.txt +++ b/docs/.custom_wordlist.txt @@ -88,3 +88,4 @@ OAuth aproxy [Dd]eserializ(e|es|ed|ing|ation) [Ss]erializ(e|es|ed|ing|ation) +tmate diff --git a/docs/changelog.md b/docs/changelog.md index 672c4195..ff1fa87f 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-07-13 + +- `garm`: fix the SSH debug (tmate) connection details so the runner actually reads them. The `TMATE_SERVER_*` variables injected into the shared runner template were written to `/home/runner/.env`, but the GitHub Actions runner sources a `.env` file from its install directory (`/home/runner/actions-runner/.env`), so the debug-SSH server host, port, and fingerprints were silently ignored. They are now written to the `.env` file the runner reads, matching the per-scaleset runner options. + ## 2026-07-12 - `garm`: fix the injected runner-option environment variables so the runner actually reads them. They were written to a file named `env`, but the GitHub Actions runner sources a `.env` file, so the Docker mirror, OpenTelemetry endpoint, job-started hook, and pre-job-script options were silently ignored. They are now written to the `.env` file the runner reads.