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
37 changes: 34 additions & 3 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,36 @@ with auth-key write scope, and tailnet ACLs that (a) own the tags in
`TAILSCALE_AUTH_TAGS` and (b) permit tailscaled-SSH to the tagged
nodes.

## Private image registry

`REGISTRY_HOST`, `REGISTRY_USERNAME`, and `REGISTRY_PASSWORD` give drukbox
access to private images on one registry host. Set the three together.
drukbox sends the credentials only for an image on that host:

- `exe` passes them to exe.dev with the host image.
- `docker` uses them when the Docker engine pulls a host image that it
does not have.

`TEMPLATE_REPOSITORY` is the repository path on that host where drukbox
publishes template images. The credential needs push permission there.
Registry access does not require a template repository.

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

`exe` boots hosts from a registry, so its templates require
`TEMPLATE_REPOSITORY`. `docker` and `docker-sbx` publish each template when
it is set, and keep the image local when it is not. The `docker-sbx` daemon
has its own registry login. drukbox loads each template into that daemon
and does not give it these credentials.

AWS, Hetzner, and Exoscale boot from machine images. They have no
templates and do not use these settings.

## AWS credentials and IAM

AWS credentials come from the SDK's default chain (instance profile,
Expand Down Expand Up @@ -504,6 +534,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`, with no scheme or path. See [Private image registry](#private-image-registry). |
| `REGISTRY_USERNAME` | — | Registry user for private image pulls and template pushes. |
| `REGISTRY_PASSWORD` | — | Registry password or token. |
| `TEMPLATE_REPOSITORY` | — | Repository path on `REGISTRY_HOST` for template images, with no tag or digest. |
| `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 @@ -546,9 +580,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
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ Drukbox encrypts the entry in the database with AES-256-GCM under
can decrypt it. Rotate the key by prepending a new one. Remove an old key only
after no stored row needs it.

Provider tokens (`EXE_API_TOKEN`, `EXE_REGISTRY_PASSWORD`,
`HETZNER_API_TOKEN`, Tailscale OAuth) and AWS credentials are read from the
Provider tokens (`EXE_API_TOKEN`, `HETZNER_API_TOKEN`, Tailscale OAuth), the
registry password (`REGISTRY_PASSWORD`), and AWS credentials are read from the
environment or the AWS SDK default chain. They are never written to the
database and never returned by the API.

Expand Down
49 changes: 47 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 @@ -94,6 +94,31 @@ 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.",
)
registry_username: str = Field(
default="",
validation_alias="REGISTRY_USERNAME",
description="Registry user for private image pulls and template pushes.",
)
registry_password: SecretStr = Field(
default=SecretStr(""),
validation_alias="REGISTRY_PASSWORD",
description="Registry password or token.",
)
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="Repository path on REGISTRY_HOST for template images, without a tag.",
)
template_build_timeout: int = Field(
default=3600,
gt=0,
Expand Down Expand Up @@ -152,6 +177,26 @@ class Settings(BaseSettings):
description="Upper bound on pool-maintainer provisions per tick, across all providers.",
)

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

def get_registry_auth(self, image: str) -> dict[str, str] | None:
# The credentials go only to the registry host that they belong to.
if self.registry_host and image.partition("/")[0] == self.registry_host:
return {
"username": self.registry_username,
"password": self.registry_password.get_secret_value(),
}

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
57 changes: 57 additions & 0 deletions src/core/tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,60 @@ 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"


def _registry_env(**overrides: str) -> dict[str, str | None]:
return {
**_base_env(),
"REGISTRY_HOST": "ghcr.io",
"REGISTRY_USERNAME": "builder",
"REGISTRY_PASSWORD": "private-token",
"TEMPLATE_REPOSITORY": "",
**overrides,
}


