Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Changed

- **Service: an image per devcontainer.json in sub-folders** ([ADR-0016](docs/decisions/0016-one-image-per-devcontainer-json.md)).
Each `.devcontainer/<folder>/devcontainer.json` is built with `--config` as an image of its own,
`<name>-<folder id>`, after the root config's `main` image. `POST /build` lists them all in a new
`images` field. The existing `image`/`registry`/`name`/`tag`/`imageBuildLogId` fields describe
`images[0]`, so a repository with a root config gets the same response as before. A repository with
only sub-folder configs used to fall back to `fallbackImage` (or fail); now it builds those configs,
and the top-level fields describe the first one. The fallback image is used only when there's no
config anywhere. Without a `fallbackImage`, that case is now a 400 (`no devcontainer.json found`)
instead of a 500. New optional request fields: `instances` builds only the listed ids (an unknown
id is a 400 listing the valid ones), and `dryRun` resolves the list and image names without building
or pushing. Two sub-folders that map to the same id are a 400.
- **Breaking:** **Template: `hostRequirements` are minimums**, as in the Dev Container spec, instead of overriding
- **Image: Node 24 LTS** (was Node 20, end-of-life since April 2026), base image pinned by digest.
The npm package's `engines` is now `node >=22`.
Expand Down
5 changes: 5 additions & 0 deletions charts/test-git-server/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ spec:
{{- else }}
mkdir -p "/work/{{ .name }}/$(dirname "{{ $configPath }}")"
cp "/seed/{{ .name }}.json" "/work/{{ .name }}/{{ $configPath }}"
{{- /* The same devcontainer.json at more locations, for repos with several configs (one image each, ADR-0015). */}}
{{- range .extraConfigPaths }}
mkdir -p "/work/{{ $repo.name }}/$(dirname "{{ . }}")"
cp "/seed/{{ $repo.name }}.json" "/work/{{ $repo.name }}/{{ . }}"
{{- end }}
{{- if .dockerfile }}
{{- /* A real Dockerfile alongside devcontainer.json, for repos whose config uses "build": {"dockerfile": ...} instead of a plain "image" reference - the CLI resolves a relative dockerfile path against the config's own directory. */}}
cp "/seed/{{ .name }}.Dockerfile" "/work/{{ .name }}/$(dirname "{{ $configPath }}")/Dockerfile"
Expand Down
22 changes: 22 additions & 0 deletions charts/test-git-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ seedRepos:
configPath: .devcontainer/gamma/devcontainer.json
devcontainerJson: |
{ "image": "mcr.microsoft.com/devcontainers/base:alpine-3.20" }
# Several devcontainer.json files in one repo - one image per config
# (ADR-0015). extraConfigPaths copies the same devcontainer.json to more
# locations. The colliding one's two folders sanitize to the same id
# ("back-end"), which the service rejects before building anything.
- name: devcontainer-json-two-folders
path: location/devcontainer-json-two-folders.git
configPath: .devcontainer/frontend/devcontainer.json
extraConfigPaths: [.devcontainer/backend/devcontainer.json]
devcontainerJson: |
{ "image": "mcr.microsoft.com/devcontainers/base:alpine-3.20" }
- name: devcontainer-json-root-and-folder
path: location/devcontainer-json-root-and-folder.git
configPath: .devcontainer/devcontainer.json
extraConfigPaths: [.devcontainer/tools/devcontainer.json]
devcontainerJson: |
{ "image": "mcr.microsoft.com/devcontainers/base:alpine-3.20" }
- name: devcontainer-json-colliding-folders
path: location/devcontainer-json-colliding-folders.git
configPath: .devcontainer/Back_End/devcontainer.json
extraConfigPaths: [.devcontainer/back-end/devcontainer.json]
devcontainerJson: |
{ "image": "mcr.microsoft.com/devcontainers/base:alpine-3.20" }
# No devcontainer.json anywhere - the "not found at all" case, distinct
# from (and adjacent to) the location-discovery repos above.
- name: devcontainer-json-missing
Expand Down
7 changes: 7 additions & 0 deletions docs/claude/notes/devcontainer-subfolder-config-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ created: 2026-09-06

# The sub-folder devcontainer.json location isn't auto-discoverable

> **Superseded (2026-10-07)** by
> [ADR-0016](../../decisions/0016-one-image-per-devcontainer-json.md). The
> service now finds every sub-folder config itself, builds one image per
> config with `--config`, and lists them in `POST /build`'s `images`. The
> human choice moved to the caller (the `instances` filter), so no separate
> discovery service is needed. The rest of this note is kept as history.

