diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4e7523..57ee3fa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,6 +107,8 @@ jobs:
"$(go env GOPATH)/bin/gitleaks" detect --source . --no-git --redact --no-banner
- name: Helm Insights contracts
run: scripts/test-helm-insights.sh
+ - name: Helm custom image contracts
+ run: scripts/test-helm-images.sh
- name: Render LoadBalancer installation
run: helm template devboxes charts/devboxes --namespace devboxes > /tmp/devboxes.yaml
- name: Validate Kubernetes resources
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3540f74..32dba5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to Devboxes are documented here. The project follows [Keep a
## [Unreleased]
+### Added
+
+- Added opt-in custom image profiles across Helm, the API, CLI, dashboard, authenticated documentation, and public documentation. Profiles support isolated pod-local service sidecars and explicitly vetted Devboxes-compatible workspace derivatives.
+- Added `devbox image profiles` and `devbox create --image PROFILE_OR_IMAGE`, resolved image allocation reporting, bounded sidecar resources and ports, strict chart/controller validation, and Helm contract tests.
+
+### Changed
+
+- Persist the fully resolved custom image profile on each workspace so stop, start, TTL expiry, and Insights template reconciliation cannot silently change an existing image contract after Helm configuration changes.
+
+### Security
+
+- Restrict custom image selection to an operator-approved catalog. Sidecars receive no Devboxes Secret, persistent-home mount, Kubernetes API token, public Service, extra capability, command override, or scheduling injection surface.
+
## [0.4.0] - 2026-07-20
### Added
diff --git a/Makefile b/Makefile
index 36e0ba9..4dc81a0 100644
--- a/Makefile
+++ b/Makefile
@@ -21,6 +21,7 @@ test:
helm:
scripts/test-helm-insights.sh
scripts/test-helm-gpu.sh
+ scripts/test-helm-images.sh
helm template devboxes charts/devboxes --namespace devboxes --set workspace.sshService.type=NodePort --set workspace.sshService.host=192.0.2.10 >/dev/null
images:
diff --git a/README.md b/README.md
index a8832a1..998578d 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,11 @@ Each workspace includes Rust, Node.js, Python, `uv`, GitHub CLI, Codex CLI, Clau
## What ships
-- A Rust `devbox` CLI for create, list, inspect, SSH, start, stop, delete, and opt-in Insights workflows.
+- A Rust `devbox` CLI for create, list, inspect, SSH, start, stop, delete, custom-image profiles, and opt-in Insights workflows.
- A FastAPI controller with an authenticated API, accessible browser workbench, Insights dashboard, documentation, metrics, health checks, and TTL cleanup.
- A versioned Helm chart with values schema validation and namespace-scoped RBAC.
- Optional operator-approved GPU profiles for NVIDIA, AMD, Intel, partitioned, or shared accelerators.
+- Optional operator-approved custom image profiles for pod-local services or vetted workspace derivatives.
- Multi-architecture controller and workspace images for `linux/amd64` and `linux/arm64`.
- Persistent SSH host identity, shell state, tool installs, account state, and source under `/home/dev`.
- GitHub Releases with macOS and Linux CLI binaries and SHA-256 checksums.
@@ -169,6 +170,35 @@ devbox create training --gpu-profile nvidia-l4 --preset large --ssh
The dashboard exposes the same profiles in its create form. Devboxes sets the resource in both container requests and limits, preserves the resolved allocation across stop and start, and surfaces scheduler reasons when capacity is unavailable. Read [GPU acceleration](docs/gpu.md) for driver prerequisites, NVIDIA and AMD examples, image contracts, sharing, security, upgrades, and troubleshooting.
+### Enable approved custom images
+
+Devboxes does not accept an unrestricted container image from a client. Instead, an operator publishes a reviewed catalog. Service profiles run an unprivileged image such as NGINX as a credential-free sidecar beside the prepared SSH workspace; workspace profiles are only for compatible Devboxes-derived images.
+
+```yaml
+workspace:
+ customImages:
+ enabled: true
+ profiles:
+ - name: nginx
+ displayName: NGINX preview
+ description: Serve a local static-site preview
+ image: docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine
+ mode: sidecar
+ ports:
+ - name: http
+ containerPort: 8080
+```
+
+Users discover and select the same catalog in the terminal or dashboard:
+
+```bash
+devbox image profiles
+devbox create docs-preview --image nginx --ssh
+devbox ssh docs-preview -- -L 8080:127.0.0.1:8080
+```
+
+Read [custom image profiles](docs/images.md) for the image contract, resource bounds, security boundary, upgrade behavior, and workspace-mode requirements.
+
### Enable Insights
Insights is disabled by default. Enable it to collect privacy-bounded local AI metrics and aggregate Git activity into a persistent controller database:
@@ -313,6 +343,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. Security repo
- [Golden path](docs/golden-path.md) for a performance-oriented installation and daily workflow.
- [CLI reference](docs/cli.md) and [API reference](docs/api.md) for client contracts.
- [GPU acceleration](docs/gpu.md) for accelerator profiles, images, scheduling, and operations.
+- [Custom image profiles](docs/images.md) for approved sidecars, compatible workspace derivatives, and their security boundary.
- [Insights](docs/insights.md) for telemetry semantics, privacy, storage, backup, and purge.
- [Configuration](docs/configuration.md) and [credentials](docs/credentials.md) for installation details.
- [Operations](docs/operations.md) and [troubleshooting](docs/troubleshooting.md) for production ownership.
diff --git a/charts/devboxes/templates/deployment.yaml b/charts/devboxes/templates/deployment.yaml
index a0c3988..f6c7383 100644
--- a/charts/devboxes/templates/deployment.yaml
+++ b/charts/devboxes/templates/deployment.yaml
@@ -24,6 +24,27 @@
{{- if and .Values.gpu.enabled (not .Values.gpu.defaultProfile) }}
{{- fail "gpu.defaultProfile is required when gpu.enabled=true" }}
{{- end }}
+{{- $customImageNames := dict }}
+{{- $customImageReferences := dict }}
+{{- range .Values.workspace.customImages.profiles }}
+{{- if hasKey $customImageNames .name }}
+{{- fail (printf "workspace.customImages contains duplicate name %q" .name) }}
+{{- end }}
+{{- if hasKey $customImageReferences .image }}
+{{- fail (printf "workspace.customImages contains duplicate image %q" .image) }}
+{{- end }}
+{{- if not (trim .displayName) }}
+{{- fail (printf "custom image profile %q displayName must not be blank" .name) }}
+{{- end }}
+{{- if contains "://" .image }}
+{{- fail (printf "custom image profile %q image must not contain a URL scheme" .name) }}
+{{- end }}
+{{- $_ := set $customImageNames .name true }}
+{{- $_ := set $customImageReferences .image true }}
+{{- end }}
+{{- if and .Values.workspace.customImages.enabled (eq (len .Values.workspace.customImages.profiles) 0) }}
+{{- fail "workspace.customImages.profiles is required when workspace.customImages.enabled=true" }}
+{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -161,6 +182,10 @@ spec:
{{- end }}
- name: DEVBOXES_WORKSPACE_IMAGE
value: {{ include "devboxes.workspaceImage" . | quote }}
+ - name: DEVBOXES_CUSTOM_IMAGES_ENABLED
+ value: {{ .Values.workspace.customImages.enabled | quote }}
+ - name: DEVBOXES_CUSTOM_IMAGES
+ value: {{ .Values.workspace.customImages.profiles | toJson | quote }}
- name: DEVBOXES_WORKSPACE_SECRET_NAME
value: {{ .Values.workspace.existingSecret | quote }}
- name: DEVBOXES_WORKSPACE_SERVICE_ACCOUNT_NAME
diff --git a/charts/devboxes/values.schema.json b/charts/devboxes/values.schema.json
index 15ad818..1d90a48 100644
--- a/charts/devboxes/values.schema.json
+++ b/charts/devboxes/values.schema.json
@@ -42,6 +42,25 @@
"required": ["image", "existingSecret", "serviceAccount", "sshService"],
"properties": {
"image": {"$ref": "#/definitions/imageWithoutPullPolicy"},
+ "customImages": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["enabled", "profiles"],
+ "properties": {
+ "enabled": {"type": "boolean"},
+ "profiles": {
+ "type": "array",
+ "maxItems": 32,
+ "items": {"$ref": "#/definitions/customImageProfile"}
+ }
+ },
+ "allOf": [
+ {
+ "if": {"properties": {"enabled": {"const": true}}},
+ "then": {"properties": {"profiles": {"minItems": 1}}}
+ }
+ ]
+ },
"existingSecret": {"type": "string", "minLength": 1},
"imagePullSecret": {"type": "string"},
"storageClass": {"type": "string"},
@@ -274,6 +293,64 @@
"tag": {"type": "string"}
}
},
+ "customImageProfile": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "displayName", "image"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 40,
+ "pattern": "^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$"
+ },
+ "displayName": {"type": "string", "minLength": 1, "maxLength": 80},
+ "description": {"type": "string", "maxLength": 160},
+ "image": {"type": "string", "minLength": 1, "maxLength": 512},
+ "mode": {"type": "string", "enum": ["sidecar", "workspace"]},
+ "pullPolicy": {"type": "string", "enum": ["Always", "IfNotPresent", "Never"]},
+ "resources": {"$ref": "#/definitions/customImageResources"},
+ "ports": {
+ "type": "array",
+ "maxItems": 8,
+ "items": {"$ref": "#/definitions/customImagePort"}
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {"mode": {"const": "workspace"}},
+ "required": ["mode"]
+ },
+ "then": {"not": {"required": ["resources"]}}
+ }
+ ]
+ },
+ "customImageResources": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "cpuRequest": {"type": "string", "minLength": 1, "maxLength": 32},
+ "memoryRequest": {"type": "string", "minLength": 1, "maxLength": 32},
+ "cpuLimit": {"type": "string", "minLength": 1, "maxLength": 32},
+ "memoryLimit": {"type": "string", "minLength": 1, "maxLength": 32}
+ }
+ },
+ "customImagePort": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "containerPort"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 63,
+ "pattern": "^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$"
+ },
+ "containerPort": {"type": "integer", "minimum": 1024, "maximum": 65535},
+ "protocol": {"type": "string", "enum": ["TCP", "UDP", "SCTP"]}
+ }
+ },
"gpuToleration": {
"type": "object",
"additionalProperties": false,
diff --git a/charts/devboxes/values.yaml b/charts/devboxes/values.yaml
index 8011757..34ca620 100644
--- a/charts/devboxes/values.yaml
+++ b/charts/devboxes/values.yaml
@@ -41,6 +41,28 @@ workspace:
image:
repository: ghcr.io/vicotrbb/devboxes-workspace
tag: ""
+ # Custom images are opt-in and operator-owned. A sidecar profile runs a
+ # compatible non-root application image beside the prepared Devboxes workspace.
+ # A workspace profile replaces it and must preserve the complete SSH contract.
+ customImages:
+ enabled: false
+ # Example profile fields:
+ # - name: nginx
+ # displayName: NGINX preview
+ # description: Serve a local static-site preview over the pod network
+ # image: docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine
+ # mode: sidecar
+ # pullPolicy: IfNotPresent
+ # resources:
+ # cpuRequest: 25m
+ # memoryRequest: 32Mi
+ # cpuLimit: 500m
+ # memoryLimit: 512Mi
+ # ports:
+ # - name: http
+ # containerPort: 8080
+ # protocol: TCP
+ profiles: []
existingSecret: devboxes-workspace
imagePullSecret: ""
storageClass: ""
diff --git a/cli/README.md b/cli/README.md
index dbcd8c6..0a5f754 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -8,6 +8,8 @@ devbox login --url https://devboxes.example.com
devbox create atlas --repo owner/project --ssh
devbox gpu profiles
devbox create inference --gpu --ssh
+devbox image profiles
+devbox create docs-preview --image nginx --ssh
```
-See the [CLI reference](../docs/cli.md) for every command, option, environment variable, output contract, and SSH workflow. [GPU acceleration](../docs/gpu.md) covers operator-approved accelerator profiles. The [golden path](../docs/golden-path.md) covers the recommended installation and performance setup.
+See the [CLI reference](../docs/cli.md) for every command, option, environment variable, output contract, and SSH workflow. [GPU acceleration](../docs/gpu.md) covers operator-approved accelerator profiles, and [custom image profiles](../docs/images.md) covers approved service and workspace images. The [golden path](../docs/golden-path.md) covers the recommended installation and performance setup.
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 86113b9..d5756a4 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -16,8 +16,9 @@ use tokio::time::{Instant, sleep};
use client::ApiClient;
use config::StoredConfig;
use models::{
- CollectorStatus, CreateDevbox, Devbox, GpuCapabilities, GpuRequest, InsightsActivity,
- InsightsActivityData, InsightsEnvelope, InsightsStatusData, InsightsSummary, Preset,
+ CollectorStatus, CreateDevbox, CustomImageCapabilities, Devbox, GpuCapabilities, GpuRequest,
+ InsightsActivity, InsightsActivityData, InsightsEnvelope, InsightsStatusData, InsightsSummary,
+ Preset,
};
#[derive(Parser)]
@@ -65,6 +66,8 @@ enum Commands {
Metrics(MetricsArgs),
/// Inspect GPU acceleration profiles configured by the operator.
Gpu(GpuArgs),
+ /// Inspect custom image profiles configured by the operator.
+ Image(ImageArgs),
}
#[derive(Args)]
@@ -94,6 +97,10 @@ struct CreateArgs {
#[arg(long)]
repo: Option
+ This installation exposes {{ images.profiles | length }} approved image profile{% if images.profiles | length != 1 %}s{% endif %}.
+ Select a profile with
+ The selected profile is resolved before any Kubernetes resource is created and is pinned to
+ the devbox Deployment. Service sidecars must run non-root and use a declared port from 1024
+ through 65535; their generated containers receive no Devboxes credentials or volume mounts.
+ Stop, start, TTL expiry, and Insights reconciliation retain the same contract. For a declared
+ service port, tunnel through SSH, for example
+
+ Approved custom images are disabled for this installation. An operator can configure a
+ reviewed image catalog through Helm. The API does not accept unrestricted image references.
+
@@ -444,6 +504,7 @@ Use an operator-approved GPU
{% endif %}
+ Use an operator-approved image
+ {% if images.enabled %}
+ --image or in the workbench. A service profile runs beside
+ the prepared SSH workspace without its Secret mounts or Kubernetes credentials. A workspace
+ profile is a vetted Devboxes-derived image that replaces the interactive container.
+
+
+
+
+
+
+
+ {% for profile in images.profiles %}
+ Profile
+ Mode
+ Pod-local ports
+ Purpose
+
+
+ {% endfor %}
+
+
+ {{ profile.name }}{{ "Service sidecar" if profile.mode == "sidecar" else "Workspace" }}
+ {% if profile.ports %}{% for port in profile.ports %}
+ {{ port.name }}:{{ port.container_port }}{% if not loop.last %}, {% endif %}{% endfor %}{% else %}None declared{% endif %}{{ profile.description or profile.display_name }}
+
+ devbox image profiles
+{% if images.profiles %}devbox create preview --image {{ images.profiles[0].name }} --ssh{% else %}devbox create preview --image PROFILE --ssh{% endif %}devbox ssh preview -- -L 8080:127.0.0.1:8080. Do not use a generic application
+ image as a workspace profile unless it preserves the complete Devboxes SSH and lifecycle contract.
+ Work through SSH and tmux
Use the browser workbench
devbox login--url, --tokendevbox create NAME--preset, --ttl, --repo, --ssh, --no-waitdevbox create NAME--preset, --ttl, --repo, --image, --ssh, --no-waitdevbox image profiles--jsondevbox list--jsondevbox status NAME--jsondevbox ssh NAMEmain.-- <ssh options>Allow the first image pull and workspace initialization a few minutes. If it remains pending, inspect the dashboard message, SSH service address availability, and pod events.
+Check the selected profile in devbox image profiles, then inspect the pod events and the custom-image container logs. Confirm the operator-approved image can pull on the selected node and that its declared application port matches the tunnel target.
Connect to the empty box, run gh auth status, then retry with gh repo clone OWNER/REPOSITORY ~/workspace/project. Confirm the shared GitHub token can read the repository.
Compute expires into a safe stopped state. Your volume is never deleted automatically.
- {{ storage_class }} storage · {{ workspace_service_type }} SSH{% if gpu.enabled %} · GPU profiles{% endif %} + {{ storage_class }} storage · {{ workspace_service_type }} SSH{% if gpu.enabled %} · GPU profiles{% endif %}{% if images.enabled %} · approved images{% endif %} @@ -102,6 +102,22 @@+ Select an operator-approved image profile. Service images run as non-root sidecars on high ports without Secret mounts or Kubernetes credentials. +
+ {% endif %} diff --git a/controller/tests/fakes.py b/controller/tests/fakes.py index dcea097..220b305 100644 --- a/controller/tests/fakes.py +++ b/controller/tests/fakes.py @@ -5,6 +5,8 @@ from devboxes_controller.manager import DevboxConflictError, DevboxNotFoundError from devboxes_controller.models import ( CreateDevboxRequest, + CustomImageAllocation, + CustomImagePortSummary, DeleteResult, Devbox, DevboxState, @@ -91,6 +93,22 @@ async def create(self, request: CreateDevboxRequest) -> Devbox: resource_name=resource_name, count=1, ) + if request.image is not None: + profile = request.image + display_name, mode = { + "nginx": ("NGINX preview", "sidecar"), + "rust-nightly": ("Rust nightly", "workspace"), + }.get(profile, (profile, "sidecar")) + box.image = CustomImageAllocation( + profile=profile, + display_name=display_name, + mode=mode, + ports=( + [CustomImagePortSummary(name="http", container_port=8080, protocol="TCP")] + if profile == "nginx" + else [] + ), + ) box.ssh_host = None box.ssh_command = None box.pod_ready = False diff --git a/controller/tests/preview_app.py b/controller/tests/preview_app.py index 82a76c7..199a726 100644 --- a/controller/tests/preview_app.py +++ b/controller/tests/preview_app.py @@ -1,5 +1,5 @@ from devboxes_controller.app import create_app -from devboxes_controller.config import GpuProfile, Settings +from devboxes_controller.config import CustomImagePort, CustomImageProfile, GpuProfile, Settings from .fakes import FakeManager @@ -25,5 +25,16 @@ count=1, ), ], + custom_images_enabled=True, + custom_images=[ + CustomImageProfile( + name="nginx", + displayName="NGINX preview", + description="Serve a local static-site preview over the pod network", + image="docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + mode="sidecar", + ports=[CustomImagePort(name="http", containerPort=8080)], + ) + ], ) app = create_app(settings, FakeManager()) # type: ignore[arg-type] diff --git a/controller/tests/test_app.py b/controller/tests/test_app.py index 08f8787..cb024a8 100644 --- a/controller/tests/test_app.py +++ b/controller/tests/test_app.py @@ -4,7 +4,7 @@ from devboxes_controller.app import create_app from devboxes_controller.auth import pkce_s256 -from devboxes_controller.config import GpuProfile, Settings +from devboxes_controller.config import CustomImagePort, CustomImageProfile, GpuProfile, Settings from .fakes import FakeManager @@ -243,7 +243,8 @@ def test_gpu_capabilities_expose_only_safe_profile_metadata() -> None: "default": True, } ], - } + }, + "images": {"enabled": False, "profiles": []}, } assert "workspaceImage" not in response.text assert "runtimeClassName" not in response.text @@ -262,6 +263,63 @@ def test_gpu_capabilities_expose_only_safe_profile_metadata() -> None: assert "devbox create inference --gpu --ssh" in documentation.text +def test_custom_image_capabilities_expose_only_safe_profile_metadata() -> None: + settings = Settings( + access_token="test-access-token-at-least-32-characters", + cookie_secure=False, + cleanup_interval_seconds=3600, + custom_images_enabled=True, + custom_images=[ + CustomImageProfile( + name="nginx", + displayName="NGINX preview", + description="Serve a local static-site preview", + image="private.example/nginx:1.27.5", + pullPolicy="Always", + ports=[CustomImagePort(name="http", containerPort=8080)], + ) + ], + ) + headers = {"Authorization": "Bearer test-access-token-at-least-32-characters"} + + with app_client(settings) as client: + response = client.get("/api/v1/capabilities", headers=headers) + created = client.post( + "/api/v1/devboxes", + headers=headers, + json={"name": "preview", "image": "nginx"}, + ) + browser_login(client) + dashboard = client.get("/") + documentation = client.get("/docs") + + assert response.status_code == 200 + assert response.json()["images"] == { + "enabled": True, + "profiles": [ + { + "name": "nginx", + "display_name": "NGINX preview", + "description": "Serve a local static-site preview", + "mode": "sidecar", + "ports": [{"name": "http", "container_port": 8080, "protocol": "TCP"}], + } + ], + } + assert "private.example/nginx:1.27.5" not in response.text + assert "pullPolicy" not in response.text + assert created.status_code == 201 + assert created.json()["image"] == { + "profile": "nginx", + "display_name": "NGINX preview", + "mode": "sidecar", + "ports": [{"name": "http", "container_port": 8080, "protocol": "TCP"}], + } + assert 'value="nginx"' in dashboard.text + assert "approved images" in dashboard.text + assert "Use an operator-approved image" in documentation.text + + def test_api_rejects_invalid_path_names_before_kubernetes() -> None: with app_client() as client: response = client.get( diff --git a/controller/tests/test_config.py b/controller/tests/test_config.py index f4de196..99b6de6 100644 --- a/controller/tests/test_config.py +++ b/controller/tests/test_config.py @@ -3,7 +3,14 @@ import pytest from pydantic import ValidationError -from devboxes_controller.config import GpuProfile, GpuToleration, Settings +from devboxes_controller.config import ( + CustomImagePort, + CustomImageProfile, + CustomImageResources, + GpuProfile, + GpuToleration, + Settings, +) def test_access_token_is_required() -> None: @@ -204,3 +211,106 @@ def test_gpu_profiles_load_from_the_helm_json_environment(monkeypatch: pytest.Mo settings = Settings(_env_file=None) assert settings.resolve_gpu_profile(None).description == "Dedicated inference" + + +def test_custom_image_profiles_are_disabled_by_default_and_resolve_exact_images() -> None: + profile = CustomImageProfile( + name="nginx", + displayName="NGINX preview", + image="docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + ports=[CustomImagePort(name="http", containerPort=8080)], + ) + settings = Settings( + access_token="test-access-token-at-least-32-characters", + custom_images_enabled=True, + custom_images=[profile], + _env_file=None, + ) + + assert settings.resolve_custom_image("nginx") is profile + assert settings.resolve_custom_image(profile.image) is profile + + disabled = Settings( + access_token="test-access-token-at-least-32-characters", + custom_images=[profile], + _env_file=None, + ) + with pytest.raises(ValueError, match="disabled by the operator"): + disabled.resolve_custom_image("nginx") + + +def test_custom_image_profiles_reject_unsafe_or_ambiguous_contracts() -> None: + with pytest.raises(ValidationError, match="container image reference"): + CustomImageProfile( + name="bad-image", + displayName="Bad image", + image="https://registry.example/nginx:latest", + ) + + with pytest.raises(ValidationError, match="duplicate port"): + CustomImageProfile( + name="duplicate-port", + displayName="Duplicate port", + image="registry.example/duplicate:1", + ports=[ + CustomImagePort(name="http", containerPort=8080), + CustomImagePort(name="https", containerPort=8080), + ], + ) + + with pytest.raises(ValidationError, match="cpuLimit"): + CustomImageResources(cpuRequest="1", cpuLimit="500m") + + with pytest.raises(ValidationError, match="greater than or equal to 1024"): + CustomImagePort(name="http", containerPort=80) + + with pytest.raises(ValidationError, match="cannot define sidecar resources"): + CustomImageProfile( + name="workspace-with-resources", + displayName="Workspace with resources", + image="registry.example/devboxes-workspace:1", + mode="workspace", + resources=CustomImageResources(), + ) + + profile = CustomImageProfile( + name="nginx", + displayName="NGINX", + image="registry.example/nginx:1", + ) + with pytest.raises(ValidationError, match="references must be unique"): + Settings( + access_token="test-access-token-at-least-32-characters", + custom_images=[profile, profile.model_copy(update={"name": "nginx-copy"})], + _env_file=None, + ) + + +def test_custom_image_profiles_load_from_the_helm_json_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DEVBOXES_ACCESS_TOKEN", "test-access-token-at-least-32-characters") + monkeypatch.setenv("DEVBOXES_CUSTOM_IMAGES_ENABLED", "true") + monkeypatch.setenv( + "DEVBOXES_CUSTOM_IMAGES", + json.dumps( + [ + { + "name": "nginx", + "displayName": " NGINX preview ", + "description": " Serve static content ", + "image": "docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + "mode": "sidecar", + "ports": [{"name": "http", "containerPort": 8080}], + } + ] + ), + ) + + settings = Settings(_env_file=None) + profile = settings.resolve_custom_image("nginx") + + assert profile.display_name == "NGINX preview" + assert profile.description == "Serve static content" + assert profile.resources is not None + assert profile.ports[0].container_port == 8080 diff --git a/controller/tests/test_insights_service.py b/controller/tests/test_insights_service.py index 0d825c4..9de812b 100644 --- a/controller/tests/test_insights_service.py +++ b/controller/tests/test_insights_service.py @@ -106,8 +106,10 @@ def test_service_ingests_gzip_and_builds_all_query_envelopes(tmp_path: Path) -> assert asyncio.run(service.ready()) is True filters = service.filters( - since="7d", - until=None, + # Keep this envelope test tied to the fixed fixture timestamps instead + # of letting a rolling relative range silently age the points out. + since="2026-07-14T19:00:00Z", + until="2026-07-14T19:20:00Z", box=None, provider=None, model=None, diff --git a/controller/tests/test_insights_store.py b/controller/tests/test_insights_store.py index 1b94d3a..b40cea9 100644 --- a/controller/tests/test_insights_store.py +++ b/controller/tests/test_insights_store.py @@ -4,6 +4,7 @@ from contextlib import closing from datetime import UTC, datetime, timedelta from pathlib import Path +from types import SimpleNamespace import pytest @@ -85,6 +86,30 @@ def test_store_migrates_readies_and_creates_an_online_backup(tmp_path: Path) -> assert copy.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0] == 1 +def test_database_size_tolerates_a_disappearing_sqlite_auxiliary_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = InsightsStore(tmp_path / "insights.db") + wal_path = Path(f"{store.path}-wal") + shm_path = Path(f"{store.path}-shm") + sizes: dict[Path, int | FileNotFoundError] = { + store.path: 100, + wal_path: FileNotFoundError(), + shm_path: 20, + } + + def stat(path: Path, *args: object, **kwargs: object) -> SimpleNamespace: + result = sizes[path] + if isinstance(result, FileNotFoundError): + raise result + return SimpleNamespace(st_size=result) + + monkeypatch.setattr(Path, "stat", stat) + + assert store._database_size_sync() == 120 + + def test_store_migrates_a_previous_empty_schema_transactionally(tmp_path: Path) -> None: database = tmp_path / "insights.db" with closing(sqlite3.connect(database)) as connection: diff --git a/controller/tests/test_manager.py b/controller/tests/test_manager.py index 3a6ef04..1c585d3 100644 --- a/controller/tests/test_manager.py +++ b/controller/tests/test_manager.py @@ -6,12 +6,14 @@ import pytest from kubernetes.client.exceptions import ApiException -from devboxes_controller.config import GpuProfile, Settings +from devboxes_controller.config import CustomImagePort, CustomImageProfile, GpuProfile, Settings from devboxes_controller.manager import ( DevboxConflictError, DevboxManager, DevboxNotFoundError, + _custom_image_allocation, _gpu_allocation, + _resolved_custom_image, _resolved_gpu_profile, _service_endpoint, _state, @@ -20,6 +22,8 @@ from devboxes_controller.models import CreateDevboxRequest, DevboxState, GpuRequest, Preset from devboxes_controller.resources import ( ANNOTATION_CREATED_AT, + ANNOTATION_CUSTOM_IMAGE_CONFIG, + ANNOTATION_CUSTOM_IMAGE_PROFILE, ANNOTATION_EXPIRES_AT, ANNOTATION_GPU_CONFIG, ANNOTATION_GPU_PROFILE, @@ -375,6 +379,93 @@ def test_create_rejects_gpu_requests_when_the_feature_is_disabled() -> None: core.create_namespaced_persistent_volume_claim.assert_not_called() +def test_create_resolves_an_approved_custom_image_before_kubernetes_writes() -> None: + apps = Mock() + apps.read_namespaced_deployment.side_effect = ApiException(status=404) + core = Mock() + core.read_namespaced_persistent_volume_claim.side_effect = ApiException(status=404) + profile = CustomImageProfile( + name="nginx", + displayName="NGINX preview", + image="docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + ports=[CustomImagePort(name="http", containerPort=8080)], + ) + manager = DevboxManager( + Settings( + access_token="test-access-token-at-least-32-characters", + custom_images_enabled=True, + custom_images=[profile], + ), + apps_api=apps, + core_api=core, + ) + manager.get = AsyncMock(return_value=Mock()) # type: ignore[method-assign] + + asyncio.run( + manager.create( + CreateDevboxRequest( + name="nginx", image="docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine" + ) + ) + ) + + deployment = apps.create_namespaced_deployment.call_args.args[1] + assert deployment["metadata"]["annotations"][ANNOTATION_CUSTOM_IMAGE_PROFILE] == "nginx" + assert [ + container["name"] for container in deployment["spec"]["template"]["spec"]["containers"] + ] == [ + "devbox", + "custom-image", + ] + + +def test_create_rejects_disabled_or_conflicting_custom_workspace_images() -> None: + apps = Mock() + core = Mock() + disabled = DevboxManager( + Settings(access_token="test-access-token-at-least-32-characters"), + apps_api=apps, + core_api=core, + ) + with pytest.raises(ValueError, match="disabled by the operator"): + asyncio.run(disabled.create(CreateDevboxRequest(name="nginx", image="nginx"))) + + gpu = GpuProfile( + name="nvidia-l4", + displayName="NVIDIA L4", + resourceName="nvidia.com/gpu", + count=1, + workspaceImage="registry.example/devboxes-cuda:12.8", + ) + workspace = CustomImageProfile( + name="rust-nightly", + displayName="Rust nightly", + image="registry.example/devboxes-rust:nightly", + mode="workspace", + ) + conflicting = DevboxManager( + Settings( + access_token="test-access-token-at-least-32-characters", + gpu_enabled=True, + gpu_default_profile="nvidia-l4", + gpu_profiles=[gpu], + custom_images_enabled=True, + custom_images=[workspace], + ), + apps_api=apps, + core_api=core, + ) + with pytest.raises(ValueError, match="cannot be combined"): + asyncio.run( + conflicting.create( + CreateDevboxRequest(name="rust", gpu=GpuRequest(), image="rust-nightly") + ) + ) + + apps.read_namespaced_deployment.assert_not_called() + core.create_namespaced_persistent_volume_claim.assert_not_called() + + def _legacy_deployment(replicas: int) -> SimpleNamespace: now = datetime.now(UTC) return SimpleNamespace( @@ -422,6 +513,31 @@ def test_resolved_gpu_profile_snapshot_survives_later_operator_changes() -> None } +def test_resolved_custom_image_snapshot_survives_later_operator_changes() -> None: + profile = CustomImageProfile( + name="nginx", + displayName="NGINX preview", + image="docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + ports=[CustomImagePort(name="http", containerPort=8080)], + ) + annotations = { + ANNOTATION_CUSTOM_IMAGE_PROFILE: profile.name, + ANNOTATION_CUSTOM_IMAGE_CONFIG: profile.model_dump_json( + by_alias=True, + exclude_none=True, + ), + } + settings = Settings(access_token="test-access-token-at-least-32-characters") + + assert _resolved_custom_image(annotations, settings) == profile + assert _custom_image_allocation(annotations).model_dump() == { + "profile": "nginx", + "display_name": "NGINX preview", + "mode": "sidecar", + "ports": [{"name": "http", "container_port": 8080, "protocol": "TCP"}], + } + + def test_insights_create_scopes_identity_to_the_retained_home_volume() -> None: apps = Mock() apps.read_namespaced_deployment.side_effect = ApiException(status=404) @@ -462,6 +578,7 @@ def test_active_legacy_workspace_is_marked_restart_required_without_template_pat deployment = _legacy_deployment(1) apps = Mock() apps.list_namespaced_deployment.return_value = SimpleNamespace(items=[deployment]) + apps.read_namespaced_deployment.return_value = deployment core = Mock() core.read_namespaced_persistent_volume_claim.return_value = SimpleNamespace( metadata=SimpleNamespace(annotations={}) @@ -484,11 +601,67 @@ def test_active_legacy_workspace_is_marked_restart_required_without_template_pat core.patch_namespaced_persistent_volume_claim.assert_called_once() +def test_insights_reconciliation_skips_a_stale_deployment_after_delete() -> None: + deployment = _legacy_deployment(1) + apps = Mock() + apps.list_namespaced_deployment.return_value = SimpleNamespace(items=[deployment]) + apps.read_namespaced_deployment.side_effect = ApiException(status=404) + core = Mock() + manager = DevboxManager( + Settings( + access_token="test-access-token-at-least-32-characters", + insights_enabled=True, + ), + apps_api=apps, + core_api=core, + ) + + assert asyncio.run(manager.reconcile_insights()) == [] + + core.create_namespaced_secret.assert_not_called() + apps.patch_namespaced_deployment.assert_not_called() + + +def test_lifecycle_lock_serializes_waiters_and_releases_deleted_workspace_entries() -> None: + manager = DevboxManager( + Settings(access_token="test-access-token-at-least-32-characters"), + apps_api=Mock(), + core_api=Mock(), + ) + + async def exercise_lock() -> None: + entered = asyncio.Event() + release = asyncio.Event() + second_acquired = False + + async def first() -> None: + async with manager._lock_lifecycle("devbox-atlas"): + entered.set() + await release.wait() + + async def second() -> None: + nonlocal second_acquired + async with manager._lock_lifecycle("devbox-atlas"): + second_acquired = True + + first_task = asyncio.create_task(first()) + await entered.wait() + second_task = asyncio.create_task(second()) + await asyncio.sleep(0) + assert second_acquired is False + release.set() + await asyncio.gather(first_task, second_task) + + asyncio.run(exercise_lock()) + + assert manager._lifecycle_locks == {} + + def test_stopped_legacy_workspace_template_is_reconciled_without_starting_it() -> None: deployment = _legacy_deployment(0) apps = Mock() apps.list_namespaced_deployment.return_value = SimpleNamespace(items=[deployment]) - apps.read_namespaced_deployment.return_value = _legacy_deployment(0) + apps.read_namespaced_deployment.side_effect = [deployment, _legacy_deployment(0)] core = Mock() core.read_namespaced_persistent_volume_claim.return_value = SimpleNamespace( metadata=SimpleNamespace( @@ -535,7 +708,7 @@ def test_insights_reconciliation_uses_the_pinned_gpu_snapshot() -> None: ) apps = Mock() apps.list_namespaced_deployment.return_value = SimpleNamespace(items=[deployment]) - apps.read_namespaced_deployment.return_value = _legacy_deployment(0) + apps.read_namespaced_deployment.side_effect = [deployment, _legacy_deployment(0)] core = Mock() core.read_namespaced_persistent_volume_claim.return_value = SimpleNamespace( metadata=SimpleNamespace( diff --git a/controller/tests/test_models.py b/controller/tests/test_models.py index 4ef8f3f..4c8d4a4 100644 --- a/controller/tests/test_models.py +++ b/controller/tests/test_models.py @@ -39,3 +39,14 @@ def test_gpu_request_rejects_raw_kubernetes_configuration() -> None: name="atlas", gpu={"profile": "nvidia", "resource_name": "nvidia.com/gpu"}, ) + + +def test_custom_image_selector_is_compact_and_does_not_accept_url_syntax() -> None: + request = CreateDevboxRequest( + name="nginx", + image=" docker.io/library/nginx:1.27.5-alpine ", + ) + + assert request.image == "docker.io/library/nginx:1.27.5-alpine" + with pytest.raises(ValidationError, match="image profile"): + CreateDevboxRequest(name="nginx", image="https://registry.example/nginx:latest") diff --git a/controller/tests/test_resources.py b/controller/tests/test_resources.py index a35e3c5..136333d 100644 --- a/controller/tests/test_resources.py +++ b/controller/tests/test_resources.py @@ -1,10 +1,18 @@ +import json from datetime import UTC, datetime import pytest -from devboxes_controller.config import GpuProfile, GpuToleration +from devboxes_controller.config import ( + CustomImagePort, + CustomImageProfile, + GpuProfile, + GpuToleration, +) from devboxes_controller.models import CreateDevboxRequest, Preset from devboxes_controller.resources import ( + ANNOTATION_CUSTOM_IMAGE_CONFIG, + ANNOTATION_CUSTOM_IMAGE_PROFILE, ANNOTATION_GPU_CONFIG, ANNOTATION_GPU_COUNT, ANNOTATION_GPU_PROFILE, @@ -198,3 +206,84 @@ def test_gpu_profile_is_applied_only_to_the_workspace_container() -> None: assert "nvidia.com/gpu" not in pod["containers"][1]["resources"]["requests"] assert "nvidia.com/gpu" not in pod["containers"][1]["resources"]["limits"] assert pod["containers"][1]["image"] == "ghcr.io/vicotrbb/devboxes-workspace:test" + + +def test_custom_service_image_is_an_isolated_sidecar_with_pinned_contract() -> None: + profile = CustomImageProfile( + name="nginx", + displayName="NGINX preview", + image="docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + pullPolicy="Always", + ports=[CustomImagePort(name="http", containerPort=8080)], + ) + deployment = build_deployment( + request(), + "devboxes", + "ghcr.io/vicotrbb/devboxes-workspace:test", + "devboxes-workspace", + "devboxes-workspace", + custom_image=profile, + ) + annotations = deployment["metadata"]["annotations"] + pod = deployment["spec"]["template"]["spec"] + main, sidecar = pod["containers"] + + assert annotations[ANNOTATION_CUSTOM_IMAGE_PROFILE] == "nginx" + assert '"mode":"sidecar"' in annotations[ANNOTATION_CUSTOM_IMAGE_CONFIG] + assert json.loads(annotations[ANNOTATION_CUSTOM_IMAGE_CONFIG])["resources"] == { + "cpuRequest": "25m", + "memoryRequest": "32Mi", + "cpuLimit": "500m", + "memoryLimit": "512Mi", + } + assert ( + deployment["spec"]["template"]["metadata"]["annotations"][ANNOTATION_CUSTOM_IMAGE_PROFILE] + == "nginx" + ) + assert main["image"] == "ghcr.io/vicotrbb/devboxes-workspace:test" + assert sidecar == { + "name": "custom-image", + "image": "docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine", + "imagePullPolicy": "Always", + "resources": { + "requests": {"cpu": "25m", "memory": "32Mi"}, + "limits": {"cpu": "500m", "memory": "512Mi"}, + }, + "securityContext": { + "runAsNonRoot": True, + "allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}, + }, + "ports": [{"name": "http", "containerPort": 8080, "protocol": "TCP"}], + } + assert all( + "workspace-secrets" not in mount["name"] for mount in sidecar.get("volumeMounts", []) + ) + + +def test_custom_workspace_image_replaces_only_the_interactive_container() -> None: + profile = CustomImageProfile( + name="rust-nightly", + displayName="Rust nightly", + image="registry.example/devboxes-workspace-rust:nightly", + mode="workspace", + pullPolicy="Always", + ) + deployment = build_deployment( + request(), + "devboxes", + "ghcr.io/vicotrbb/devboxes-workspace:test", + "devboxes-workspace", + "devboxes-workspace", + custom_image=profile, + insights_enabled=True, + instance_id="99999999-9999-4999-8999-999999999999", + insights_endpoint="http://devboxes:8000", + insights_credential="v1.99999999-9999-4999-8999-999999999999.atlas." + "a" * 43, + ) + main, insights = deployment["spec"]["template"]["spec"]["containers"] + + assert main["image"] == "registry.example/devboxes-workspace-rust:nightly" + assert main["imagePullPolicy"] == "Always" + assert insights["name"] == "insights-agent" + assert insights["image"] == "ghcr.io/vicotrbb/devboxes-workspace:test" diff --git a/docs/api.md b/docs/api.md index 2b96549..4ef6cc8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,7 +29,7 @@ The shared token controls every devbox in the installation, including permanent | `POST` | `/auth/cli/authorize` | 303 | Approve or deny a CSRF-protected CLI request | | `POST` | `/api/v1/auth/cli/token` | 200 | Exchange a one-time code and PKCE verifier | | `GET` | `/api/v1/whoami` | 200 | Verify authentication and identity | -| `GET` | `/api/v1/capabilities` | 200 | Discover installation GPU profiles | +| `GET` | `/api/v1/capabilities` | 200 | Discover installation GPU and custom image profiles | | `GET` | `/api/v1/devboxes` | 200 | List managed devboxes | | `POST` | `/api/v1/devboxes` | 201 | Create a devbox | | `GET` | `/api/v1/devboxes/{name}` | 200 | Read one devbox | @@ -54,7 +54,7 @@ responses are `no-store`, codes are single-use, and no refresh token is returned ## Installation capabilities -Authenticated clients use `GET /api/v1/capabilities` to discover optional installation features. GPU capability discovery returns only the safe user contract, not profile images, RuntimeClasses, supplemental groups, selectors, or tolerations: +Authenticated clients use `GET /api/v1/capabilities` to discover optional installation features. Capability discovery returns only the safe user contract, not profile images, pull policies, resource limits, RuntimeClasses, supplemental groups, selectors, or tolerations: ```json { @@ -71,11 +71,25 @@ Authenticated clients use `GET /api/v1/capabilities` to discover optional instal "default": true } ] + }, + "images": { + "enabled": true, + "profiles": [ + { + "name": "nginx", + "display_name": "NGINX preview", + "description": "Serve a local static-site preview", + "mode": "sidecar", + "ports": [ + {"name": "http", "container_port": 8080, "protocol": "TCP"} + ] + } + ] } } ``` -When GPU support is disabled, `enabled` is false, `default_profile` is null, and `profiles` is empty. Clients should treat new top-level capabilities as additive. +When GPU support is disabled, `gpu.enabled` is false, `default_profile` is null, and its `profiles` is empty. When custom images are disabled, `images.enabled` is false and its `profiles` is empty. Clients should treat new top-level capabilities as additive. ## Create a devbox @@ -103,6 +117,7 @@ Request fields: | `repository` | string or null | `owner/repository` or an HTTPS GitHub repository URL | | `gpu` | object or null | Optional GPU request; omit or use null for CPU-only | | `gpu.profile` | string or null | Configured profile name; null selects the operator default | +| `image` | string or null | Operator-approved image profile name or its exact configured image reference | Unknown fields are rejected. Creating an existing name returns `409 Conflict`. Recreating a deleted name reuses its retained PVC, and expands it when the new preset requests more storage. @@ -119,6 +134,8 @@ Request the operator's default GPU profile with an empty nested object: Select an exact profile with `"gpu": {"profile": "nvidia-l4"}`. The controller rejects GPU requests while the feature is disabled and rejects unknown profile names before creating any Kubernetes resource. Clients cannot send resource names, counts, images, RuntimeClasses, supplemental groups, selectors, or tolerations. Read [GPU acceleration](gpu.md) for the operator contract. +Select a custom image with `"image": "nginx"`. The controller resolves the selector against the enabled catalog before it creates the PVC, Deployment, or SSH Service. A selector may be the stable profile name or an exact configured image reference; a raw unapproved reference is rejected. Clients cannot send a command, volume, Service, resource envelope, port mapping, capability, or scheduling policy. Read [custom image profiles](images.md) for sidecar and workspace requirements. + ## Devbox response ```json @@ -137,7 +154,8 @@ Select an exact profile with `"gpu": {"profile": "nvidia-l4"}`. The controller r "restarts": 0, "storage_size": "30Gi", "message": null, - "gpu": null + "gpu": null, + "image": null } ``` @@ -154,6 +172,21 @@ GPU boxes return their resolved allocation: } ``` +Custom image boxes return their resolved user-facing allocation: + +```json +{ + "image": { + "profile": "nginx", + "display_name": "NGINX preview", + "mode": "sidecar", + "ports": [ + {"name": "http", "container_port": 8080, "protocol": "TCP"} + ] + } +} +``` + States are: - `starting`, the pod or SSH address is not ready. @@ -161,7 +194,7 @@ States are: - `stopped`, the Deployment has zero replicas and the home volume remains. - `degraded`, the pod failed or a known image or restart failure is visible. -Timestamps are RFC 3339 values. `ssh_host`, `ssh_command`, `pod_name`, and `message` can be null while resources converge. `gpu` is null for CPU-only boxes and remains stable across stop and start. +Timestamps are RFC 3339 values. `ssh_host`, `ssh_command`, `pod_name`, and `message` can be null while resources converge. `gpu` is null for CPU-only boxes and `image` is null when no custom profile was selected. Both resolved allocations remain stable across stop and start. ## Lifecycle semantics @@ -215,7 +248,7 @@ Validation failures contain a list of structured errors. Common status codes are | 403 | Missing or invalid browser CSRF token | | 404 | Devbox does not exist | | 409 | Devbox name already exists | -| 422 | Invalid path, request field, repository, preset, TTL, disabled GPU feature, or unknown GPU profile | +| 422 | Invalid path, request field, repository, preset, TTL, disabled feature, or unknown GPU or image profile | | 503 | Controller cannot reach the Kubernetes API through `/ready` | Clients should preserve the status code, treat error payload text as diagnostic rather than stable machine data, and retry only transient transport or readiness failures. diff --git a/docs/architecture.md b/docs/architecture.md index cc4267a..6b68bfd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ The controller translates lifecycle operations into native Kubernetes resources - One `PersistentVolumeClaim` per devbox, mounted at `/home/dev`. - When Insights is enabled, one scoped ingest `Secret` per devbox and one central Insights `PersistentVolumeClaim` for the controller. -Resource names are deterministic (`devbox-NAME`), and labels plus annotations carry controller ownership, creation time, expiry, preset, repository, retained storage size, and any resolved GPU allocation. +Resource names are deterministic (`devbox-NAME`), and labels plus annotations carry controller ownership, creation time, expiry, preset, repository, retained storage size, and any resolved GPU or custom image allocation. ## GPU resolution path @@ -56,6 +56,32 @@ The create API accepts only an optional profile name. The controller resolves th The resolved profile is stored as a bounded Deployment annotation. Existing boxes therefore retain their allocation across stop, start, TTL expiry, and template reconciliation even if the Helm catalog later changes. Capability discovery publishes only profile names, labels, descriptions, resources, and counts. Scheduling details and images remain operator policy. +## Custom image resolution path + +Custom images use the same operator-owned catalog pattern while preserving the Devboxes workspace contract: + +```text +Helm custom image profiles + | + v +validated controller settings + | + +---- authenticated capability catalog ----> CLI and dashboard + | +user selects profile name or exact approved reference + | + v +resolved pinned snapshot + | + +---- sidecar profile ----> credential-free custom-image container + | + +---- workspace profile --> verified Devboxes-compatible main container +``` + +The controller resolves a selector before it creates a PVC, Deployment, or SSH Service. A default `sidecar` profile runs a compatible non-root service image in the same pod network namespace as the prepared workspace. It has bounded configured resources and optional high pod-local ports, but no Devboxes Secret mount, home PVC mount, Kubernetes API token, public Service, extra capability, command override, or scheduling policy. `runAsNonRoot` is enforced, privilege escalation is disabled, and all capabilities are dropped. The SSH workspace remains the only interactive and credential-bearing container. + +A `workspace` profile deliberately replaces the main interactive image and is restricted to a compatible Devboxes-derived image. The controller prevents it from competing with a GPU profile that independently selects `workspaceImage`. The complete resolved profile is stored in a Deployment annotation, so stop, start, TTL expiry, and Insights template reconciliation retain the original image policy even if Helm values later change. Capability discovery exposes only profile labels, modes, descriptions, and declared ports. + ## Persistence model Disconnecting SSH leaves the pod and tmux session running. Stopping scales the Deployment to zero, which ends processes but leaves the PVC. Deleting removes the Deployment and Service while retaining the PVC by default. Purging explicitly deletes the PVC. @@ -79,6 +105,7 @@ The workspace entrypoint refuses to start without `SSH_AUTHORIZED_KEYS`. It prep - Controller RBAC is a Role scoped to the release namespace; it cannot manage cluster-wide resources. - Workspace service accounts have no RBAC binding and do not mount Kubernetes API tokens. - GPU clients can choose only a configured profile name. They cannot inject images, device resources, RuntimeClasses, supplemental groups, selectors, tolerations, privileged mode, host paths, or device paths. +- Custom-image clients can choose only a configured profile name or exact configured image reference. They cannot inject a registry, command, Service, volume, port mapping, resource request, capability, host path, ServiceAccount, or scheduling field. Sidecar containers drop all Linux capabilities and cannot mount Devboxes credentials or persistent storage. - Workspace Secrets are mounted read-only with mode `0440`, scoped to the workspace group, and are not embedded in either image. - Each Insights ingest credential is HMAC-signed, write-only, scoped to one box and UUID instance, and stored in a dedicated namespaced Secret. It is never a controller, browser, or CLI credential. - The controller runs as a non-root user with a read-only root filesystem and all Linux capabilities dropped. diff --git a/docs/cli.md b/docs/cli.md index f136e02..da45f30 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -105,6 +105,7 @@ devbox create atlas --preset medium --ttl 24 --repo owner/project --ssh | `--repo OWNER/REPOSITORY` | Clone a GitHub repository on first boot | | `--gpu` | Request the operator's default GPU profile | | `--gpu-profile PROFILE` | Request an exact operator-approved profile; implies `--gpu` | +| `--image PROFILE_OR_IMAGE` | Select an operator-approved custom image profile or its exact configured image reference | | `--no-wait` | Return after the API accepts the request | | `--ssh` | Wait for readiness, then connect | @@ -119,6 +120,17 @@ devbox create training --gpu-profile nvidia-l4 --preset large --ssh The controller rejects a GPU request when the feature is disabled or the profile is unknown. If Kubernetes cannot schedule the requested resource before the wait timeout, the error includes the latest scheduler reason. Use `--no-wait` for intentionally queued work and inspect it with `devbox status`. +Custom image selection is also opt-in. Discover profiles before creating a box, then use the stable profile name. The exact configured image reference is accepted for parity with existing container-oriented workflows, but arbitrary references are rejected before Kubernetes resources are created: + +```bash +devbox image profiles +devbox create docs-preview --image nginx --ssh +devbox create docs-preview --image docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine --ssh +devbox ssh docs-preview -- -L 8080:127.0.0.1:8080 +``` + +Service profiles run as isolated, non-root, pod-local sidecars on high ports. Workspace profiles are only for compatible Devboxes workspace derivatives. See [custom image profiles](images.md) for the full contract and security boundary. + ### `devbox gpu profiles` Discover the profiles available for new boxes: @@ -130,9 +142,20 @@ devbox gpu profiles --json `devbox gpu` is a shorthand for the same catalog. Human output shows the profile identifier, display name, resource count, Kubernetes resource name, description, and default marker. JSON returns `enabled`, `default_profile`, and `profiles`. When the operator disables GPU support, the command reports that state instead of presenting stale profiles. +### `devbox image profiles` + +Discover custom image profiles available for new boxes: + +```bash +devbox image profiles +devbox image profiles --json +``` + +Human output shows the profile identifier, display name, mode, declared pod-local ports, and description. It intentionally omits the underlying image reference, pull policy, resource envelope, and other operator policy. JSON returns `enabled` and `profiles`. When the operator disables custom images, the command reports that state rather than accepting stale profile names. + ### `devbox list` -List boxes sorted by creation time, newest first. Human output includes an `ACCELERATOR` column containing `cpu` or the resolved GPU profile. +List boxes sorted by creation time, newest first. Human output includes an `ACCELERATOR` column containing `cpu` or the resolved GPU profile and an `IMAGE` column containing the resolved custom profile when present. ```bash devbox list @@ -141,7 +164,7 @@ devbox list --json ### `devbox status NAME` -Show state, preset, storage, accelerator allocation, expiry, repository, SSH address, and any readiness or scheduling message. +Show state, preset, storage, accelerator and custom image allocations, expiry, repository, SSH address, and any readiness or scheduling message. ```bash devbox status atlas @@ -236,7 +259,7 @@ JSON preserves nullable measurements and response metadata. CSV prefixes spreads ## Output and scripting -Human-readable results go to stdout. Progress and connection-wait messages go to stderr. Failures return a nonzero exit status. `--json` emits formatted JSON for list, status, create, start, stop, GPU capability, and metrics workflows, which can be consumed with `jq`: +Human-readable results go to stdout. Progress and connection-wait messages go to stderr. Failures return a nonzero exit status. `--json` emits formatted JSON for list, status, create, start, stop, GPU and custom-image capability, and metrics workflows, which can be consumed with `jq`: ```bash devbox list --json | jq -r '.[] | select(.state == "ready") | .name' diff --git a/docs/configuration.md b/docs/configuration.md index 71354f6..ef7af67 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -56,6 +56,54 @@ master token in derived mode, or rotating the dedicated key, revokes all issued The namespace must permit the workspace pod's documented `sudo` capability set. Kubernetes Pod Security `baseline` is compatible; `restricted` is not. +## Custom image profiles + +Custom images are disabled by default. Enable a small operator-owned catalog when users need a prebuilt service or a tested Devboxes workspace derivative. The controller accepts only a configured profile name or its exact configured image reference, resolves it before creating Kubernetes resources, and pins the complete profile on the resulting Deployment. + +| Value | Default | Meaning | +| --- | --- | --- | +| `workspace.customImages.enabled` | `false` | Publish configured profiles and accept image requests | +| `workspace.customImages.profiles[].name` | none | Stable lowercase identifier exposed to clients | +| `workspace.customImages.profiles[].displayName` | none | Human-readable catalog label | +| `workspace.customImages.profiles[].description` | empty | Short service or workspace purpose | +| `workspace.customImages.profiles[].image` | none | Reviewed container reference, preferably digest-pinned | +| `workspace.customImages.profiles[].mode` | `sidecar` | `sidecar` for application images or `workspace` for a compatible Devboxes derivative | +| `workspace.customImages.profiles[].pullPolicy` | `IfNotPresent` | `Always`, `IfNotPresent`, or `Never` | +| `workspace.customImages.profiles[].resources.cpuRequest` | `25m` | Sidecar CPU request; rejected for a workspace profile | +| `workspace.customImages.profiles[].resources.memoryRequest` | `32Mi` | Sidecar memory request; rejected for a workspace profile | +| `workspace.customImages.profiles[].resources.cpuLimit` | `500m` | Sidecar CPU limit; rejected for a workspace profile | +| `workspace.customImages.profiles[].resources.memoryLimit` | `512Mi` | Sidecar memory limit; rejected for a workspace profile | +| `workspace.customImages.profiles[].ports` | `[]` | Optional named pod-local ports from 1024 through 65535 for discovery and SSH tunnels | + +Example service sidecar profile: + +```yaml +workspace: + customImages: + enabled: true + profiles: + - name: nginx + displayName: NGINX preview + description: Serve a local static-site preview + image: docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine + mode: sidecar + pullPolicy: IfNotPresent + resources: + cpuRequest: 25m + memoryRequest: 32Mi + cpuLimit: 500m + memoryLimit: 512Mi + ports: + - name: http + containerPort: 8080 +``` + +A sidecar receives no Devboxes Secret, persistent-volume mount, Kubernetes service-account token, extra capability, or public Kubernetes Service. It must run as a non-root user on an unprivileged port from 1024 through 65535. It shares only the pod network namespace, so a user can reach a declared port through `devbox ssh NAME -- -L LOCAL:127.0.0.1:PORT`. + +`mode: workspace` replaces the interactive container. Use it only for a tested derivative of the matching Devboxes workspace image that preserves the entrypoint, `dev` user, SSH service on port `2222`, persistent-home setup, mounted Secret handling, and readiness behavior. Workspace profiles use the selected Devboxes preset for compute and cannot declare the sidecar-only `resources` envelope. A workspace image cannot combine with a GPU profile that defines `workspaceImage`; a sidecar profile can combine with GPU because it does not alter the primary workspace container. + +The chart rejects unknown fields, duplicate profile names or image references, unsafe image strings, privileged ports, more than 32 profiles, and enabled configurations without profiles. The controller validates resource quantities and limits before serving requests. Existing devboxes retain their pinned profiles if the catalog later changes or is disabled. See [custom image profiles](images.md) for the full security and lifecycle model. + ## GPU acceleration GPU acceleration is disabled by default. Operators configure one or more named profiles and choose a default profile for `devbox create --gpu`. CPU-only creation remains available regardless of whether GPU support is enabled. diff --git a/docs/development.md b/docs/development.md index 59f9f52..3356be9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,7 +49,7 @@ Set `DEVBOX_CONFIG` to a temporary path during development to avoid changing you The server-rendered UI lives under `controller/src/devboxes_controller/templates`, with plain CSS and JavaScript under `static`. Preserve WCAG 2.2 AA contrast, complete keyboard operation, visible focus, textual status, responsive layouts, and reduced-motion support. Keep inline scripts and handlers out of templates so the Content Security Policy remains strict. -The test fake can preview all lifecycle states and multiple GPU profiles without a Kubernetes cluster: +The test fake can preview all lifecycle states, multiple GPU profiles, and an approved non-root, high-port custom image profile without a Kubernetes cluster: ```bash cd controller @@ -91,6 +91,8 @@ The CI Kind job builds both images and the CLI, loads the images into a clean cl GPU coverage is intentionally layered because ordinary Kind workers do not expose production accelerator hardware. Controller tests prove disabled and named-profile API behavior, exact pod resources and scheduling fields, scheduler diagnostics, allocation reporting, and pinned profile reconciliation. CLI tests prove command parsing and request shape. `scripts/test-helm-gpu.sh` proves disabled, enabled, and invalid chart contracts. The clean-cluster test enables an intentionally unschedulable extended resource and proves capability discovery, CLI and API selection, the generated pod contract, Pending diagnostics, and complete cleanup. A real GPU cluster remains the acceptance environment for vendor driver, runtime, image, and workload compatibility. +Custom image coverage is layered too. Controller tests prove catalog parsing, disabled and unknown-selector rejection before Kubernetes writes, exact sidecar and workspace manifests, secret and volume isolation, response reporting, pinned-profile reconciliation, and GPU workspace-image conflict rejection. CLI tests prove profile discovery and `--image` request shape. `scripts/test-helm-images.sh` proves disabled, enabled, and invalid chart contracts. The clean-cluster test builds a small service fixture, loads it into Kind, verifies the sidecar has no workspace mounts or credentials, reaches it over pod loopback, exercises SSH tunneling, and verifies cleanup. A deployment with real private registries or workspace-mode derivatives also requires the operator's production image, registry, architecture, and SSH lifecycle acceptance checks. + Privacy tests use fixtures derived from exact Codex and Claude Code clients pinned in the workspace image. Fixtures must use synthetic values. Never commit a real prompt, response, command, path, repository name, email address, provider credential, account identifier, or session identifier. The agent and controller sanitizers are separate trust boundaries and both require regression coverage. ## Release contract diff --git a/docs/golden-path.md b/docs/golden-path.md index b962cf8..29dd139 100644 --- a/docs/golden-path.md +++ b/docs/golden-path.md @@ -62,6 +62,8 @@ Do not depend on a cache existing on only one node. Kubernetes may schedule the GPU profiles may select a larger derived workspace image. Pre-pull each configured GPU image only on nodes eligible for that profile, and validate host driver compatibility before making the profile the default. +When using custom image profiles, pre-pull each reviewed sidecar image on every node that can host a regular workspace, or use a nearby registry mirror. Keep profile resource envelopes conservative and verify that an application port is reachable over the pod loopback interface. For a workspace-mode profile, apply the same SSH, retained-home, and restart checks as the release workspace image before publishing it. + ## 4. Install and verify the CLI Use the checksummed release installer, authenticate over HTTPS, and verify the identity returned by the controller. @@ -102,6 +104,16 @@ devbox create inference --gpu --preset medium --ssh CPU and memory presets remain independent from accelerator selection. Start with the smallest preset that satisfies host-side preprocessing and compilation, then measure before increasing it. +When approved custom images are enabled, use a profile rather than a raw registry reference. A service profile is useful for a local application dependency or preview server while the Devboxes workspace remains your SSH entry point: + +```bash +devbox image profiles +devbox create docs-preview --preset small --image nginx --ssh +devbox ssh docs-preview -- -L 8080:127.0.0.1:8080 +``` + +Inspect the profile mode before creation. A sidecar profile can combine with a GPU profile; a workspace profile cannot be paired with a GPU profile that sets its own workspace image. The profile is pinned on the created box, so delete and recreate an intentional test box when you want to verify a catalog revision. + ## 6. Tune in the right order Measure before changing the cluster. Startup and interactive performance usually improve in this order: diff --git a/docs/images.md b/docs/images.md new file mode 100644 index 0000000..d525ebc --- /dev/null +++ b/docs/images.md @@ -0,0 +1,121 @@ +# Custom image profiles + +Devboxes can run an operator-approved container image with a new devbox. This makes it practical to test a prebuilt open-source service without Docker-in-Docker, while keeping the prepared SSH workspace, persistent home volume, and Kubernetes policy intact. + +The feature is disabled by default. An operator defines a small catalog through Helm, and users select a profile through the CLI or browser workbench. The controller never accepts an arbitrary image reference that is not already in that catalog. + +## Why profiles are the API + +An ordinary application image is not a complete Devboxes workspace. For example, an NGINX image does not contain the Devboxes entrypoint, the `dev` SSH user, persistent-home initialization, or the required secret bootstrap. Replacing the interactive container with it would make SSH readiness fail. + +Image profiles make this distinction explicit: + +- `sidecar`, the default, runs an application image that is compatible with the non-root sidecar contract beside the prepared Devboxes workspace. This is the right mode for NGINX, databases, emulators, documentation servers, and similar prebuilt services. +- `workspace` replaces the interactive Devboxes image. Use it only for a tested derivative of the matching Devboxes workspace image that preserves the complete SSH and lifecycle contract. + +The catalog is returned by the authenticated API, printed by `devbox image profiles`, and rendered in the dashboard. It publishes safe user-facing names, descriptions, modes, and pod-local ports. It does not publish the underlying image reference, pull policy, or resource limits. + +## Configure approved images + +Set `workspace.customImages.enabled=true` and define one or more profiles in the Helm values file: + +```yaml +workspace: + customImages: + enabled: true + profiles: + - name: nginx + displayName: NGINX preview + description: Serve a local static-site preview over the pod network + image: docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine + mode: sidecar + pullPolicy: IfNotPresent + resources: + cpuRequest: 25m + memoryRequest: 32Mi + cpuLimit: 500m + memoryLimit: 512Mi + ports: + - name: http + containerPort: 8080 + protocol: TCP +``` + +Apply the values with the normal Helm upgrade: + +```bash +helm upgrade --install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ + --version VERSION \ + --namespace devboxes \ + --values values.yaml +``` + +| Value | Meaning | +| --- | --- | +| `workspace.customImages.enabled` | Accept custom image requests and publish the catalog. Defaults to `false`. | +| `workspace.customImages.profiles[].name` | Stable lowercase profile identifier used by clients. | +| `workspace.customImages.profiles[].displayName` | Human-readable label in CLI and dashboard. | +| `workspace.customImages.profiles[].description` | Short task-oriented description. | +| `workspace.customImages.profiles[].image` | Approved container image reference. Pin a tag or digest in durable values. | +| `workspace.customImages.profiles[].mode` | `sidecar` or `workspace`. Omit for the safer `sidecar` default. | +| `workspace.customImages.profiles[].pullPolicy` | `Always`, `IfNotPresent`, or `Never`. Omit for `IfNotPresent`. | +| `workspace.customImages.profiles[].resources` | CPU and memory requests and limits for a sidecar profile. Workspace profiles reject this sidecar-only field. | +| `workspace.customImages.profiles[].ports` | Optional named, pod-local application ports from 1024 through 65535 for status and documentation. | + +The chart rejects unknown fields, duplicate profile names, duplicate image references, blank labels, URL-scheme image strings, privileged ports, and enabled configurations without profiles. The controller also validates resource quantities and limits when it starts. + +## Use a service image + +Discover the catalog first. The profile name is the recommended stable selector: + +```bash +devbox image profiles +devbox create docs-preview --image nginx --ssh +``` + +For direct parity with an existing container reference, the CLI also accepts an exact image reference that matches a configured profile: + +```bash +devbox create docs-preview --image docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine --ssh +``` + +The controller resolves either form to the same profile before it creates a PVC, Deployment, or SSH Service. An unapproved reference fails with `422 Unprocessable Content` and lists only the available profile names. + +The image runs as a `custom-image` sidecar in the same pod network namespace as the prepared workspace. It must declare a non-root image user and listen on a port from 1024 through 65535; Kubernetes sets `runAsNonRoot: true`, disables privilege escalation, and drops every Linux capability. It does not receive the Devboxes Secret, a persistent-volume mount, a Kubernetes API token, or a public Kubernetes Service. Reach a declared port from the workspace or tunnel it through SSH: + +```bash +devbox ssh docs-preview -- -L 8080:127.0.0.1:8080 +# Open http://127.0.0.1:8080 in the local browser. +``` + +The dashboard exposes the same approved profiles in the create form. Choosing a profile explains whether it is a service sidecar or replacement workspace, and shows any declared pod-local ports. Existing devbox rows and `devbox status` report the resolved profile and ports. + +## Use a complete workspace image + +Use `mode: workspace` only after proving that the image is a compatible derivative. It replaces the primary interactive container, so it must retain all of these properties: + +1. The Devboxes entrypoint and SSH daemon listening on port `2222`. +2. The `dev` user, persistent `/home/dev` layout, and tmux startup flow. +3. Runtime handling for the mounted workspace Secret and optional repository clone. +4. Compatibility with the selected Kubernetes security context and readiness probe. + +Start from the matching released Devboxes workspace image, add only the required tooling, then test SSH readiness, retained-home reuse, stop and start, and Insights reconciliation before publishing the profile. Workspace profiles keep the selected Devboxes preset for compute and must not set the sidecar-only `resources` field. Do not declare a generic application image such as NGINX as a `workspace` profile. + +A workspace profile can combine with a GPU profile only when that GPU profile does not already select its own `workspaceImage`. This prevents two independent policies from silently competing for the interactive container image. Sidecar profiles can combine with GPU profiles because they do not change the main workspace image or receive GPU resources. + +## Security and operations + +Custom image profiles are appropriate only for Devboxes' existing trusted single-operator model. The operator chooses and reviews every image, resource envelope, pull policy, and port declaration. Users can select a profile but cannot inject a registry, command, Service, volume, host path, capability, resource request, or Kubernetes scheduling field. + +Treat every catalog image as part of the trusted software supply chain: + +1. Prefer a digest or a pinned, regularly reviewed tag over `latest`. +2. Scan and provenance-verify the image according to the registry and organization policy. +3. Test image pulls from every eligible node and keep required pull credentials valid. +4. Use bounded CPU and memory values for sidecars, then inspect scheduler and runtime behavior under the intended preset. +5. Keep application ports pod-local unless a separately reviewed exposure path is required. +6. Choose images that already run unprivileged and use high ports. The tested NGINX example is `nginxinc/nginx-unprivileged`; the standard root-oriented NGINX image is intentionally incompatible with this policy. + +The fully resolved profile is stored as a Deployment annotation when the devbox is created. Stop, start, TTL expiry, and Insights template reconciliation retain that pinned contract even if Helm values later change. Disabling the feature rejects new requests and hides the catalog, but existing boxes keep their stored sidecar or workspace selection. Delete and recreate a devbox to choose a newer profile while retaining its home volume. + +If an approved image cannot pull or its sidecar crashes, Devboxes reports the Kubernetes image or restart failure through `devbox status` and the dashboard. Check the image reference, pull Secret, node network path, image architecture, and application logs before changing the catalog. diff --git a/docs/index.md b/docs/index.md index f494b6f..848fea4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ Devboxes turns Kubernetes capacity into persistent, SSH-accessible development e - [CLI reference](cli.md), commands, global flags, environment variables, output, and SSH forwarding. - [API reference](api.md), authentication, endpoints, request and response contracts, and errors. - [GPU acceleration](gpu.md), profile design, cluster prerequisites, images, scheduling, security, and diagnosis. +- [Custom image profiles](images.md), approved service sidecars, compatible workspace derivatives, security boundaries, and lifecycle behavior. - [Insights](insights.md), opt-in AI telemetry, aggregate Git activity, privacy boundaries, retention, backup, and purge. - The authenticated `/docs` page in a running controller, an operator-focused guide rendered with installation-specific values. @@ -33,4 +34,4 @@ Devboxes turns Kubernetes capacity into persistent, SSH-accessible development e ## Supported scope -Devboxes supports one trusted operator or trusted operator group per installation. The controller is namespaced, each workspace has persistent home storage, each SSH Service uses either `LoadBalancer` or `NodePort`, and GPU devices are optional operator-owned profiles. Multi-tenant authorization, untrusted workload isolation, browser terminals, cluster-wide GPU driver installation, and automatic PVC deletion are outside the current scope. +Devboxes supports one trusted operator or trusted operator group per installation. The controller is namespaced, each workspace has persistent home storage, each SSH Service uses either `LoadBalancer` or `NodePort`, and GPU devices and custom images are optional operator-owned profiles. Multi-tenant authorization, arbitrary untrusted container execution, browser terminals, cluster-wide GPU driver installation, and automatic PVC deletion are outside the current scope. diff --git a/docs/operations.md b/docs/operations.md index b653367..484578e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -40,6 +40,8 @@ Use node selectors, affinity, tolerations, and an existing PriorityClass only wh For GPU capacity, count the extended-resource units requested by active profile allocations and interpret them according to the vendor plugin's dedicated, partitioned, or sharing mode. `count: 1` means one advertised unit, which is not always one physical board. Monitor allocatable resources and Pending events on every GPU pool. Keep CPU-only headroom because GPU workspaces still request the selected CPU and memory preset. +For custom image profiles, add each sidecar's configured CPU and memory request to the selected workspace preset when forecasting node pressure. A catalog profile is trusted supply-chain policy, not an ad hoc developer image setting. Pin or regularly review image references, validate every supported architecture, verify registry credentials from each eligible node, and record the intended pod-local port and resource envelope. + Profile selectors and tolerations are policy, not capacity detection. Before publishing a profile, prove that at least one node matches all selectors, tolerates the intended taints, advertises the exact resource, and can pull the profile image. Document whether each profile is dedicated or shared. ## Backups and restore @@ -88,6 +90,8 @@ Then run `scripts/verify-install.sh`, confirm `/ready`, list existing boxes, cre When GPU configuration or images change, render the profile JSON before applying, run `devbox gpu profiles` after rollout, and create a disposable box for every changed profile. Verify the vendor diagnostic inside the box, stop and start it, and confirm the same profile contract remains. Stopping releases live device capacity, so Kubernetes may select a different physical device on start. Existing GPU Deployments retain their resolved snapshots; a Helm profile edit affects only later creations. Plan migrations as explicit delete and recreate operations, with a separate PVC retention decision. +When custom image profiles change, render the catalog before applying, run `devbox image profiles`, and create a disposable test box for every changed profile. For a sidecar, verify a declared non-root image user, a port from 1024 through 65535, the container list, no unexpected Secret or PVC mount, application health over pod loopback, SSH tunneling, stop and start, and cleanup. For a workspace-mode profile, also verify SSH readiness, persistent-home reuse, repository bootstrap, and Insights behavior if enabled. Existing Deployments retain their creation-time resolved snapshot; changing or disabling a Helm profile rejects new requests but does not rewrite existing boxes. Retire a profile only after inventorying `devbox list --json` and making an explicit delete or migration plan. + Enabling Insights does not force-restart an active legacy workspace. `devbox metrics status` reports `restart_required` until the box goes through a normal stop and start. Stopped workspaces are updated without starting compute. Confirm the expected state before and after the rollout. Prefer a reviewed values file over `--reuse-values`. It makes removed defaults and configuration drift visible. @@ -105,6 +109,8 @@ A Helm rollback changes controller resources, not the contents of workspace PVCs Disabling GPU support or rolling back the catalog rejects new GPU requests but does not rewrite existing GPU Deployments. This is intentional. Inventory allocations with `devbox list --json` before retiring drivers, runtimes, images, or node pools that those boxes still require. +Disabling custom image support or removing a profile similarly rejects new requests but does not remove a running sidecar or change an existing workspace image. Inventory image allocations before retiring a registry image or pull credential, then delete or migrate each affected box deliberately. + ## Token rotation Replace the configured controller Secret value, then restart the controller: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 24cefde..60cc18c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -115,6 +115,31 @@ Replace `nvidia.com/gpu` with the profile's resource. `Insufficient RESOURCE` me Do not patch the generated Deployment or add privileged mode and host device mounts. The controller owns that resource, and manual changes make the box non-reproducible. Correct the driver, capacity, or Helm profile, then delete and recreate a disposable test box. Existing boxes intentionally retain their creation-time profile snapshot. +## A custom image profile fails + +First confirm the selected profile and the generated container contract: + +```bash +devbox status atlas +devbox image profiles +kubectl get deployment devbox-atlas -n devboxes \ + -o jsonpath='{.metadata.annotations.image\.devboxes\.bonalab\.org/profile}{"\n"}{.spec.template.spec.containers[*].name}{"\n"}' +kubectl describe pod -n devboxes \ + -l devboxes.bonalab.org/name=atlas +``` + +For a sidecar profile, inspect its own logs and verify the declared pod-local port from the workspace container: + +```bash +kubectl logs deployment/devbox-atlas -n devboxes -c custom-image --tail=100 +kubectl exec deployment/devbox-atlas -n devboxes -c devbox -- \ + curl --fail http://127.0.0.1:8080/ +``` + +`ErrImagePull` and `ImagePullBackOff` indicate a registry, architecture, reference, or pull-credential problem. Validate the approved image on an eligible node before changing the profile. A crash loop after a successful pull is application behavior; check the image's expected environment and command, remembering that Devboxes intentionally does not inject credentials, persistent storage, or a public Service into a sidecar. Sidecars must already run as a non-root user and use a port from 1024 through 65535. For NGINX, use the unprivileged image variant rather than weakening the generated security context. + +For a workspace-mode profile, test the complete Devboxes contract instead of treating it like a generic container. The primary container must initialize the persistent home and start SSH on port `2222`. Revert the profile to a known compatible Devboxes-derived image if SSH readiness fails. Do not work around this by patching a generated Deployment, adding privilege, or mounting host paths. + ## SSH address remains pending For `LoadBalancer`, inspect `.status.loadBalancer.ingress` and the load-balancer controller events: diff --git a/scripts/fixtures/custom-image/Dockerfile b/scripts/fixtures/custom-image/Dockerfile new file mode 100644 index 0000000..e6395d3 --- /dev/null +++ b/scripts/fixtures/custom-image/Dockerfile @@ -0,0 +1,5 @@ +FROM nginxinc/nginx-unprivileged:1.27.5-alpine + +USER root +RUN printf '%s\n' 'devboxes custom image e2e' > /usr/share/nginx/html/index.html +USER 101 diff --git a/scripts/kind-e2e.sh b/scripts/kind-e2e.sh index 574c4cd..cf1a410 100755 --- a/scripts/kind-e2e.sh +++ b/scripts/kind-e2e.sh @@ -5,6 +5,8 @@ cluster="${DEVBOXES_E2E_CLUSTER:-devboxes-e2e}" namespace="${DEVBOXES_NAMESPACE:-devboxes}" controller_port="${DEVBOXES_E2E_CONTROLLER_PORT:-18000}" ssh_port="${DEVBOXES_E2E_SSH_PORT:-12222}" +custom_image_ssh_port="${DEVBOXES_E2E_CUSTOM_IMAGE_SSH_PORT:-12223}" +custom_image_tunnel_port="${DEVBOXES_E2E_CUSTOM_IMAGE_TUNNEL_PORT:-18080}" node_image="${DEVBOXES_E2E_NODE_IMAGE:-kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f}" published_version="${DEVBOXES_E2E_PUBLISHED_VERSION:-}" released_cli="${DEVBOXES_E2E_CLI:-}" @@ -14,6 +16,8 @@ token="e2e-access-token-at-least-32-characters" temporary_directory="$(mktemp -d)" controller_port_forward="" ssh_port_forward="" +custom_image_ssh_port_forward="" +custom_image_tunnel="" previous_context="" for command in kind kubectl helm docker curl jq ssh ssh-keygen nc python3; do @@ -49,6 +53,8 @@ cleanup() { kubectl --context "kind-$cluster" logs -n "$namespace" deployment/devboxes --tail=200 >&2 || true kubectl --context "kind-$cluster" logs -n "$namespace" deployment/devbox-smoke \ -c insights-agent --tail=200 >&2 || true + kubectl --context "kind-$cluster" logs -n "$namespace" deployment/devbox-image-smoke \ + -c custom-image --tail=200 >&2 || true if [[ -f "$temporary_directory/controller-port-forward.log" ]]; then cat "$temporary_directory/controller-port-forward.log" >&2 fi @@ -68,6 +74,12 @@ cleanup() { if [[ -n "$ssh_port_forward" ]]; then kill "$ssh_port_forward" >/dev/null 2>&1 || true fi + if [[ -n "$custom_image_tunnel" ]]; then + kill "$custom_image_tunnel" >/dev/null 2>&1 || true + fi + if [[ -n "$custom_image_ssh_port_forward" ]]; then + kill "$custom_image_ssh_port_forward" >/dev/null 2>&1 || true + fi if [[ -n "$controller_port_forward" ]]; then kill "$controller_port_forward" >/dev/null 2>&1 || true fi @@ -314,7 +326,11 @@ if [[ -n "$published_version" ]]; then else docker build --tag devboxes-controller:e2e controller docker build --tag devboxes-workspace:e2e workspace - kind load docker-image --name "$cluster" devboxes-controller:e2e devboxes-workspace:e2e + docker build --tag devboxes-test-nginx:e2e scripts/fixtures/custom-image + kind load docker-image --name "$cluster" \ + devboxes-controller:e2e \ + devboxes-workspace:e2e \ + devboxes-test-nginx:e2e DEVBOXES_ACCESS_TOKEN="$token" \ DEVBOXES_SSH_PUBLIC_KEY="$temporary_directory/id_ed25519.pub" \ @@ -335,6 +351,19 @@ else --set-string 'gpu.profiles[0].resourceName=example.com/gpu' \ --set 'gpu.profiles[0].count=1' \ --set 'gpu.profiles[0].supplementalGroups[0]=44' \ + --set workspace.customImages.enabled=true \ + --set 'workspace.customImages.profiles[0].name=test-nginx' \ + --set-string 'workspace.customImages.profiles[0].displayName=Test NGINX' \ + --set-string 'workspace.customImages.profiles[0].description=Kind service sidecar fixture' \ + --set-string 'workspace.customImages.profiles[0].image=devboxes-test-nginx:e2e' \ + --set 'workspace.customImages.profiles[0].mode=sidecar' \ + --set 'workspace.customImages.profiles[0].pullPolicy=Never' \ + --set-string 'workspace.customImages.profiles[0].resources.cpuRequest=25m' \ + --set-string 'workspace.customImages.profiles[0].resources.memoryRequest=32Mi' \ + --set-string 'workspace.customImages.profiles[0].resources.cpuLimit=100m' \ + --set-string 'workspace.customImages.profiles[0].resources.memoryLimit=128Mi' \ + --set 'workspace.customImages.profiles[0].ports[0].name=http' \ + --set 'workspace.customImages.profiles[0].ports[0].containerPort=8080' \ --set workspace.sshService.type=NodePort \ --set workspace.sshService.host=dev-node.example.test fi @@ -412,6 +441,125 @@ if [[ -z "$published_version" ]]; then kubectl -n "$namespace" wait --for=delete deployment/devbox-gpu-smoke --timeout=2m kubectl -n "$namespace" wait --for=delete pvc/devbox-gpu-smoke-home --timeout=2m fi + +if [[ -z "$published_version" ]]; then + image_capabilities="$(api "http://127.0.0.1:$controller_port/api/v1/capabilities")" + jq -e ' + .images == { + "enabled": true, + "profiles": [{ + "name": "test-nginx", + "display_name": "Test NGINX", + "description": "Kind service sidecar fixture", + "mode": "sidecar", + "ports": [{"name": "http", "container_port": 8080, "protocol": "TCP"}] + }] + } + ' <<<"$image_capabilities" >/dev/null + if [[ -n "$released_cli" ]]; then + cli --json image profiles \ + | jq -e '.enabled == true and .profiles[0].name == "test-nginx"' >/dev/null + cli --json create image-smoke --image test-nginx --no-wait \ + | jq -e ' + .image.profile == "test-nginx" + and .image.mode == "sidecar" + and .image.ports == [{"name": "http", "container_port": 8080, "protocol": "TCP"}] + ' >/dev/null + else + api \ + -H 'Content-Type: application/json' \ + -d '{"name":"image-smoke","image":"test-nginx"}' \ + "http://127.0.0.1:$controller_port/api/v1/devboxes" \ + | jq -e ' + .image.profile == "test-nginx" + and .image.mode == "sidecar" + ' >/dev/null + fi + kubectl -n "$namespace" rollout status deployment/devbox-image-smoke --timeout="$workspace_timeout" + image_deployment="$(kubectl -n "$namespace" get deployment devbox-image-smoke -o json)" + jq -e ' + (.spec.template.spec.containers[] | select(.name == "custom-image")) as $sidecar + | .metadata.annotations["image.devboxes.bonalab.org/profile"] == "test-nginx" + and (.metadata.annotations["image.devboxes.bonalab.org/resolved-config"] | fromjson | .name == "test-nginx" and .mode == "sidecar") + and ([.spec.template.spec.containers[].name] == ["devbox", "custom-image", "insights-agent"]) + and .spec.template.spec.automountServiceAccountToken == false + and $sidecar.image == "devboxes-test-nginx:e2e" + and $sidecar.imagePullPolicy == "Never" + and $sidecar.env == null + and $sidecar.volumeMounts == null + and $sidecar.securityContext.runAsNonRoot == true + and $sidecar.securityContext.allowPrivilegeEscalation == false + and $sidecar.securityContext.capabilities.drop == ["ALL"] + and $sidecar.resources.requests == {"cpu": "25m", "memory": "32Mi"} + and $sidecar.resources.limits == {"cpu": "100m", "memory": "128Mi"} + and $sidecar.ports == [{"name": "http", "containerPort": 8080, "protocol": "TCP"}] + ' <<<"$image_deployment" >/dev/null + kubectl -n "$namespace" exec deployment/devbox-image-smoke -c devbox -- \ + curl -fsS http://127.0.0.1:8080/ \ + | grep -Fx 'devboxes custom image e2e' >/dev/null + + kubectl -n "$namespace" port-forward service/devbox-image-smoke-ssh \ + "$custom_image_ssh_port:22" >"$temporary_directory/custom-image-ssh-port-forward.log" 2>&1 & + custom_image_ssh_port_forward=$! + for _ in {1..30}; do + nc -z 127.0.0.1 "$custom_image_ssh_port" >/dev/null 2>&1 && break + sleep 1 + done + nc -z 127.0.0.1 "$custom_image_ssh_port" >/dev/null 2>&1 + ssh \ + -i "$temporary_directory/id_ed25519" \ + -p "$custom_image_ssh_port" \ + -N \ + -L "$custom_image_tunnel_port:127.0.0.1:8080" \ + -o ExitOnForwardFailure=yes \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + dev@127.0.0.1 >"$temporary_directory/custom-image-tunnel.log" 2>&1 & + custom_image_tunnel=$! + for _ in {1..30}; do + if curl -fsS "http://127.0.0.1:$custom_image_tunnel_port/" \ + >"$temporary_directory/custom-image-body.txt"; then + break + fi + kill -0 "$custom_image_tunnel" >/dev/null 2>&1 || break + sleep 1 + done + grep -Fx 'devboxes custom image e2e' "$temporary_directory/custom-image-body.txt" >/dev/null + kill "$custom_image_tunnel" >/dev/null 2>&1 || true + wait "$custom_image_tunnel" 2>/dev/null || true + custom_image_tunnel="" + kill "$custom_image_ssh_port_forward" >/dev/null 2>&1 || true + wait "$custom_image_ssh_port_forward" 2>/dev/null || true + custom_image_ssh_port_forward="" + + if [[ -n "$released_cli" ]]; then + cli --json stop image-smoke >/dev/null + cli --json start image-smoke >/dev/null + else + api -X POST "http://127.0.0.1:$controller_port/api/v1/devboxes/image-smoke/stop" >/dev/null + api -X POST "http://127.0.0.1:$controller_port/api/v1/devboxes/image-smoke/start" >/dev/null + fi + kubectl -n "$namespace" rollout status deployment/devbox-image-smoke --timeout="$workspace_timeout" + image_after_restart="$(kubectl -n "$namespace" get deployment devbox-image-smoke -o json)" + jq -e ' + .metadata.annotations["image.devboxes.bonalab.org/profile"] == "test-nginx" + and ([.spec.template.spec.containers[].name] == ["devbox", "custom-image", "insights-agent"]) + ' <<<"$image_after_restart" >/dev/null + kubectl -n "$namespace" exec deployment/devbox-image-smoke -c devbox -- \ + curl -fsS http://127.0.0.1:8080/ \ + | grep -Fx 'devboxes custom image e2e' >/dev/null + if [[ -n "$released_cli" ]]; then + cli delete image-smoke --purge --yes >/dev/null + else + api -X DELETE \ + "http://127.0.0.1:$controller_port/api/v1/devboxes/image-smoke?purge=true" >/dev/null + fi + kubectl -n "$namespace" wait --for=delete deployment/devbox-image-smoke --timeout=2m + kubectl -n "$namespace" wait --for=delete service/devbox-image-smoke-ssh --timeout=2m + kubectl -n "$namespace" wait --for=delete pvc/devbox-image-smoke-home --timeout=2m + kubectl -n "$namespace" wait --for=delete secret/devbox-image-smoke-insights --timeout=2m +fi + if [[ -n "$released_cli" ]]; then cli --json list | jq -e 'type == "array"' >/dev/null diff --git a/scripts/test-helm-images.sh b/scripts/test-helm-images.sh new file mode 100755 index 0000000..bc31f5d --- /dev/null +++ b/scripts/test-helm-images.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +project_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +chart="$project_directory/charts/devboxes" +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT + +helm lint "$chart" --strict + +helm template devboxes "$chart" --namespace devboxes \ + >"$temporary_directory/disabled.yaml" +grep -Fq 'name: DEVBOXES_CUSTOM_IMAGES_ENABLED' "$temporary_directory/disabled.yaml" +grep -Fq 'name: DEVBOXES_CUSTOM_IMAGES' "$temporary_directory/disabled.yaml" +grep -Fq 'value: "false"' "$temporary_directory/disabled.yaml" + +image_profile=( + --set workspace.customImages.enabled=true + --set 'workspace.customImages.profiles[0].name=nginx' + --set-string 'workspace.customImages.profiles[0].displayName=NGINX preview' + --set-string 'workspace.customImages.profiles[0].description=Serve a local preview' + --set-string 'workspace.customImages.profiles[0].image=docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine' + --set 'workspace.customImages.profiles[0].mode=sidecar' + --set 'workspace.customImages.profiles[0].pullPolicy=Always' + --set-string 'workspace.customImages.profiles[0].resources.cpuRequest=25m' + --set-string 'workspace.customImages.profiles[0].resources.memoryRequest=32Mi' + --set-string 'workspace.customImages.profiles[0].resources.cpuLimit=500m' + --set-string 'workspace.customImages.profiles[0].resources.memoryLimit=512Mi' + --set 'workspace.customImages.profiles[0].ports[0].name=http' + --set 'workspace.customImages.profiles[0].ports[0].containerPort=8080' + --set 'workspace.customImages.profiles[0].ports[0].protocol=TCP' +) + +helm template devboxes "$chart" --namespace devboxes "${image_profile[@]}" \ + >"$temporary_directory/enabled.yaml" +grep -Fq 'name: DEVBOXES_CUSTOM_IMAGES_ENABLED' "$temporary_directory/enabled.yaml" +grep -Fq 'value: "true"' "$temporary_directory/enabled.yaml" +grep -Fq 'docker.io/nginxinc/nginx-unprivileged:1.27.5-alpine' "$temporary_directory/enabled.yaml" +grep -Fq 'containerPort' "$temporary_directory/enabled.yaml" + +if helm template devboxes "$chart" --namespace devboxes \ + --set workspace.customImages.enabled=true \ + >"$temporary_directory/missing.yaml" \ + 2>"$temporary_directory/missing.err"; then + printf 'error: custom image render accepted an enabled feature without profiles\n' >&2 + exit 1 +fi +grep -Fq 'profiles' "$temporary_directory/missing.err" + +if helm template devboxes "$chart" --namespace devboxes \ + "${image_profile[@]}" \ + --set 'workspace.customImages.profiles[1].name=nginx' \ + --set-string 'workspace.customImages.profiles[1].displayName=Duplicate NGINX' \ + --set-string 'workspace.customImages.profiles[1].image=registry.example/nginx:2' \ + >"$temporary_directory/duplicate.yaml" \ + 2>"$temporary_directory/duplicate.err"; then + printf 'error: custom image render accepted duplicate profile names\n' >&2 + exit 1 +fi +grep -Fq 'duplicate name' "$temporary_directory/duplicate.err" + +if helm template devboxes "$chart" --namespace devboxes \ + "${image_profile[@]}" \ + --set-string 'workspace.customImages.profiles[0].image=https://registry.example/nginx:1' \ + >"$temporary_directory/invalid-image.yaml" \ + 2>"$temporary_directory/invalid-image.err"; then + printf 'error: custom image render accepted a URL scheme\n' >&2 + exit 1 +fi +grep -Fq 'image must not contain a URL scheme' "$temporary_directory/invalid-image.err" + +if helm template devboxes "$chart" --namespace devboxes \ + "${image_profile[@]}" \ + --set 'workspace.customImages.profiles[0].ports[0].containerPort=80' \ + >"$temporary_directory/low-port.yaml" \ + 2>"$temporary_directory/low-port.err"; then + printf 'error: custom image render accepted a privileged sidecar port\n' >&2 + exit 1 +fi +grep -Fq 'minimum:' "$temporary_directory/low-port.err" + +if helm template devboxes "$chart" --namespace devboxes \ + "${image_profile[@]}" \ + --set 'workspace.customImages.profiles[0].mode=workspace' \ + >"$temporary_directory/workspace-resources.yaml" \ + 2>"$temporary_directory/workspace-resources.err"; then + printf 'error: custom image render accepted sidecar resources for a workspace profile\n' >&2 + exit 1 +fi + +printf 'Verified disabled, enabled, and invalid custom image Helm contracts.\n'