Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/agent/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<id>, <cmd>, ...)` |
Expand Down
3 changes: 2 additions & 1 deletion docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>`, or `stack-<id>-<service>`) 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. |

Expand Down
34 changes: 34 additions & 0 deletions lib/stack/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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-<id>`
for a stacklet with one service, `stack-<id>-<service>` 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.
Expand Down
51 changes: 23 additions & 28 deletions stacklets/code/hooks/on_install_success.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 []
Expand All @@ -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)
23 changes: 11 additions & 12 deletions stacklets/docs/hooks/on_install_success.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/framework/test_hook_ctx_run_in_container.py
Original file line number Diff line number Diff line change
@@ -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)
Loading