## What happened

While building `devcontainer_config_discovery.feature`, `test-git-server`
Expand Down
29 changes: 28 additions & 1 deletion docs/claude/plans/004-devcontainer-instance-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,34 @@ created: 2026-10-06

# List support: `/build` returns a list of images → one Coder agent per image

## Status (2026-10-06): planned
## Status (2026-10-07): Phase B step 1 (service) implemented; provider, module and template still open

The service side is on `feat/multi-config-builds`, recorded in
[ADR-0016](../../decisions/0016-one-image-per-devcontainer-json.md) (0013 was
already taken). It differs from this plan in these ways:

- **Additive, not breaking.** The 1.x API is stable
([Versioning](../../project/versioning.md)), so the response keeps
`image`/`registry`/`name`/`tag`/`imageBuildLogId` and adds `images[]`. The
top-level fields describe `images[0]`: `main` when there is a root config,
else the first sub-folder item.
- **400, not 422,** for an unknown `instances` id, an id collision, or an
empty id. The "no configs" case is also a 400, and only when there's no
`fallbackImage`. With one, the fallback is a single `main` item, used only
when no config exists at any of the three locations.
- **ids** also drop leading and trailing `-`, so they stay valid in image
names and as DNS labels. Symlinked sub-folders are skipped.
- **`instances: []`** fails the shape check. Use `null` or omit it for "all".
- **Dry runs** count as `status="dry_run"` in `devcontainer_builder_builds_total`.
- **New code:** discovery is in `service/src/config-discovery.ts`, and
`buildInstance()`/`devcontainerBuildArgs()` are in `build.ts`. BDD coverage
is in `devcontainer_config_discovery.feature`, with new seed repos
`devcontainer-json-{two-folders,root-and-folder,colliding-folders}`. Not run
yet, because it needs the cluster.

