From c90474c3367c19b6936d441891920008e2d94239 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sat, 5 Sep 2026 13:44:52 +0200 Subject: [PATCH] Adopt separate registry access and template destination settings --- backend/druks/setup_env.py | 32 +++++++++++++-- backend/tests/test_setup_env.py | 72 ++++++++++++++++++++++----------- docs/configuration.md | 61 ++++++++++++++++++++++++++++ docs/writing-an-app.md | 8 ++++ 4 files changed, 147 insertions(+), 26 deletions(-) diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index 2f962832..d2df1ae3 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -39,6 +39,10 @@ "REDIS_URL", "DRUKS_AUTH_HEADER", "DEFAULT_HOST_PROVIDER", + "TEMPLATE_REPOSITORY", + "REGISTRY_HOST", + "REGISTRY_USERNAME", + "REGISTRY_PASSWORD", "SERVICE_TOKENS", "DRUKS_AUTH_MODE", "DRUKS_AUTH_JWKS_URL", @@ -76,6 +80,8 @@ "browser_login_tz", "timeout", ), + "registry": ("host", "username", "password"), + "templates": ("repository",), "env": (), } @@ -200,6 +206,16 @@ def run_setup( # to remote stacks verbatim; the local docker shape renders no provider table. # Provider reference: https://github.com/czpython/drukbox (docs/deploy.md). +# Shared access to private images in this installation. +[registry] +host = "" +username = "" +password = "" + +# Repository path within the registry for built sandbox templates. +[templates] +repository = "" + # Raw environment for processes druks does not model (drukbox, Caddy, libraries # reading os.environ); keys render verbatim unless owned by druks. [env] @@ -225,9 +241,6 @@ def _fresh_values(*, provider: str, home: str) -> tuple[tuple[tuple[str, ...], s (("sandbox", "service_url"), "http://127.0.0.1:8780"), (("sandbox", "service_token"), _hex_secret()), (("sandbox", "exe", "EXE_API_TOKEN"), ""), - (("sandbox", "exe", "EXE_IMAGE_REGISTRY"), ""), - (("sandbox", "exe", "EXE_REGISTRY_USERNAME"), ""), - (("sandbox", "exe", "EXE_REGISTRY_PASSWORD"), ""), (("sandbox", "exe", "TAILSCALE_TAILNET"), ""), (("sandbox", "exe", "TAILSCALE_OAUTH_CLIENT_ID"), ""), (("sandbox", "exe", "TAILSCALE_OAUTH_CLIENT_SECRET"), ""), @@ -388,6 +401,19 @@ def _render_env( ( ("DEFAULT_HOST_PROVIDER", provider), ("SERVICE_TOKENS", service_tokens), + ("REGISTRY_HOST", _get_string(config, ("registry", "host"))), + ( + "TEMPLATE_REPOSITORY", + _get_string(config, ("templates", "repository")), + ), + ( + "REGISTRY_USERNAME", + _get_string(config, ("registry", "username")), + ), + ( + "REGISTRY_PASSWORD", + _get_string(config, ("registry", "password")), + ), ), ), ) diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index 8ab3ae64..a87189a5 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -51,9 +51,6 @@ def test_fresh_exe_render_matches_the_deployment_contract(tmp_path): assert values["DRUKS_DATA_DIR"] == "/home/op/druks-data" assert "EXE_API_TOKEN" not in values assert "TAILSCALE_TAILNET" not in values - for key in ("EXE_IMAGE_REGISTRY", "EXE_REGISTRY_USERNAME", "EXE_REGISTRY_PASSWORD"): - assert config["sandbox"]["exe"][key] == "" - assert key not in values assert len(config["secrets"]["postgres_password"]) == 64 assert len(config["sandbox"]["service_token"]) == 64 assert (tmp_path / ".gitignore").read_text().splitlines() == [ @@ -436,35 +433,64 @@ def test_setup_toml_is_the_settings_source(tmp_path, monkeypatch): assert settings.sandbox.timeout == float(config["sandbox"]["timeout"]) -@pytest.mark.parametrize("repository", ["ghcr.io/acme/templates", "docker.io/acme/templates"]) -def test_exe_template_registry_uses_existing_provider_contract(tmp_path, repository): +@pytest.mark.parametrize("provider", ["docker", "exe", "exoscale"]) +def test_template_registry_renders_once_for_every_provider(tmp_path, provider): + env_path = tmp_path / ".env" + _run( + env_path, + provider=provider, + set_values=( + "registry.host=ghcr.io", + "templates.repository=acme/sandbox-templates", + "registry.username=builder", + "registry.password=registry-token", + ), + ) + values = read_env(env_path) + assert values["TEMPLATE_REPOSITORY"] == "acme/sandbox-templates" + assert values["REGISTRY_HOST"] == "ghcr.io" + assert values["REGISTRY_USERNAME"] == "builder" + assert values["REGISTRY_PASSWORD"] == "registry-token" + assert env_path.read_text().count("TEMPLATE_REPOSITORY=") == 1 + assert stat.S_IMODE(env_path.stat().st_mode) == 0o600 + + +def test_template_registry_cannot_be_overridden_by_provider_or_env(tmp_path): env_path = tmp_path / ".env" printed = [] + _run( + env_path, + print_fn=printed.append, + set_values=( + "registry.host=ghcr.io", + "templates.repository=acme/sandbox-templates", + "sandbox.exe.TEMPLATE_REPOSITORY=ghcr.io/other/templates", + "env.REGISTRY_PASSWORD=wrong-secret", + ), + ) + values = read_env(env_path) + assert values["TEMPLATE_REPOSITORY"] == "acme/sandbox-templates" + assert "REGISTRY_PASSWORD" not in values + assert "reserved by druks" in "\n".join(printed) + assert "wrong-secret" not in "\n".join(printed) + + +def test_registry_access_renders_without_template_destination(tmp_path): + env_path = tmp_path / ".env" assert ( _run( env_path, - print_fn=printed.append, + provider="docker", set_values=( - "sandbox.exe.EXE_API_TOKEN=exe-token", - "sandbox.exe.TAILSCALE_TAILNET=tail.ts.net", - f"sandbox.exe.EXE_IMAGE_REGISTRY={repository}", - "sandbox.exe.EXE_REGISTRY_USERNAME=builder", - "sandbox.exe.EXE_REGISTRY_PASSWORD=registry-token", + "registry.host=ghcr.io", + "registry.username=operator", + "registry.password=token", ), ) == 0 ) values = read_env(env_path) - expected = { - "EXE_IMAGE_REGISTRY": repository, - "EXE_REGISTRY_USERNAME": "builder", - "EXE_REGISTRY_PASSWORD": "registry-token", - } - for key, value in expected.items(): - assert values[key] == value - assert env_path.read_text().count(f"{key}=") == 1 - assert "REGISTRY_HOST" not in values + assert values["REGISTRY_HOST"] == "ghcr.io" + assert values["REGISTRY_USERNAME"] == "operator" + assert values["REGISTRY_PASSWORD"] == "token" assert "TEMPLATE_REPOSITORY" not in values - assert "registry-token" not in "\n".join(printed) - assert stat.S_IMODE(env_path.stat().st_mode) == 0o600 - assert stat.S_IMODE((tmp_path / "druks.toml").stat().st_mode) == 0o600 diff --git a/docs/configuration.md b/docs/configuration.md index 951ddfe7..73d50b16 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,6 +42,8 @@ host-run development template for that environment plane. | `[paths]` | Host data and harness configuration paths | | `[sandbox]` | Drukbox provider, service URL, token behavior, and image override | | `[sandbox.]` | Provider environment passed through to the remote stack | +| `[registry]` | Registry host and credentials for private image access | +| `[templates]` | Shared repository path for sandbox template images | | `[env]` | Additional deployment environment settings rendered verbatim | A blank string means unset, and the renderer omits it from `.env`. Use `[env]` for settings @@ -465,3 +467,62 @@ ordinary Postgres fields, although APIs withhold or mask their values. Treat access to Postgres and its backups as access to those credentials. GitHub App private keys — the operator identity's and the review app's — are database values under the envelope, no longer files mounted into the process. + +## Registry access and sandbox templates + +A Druks installation serves one operator or organization. Registry access and +template publishing are separate settings in `druks.toml`: + +```toml +[registry] +host = "ghcr.io" +username = "builder" +password = "" + +[templates] +repository = "acme/sandbox-templates" +``` + +For Docker Hub, set `registry.host = "docker.io"`. The host must have no URL +scheme or path. The template repository is a path within that registry, +with no tag or digest. + +`druks setup` renders `REGISTRY_HOST`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, +and `TEMPLATE_REPOSITORY` for Drukbox. Do not duplicate these keys in +`[sandbox.]` or `[env]`. These settings require a Drukbox release +with separate registry access and template destination configuration. +When adopting that release, replace `EXE_IMAGE_REGISTRY`, +`EXE_REGISTRY_USERNAME`, and `EXE_REGISTRY_PASSWORD` in `[sandbox.exe]` with +the tables above. Split the full repository into its host and repository path. + +Set all three registry values together. Registry access works without a +template destination: exe can boot other private images on that registry. +The registry's permissions determine which repositories the credential can +access. To build and publish templates, also set `templates.repository` and +use a credential with push permission. Leave the template repository blank +for local Docker builds without publication. + +Druks sends labels such as `site-builder-build`. Drukbox creates tags such as +`ghcr.io/acme/sandbox-templates:site-builder-build-` and pins the +published image by digest. App authors do not manage registry paths or +credentials. Druks writes configuration files with mode `0600`. + +All template images share repository access and retention policy. Drukbox +does not delete remote registry manifests. Retain images that active templates use. + +After editing `druks.toml`, render `.env` with the installation path and the +deployment user's home directory: + +```bash +druks setup /path/to/install/.env --home /home/operator +``` + +Then recreate the Drukbox service with a release that supports these settings. +Use the installation's Compose files. A restart alone does not load a changed +container environment. Druks writes both configuration files with mode `0600`. + +If a template already failed, use the authenticated Drukbox `GET /templates` +API to find the failed record. Delete only that record with +`DELETE /templates/{template_id}`, then run `druks doctor` in the Druks +container to create it again. Creating the same template without deleting +the failed record returns the existing failure. diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index e5b9f319..1b7d79bd 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -149,6 +149,14 @@ class BuildSite(Workflow): sandbox = Sandbox(setup="sandboxes/build.sh") ``` +Druks derives the template label from the app name and setup script stem. +For example, app `site_builder` with `sandboxes/build.sh` sends +`site-builder-build`. Labels describe purpose; they do not change template +reuse. Declarations with the same setup content share a template for the +same base image and provider. The label comes from one of those declarations. +The operator configures [one shared registry](configuration.md#registry-access-and-sandbox-templates). +Drukbox adds a unique build identifier to the tag and stores the published digest. + Place the file at `site_builder/sandboxes/build.sh`. The path is relative to the app package.