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
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ and setup-script hash. `POST /templates` creates a `building` record and
returns `202 Accepted`. Callers poll until the template becomes
`available` or `failed`. Templates outlive hosts. Each provider builds
and deletes its own templates behind `TemplateCapability`.
OCI builds use one shared image repository when configured. Labels describe
purpose; unique build tags identify publications. Published template images
use repository digests. See [registry configuration](deploy.md#shared-template-image-repository).

A host request can name an available template by its ID — the ID that
the create returned. The template's image becomes the host image. An
Expand Down
55 changes: 52 additions & 3 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ Core, optional:
| `SERVICE_LABEL` | `drukbox` | Label stamped onto provider resources (VM tags, SG tags). |
| `UVICORN_HOST` | `0.0.0.0` | API bind address. Set `127.0.0.1` to restrict to loopback. |
| `PROVISIONING_GRACE_SECONDS` | `600` | Safety TTL on in-flight hosts so the janitor reaps row + VM if the client disconnects mid-provision. Must exceed the worst-case provision duration. |
| `REGISTRY_HOST` | — | Registry host for private images, such as `ghcr.io` or `docker.io`. |
| `TEMPLATE_REPOSITORY` | — | Shared template repository path within `REGISTRY_HOST`, with no tag or digest. |
| `REGISTRY_USERNAME` | — | Registry user for template pushes and private exe pulls. |
| `REGISTRY_PASSWORD` | — | Registry password or token. |
| `TEMPLATE_BUILD_TIMEOUT` | `3600` | Max age in seconds of an unfinished template build before the janitor marks it failed. |
| `TEMPLATE_FAILED_RETENTION` | `86400` | Seconds that failed template records and diagnostics remain before the janitor deletes them. |
| `TEMPLATE_UNUSED_TTL` | `1209600` | Seconds that an available template remains after its last use, or creation when never used. |
Expand Down Expand Up @@ -334,9 +338,6 @@ exe.dev provider:
| --- | --- | --- |
| `EXE_API_TOKEN` | — (required) | Bearer token for the exe.dev exec API. |
| `EXE_DEFAULT_IMAGE` | — (required) | Image used when the caller omits `image`. |
| `EXE_IMAGE_REGISTRY` | — | Repository prefix for derived template images. A VM created from this registry gets `--registry-auth` so exe.dev can pull a private image. |
| `EXE_REGISTRY_USERNAME` | — | Username for the derived-template image registry. |
| `EXE_REGISTRY_PASSWORD` | — | Password or token for the derived-template image registry. |
| `EXE_API_URL` | `https://exe.dev` | API base URL. |
| `EXE_API_TIMEOUT` | `30.0` | Timeout for exe.dev API calls. |
| `EXE_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh exe.dev sandbox. |
Expand Down Expand Up @@ -422,3 +423,51 @@ The published image does not contain the `sbx` CLI. Mount the binary and
the auth store of the host, as
[Local microVMs with Docker Sandboxes](#local-microvms-with-docker-sandboxes)
shows. Set `DOCKER_SANDBOXES_API` to the mounted daemon socket.

## Shared template image repository

Registry access applies to private images, including boot images that are
not templates. Set the registry host and credentials together. Set a template
repository separately when this installation must publish template images:

```dotenv
REGISTRY_HOST=ghcr.io
TEMPLATE_REPOSITORY=acme/sandbox-templates
REGISTRY_USERNAME=builder
REGISTRY_PASSWORD=<registry-token>
```

For Docker Hub, set `REGISTRY_HOST=docker.io`. Registry access does not
require `TEMPLATE_REPOSITORY`. Publishing templates requires a repository
and a credential that can push to it. Keep the credentials in
the service environment. Do not send them in a template request.

The caller supplies a descriptive `label`, such as `site-builder-build`.
Drukbox converts it to lowercase letters, digits, and hyphens. Each build
gets a unique UUID suffix. A tag has this form:

```text
ghcr.io/acme/sandbox-templates:site-builder-build-01992000123470008000123456789abc
```

After the push, Drukbox stores the tag and digest together in the template's
`image` field: `ghcr.io/acme/sandbox-templates:<purpose>-<build-id>@sha256:<digest>`.
Hosts use that digest. The tag identifies the build for local image cleanup.
The label does not change template identity: the provider, base image, and setup script hash
still determine reuse. A repeated request returns the existing record.
To retry a failed build, delete its template record, then create it again.

The `exe` provider sends pull credentials for boot images on `REGISTRY_HOST`,
including images outside the template repository. Credentials never go to a
different registry host. Template builds also require `TEMPLATE_REPOSITORY`. `docker` and
`docker-sbx` publish to it when configured. Without a template destination,
they keep their template images local. Native VM image providers do not use
this OCI destination. The `docker-sbx` daemon has its own image store and
registry access. Configure its private pulls or load the template as described
in [local microVM setup](#local-microvms-with-docker-sandboxes).
Shared push credentials do not configure that daemon.

All templates share repository access and retention policy. The janitor
removes template records and local images. It does not delete remote registry
manifests. Configure registry retention separately, and retain images that
active templates still reference.
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ covered in [Networking](networking.md). The security-relevant summary:

## Secrets and in-VM metadata

Provider tokens (`EXE_API_TOKEN`, `EXE_REGISTRY_PASSWORD`,
Provider tokens (`EXE_API_TOKEN`, `REGISTRY_PASSWORD`,
`HETZNER_API_TOKEN`, Tailscale OAuth) and AWS credentials are read from
the environment / the AWS SDK default chain and never written to the
database or returned by the API. Host secret recipes are encrypted in
Expand Down
40 changes: 38 additions & 2 deletions src/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from functools import lru_cache
from typing import Annotated
from typing import Annotated, Self

from pydantic import BeforeValidator, Field, SecretStr
from pydantic import BeforeValidator, Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
from sqlalchemy_encrypted_field import validate_keys

Expand Down Expand Up @@ -77,6 +77,30 @@ class Settings(BaseSettings):
validation_alias="PROVISIONING_GRACE_SECONDS",
description="Safety TTL on the host row while provisioning is in flight.",
)
registry_host: str = Field(
default="",
validation_alias="REGISTRY_HOST",
pattern=r"^$|^[a-z0-9.-]+(?::[0-9]+)?$",
description="Registry host for private images, such as ghcr.io or docker.io.",
)
template_repository: str = Field(
default="",
validation_alias="TEMPLATE_REPOSITORY",
pattern=(
r"^$|^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*"
r"(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$"
),
description="Template repository path within REGISTRY_HOST, without a tag.",
)
registry_username: str = Field(
default="",
validation_alias="REGISTRY_USERNAME",
)
registry_password: SecretStr = Field(
default=SecretStr(""),
validation_alias="REGISTRY_PASSWORD",
)

template_build_timeout: int = Field(
default=3600,
gt=0,
Expand Down Expand Up @@ -135,6 +159,18 @@ class Settings(BaseSettings):
description="Upper bound on pool-maintainer provisions per tick, across all providers.",
)

@model_validator(mode="after")
def validate_registry(self) -> Self:
values = {
"REGISTRY_HOST": self.registry_host,
"REGISTRY_USERNAME": self.registry_username,
"REGISTRY_PASSWORD": self.registry_password.get_secret_value(),
}
if (self.template_repository or any(values.values())) and not all(values.values()):
missing = ", ".join(name for name, value in values.items() if not value)
raise ValueError(f"Registry is incomplete. Set: {missing}")
return self

def get_pool_targets(self) -> dict[str, int]:
# POOL_SIZE seeds the default provider's target and POOL_SIZES
# overrides per provider; providers at zero drop out entirely.
Expand Down
99 changes: 99 additions & 0 deletions src/core/tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,102 @@ def test_load_test_env_overrides_ambient_values(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setenv("TAILSCALE_ENABLED", "false")
conftest.load_test_env()
assert os.environ["TAILSCALE_ENABLED"] == "true"


@pytest.mark.parametrize("host", ["ghcr.io", "docker.io", "registry.example:5000"])
def test_registry_access_does_not_require_templates(monkeypatch, host):
settings = _settings_with(
monkeypatch,
{
**_base_env(),
"REGISTRY_HOST": host,
"REGISTRY_USERNAME": "builder",
"REGISTRY_PASSWORD": "private-token",
"TEMPLATE_REPOSITORY": "",
},
)
assert settings.registry_host == host
assert settings.template_repository == ""
assert settings.registry_password.get_secret_value() == "private-token"
assert "private-token" not in repr(settings)


@pytest.mark.parametrize("repository", ["acme/templates", "org/team/templates"])
def test_template_destination_uses_registry_access(monkeypatch, repository):
settings = _settings_with(
monkeypatch,
{
**_base_env(),
"REGISTRY_HOST": "ghcr.io",
"REGISTRY_USERNAME": "builder",
"REGISTRY_PASSWORD": "private-token",
"TEMPLATE_REPOSITORY": repository,
},
)
assert settings.template_repository == repository


@pytest.mark.parametrize("host", ["https://ghcr.io", "ghcr.io/acme", "ghcr.io@evil.example"])
def test_registry_host_rejects_url_and_path(monkeypatch, host):
with pytest.raises(ValueError, match="REGISTRY_HOST"):
_settings_with(
monkeypatch,
{
**_base_env(),
"REGISTRY_HOST": host,
"REGISTRY_USERNAME": "builder",
"REGISTRY_PASSWORD": "private-token",
},
)


@pytest.mark.parametrize(
"repository",
[
"https://ghcr.io/acme/templates",
"acme/templates:latest",
"acme/templates@sha256:abc",
"acme/",
],
)
def test_template_repository_rejects_url_tag_or_digest(monkeypatch, repository):
with pytest.raises(ValueError, match="TEMPLATE_REPOSITORY"):
_settings_with(
monkeypatch,
{
**_base_env(),
"REGISTRY_HOST": "ghcr.io",
"REGISTRY_USERNAME": "builder",
"REGISTRY_PASSWORD": "private-token",
"TEMPLATE_REPOSITORY": repository,
},
)


def test_partial_registry_names_missing_setting_without_secret(monkeypatch):
with pytest.raises(ValueError) as error:
_settings_with(
monkeypatch,
{
**_base_env(),
"REGISTRY_HOST": "ghcr.io",
"REGISTRY_USERNAME": "",
"REGISTRY_PASSWORD": "private-token",
},
)
assert "REGISTRY_USERNAME" in str(error.value)
assert "private-token" not in str(error.value)


def test_template_destination_requires_registry_access(monkeypatch):
with pytest.raises(ValueError, match="REGISTRY_HOST"):
_settings_with(
monkeypatch,
{
**_base_env(),
"REGISTRY_HOST": "",
"REGISTRY_USERNAME": "",
"REGISTRY_PASSWORD": "",
"TEMPLATE_REPOSITORY": "acme/templates",
},
)
17 changes: 16 additions & 1 deletion src/providers/docker/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,30 @@ async def push_image(
*,
username: str | None = None,
password: str | None = None,
) -> None:
) -> str:
# Credentials travel per call as an X-Registry-Auth header; the
# global docker credential store is never touched.
auth = {"username": username, "password": password} if username and password else None
try:
await self._get_client().images.push(image, auth=auth)
metadata = await self._get_client().images.inspect(image)
except (aiodocker.DockerError, aiohttp.ClientError) as exc:
raise DockerTransportError(_detail(exc)) from exc

repository = image.rpartition(":")[0]
# Docker omits docker.io and its library namespace in RepoDigests.
digest_repository = repository
if repository.startswith("docker.io/"):
digest_repository = repository.removeprefix("docker.io/").removeprefix("library/")
digests = [
digest.partition("@")[2]
for digest in metadata.get("RepoDigests") or []
if digest.startswith((f"{repository}@sha256:", f"{digest_repository}@sha256:"))
]
if len(digests) != 1:
raise DockerTransportError(f"Pushed image {image!r} has no unique repository digest")
return f"{image}@{digests[0]}"

async def server_version(self) -> str:
try:
version = await self._get_client().version()
Expand Down
35 changes: 24 additions & 11 deletions src/providers/docker/images.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import hashlib
import io
import re
import tarfile

from uuid6 import uuid7

from core.settings import get_settings
from providers.exceptions import ProviderNotFoundError, ProviderTransportError

from .api import DockerAPI
Expand All @@ -10,13 +13,11 @@

def derive_image_name(
*,
base_image: str,
setup_script: str,
label: str,
repository: str = "drukbox-template",
) -> str:
identity = base_image.encode("utf-8") + b"\0" + setup_script.encode("utf-8")
digest = hashlib.sha256(identity).hexdigest()[:12]
return f"{repository}:{digest}"
purpose = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")[:95].rstrip("-")
return f"{repository}:{purpose or 'template'}-{uuid7().hex}"


def create_build_context(*, base_image: str, setup_script: str) -> bytes:
Expand All @@ -41,24 +42,36 @@ async def build_derived_image(
*,
base_image: str,
setup_script: str,
repository: str = "drukbox-template",
label: str,
) -> str:
settings = get_settings()
image = derive_image_name(
base_image=base_image,
setup_script=setup_script,
repository=repository,
label=label,
repository=(
f"{settings.registry_host}/{settings.template_repository}"
if settings.template_repository
else "drukbox-template"
),
)
context_tar = create_build_context(base_image=base_image, setup_script=setup_script)
try:
await docker.build_image(image, context_tar)
if settings.template_repository:
return await docker.push_image(
image,
username=settings.registry_username,
password=settings.registry_password.get_secret_value(),
)
except DockerProviderError as exc:
raise ProviderTransportError(str(exc)) from exc
return image


async def remove_derived_image(docker: DockerAPI, image: str) -> None:
try:
await docker.remove_image(image)
# Keep the unique build tag in the pinned reference so cleanup removes
# this build's tag, even when another build has the same digest.
await docker.remove_image(image.partition("@")[0])
except DockerImageNotFoundError as exc:
raise ProviderNotFoundError(f"docker image '{image}' was not found") from exc
except DockerProviderError as exc:
Expand Down
1 change: 1 addition & 0 deletions src/providers/docker/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ async def build_template_image(
self.api,
base_image=base_image,
setup_script=setup_script,
label=label,
)

async def delete_template_image(self, image: str) -> None:
Expand Down
Loading