Next: the provider's `images`/`instances`/dry run in `ModifyPlan` (step 2),
then the module (step 3), the template (step 4), and the guide and README
(step 5). Phase A (the template's list-of-one refactor) is still open.

## Context

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ and per-domain reference pages.
|---|---|
| `health.feature` | Liveness/readiness signals, including a deliberately-never-ready pod |
| `request_validation.feature` | `/build`'s up-front shape check, independent of whether the values are usable |
| `devcontainer_config_discovery.feature` | The devcontainer CLI's real auto-discovery locations (root, `.devcontainer/`, *not* an arbitrary sub-folder) |
| `devcontainer_config_discovery.feature` | Every devcontainer.json location the spec allows (root, `.devcontainer/`, `.devcontainer/<folder>/`), one image per config, `instances`, `dryRun`, and the 400s for an unbuildable list |
| `devcontainer_config_content.feature` | A discoverable-but-unusable `devcontainer.json` fails clearly, including the `"build":{"dockerfile":...}` path |
| `image_resolution.feature` | Name/tag/registry defaulting and mapping-rule resolution |
| `registry_auth.feature` | Ambient vs. per-request registry push credentials against a real auth-enforcing registry |
Expand Down
128 changes: 128 additions & 0 deletions docs/decisions/0016-one-image-per-devcontainer-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<title>ADR-0016</title>

# ADR-0016: One image per devcontainer.json, listed in `POST /build`'s response

Status: accepted
Date: 2026-10-07

## Context

The [containers.dev spec](https://containers.dev/implementors/spec/#devcontainerjson)
allows a devcontainer.json in three places: `.devcontainer/devcontainer.json`,
`.devcontainer.json`, and `.devcontainer/<folder>/devcontainer.json`. A
repository can have several of the sub-folder kind side by side (`backend`,
`frontend`, ...). The Dev Containers CLI only finds the first two on its own;
a sub-folder config needs `--config <path>`. devcontainer-builder never passed
`--config`, so a repository with only sub-folder configs failed, or (since
[ADR-0013](0013-fallback-config-for-repos-without-one.md)) quietly got the
fallback image. See
[the note on sub-folder discovery](https://github.com/DeepSpaceCartel/devcontainer-builder/blob/main/docs/claude/notes/devcontainer-subfolder-config-discovery.md).

The note concluded that picking *one* sub-folder config needs a human. Plan
004 sidesteps the choice: build all of them, one image each, and let the
caller (the Coder template) run one agent per image, with an optional filter.

`POST /build` is part of the stable 1.x HTTP API
([Versioning](../project/versioning.md)): existing callers read the
single-image fields `image`, `registry`, `name`, `tag` and `imageBuildLogId`,
and must keep working unchanged.

## Decision

**Discovery.** After the clone, the service lists every config:

- **`main`:** the root config. At most one counts:
`.devcontainer/devcontainer.json`, else `.devcontainer.json` (the CLI's own
order).
- **One item per `.devcontainer/<folder>/devcontainer.json`,** exactly one
level deep. Its `id` is the folder name lower-cased, with every character
outside `[a-z0-9-]` replaced by `-` and leading and trailing `-` removed, so
it fits an image name and a Kubernetes DNS label. Symlinked folders and
files are skipped: an untrusted clone shouldn't point the build outside
itself.
- **Order:** `main` first, then the folders sorted by `id`.
- **400 before anything is built** when two locations give the same `id`
(`Back_End` and `back-end`, or a folder named `main` next to a root config),
or a folder name leaves an empty `id`. The error names the folders.

**Response, additive.** The response gains
`images: [{id, configPath, image, registry, name, tag, imageBuildLogId}]`.
The existing top-level fields stay and **describe `images[0]`**:

- A repository with a root config, and no `instances` filter that leaves it
out, gets exactly today's response, plus `images` holding the `main` item.
`main` is built with today's argv (no `--config`), and named as before.
- With no root config, `images[0]` is the first sub-folder item in `id`
order, so the top-level fields describe it. We chose this over leaving
them empty, which would break the schema for every 1.x caller, and over
inventing a synthetic "main", which would name an image nobody built. A
1.x caller that knows nothing about lists still gets a real, pushed,
deterministic image. Before this change it got the fallback image or an
error.

**Naming.** `main` keeps today's name (`image.name`, else the derived
default). Every other item gets `-<id>` appended to that same name, whether
it was derived or given explicitly. `tag` (`image.tag`, else `sha-<7>`) and
`registry` are the same for every item, so a caller's per-workspace tag
applies to all of them.

**Building.** Items are built one after another, each in its own
`image.build_push` span with its own captured log (`imageBuildLogId`) and its
own build timeout. A non-root item passes `--config <clone>/<configPath>` to
`devcontainer build`. The config label and the remote-user probe
([ADR-0012](0012-dev-container-to-kubernetes-runtime-mapping.md)) read that
item's own devcontainer.json and its own pushed image. If one item fails,
the request fails with 500 as before, and when it selected more than one
item, the error message ends with that item's id and config path and the
images already pushed (`already pushed: <ref>, ...` or `none`). Already
pushed images are not rolled back.

**Request, additive.** Two optional fields:

- `instances: string[] | null` builds only those ids, in discovery order.
An unknown id is a **400** listing the repository's ids. Plan 004 said 422.
We chose 400 because every other request problem found after the clone (no
registry resolved, a pinned host key missing) is already a 400
`BuildRequestError`, and this endpoint has never returned a 422.
- `dryRun: boolean` clones, discovers, filters and resolves names, then
stops. It returns the same response without `imageBuildLogId` and builds
nothing. It exists so Terraform can know the list's keys and image
references at plan time. It counts as `status="dry_run"` in
`devcontainer_builder_builds_total`.

**Fallback image** ([ADR-0013](0013-fallback-config-for-repos-without-one.md))
applies only when **no** config is found at any of the three locations. The
result is a single `main` item with `configPath:
.devcontainer/devcontainer.json`, a file that exists only in the scratch
clone. A repository with only sub-folder configs builds those, not the
fallback. With no configs and no `fallbackImage`, the request is now a
**400** (`no devcontainer.json found in repository ...`) before anything
runs. Before, it was a 500 from the CLI's "Dev container config ... not
found".

**Unchanged:** `GET /devcontainer` and `GET`/`DELETE /image` work on one
image at a time. A caller loops over `images`.

## Consequences

- Repositories with sub-folder configs build. ADR-0013's consequence that
such repositories "silently get the fallback image" no longer holds.
- A 1.x caller that predates `images` (an older Terraform provider) still
works. But for a repository with several configs, it only tracks
`images[0]`. The other images are built and pushed, and an older provider
won't delete them on destroy. A repository with a single root config, which
is the case every caller handled before, is unaffected.
- The single-image fields change meaning slightly for a repository with no
root config. They used to describe the fallback image, or the request
failed. Now they describe its first sub-folder item, named `<name>-<id>`.
- Repositories without any config, on a service without `fallbackImage`,
now get a 400 instead of a 500. The chart sets `fallbackImage` by default,
so most deployments never see this.
- A build with N items takes about N times as long, and its timeout applies
per item, not to the whole request. A failure part-way leaves the earlier
images pushed. The error says which, so the caller can clean them up with
`DELETE /image`.
- Every `dryRun` costs a shallow clone. Callers that plan often (Terraform)
pay that on every plan.
- `dockerComposeFile` configs are not specially handled. They fail the same
way they did before, now per item.
3 changes: 2 additions & 1 deletion docs/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ change, never rewritten in place).
| [0010](0010-command-output-capture.md) | Capture git/docker subprocess output to files instead of stdout | accepted |
| [0011](0011-devcontainer-metadata-endpoint.md) | `GET /devcontainer`: merged Dev Container metadata, read from the image | accepted |
| [0012](0012-dev-container-to-kubernetes-runtime-mapping.md) | Dev Container → Kubernetes runtime mapping | accepted (`hostRequirements` part superseded by [0014](0014-host-requirements-are-minimums.md)) |
| [0013](0013-fallback-config-for-repos-without-one.md) | A fallback image for repositories without a devcontainer.json | accepted |
| [0013](0013-fallback-config-for-repos-without-one.md) | A fallback image for repositories without a devcontainer.json | accepted (sub-folder consequence superseded by [0016](0016-one-image-per-devcontainer-json.md)) |
| [0014](0014-host-requirements-are-minimums.md) | `hostRequirements` are minimums | accepted |
| [0015](0015-kubernetes-customizations.md) | `customizations.kubernetes` for pod resources and node placement | accepted |
| [0016](0016-one-image-per-devcontainer-json.md) | One image per devcontainer.json, listed in `POST /build`'s response | accepted |
2 changes: 1 addition & 1 deletion docs/reference/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ concern documented in
| Clone timeout (seconds) | `--clone-timeout` | `CLONE_TIMEOUT_SECONDS` | `build.cloneTimeoutSeconds` | `600` |
| Build+push timeout (seconds) | `--build-timeout` | `BUILD_TIMEOUT_SECONDS` | `build.timeoutSeconds` | `3600` |
| Concurrent builds per instance | `--max-concurrent-builds` | `MAX_CONCURRENT_BUILDS` | `build.maxConcurrent` | `4` (`0` = no limit) |
| Fallback image (repos without a devcontainer.json, [ADR-0013](../decisions/0013-fallback-config-for-repos-without-one.md)) | `--fallback-image` | `FALLBACK_IMAGE` | `build.fallbackImage` | *(unset — such builds fail)*; the chart sets `mcr.microsoft.com/devcontainers/base:ubuntu` |
| Fallback image (repos without any devcontainer.json, not even in a sub-folder, [ADR-0013](../decisions/0013-fallback-config-for-repos-without-one.md)) | `--fallback-image` | `FALLBACK_IMAGE` | `build.fallbackImage` | *(unset — such builds fail with 400)*; the chart sets `mcr.microsoft.com/devcontainers/base:ubuntu` |
| Sentry/GlitchTip DSN | `--sentry-dsn` | `SENTRY_DSN` | `sentry.dsn` | *(unset — error tracking off)* |
| Service name | `--service-name` | `SERVICE_NAME` | `observability.serviceName` | `devcontainer-builder` |
| Deployment environment | `--environment` | `DEPLOYMENT_ENVIRONMENT` | `observability.environment` | `development` |
Expand Down
13 changes: 10 additions & 3 deletions docs/reference/devcontainer-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ variables**.
## Where the configuration is found

`.devcontainer/devcontainer.json`, then `.devcontainer.json`, at the root of
the repository, as the Dev Containers CLI looks for them. Configurations in
sub-folders (`.devcontainer/<name>/devcontainer.json`) aren't picked up; such
a repository gets the fallback image.
the repository, as the Dev Containers CLI looks for them. That's the `main`
image. Each configuration in a sub-folder
(`.devcontainer/<name>/devcontainer.json`) is built as an image of its own,
named `<image name>-<name>`, and `POST /build` lists them all in `images`
([ADR-0016](../decisions/0016-one-image-per-devcontainer-json.md)). Only a
repository with no configuration anywhere gets the fallback image.

The Coder template still starts one workspace container, from the response's
top-level `image`: `main`, or the first sub-folder image when there is no
root configuration. One agent per image is planned.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ nav:
- "0013. Fallback image for repositories without a config": decisions/0013-fallback-config-for-repos-without-one.md
- "0014. hostRequirements are minimums": decisions/0014-host-requirements-are-minimums.md
- "0015. customizations.kubernetes for pod resources and node placement": decisions/0015-kubernetes-customizations.md
- "0016. One image per devcontainer.json": decisions/0016-one-image-per-devcontainer-json.md

theme:
name: material
Expand Down
Loading
Loading