From 4335864cf4c234b6c97d744d906e238efb659fc3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 23 Sep 2026 22:10:58 +0200 Subject: [PATCH 1/3] feat(stack): let hooks run commands in their stacklet's container Hooks that reach into a container built a `docker exec` string for `ctx.shell`, or called `subprocess` themselves. The string breaks on quoting, and a failed `ctx.shell` prints the whole command, including any password or client secret in it. `ctx.run_in_container(argv, service=None, user=None)` runs `argv` in `stack-`, or `stack--`, as an argument list with no shell. It returns stdout. On failure it raises with the container, the exit code and the last stderr line, never the arguments. --- docs/agent/dev.md | 1 + docs/stack-reference.md | 3 +- lib/stack/hooks.py | 34 +++++ .../test_hook_ctx_run_in_container.py | 118 ++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/unit/framework/test_hook_ctx_run_in_container.py diff --git a/docs/agent/dev.md b/docs/agent/dev.md index de11ad5c..7bf2f8a9 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -112,6 +112,7 @@ Python hooks (preferred) define `def run(ctx):`. `ctx` is the framework hook con | `ctx.secret(name)` | read a secret; `ctx.secret(name, value)` writes one | | `ctx.step(msg)` | progress line to the user | | `ctx.shell(cmd)` | streaming shell with error handling | +| `ctx.run_in_container(argv, service=, user=)` | a command in the stacklet's container, as a list, no shell; failures never repeat the arguments, so use it for anything that carries a credential | | `ctx.http_get(url, headers=...)` | parsed-JSON GET | | `ctx.http_post(url, body, content_type=..., headers=...)` | parsed-JSON POST | | `ctx.stack` | full Stack instance for `run_cli_command(, , ...)` | diff --git a/docs/stack-reference.md b/docs/stack-reference.md index abd93edb..83370902 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -701,7 +701,8 @@ runs them again from scratch. | `ctx.env` | `dict` | Rendered environment variables. | | `ctx.secret(name)` | `callable` | Read a secret. `ctx.secret(name, value)` writes one. | | `ctx.step(msg)` | `callable` | Print a progress line. | -| `ctx.shell(cmd)` | `callable` | Run a shell command with streaming output and error handling. | +| `ctx.shell(cmd)` | `callable` | Run a shell command with streaming output and error handling. The error message includes the command. | +| `ctx.run_in_container(argv, service=None, user=None)` | `callable` | Run `argv` in the stacklet's container (`stack-`, or `stack--`) with `docker exec`, as a list and without a shell. Returns stdout. On failure it raises with the container, the exit code and the last stderr line, never the arguments, so a password or secret can be passed. | | `ctx.http_post(url, body)` | `callable` | HTTP POST, returns parsed JSON. | | `ctx.http_get(url)` | `callable` | HTTP GET, returns parsed JSON. | diff --git a/lib/stack/hooks.py b/lib/stack/hooks.py index 73bdd4e6..64cc42aa 100644 --- a/lib/stack/hooks.py +++ b/lib/stack/hooks.py @@ -16,6 +16,7 @@ ctx.env — rendered environment variables ctx.step() — report progress ctx.shell() — run a system command + ctx.run_in_container() — run a command in the stacklet's container """ from __future__ import annotations @@ -93,6 +94,39 @@ def shell_live(self, cmd: str): if result.returncode != 0: raise RuntimeError(f"Command failed (exit {result.returncode}): {cmd}") + def run_in_container(self, argv: list[str], service: str | None = None, + user: str | None = None) -> str: + """Run a command inside this stacklet's container. Returns stdout. + + The container is found by the naming convention: `stack-` + for a stacklet with one service, `stack--` when + `service` is given. `user` is passed to `docker exec --user`, + for images whose tools refuse to run as root. + + The arguments go to `docker exec` as a list, with no shell in + between. A value is never split, quoted or expanded, so a + password or client secret can be passed as it is. + + A failure raises RuntimeError with the container, the exit code + and the last line of stderr, which is where a service's CLI + states its reason. The arguments are left out of the message on + purpose: it is printed to the terminal, and they may hold a + secret. `shell` includes its command, so use this for anything + that carries a credential. + """ + container = f"stack-{self.stacklet_id}" + (f"-{service}" if service else "") + command = ["docker", "exec", *(["--user", user] if user else []), container, *argv] + result = subprocess.run( + command, capture_output=True, text=True, + env={**os.environ, **self.env}, timeout=1200, + ) + if result.returncode != 0: + lines = (result.stderr or result.stdout).strip().splitlines() + reason = f": {lines[-1]}" if lines else "" + raise RuntimeError( + f"{argv[0]} in {container} failed (exit {result.returncode}){reason}") + return result.stdout + @property def secret(self): """Read/write secrets scoped to the current stacklet. diff --git a/tests/unit/framework/test_hook_ctx_run_in_container.py b/tests/unit/framework/test_hook_ctx_run_in_container.py new file mode 100644 index 00000000..56aad47c --- /dev/null +++ b/tests/unit/framework/test_hook_ctx_run_in_container.py @@ -0,0 +1,118 @@ +"""ctx.run_in_container(): run a command inside the stacklet's own container. + +Hooks run on the host and often have to reach into a container: create +an account with the service's admin CLI, run a management script. With +`ctx.shell` that meant building a `docker exec` command string, which +breaks on quoting and, on failure, printed the whole command line +including any password it carried. `ctx.run_in_container` takes the arguments as a +list, never goes through a shell, finds the container by the naming +convention, and reports a failure without the arguments. + +A stand-in `docker` script on PATH records what it receives, so these +tests pin the exact `docker exec` command line without Docker. +""" + +import json +import sys +import textwrap +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) + +from stack.hooks import build_hook_ctx # noqa: E402 + + +@pytest.fixture +def docker(tmp_path, monkeypatch): + """A `docker` that records its arguments and answers like the real one. + + It prints `STDOUT` and `STDERR` from its environment and exits with + `EXIT`, so each test decides what the command in the container did. + """ + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "docker-args.json" + script = bin_dir / "docker" + script.write_text(textwrap.dedent(f"""\ + #!{sys.executable} + import json, os, sys + with open({str(log)!r}, "w") as f: + json.dump(sys.argv[1:], f) + sys.stdout.write(os.environ.get("STDOUT", "")) + sys.stderr.write(os.environ.get("STDERR", "")) + sys.exit(int(os.environ.get("EXIT", "0"))) + """)) + script.chmod(0o755) + monkeypatch.setenv("PATH", f"{bin_dir}:{__import__('os').environ['PATH']}") + + class Docker: + def args(self): + return json.loads(log.read_text()) + + def answer(self, stdout="", stderr="", exit_code=0): + monkeypatch.setenv("STDOUT", stdout) + monkeypatch.setenv("STDERR", stderr) + monkeypatch.setenv("EXIT", str(exit_code)) + + return Docker() + + +def _ctx(stacklet_id="code"): + return build_hook_ctx(stacklet_id, env={}, step_fn=lambda msg: None) + + +class TestWhichContainer: + """The container follows the naming convention in dev.md.""" + + def test_a_single_service_stacklet_runs_in_stack_id(self, docker): + _ctx("code").run_in_container(["forgejo", "admin", "auth", "list"]) + assert docker.args() == ["exec", "stack-code", "forgejo", "admin", "auth", "list"] + + def test_a_named_service_runs_in_stack_id_service(self, docker): + _ctx("docs").run_in_container(["python3", "manage.py", "check"], service="paperless") + assert docker.args() == ["exec", "stack-docs-paperless", "python3", "manage.py", "check"] + + def test_runs_as_the_given_user(self, docker): + """Forgejo's CLI refuses to run as root; its image expects `git`.""" + _ctx("code").run_in_container(["forgejo", "--version"], user="git") + assert docker.args() == ["exec", "--user", "git", "stack-code", "forgejo", "--version"] + + +class TestArguments: + + def test_each_argument_arrives_as_written(self, docker): + """No shell in between: spaces, quotes and $ are not interpreted, + and a value cannot end one argument and start another.""" + tricky = ['family account', 'pa"ss $HOME; rm -rf /', "it's"] + _ctx("code").run_in_container(["tool", *tricky]) + assert docker.args()[2:] == ["tool", *tricky] + + +class TestResult: + + def test_returns_what_the_command_printed(self, docker): + docker.answer(stdout="ID\tName\n1\tfamily account\n") + assert _ctx("code").run_in_container(["forgejo", "admin", "auth", "list"]) == ( + "ID\tName\n1\tfamily account\n") + + def test_a_failure_says_where_and_why(self, docker): + """The last stderr line is what a service's CLI uses for its + reason, and what a hook checks for ("already exists").""" + docker.answer(stderr="some detail\nuser already exists\n", exit_code=1) + with pytest.raises(RuntimeError) as failed: + _ctx("code").run_in_container(["forgejo", "admin", "user", "create"]) + message = str(failed.value) + assert "stack-code" in message + assert "exit 1" in message + assert "user already exists" in message + + def test_a_failure_never_repeats_the_arguments(self, docker): + """Arguments carry passwords and client secrets, and the message + is printed to the terminal.""" + docker.answer(stderr="bad input\n", exit_code=2) + with pytest.raises(RuntimeError) as failed: + _ctx("code").run_in_container(["forgejo", "admin", "user", "create", + "--password", "hunter2-secret"]) + assert "hunter2-secret" not in str(failed.value) From d23d15ebfac727a61e47d5072821de8c567abdd9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 23 Sep 2026 22:11:16 +0200 Subject: [PATCH 2/3] security(code): keep admin passwords out of failed install output When Forgejo refused to create the tech admin or an admin from `users.toml` for any reason other than "already exists", the warning printed the failed `docker exec` command, and with it the account's password. The accounts are now created with `ctx.run_in_container`, which reports the reason without the arguments. --- stacklets/code/hooks/on_install_success.py | 51 ++++++++++------------ 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/stacklets/code/hooks/on_install_success.py b/stacklets/code/hooks/on_install_success.py index 6762badb..16c65ae2 100644 --- a/stacklets/code/hooks/on_install_success.py +++ b/stacklets/code/hooks/on_install_success.py @@ -5,6 +5,27 @@ """ +def _create_admin(ctx, username: str, email: str, password: str) -> None: + """Create one Forgejo admin account, or report that it exists. + + Runs Forgejo's admin CLI in the container through ctx.run_in_container: the + password is an argument, and a failed call must not print it. + Forgejo's CLI refuses to run as root, so it runs as `git`. + """ + try: + ctx.run_in_container(["forgejo", "admin", "user", "create", + "--username", username, "--password", password, + "--email", email, "--admin"], user="git") + ctx.step(f"Admin account created: {username}") + except RuntimeError as e: + # The CLI's last stderr line names the reason, and + # ctx.run_in_container puts it in the message. + if "already exists" in str(e).lower(): + ctx.step(f"Admin account already exists: {username}") + else: + ctx.warn(f"Could not create admin {username}: {e}") + + def run(ctx): from stack.users import ( TECH_ADMIN_USERNAME, TECH_ADMIN_EMAIL, @@ -18,20 +39,7 @@ def run(ctx): # ── Tech admin ─────────────────────────────────────────────────── ctx.step(f"Creating tech admin: {TECH_ADMIN_USERNAME}") - try: - ctx.shell( - f'docker exec --user git stack-code forgejo admin user create ' - f'--username "{TECH_ADMIN_USERNAME}" ' - f'--password "{admin_password}" ' - f'--email "{TECH_ADMIN_EMAIL}" ' - f'--admin' - ) - ctx.step(f"Tech admin created: {TECH_ADMIN_USERNAME}") - except RuntimeError as e: - if "already exists" in str(e).lower(): - ctx.step(f"Tech admin already exists: {TECH_ADMIN_USERNAME}") - else: - ctx.warn(f"Could not create tech admin {TECH_ADMIN_USERNAME}: {e}") + _create_admin(ctx, TECH_ADMIN_USERNAME, TECH_ADMIN_EMAIL, admin_password) # ── Admin-role users from users.toml ───────────────────────────── users = ctx.users or [] @@ -46,17 +54,4 @@ def run(ctx): continue ctx.step(f"Creating admin account: {username}") - try: - ctx.shell( - f'docker exec --user git stack-code forgejo admin user create ' - f'--username "{username}" ' - f'--password "{password}" ' - f'--email "{email}" ' - f'--admin' - ) - ctx.step(f"Admin account created: {username}") - except RuntimeError as e: - if "already exists" in str(e).lower(): - ctx.step(f"Admin account already exists: {username}") - else: - ctx.warn(f"Could not create admin {username}: {e}") + _create_admin(ctx, username, email, password) From c5d24662b30dc124a3c408495a932e7740be8e6f Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 23 Sep 2026 22:11:16 +0200 Subject: [PATCH 3/3] fix(docs): create Paperless admins whose password contains a quote Admin accounts are created by a Python script run inside the Paperless container, with the username, email and password written into it as '...' strings. A password containing a quote ended the string early, so the account was not created, or the rest ran as code. The values are now written as Python literals, and the script runs through `ctx.run_in_container`, so a failure no longer prints the password either. --- stacklets/docs/hooks/on_install_success.py | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/stacklets/docs/hooks/on_install_success.py b/stacklets/docs/hooks/on_install_success.py index 471e90bf..653c6d9f 100644 --- a/stacklets/docs/hooks/on_install_success.py +++ b/stacklets/docs/hooks/on_install_success.py @@ -40,7 +40,6 @@ def _create_admin_users(ctx, token): via Django's manage.py shell (bypasses password validators so short initial passwords like first-name-lowercased work). """ - import subprocess from stack.users import user_id, get_user_password users = ctx.users @@ -63,21 +62,21 @@ def _create_admin_users(ctx, token): # create_superuser inside the container bypasses Django password # validators. The script is idempotent: existing users are skipped. + # The values are Python literals (repr), so a quote in a password + # cannot end the string and run as code. script = ( "from django.contrib.auth.models import User; " - f"User.objects.create_superuser('{uid}', '{email}', '{password}') " - f"if not User.objects.filter(username='{uid}').exists() else None" + f"User.objects.create_superuser({uid!r}, {email!r}, {password!r}) " + f"if not User.objects.filter(username={uid!r}).exists() else None" ) - result = subprocess.run( - ["docker", "exec", "stack-docs-paperless", - "python3", "/usr/src/paperless/src/manage.py", "shell", "-c", script], - capture_output=True, text=True, timeout=30, - ) - if result.returncode == 0: + # ctx.run_in_container, because the script carries the password: a failure + # reports the reason, never the arguments. + try: + ctx.run_in_container(["python3", "/usr/src/paperless/src/manage.py", "shell", "-c", script], + service="paperless") ctx.step(f"Admin account created in Docs: {uid}") - else: - err = (result.stderr or result.stdout).strip().split("\n")[-1] - ctx.step(f"Could not create Docs admin {uid}: {err}") + except RuntimeError as e: + ctx.step(f"Could not create Docs admin {uid}: {e}") def _seed_taxonomy(ctx, token):