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
24 changes: 5 additions & 19 deletions charms/garm/src/garm_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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"',
Expand All @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions charms/garm/src/runner_paths.py
Original file line number Diff line number Diff line change
@@ -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}"
22 changes: 11 additions & 11 deletions charms/garm/src/runner_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions charms/garm/tests/unit/test_garm_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,61 @@ 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
assert "TMATE_SERVER_ED25519_FINGERPRINT=SHA256:ed" in patched
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.
Expand Down
1 change: 1 addition & 0 deletions docs/.custom_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,5 @@ OAuth
aproxy
[Dd]eserializ(e|es|ed|ing|ation)
[Ss]erializ(e|es|ed|ing|ation)
tmate
[Rr]eplan(s|ned|ning)?
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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.
- `garm`: reliably apply proxy changes to the GARM workload. The charm gated the workload's Pebble layer rewrite on a hash of the on-disk config file, which excluded the proxy environment *values* — so changing or clearing a proxy value while the variable set stayed the same never rewrote the layer, leaving the old value in the plan. The on-disk file could also drift from the layer and wedge it permanently. The charm now compares the freshly rendered hash (which includes the proxy values) against the `config_hash` stored in the container's current Pebble plan, so proxy value changes and clears reliably replan the workload.

## 2026-07-12
Expand Down
Loading