Skip to content
Draft
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
32 changes: 29 additions & 3 deletions backend/druks/setup_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -76,6 +80,8 @@
"browser_login_tz",
"timeout",
),
"registry": ("host", "username", "password"),
"templates": ("repository",),
"env": (),
}

Expand Down Expand Up @@ -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]
Expand All @@ -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"), ""),
Expand Down Expand Up @@ -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")),
),
),
),
)
Expand Down
72 changes: 49 additions & 23 deletions backend/tests/test_setup_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == [
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>]` | 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
Expand Down Expand Up @@ -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 = "<registry-token>"

[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.<provider>]` 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-<build-id>` 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.
8 changes: 8 additions & 0 deletions docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down