def test_registry_access_does_not_require_a_template_repository(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = _settings_with(monkeypatch, _registry_env())

assert settings.registry_host == "ghcr.io"
assert settings.registry_password.get_secret_value() == "private-token"
assert "private-token" not in repr(settings)


def test_template_repository_requires_registry_access(monkeypatch: pytest.MonkeyPatch) -> None:
env = _registry_env(
REGISTRY_HOST="",
REGISTRY_USERNAME="",
REGISTRY_PASSWORD="",
TEMPLATE_REPOSITORY="acme/templates",
)

with pytest.raises(ValueError, match="REGISTRY_HOST, REGISTRY_USERNAME, REGISTRY_PASSWORD"):
_settings_with(monkeypatch, env)


def test_partial_registry_access_names_the_missing_setting_without_the_secret(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with pytest.raises(ValueError) as error:
_settings_with(monkeypatch, _registry_env(REGISTRY_USERNAME=""))

assert "Set: REGISTRY_USERNAME" in str(error.value)
assert "private-token" not in str(error.value)


@pytest.mark.parametrize("host", ["https://ghcr.io", "ghcr.io/acme"])
def test_registry_host_rejects_a_scheme_or_path(monkeypatch: pytest.MonkeyPatch, host: str) -> None:
with pytest.raises(ValueError, match="REGISTRY_HOST"):
_settings_with(monkeypatch, _registry_env(REGISTRY_HOST=host))


@pytest.mark.parametrize("repository", ["acme/templates:latest", "acme/templates@sha256:abc"])
def test_template_repository_rejects_a_tag_or_digest(
monkeypatch: pytest.MonkeyPatch, repository: str
) -> None:
with pytest.raises(ValueError, match="TEMPLATE_REPOSITORY"):
_settings_with(monkeypatch, _registry_env(TEMPLATE_REPOSITORY=repository))
6 changes: 5 additions & 1 deletion src/providers/docker/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ async def run_container(
env: dict[str, str],
labels: dict[str, str],
ssh_host: str,
registry_auth: dict[str, str] | None = None,
) -> str:
config = {
"Image": image,
Expand All @@ -59,7 +60,10 @@ async def run_container(
},
}
try:
container = await self._get_client().containers.run(config, name=name)
# The engine pulls an image that it does not have.
container = await self._get_client().containers.run(
config, name=name, auth=registry_auth
)
except (aiodocker.DockerError, aiohttp.ClientError) as exc:
raise DockerTransportError(_detail(exc)) from exc
return container.id
Expand Down
12 changes: 11 additions & 1 deletion src/providers/docker/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import io
import tarfile

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

from .api import DockerAPI
Expand Down Expand Up @@ -41,8 +42,11 @@ async def build_derived_image(
*,
base_image: str,
setup_script: str,
repository: str = "drukbox-template",
) -> str:
settings = get_settings()
repository = "drukbox-template"
if settings.template_repository:
repository = f"{settings.registry_host}/{settings.template_repository}"
image = derive_image_name(
base_image=base_image,
setup_script=setup_script,
Expand All @@ -51,6 +55,12 @@ async def build_derived_image(
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:
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
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 @@ -106,6 +106,7 @@ async def create_vm(
env=container_env,
labels=labels,
ssh_host=str(self.settings.ssh_host),
registry_auth=get_settings().get_registry_auth(image),
)
except DockerProviderError as exc:
raise ProviderTransportError(str(exc)) from exc
Expand Down
6 changes: 5 additions & 1 deletion src/providers/docker/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@ async def test_run_container_publishes_on_the_ssh_host_and_passes_env() -> None:
env={"KEY": "value", "MULTI": "line one\nline two"},
labels={"managed-by": "drukbox"},
ssh_host="100.64.0.10",
registry_auth={"username": "bot", "password": "secret"},
)

assert container_id == "abc123"
config = fake.containers.run.await_args.args[0]
assert fake.containers.run.await_args.kwargs == {"name": "sb-test"}
assert fake.containers.run.await_args.kwargs == {
"name": "sb-test",
"auth": {"username": "bot", "password": "secret"},
}
assert config["Image"] == "sandbox:latest"
assert config["Env"] == ["KEY=value", "MULTI=line one\nline two"]
assert config["Labels"] == {"managed-by": "drukbox"}
Expand Down
29 changes: 28 additions & 1 deletion src/providers/docker/tests/test_images.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import io
import tarfile
from unittest.mock import AsyncMock, MagicMock

from providers.docker.images import create_build_context, derive_image_name
import pytest
from pydantic import SecretStr

from core.settings import get_settings
from providers.docker.images import (
build_derived_image,
create_build_context,
derive_image_name,
)


def test_create_build_context_contains_the_base_and_verbatim_script() -> None:
Expand Down Expand Up @@ -31,3 +40,21 @@ def test_derive_image_name_is_deterministic_and_base_specific() -> None:
assert first.startswith("drukbox-template:")
assert len(first.removeprefix("drukbox-template:")) == 12
assert different_base != first


async def test_build_derived_image_publishes_to_the_template_repository(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(get_settings(), "registry_host", "ghcr.io")
monkeypatch.setattr(get_settings(), "registry_username", "bot")
monkeypatch.setattr(get_settings(), "registry_password", SecretStr("secret"))
monkeypatch.setattr(get_settings(), "template_repository", "acme/templates")
docker = MagicMock(build_image=AsyncMock(), push_image=AsyncMock())

image = await build_derived_image(
docker, base_image="sandbox:base", setup_script="apt-get update"
)

assert image.startswith("ghcr.io/acme/templates:")
assert docker.build_image.await_args.args[0] == image
docker.push_image.assert_awaited_once_with(image, username="bot", password="secret")
25 changes: 24 additions & 1 deletion src/providers/docker/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
from unittest.mock import AsyncMock, MagicMock

import pytest
from pydantic import ValidationError
from pydantic import SecretStr, ValidationError

from core.settings import get_settings
from providers.docker.exceptions import (
DockerImageNotFoundError,
DockerTransportError,
Expand Down Expand Up @@ -56,6 +57,28 @@ async def test_create_vm_publishes_and_advertises_the_ssh_host(ssh_host: str):
assert "-----BEGIN OPENSSH PRIVATE KEY-----" in result.private_key


@pytest.mark.parametrize(
("image", "registry_auth"),
[
("ghcr.io/acme/private-base:latest", {"username": "bot", "password": "secret"}),
("docker.io/library/ubuntu:24.04", None),
],
)
@pytest.mark.asyncio
async def test_create_vm_sends_registry_auth_only_to_the_registry_host(
monkeypatch: pytest.MonkeyPatch, image: str, registry_auth: dict[str, str] | None
):
monkeypatch.setattr(get_settings(), "registry_host", "ghcr.io")
monkeypatch.setattr(get_settings(), "registry_username", "bot")
monkeypatch.setattr(get_settings(), "registry_password", SecretStr("secret"))
api = _api_mock()
provider = DockerProvider(api, _settings())

await provider.create_vm(name="sb-test", image=image, env={})

assert api.run_container.await_args.kwargs["registry_auth"] == registry_auth


@pytest.mark.parametrize("ssh_host", ["0.0.0.0", "::", "", "sandbox.example"])
def test_settings_reject_an_ssh_host_callers_cannot_dial(ssh_host: str):
with pytest.raises(ValidationError):
Expand Down
Loading
Loading