diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b5fdb..7555171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,17 +66,6 @@ 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//devcontainer.json` is built with `--config` as an image of its own, - `-`, 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`. diff --git a/charts/test-git-server/templates/deployment.yaml b/charts/test-git-server/templates/deployment.yaml index 08723f7..a372f79 100644 --- a/charts/test-git-server/templates/deployment.yaml +++ b/charts/test-git-server/templates/deployment.yaml @@ -55,11 +55,6 @@ 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" diff --git a/charts/test-git-server/values.yaml b/charts/test-git-server/values.yaml index 5505473..2fb0e23 100644 --- a/charts/test-git-server/values.yaml +++ b/charts/test-git-server/values.yaml @@ -68,28 +68,6 @@ 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 diff --git a/docs/claude/notes/devcontainer-subfolder-config-discovery.md b/docs/claude/notes/devcontainer-subfolder-config-discovery.md index f3930d9..44461e7 100644 --- a/docs/claude/notes/devcontainer-subfolder-config-discovery.md +++ b/docs/claude/notes/devcontainer-subfolder-config-discovery.md @@ -5,13 +5,6 @@ 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` diff --git a/docs/claude/plans/004-devcontainer-instance-list.md b/docs/claude/plans/004-devcontainer-instance-list.md index a1a5a4c..da577c0 100644 --- a/docs/claude/plans/004-devcontainer-instance-list.md +++ b/docs/claude/plans/004-devcontainer-instance-list.md @@ -5,34 +5,20 @@ created: 2026-10-06 # List support: `/build` returns a list of images → one Coder agent per image -## 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. +## Status (2026-10-07): abandoned + +Dropped before 1.0 by the maintainer. The service side (#60, ADR-0016) was +merged and then reverted, so `images[]`, `instances` and `dryRun` never ship +in a release and aren't part of the 1.x API. The template refactor (#65) and +the provider's `images`/`instances` (provider #11) were closed unmerged. + +The blocker that settled it: Coder names an agent after its Terraform +resource name (`ConvertState` in `provisioner/terraform/resources.go` fails +with `duplicate agent name`), so a `for_each` over `coder_agent` can't give +one agent per image. + +A repository whose only configs are in `.devcontainer//` behaves as +in 0.5.0: it falls back to `fallbackImage`, or fails. ## Context diff --git a/docs/claude/plans/005-v1-readiness.md b/docs/claude/plans/005-v1-readiness.md index a57c5fa..a822c0b 100644 --- a/docs/claude/plans/005-v1-readiness.md +++ b/docs/claude/plans/005-v1-readiness.md @@ -51,8 +51,8 @@ All merged on `main`, each verified as noted: - **Features:** - `customizations.kubernetes` for pod resources and node placement #59 (ADR-0015; scratch workspace: requests and limits applied, hostRequirements and placement ignored with warnings); - - one image per devcontainer.json in sub-folders #60 (ADR-0016, additive API; scratch release: - dry run, a folder build, a root build unchanged, unknown ids → 400). + - one image per devcontainer.json in sub-folders #60: reverted, the feature is abandoned (see + [plan 004](004-devcontainer-instance-list.md)). - **Tests:** - route tests and a fake registry #61: 87% → 94% lines, 74% → 88% branches, 129 unit tests; - BDD `request_validation` on the cluster: 53 of 53 scenarios. @@ -76,10 +76,6 @@ All merged on `main`, each verified as noted: ## Still open before tagging -- **Multiple configurations end to end:** - - provider `images`/`instances` (PR in progress); - - template Phase A, keyed by instance with `moved` blocks (PR in progress); - - then Phase B, which needs a provider release. - **Harbor tutorial** once rts #37 is applied, including whether `DELETE /image` by digest can remove another tag that shares it. @@ -117,6 +113,4 @@ All merged on `main`, each verified as noted: `hostRequirements`. Needs: the service exposing it through `GET /devcontainer`, the provider's data source, and the template mapping (capped like today), plus an ADR. - **Harbor** (rts #37): a self-hosted registry, after which come private-registry tutorials. -- **Multiple Dev Container configurations** ([plan 004](004-devcontainer-instance-list.md)): - - the service side can land additively (`images[]` next to today's single-image fields); - - the template side needs a provider release. +- **Multiple Dev Container configurations:** abandoned ([plan 004](004-devcontainer-instance-list.md)). diff --git a/docs/concepts/testing.md b/docs/concepts/testing.md index 6126177..f71da4d 100644 --- a/docs/concepts/testing.md +++ b/docs/concepts/testing.md @@ -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` | Every devcontainer.json location the spec allows (root, `.devcontainer/`, `.devcontainer//`), one image per config, `instances`, `dryRun`, and the 400s for an unbuildable list | +| `devcontainer_config_discovery.feature` | The devcontainer CLI's real auto-discovery locations (root, `.devcontainer/`, *not* an arbitrary sub-folder) | | `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 | diff --git a/docs/decisions/0016-one-image-per-devcontainer-json.md b/docs/decisions/0016-one-image-per-devcontainer-json.md deleted file mode 100644 index 6c3062b..0000000 --- a/docs/decisions/0016-one-image-per-devcontainer-json.md +++ /dev/null @@ -1,128 +0,0 @@ -ADR-0016 - -# 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//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 `. 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//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 `-` 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 /` 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: , ...` 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 `-`. -- 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. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index 5316e08..bbdc4ff 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -31,7 +31,6 @@ 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 (sub-folder consequence superseded by [0016](0016-one-image-per-devcontainer-json.md)) | +| [0013](0013-fallback-config-for-repos-without-one.md) | A fallback image for repositories without a devcontainer.json | accepted | | [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 | diff --git a/docs/reference/CONFIGURATION.md b/docs/reference/CONFIGURATION.md index dc4d8c8..fcc5aa5 100644 --- a/docs/reference/CONFIGURATION.md +++ b/docs/reference/CONFIGURATION.md @@ -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 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` | +| 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` | | 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` | diff --git a/docs/reference/devcontainer-json.md b/docs/reference/devcontainer-json.md index 7e40dde..dc44705 100644 --- a/docs/reference/devcontainer-json.md +++ b/docs/reference/devcontainer-json.md @@ -58,13 +58,6 @@ 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. That's the `main` -image. Each configuration in a sub-folder -(`.devcontainer//devcontainer.json`) is built as an image of its own, -named `-`, 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. +the repository, as the Dev Containers CLI looks for them. Configurations in +sub-folders (`.devcontainer//devcontainer.json`) aren't picked up; such +a repository gets the fallback image. diff --git a/mkdocs.yml b/mkdocs.yml index 4a7059a..4f654f9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,7 +67,6 @@ 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 diff --git a/service/features/devcontainer_config_discovery.feature b/service/features/devcontainer_config_discovery.feature index 1089423..821cfd2 100644 --- a/service/features/devcontainer_config_discovery.feature +++ b/service/features/devcontainer_config_discovery.feature @@ -7,19 +7,15 @@ Feature: devcontainer.json discovery after clone recognizes three locations, in precedence order: .devcontainer/ devcontainer.json, .devcontainer.json, and .devcontainer// devcontainer.json (one sub-folder deep, 's name unspecified). - The service builds one image per config it finds (ADR-0016): the root - one as "main", each sub-folder one as an item named after its folder, - listed in the response's `images`. test-git-server seeds one real repo - per location, plus repos with several configs (see - charts/test-git-server/values.yaml's devcontainer-json-* entries) - - these are real builds, so "success" means the devcontainer CLI actually - found and used the config, not an inferred signal. Every seed repo in - this feature shares byte-identical devcontainer.json content - only its - location differs - so what matters is the list of items and their - names (image *content* correctness is image_resolution.feature's own, - separate concern). The service runs without a fallbackImage here, so a - repository with no config at all is an error rather than a fallback - build - except in the one scenario that turns it on. + test-git-server seeds one real repo per location (see + charts/test-git-server/values.yaml's devcontainer-json-* entries) - these + are real builds, so "success" means the devcontainer CLI actually found + and used the config, not an inferred signal. Every seed repo in this + feature shares byte-identical devcontainer.json content - only its + location differs - so the resulting image reference carries no + information this feature cares about; a 200 alone is already the full, + direct proof discovery found and used the config (image *content* + correctness is image_resolution.feature's own, separate concern). This deploys its own dedicated, trust-configured BuildKit instance (test-buildkit) and its own disposable in-cluster registry @@ -139,7 +135,6 @@ Feature: devcontainer.json discovery after clone | --set | buildkit.endpoint= | | --set | extraEnv[0].name=ALLOW_INSECURE_GIT_PROTOCOLS | | --set-string | extraEnv[0].value=true | - | --set-string | build.fallbackImage= | | --wait | True | | --timeout | 120s | Then the command exited with 0 @@ -155,22 +150,11 @@ Feature: devcontainer.json discovery after clone And "" label "app.kubernetes.io/instance" is "devcontainer-builder" @client-request - Scenario Outline: A devcontainer.json at the root or the standard .devcontainer/ location is the main image + Scenario Outline: A devcontainer.json at the root or the standard .devcontainer/ location is found automatically When I send a POST request to Endpoint known as "" path "/build" with: | TYPE | KEY | VALUE | | BODY | | {"repository":"/location/.git","image":{"registry":""}} | Then the response status is 200 - Given the value at "join(',', images[*].id)" from the last response is known as "" - Then the value known as "" equals "main" - Given the value at "images[0].configPath" from the last response is known as "" - Then the value known as "" equals "" - # The single-image fields every 1.x caller reads are images[0], named - # exactly as before this list existed. - Given the value at "name" from the last response is known as "" - Then the value known as "" equals "" - Given the value at "images[0].image" from the last response is known as "" - Given the value at "image" from the last response is known as "" - Then the value known as "" equals "" When I remove Docker Buildx Builder known as "" Then the command exited with 0 @@ -186,168 +170,39 @@ Feature: devcontainer.json discovery after clone Then the command exited with 0 Examples: - | repo | configPath | - | devcontainer-json-root | .devcontainer.json | - | devcontainer-json-standard | .devcontainer/devcontainer.json | + | repo | + | devcontainer-json-root | + | devcontainer-json-standard | - @client-request - Scenario Outline: A devcontainer.json in a sub-folder is built with --config, as an image named after the folder - # The devcontainer CLI only finds the root and standard locations on - # its own; a sub-folder config needs an explicit --config , - # which the service passes for each one it finds (ADR-0016). Three - # folder names prove discovery isn't tied to a particular one. A - # sub-folder item's image name gets "-" appended; with no root - # config, it's also the first item, so the single-image fields - # describe it. + @negative @client-request + Scenario Outline: A devcontainer.json in a sub-folder is not found automatically - a known gap, not this service's own logic + # The spec itself only says a sub-folder config MAY exist - it + # deliberately leaves 's name unspecified, since a tool can't + # know which of possibly several sub-folder configs to pick without + # being told explicitly (the spec: "consider providing a mechanism for + # users to select one when appropriate"). The devcontainer CLI reflects + # exactly that: it auto-discovers only the root and standard + # .devcontainer/devcontainer.json locations - a sub-folder config needs + # an explicit --config , which build.ts does not currently pass + # (the /build request has no field for it). Three sub-folder names + # prove this fails the same way regardless of which folder name is + # used - it's not a naming mismatch, the location itself isn't checked. + # The real "not found" text never reaches the HTTP response body + # (which only ever gets "... exited with code 1") - it's captured to a + # file instead (ADR-0010), fetched here via the failure response's own + # logId, not a pod-log poll. When I send a POST request to Endpoint known as "" path "/build" with: | TYPE | KEY | VALUE | | BODY | | {"repository":"/location/.git","image":{"registry":""}} | - Then the response status is 200 - Given the value at "join(',', images[*].id)" from the last response is known as "" - Then the value known as "" equals "" - Given the value at "images[0].configPath" from the last response is known as "" - Then the value known as "" equals ".devcontainer//devcontainer.json" - Given the value at "name" from the last response is known as "" - Then the value known as "" equals "-" - - When I remove Docker Buildx Builder known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release "" with --wait --timeout 120s - Then the command exited with 0 - - Examples: - | repo | folder | - | devcontainer-json-subfolder-alpha | alpha | - | devcontainer-json-subfolder-beta | beta | - | devcontainer-json-subfolder-gamma | gamma | - - @client-request - Scenario: Every devcontainer.json in a repository becomes its own pushed image - When I send a POST request to Endpoint known as "" path "/build" with: - | TYPE | KEY | VALUE | - | BODY | | {"repository":"/location/devcontainer-json-root-and-folder.git","image":{"registry":""}} | - Then the response status is 200 - Given the value at "join(',', images[*].id)" from the last response is known as "" - Then the value known as "" equals "main,tools" - Given the value at "name" from the last response is known as "" - Then the value known as "" equals "devcontainer-json-root-and-folder" - Given the value at "images[1].name" from the last response is known as "" - Then the value known as "" equals "devcontainer-json-root-and-folder-tools" - Given the value at "images[1].imageBuildLogId" from the last response is known as "" - Given the value at "tag" from the last response is known as "" - - When I send a GET request to Endpoint known as "" path "/image" with: - | TYPE | KEY | VALUE | - | QUERY | registry | | - | QUERY | name | devcontainer-json-root-and-folder-tools | - | QUERY | tag | | - Then the response status is 200: - | SOURCE | CONDITION | VALUE | - | BODY | contains | "exists":true | - When I send a GET request to Endpoint known as "" path "/logs/" + Then the response status is 500: + | SOURCE | CONDITION | VALUE | + | BODY | contains | exited with code 1 | + Given the value at "logId" from the last response is known as "" + When I send a GET request to Endpoint known as "" path "/logs/" Then the response status is 200: - | SOURCE | CONDITION | VALUE | - | BODY | contains | building "tools" from .devcontainer/tools/devcontainer.json | - - When I remove Docker Buildx Builder known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release "" with --wait --timeout 120s - Then the command exited with 0 - - @client-request - Scenario: Sub-folder items without a root config are sorted by id, and instances picks some - When I send a POST request to Endpoint known as "" path "/build" with: - | TYPE | KEY | VALUE | - | BODY | | {"repository":"/location/devcontainer-json-two-folders.git","image":{"registry":""},"instances":["frontend"]} | - Then the response status is 200 - Given the value at "join(',', images[*].id)" from the last response is known as "" - Then the value known as "" equals "frontend" - Given the value at "name" from the last response is known as "" - Then the value known as "" equals "devcontainer-json-two-folders-frontend" - - When I send a POST request to Endpoint known as "" path "/build" with: - | TYPE | KEY | VALUE | - | BODY | | {"repository":"/location/devcontainer-json-two-folders.git","image":{"registry":""},"dryRun":true} | - Then the response status is 200 - Given the value at "join(',', images[*].id)" from the last response is known as "" - Then the value known as "" equals "backend,frontend" - - When I remove Docker Buildx Builder known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release "" with --wait --timeout 120s - Then the command exited with 0 - - @client-request - Scenario: A dry run returns the list with its image names, and pushes nothing - When I send a POST request to Endpoint known as "" path "/build" with: - | TYPE | KEY | VALUE | - | BODY | | {"repository":"/location/devcontainer-json-root-and-folder.git","image":{"registry":""},"dryRun":true} | - Then the response status is 200: - | SOURCE | CONDITION | VALUE | - | BODY | contains | "id":"main" | - | BODY | contains | "id":"tools" | - Given the value at "images[1].image" from the last response is known as "" - Then the value known as "" contains "/devcontainer-json-root-and-folder-tools:sha-" - Given the value at "tag" from the last response is known as "" - Given the value at "imageBuildLogId || images[0].imageBuildLogId || images[1].imageBuildLogId || 'none'" from the last response is known as "" - Then the value known as "" equals "none" - - When I send a GET request to Endpoint known as "" path "/image" with: - | TYPE | KEY | VALUE | - | QUERY | registry | | - | QUERY | name | devcontainer-json-root-and-folder-tools | - | QUERY | tag | | - Then the response status is 200: - | SOURCE | CONDITION | VALUE | - | BODY | contains | "exists":false | - - When I remove Docker Buildx Builder known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release known as "" - Then the command exited with 0 - When I uninstall Helm Release "" with --wait --timeout 120s - Then the command exited with 0 - - @negative @client-request - Scenario Outline: A list the service can't build is a 400 before anything is built - # The Background deploys without a fallbackImage, so a repository with - # no devcontainer.json anywhere is a request problem too. - When I send a POST request to Endpoint known as "" path "/build" with: - | TYPE | KEY | VALUE | - | BODY | | {"repository":"/location/.git","image":{"registry":""}} | - Then the response status is 400: - | SOURCE | CONDITION | VALUE | - | BODY | contains | | + | SOURCE | CONDITION | VALUE | + | BODY | contains | Dev container config | + | BODY | contains | not found | When I remove Docker Buildx Builder known as "" Then the command exited with 0 @@ -363,28 +218,32 @@ Feature: devcontainer.json discovery after clone Then the command exited with 0 Examples: - | repo | extra | error | - | devcontainer-json-root-and-folder | ,"instances":["nope"] | unknown instance id(s) \\"nope\\" - this repository has: \\"main\\", \\"tools\\" | - | devcontainer-json-colliding-folders | | map to the same instance id: \\"back-end\\" | - | devcontainer-json-missing | | no devcontainer.json found in repository | + | repo | + | devcontainer-json-subfolder-alpha | + | devcontainer-json-subfolder-beta | + | devcontainer-json-subfolder-gamma | - @client-request - Scenario: With a fallbackImage, a repository without any devcontainer.json is a single main item - When I upgrade Helm Release known as "" with: - | OPTION | VALUE | - | --reuse-values | True | - | --set | build.fallbackImage=mcr.microsoft.com/devcontainers/base:alpine-3.20 | - | --wait | True | - | --timeout | 120s | - Then the command exited with 0 + @negative @client-request + Scenario: A repository with no devcontainer.json at any location fails clearly + # Distinct from the sub-folder cases above: this repo has no config at + # ANY of the three locations, not just an unchecked one - same CLI + # error text either way, since "checked here, found nothing" and + # "never checked here" are indistinguishable from the CLI's own output. + # The distinction that matters is at the fixture level (see + # devcontainer-json-missing in charts/test-git-server/values.yaml, + # noConfig: true), proving this failure mode is reachable at all. When I send a POST request to Endpoint known as "" path "/build" with: - | TYPE | KEY | VALUE | - | BODY | | {"repository":"/location/devcontainer-json-missing.git","image":{"registry":""},"dryRun":true} | - Then the response status is 200 - Given the value at "join(',', images[*].id)" from the last response is known as "" - Then the value known as "" equals "main" - Given the value at "images[0].configPath" from the last response is known as "" - Then the value known as "" equals ".devcontainer/devcontainer.json" + | TYPE | KEY | VALUE | + | BODY | | {"repository":"/location/devcontainer-json-missing.git","image":{"registry":""}} | + Then the response status is 500: + | SOURCE | CONDITION | VALUE | + | BODY | contains | exited with code 1 | + Given the value at "logId" from the last response is known as "" + When I send a GET request to Endpoint known as "" path "/logs/" + Then the response status is 200: + | SOURCE | CONDITION | VALUE | + | BODY | contains | Dev container config | + | BODY | contains | not found | When I remove Docker Buildx Builder known as "" Then the command exited with 0 diff --git a/service/src/build-request-schema.test.ts b/service/src/build-request-schema.test.ts deleted file mode 100644 index f1f8c0c..0000000 --- a/service/src/build-request-schema.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// The new POST /build fields' shape rules (ADR-0016), checked against the -// TypeBox schema Fastify validates with. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { Value } from "@sinclair/typebox/value"; -import { BuildRequestSchema, BuildResponseSchema } from "./schemas.js"; - -const base = { repository: "https://github.com/deepspacecartel/devcontainer-builder-examples.git" }; - -test("instances: optional, null, or a non-empty list of non-empty strings", () => { - assert.ok(Value.Check(BuildRequestSchema, base)); - assert.ok(Value.Check(BuildRequestSchema, { ...base, instances: null })); - assert.ok(Value.Check(BuildRequestSchema, { ...base, instances: ["main", "backend"] })); - assert.ok(!Value.Check(BuildRequestSchema, { ...base, instances: [] })); - assert.ok(!Value.Check(BuildRequestSchema, { ...base, instances: [""] })); - assert.ok(!Value.Check(BuildRequestSchema, { ...base, instances: "main" })); - assert.ok(!Value.Check(BuildRequestSchema, { ...base, instances: [1] })); -}); - -test("dryRun: optional boolean", () => { - assert.ok(Value.Check(BuildRequestSchema, { ...base, dryRun: true })); - assert.ok(Value.Check(BuildRequestSchema, { ...base, dryRun: false })); - assert.ok(!Value.Check(BuildRequestSchema, { ...base, dryRun: "yes" })); -}); - -test("response: today's single-image fields plus images, imageBuildLogId optional for a dry run", () => { - const item = { - id: "main", - configPath: ".devcontainer.json", - image: "r.example/repo:sha-abcdef0", - registry: "r.example", - name: "repo", - tag: "sha-abcdef0", - }; - const response = { image: item.image, registry: "r.example", name: "repo", tag: "sha-abcdef0", branch: "main", commit: "abc", images: [item] }; - assert.ok(Value.Check(BuildResponseSchema, response)); - assert.ok(Value.Check(BuildResponseSchema, { ...response, imageBuildLogId: "docker-x", images: [{ ...item, imageBuildLogId: "docker-x" }] })); - assert.ok(!Value.Check(BuildResponseSchema, { ...response, images: [] })); -}); diff --git a/service/src/build.test.ts b/service/src/build.test.ts index b1b37ff..da68796 100644 --- a/service/src/build.test.ts +++ b/service/src/build.test.ts @@ -6,48 +6,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { clonedBranch, devcontainerBuildArgs, gitCloneArgs, onceUntilFailure } from "./build.js"; - -const noBuildOptions = { platforms: [], noCache: false, cacheFrom: undefined, cacheTo: undefined, mode: undefined }; - -test("devcontainer build args: a root config keeps today's argv, no --config", () => { - for (const configPath of [".devcontainer/devcontainer.json", ".devcontainer.json"]) { - assert.deepEqual(devcontainerBuildArgs("/w/repo", configPath, "r/x:t", noBuildOptions), [ - "build", - "--workspace-folder", - "/w/repo", - "--image-name", - "r/x:t", - "--push", - ]); - } -}); - -test("devcontainer build args: a sub-folder config is passed with --config", () => { - assert.deepEqual( - devcontainerBuildArgs("/w/repo", ".devcontainer/backend/devcontainer.json", "r/x-backend:t", { - ...noBuildOptions, - platforms: ["linux/amd64", "linux/arm64"], - noCache: true, - mode: "never", - }), - [ - "build", - "--workspace-folder", - "/w/repo", - "--config", - "/w/repo/.devcontainer/backend/devcontainer.json", - "--image-name", - "r/x-backend:t", - "--push", - "--platform", - "linux/amd64,linux/arm64", - "--no-cache", - "--buildkit", - "never", - ], - ); -}); +import { clonedBranch, gitCloneArgs, onceUntilFailure } from "./build.js"; test("onceUntilFailure shares one in-flight attempt and remembers success", async () => { let calls = 0; diff --git a/service/src/build.ts b/service/src/build.ts index f4ff349..286042b 100644 --- a/service/src/build.ts +++ b/service/src/build.ts @@ -2,8 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile, chmod, cp, readFile } from "node:fs/prom import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; import { trace, SpanStatusCode } from "@opentelemetry/api"; -import type { BuildImage, BuildRequest, BuildResponse, RegistryCredentials } from "./types.js"; -import { MAIN_INSTANCE_ID, discoverConfigs, isRootConfigPath, instanceImageName, selectInstances } from "./config-discovery.js"; +import type { BuildRequest, BuildResponse, RegistryCredentials } from "./types.js"; import { serviceConfig, type GitCredentialEntry, type RegistryMappingRule, type SshHostKeyPolicy } from "./config.js"; import { logger } from "./logger.js"; import { openCommandLog, closeCommandLog, type CommandLogKind } from "./command-log.js"; @@ -20,6 +19,17 @@ export function fallbackConfig(image: string): string { return JSON.stringify({ image }, null, 2) + "\n"; } +async function readFirstExisting(paths: string[]): Promise { + for (const path of paths) { + try { + return await readFile(path, "utf8"); + } catch { + // try the next location + } + } + return undefined; +} + // git clone and `devcontainer build --push` are both plain child_process // subprocesses (see `run`/`runCapture` below) - OTel's auto-instrumentation // only patches library calls (http/undici/fs/dns/...), never arbitrary @@ -366,201 +376,119 @@ export async function buildDevcontainer(req: BuildRequest): Promise/devcontainer.json` as - // an item of its own. - let discovered = await discoverConfigs(repoDir); - // No devcontainer.json anywhere: build the configured fallback image + const image = `${registry}/${name}:${tag}`; + + const platforms = req.platforms ?? serviceConfig.defaultPlatforms; + const noCache = req.buildOptions?.noCache ?? serviceConfig.defaultBuildOptions.noCache; + const cacheFrom = req.buildOptions?.cacheFrom ?? serviceConfig.defaultBuildOptions.cacheFrom; + const cacheTo = req.buildOptions?.cacheTo ?? serviceConfig.defaultBuildOptions.cacheTo; + const mode = req.buildOptions?.mode ?? serviceConfig.defaultBuildOptions.mode; + + // devcontainer.json properties the CLI's own devcontainer.metadata label + // leaves out (workspaceFolder, runArgs, initializeCommand), recorded in + // the image too - see CONFIG_LABEL / ADR-0012. Same discovery order as + // the CLI; an unparseable file gets no label (the CLI reports the real + // error itself). Added by a second, layer-less build on top of the + // pushed image rather than `devcontainer build --label`: the CLI only + // forwards --label on its image+Features path, never for a + // build.dockerfile config (checked in 0.89.0). + let configText = await readFirstExisting([ + join(repoDir, ".devcontainer", "devcontainer.json"), + join(repoDir, ".devcontainer.json"), + ]); + // No devcontainer.json at all: build the configured fallback image // instead of failing (ADR-0013), from a config written into this // scratch clone only - the repository never sees it, so a workspace // can tell the image came from the fallback and offer to add one. - if (discovered.length === 0) { - if (!serviceConfig.fallbackImage) { - throw new BuildRequestError( - `no devcontainer.json found in repository ${repositoryForLogs} (looked for .devcontainer/devcontainer.json, .devcontainer.json and .devcontainer//devcontainer.json)`, - ); - } + if (configText === undefined && serviceConfig.fallbackImage) { + configText = fallbackConfig(serviceConfig.fallbackImage); await mkdir(join(repoDir, ".devcontainer"), { recursive: true }); - await writeFile(join(repoDir, ".devcontainer", "devcontainer.json"), fallbackConfig(serviceConfig.fallbackImage)); + await writeFile(join(repoDir, ".devcontainer", "devcontainer.json"), configText); logger.info({ event: "build.config.fallback", "git.repository.url": repositoryForLogs, "container.image.name": serviceConfig.fallbackImage }); - discovered = [{ id: MAIN_INSTANCE_ID, configPath: ".devcontainer/devcontainer.json" }]; } - - const images: BuildImage[] = selectInstances(discovered, req.instances).map(({ id, configPath }) => { - const itemName = instanceImageName(name, id); - return { id, configPath, image: `${registry}/${itemName}:${tag}`, registry, name: itemName, tag }; - }); - - if (!req.dryRun) { - const options: BuildInstanceOptions = { - workDir, - repoDir, - platforms: req.platforms ?? serviceConfig.defaultPlatforms, - noCache: req.buildOptions?.noCache ?? serviceConfig.defaultBuildOptions.noCache, - cacheFrom: req.buildOptions?.cacheFrom ?? serviceConfig.defaultBuildOptions.cacheFrom, - cacheTo: req.buildOptions?.cacheTo ?? serviceConfig.defaultBuildOptions.cacheTo, - mode: req.buildOptions?.mode ?? serviceConfig.defaultBuildOptions.mode, - registryCredentials: req.registryCredentials, - }; - // One after another: they share the remote builder, and usually - // base layers, and each has its own log and timeout. - const pushed: string[] = []; - for (const item of images) { - try { - item.imageBuildLogId = await buildInstance(item, options); - } catch (err) { - if (images.length > 1 && err instanceof Error) { - err.message += ` (image "${item.id}" from ${item.configPath}; already pushed: ${pushed.length > 0 ? pushed.join(", ") : "none"})`; - } - throw err; - } - pushed.push(item.image); + const configLabel = configText !== undefined ? configLabelValue(configText) : undefined; + + // The remote user's uid/gid/home, read from the pushed image's own + // /etc/passwd by a throwaway BuildKit stage (`getent`, else a grep for + // images without it) and exported as a single file - no layers come + // back to this service. Recorded in the config label so a pod can run + // as that user from the start (ADR-0012). Best effort: an image without + // a shell, or a user missing from /etc/passwd, just gets no account. + const probeRemoteUserAccount = async (env: NodeJS.ProcessEnv, logStream: NodeJS.WritableStream, signal: AbortSignal) => { + try { + const platform = platforms[0] ?? "linux/amd64"; + const auth = req.registryCredentials ? { username: req.registryCredentials.username, password: req.registryCredentials.password } : undefined; + const pushed = await readImageConfig(registry, name, tag, platform, auth); + const metadata = pushed.found ? pushed.labels[METADATA_LABEL] : undefined; + if (!pushed.found || metadata === undefined) return {}; + const user = remoteUserFor(metadata, pushed.user); + + const probeDir = join(workDir, "user-probe"); + const outDir = join(probeDir, "out"); + await mkdir(outDir, { recursive: true }); + await writeFile( + join(probeDir, "Dockerfile"), + [ + `FROM ${image} AS probe`, + `ARG DEVCONTAINER_USER`, + `RUN (getent passwd "$DEVCONTAINER_USER" || grep -E "^$DEVCONTAINER_USER:|^[^:]*:[^:]*:$DEVCONTAINER_USER:" /etc/passwd) | head -n 1 > /devcontainer-user`, + `FROM scratch`, + `COPY --from=probe /devcontainer-user /`, + ``, + ].join("\n"), + ); + const probeArgs = ["buildx", "build", "--build-arg", `DEVCONTAINER_USER=${user}`, "--platform", platform]; + probeArgs.push("--output", `type=local,dest=${outDir}`, probeDir); + await run("docker", probeArgs, { env, logStream, signal }); + const account = parsePasswdEntry(await readFile(join(outDir, "devcontainer-user"), "utf8")); + return account ? { remoteUserAccount: account } : {}; + } catch (err) { + logStream.write(`remote user probe failed, continuing without it: ${err instanceof Error ? err.message : err}\n`); + return {}; } - } + }; - const first = images[0]!; - return { - image: first.image, - registry, - name: first.name, - tag, - branch, - commit: headSha.trim(), - gitCloneLogId, - ...(first.imageBuildLogId !== undefined ? { imageBuildLogId: first.imageBuildLogId } : {}), - images, + const runBuild = async (baseEnv: NodeJS.ProcessEnv, logStream: NodeJS.WritableStream, signal: AbortSignal) => { + const env = builderEnv(baseEnv); + const args = ["build", "--workspace-folder", repoDir, "--image-name", image, "--push"]; + if (platforms.length > 0) args.push("--platform", platforms.join(",")); + if (noCache) args.push("--no-cache"); + if (cacheFrom) args.push("--cache-from", cacheFrom); + if (cacheTo) args.push("--cache-to", cacheTo); + if (mode) args.push("--buildkit", mode); + await run("devcontainer", args, { env, logStream, signal }); + + if (configLabel !== undefined) { + const label = { ...JSON.parse(configLabel), ...(await probeRemoteUserAccount(env, logStream, signal)) }; + const labelDir = join(workDir, "config-label"); + await mkdir(labelDir, { recursive: true }); + await writeFile(join(labelDir, "Dockerfile"), `FROM ${image}\n`); + const labelArgs = ["buildx", "build", "--push", "--label", `${CONFIG_LABEL}=${JSON.stringify(label)}`, "-t", image]; + if (platforms.length > 0) labelArgs.push("--platform", platforms.join(",")); + labelArgs.push(labelDir); + await run("docker", labelArgs, { env, logStream, signal }); + } }; + + const buildTimeout = phaseTimeout("image build and push", serviceConfig.buildTimeoutSeconds); + const { logId: imageBuildLogId } = await withSpan( + "image.build_push", + { "image.registry": registry, "image.name": name, "image.tag": tag }, + () => + withCommandLog("docker", "image.build_push.output_captured", async (logStream) => { + const { signal } = buildTimeout; + // Before withRegistryAuthEnv copies the ambient DOCKER_CONFIG, so + // the copy already has the builder in it. + await ensureRemoteBuilder(logStream, signal); + logStream.write(`using remote buildx builder ${serviceConfig.buildxBuilderName}\n`); + await (req.registryCredentials + ? withRegistryAuthEnv(req.registryCredentials, (env) => runBuild(env, logStream, signal)) + : runBuild(process.env, logStream, signal)); + }), + ).finally(buildTimeout.dispose); + + return { image, registry, name, tag, branch, commit: headSha.trim(), gitCloneLogId, imageBuildLogId }; } finally { await rm(workDir, { recursive: true, force: true }); } } - -interface BuildInstanceOptions { - workDir: string; - repoDir: string; - platforms: string[]; - noCache: boolean; - cacheFrom: string | null | undefined; - cacheTo: string | null | undefined; - mode: "auto" | "never" | undefined; - registryCredentials: RegistryCredentials | undefined; -} - -export function devcontainerBuildArgs( - repoDir: string, - configPath: string, - image: string, - opts: Pick, -): string[] { - const args = ["build", "--workspace-folder", repoDir]; - // The CLI only finds the root config on its own; a sub-folder one - // needs naming. A root config keeps exactly the argv it always had. - if (!isRootConfigPath(configPath)) args.push("--config", join(repoDir, configPath)); - args.push("--image-name", image, "--push"); - if (opts.platforms.length > 0) args.push("--platform", opts.platforms.join(",")); - if (opts.noCache) args.push("--no-cache"); - if (opts.cacheFrom) args.push("--cache-from", opts.cacheFrom); - if (opts.cacheTo) args.push("--cache-to", opts.cacheTo); - if (opts.mode) args.push("--buildkit", opts.mode); - return args; -} - -// `devcontainer build --push` for one item, then the config label on top. -// Returns the id of its captured output. -async function buildInstance(item: BuildImage, opts: BuildInstanceOptions): Promise { - const { workDir, repoDir, platforms } = opts; - const { id, configPath, image, registry, name, tag } = item; - const configFile = join(repoDir, configPath); - - // devcontainer.json properties the CLI's own devcontainer.metadata label - // leaves out (workspaceFolder, runArgs, initializeCommand), recorded in - // the image too - see CONFIG_LABEL / ADR-0012. Read from this item's own - // file; an unparseable one gets no label (the CLI reports the real error - // itself). Added by a second, layer-less build on top of the pushed - // image rather than `devcontainer build --label`: the CLI only forwards - // --label on its image+Features path, never for a build.dockerfile - // config (checked in 0.89.0). - let configText: string | undefined; - try { - configText = await readFile(configFile, "utf8"); - } catch { - configText = undefined; - } - const configLabel = configText !== undefined ? configLabelValue(configText) : undefined; - - // The remote user's uid/gid/home, read from the pushed image's own - // /etc/passwd by a throwaway BuildKit stage (`getent`, else a grep for - // images without it) and exported as a single file - no layers come - // back to this service. Recorded in the config label so a pod can run - // as that user from the start (ADR-0012). Best effort: an image without - // a shell, or a user missing from /etc/passwd, just gets no account. - const probeRemoteUserAccount = async (env: NodeJS.ProcessEnv, logStream: NodeJS.WritableStream, signal: AbortSignal) => { - try { - const platform = platforms[0] ?? "linux/amd64"; - const auth = opts.registryCredentials ? { username: opts.registryCredentials.username, password: opts.registryCredentials.password } : undefined; - const pushed = await readImageConfig(registry, name, tag, platform, auth); - const metadata = pushed.found ? pushed.labels[METADATA_LABEL] : undefined; - if (!pushed.found || metadata === undefined) return {}; - const user = remoteUserFor(metadata, pushed.user); - - const probeDir = join(workDir, "user-probe", id); - const outDir = join(probeDir, "out"); - await mkdir(outDir, { recursive: true }); - await writeFile( - join(probeDir, "Dockerfile"), - [ - `FROM ${image} AS probe`, - `ARG DEVCONTAINER_USER`, - `RUN (getent passwd "$DEVCONTAINER_USER" || grep -E "^$DEVCONTAINER_USER:|^[^:]*:[^:]*:$DEVCONTAINER_USER:" /etc/passwd) | head -n 1 > /devcontainer-user`, - `FROM scratch`, - `COPY --from=probe /devcontainer-user /`, - ``, - ].join("\n"), - ); - const probeArgs = ["buildx", "build", "--build-arg", `DEVCONTAINER_USER=${user}`, "--platform", platform]; - probeArgs.push("--output", `type=local,dest=${outDir}`, probeDir); - await run("docker", probeArgs, { env, logStream, signal }); - const account = parsePasswdEntry(await readFile(join(outDir, "devcontainer-user"), "utf8")); - return account ? { remoteUserAccount: account } : {}; - } catch (err) { - logStream.write(`remote user probe failed, continuing without it: ${err instanceof Error ? err.message : err}\n`); - return {}; - } - }; - - const runBuild = async (baseEnv: NodeJS.ProcessEnv, logStream: NodeJS.WritableStream, signal: AbortSignal) => { - const env = builderEnv(baseEnv); - await run("devcontainer", devcontainerBuildArgs(repoDir, configPath, image, opts), { env, logStream, signal }); - - if (configLabel !== undefined) { - const label = { ...JSON.parse(configLabel), ...(await probeRemoteUserAccount(env, logStream, signal)) }; - const labelDir = join(workDir, "config-label", id); - await mkdir(labelDir, { recursive: true }); - await writeFile(join(labelDir, "Dockerfile"), `FROM ${image}\n`); - const labelArgs = ["buildx", "build", "--push", "--label", `${CONFIG_LABEL}=${JSON.stringify(label)}`, "-t", image]; - if (platforms.length > 0) labelArgs.push("--platform", platforms.join(",")); - labelArgs.push(labelDir); - await run("docker", labelArgs, { env, logStream, signal }); - } - }; - - const buildTimeout = phaseTimeout("image build and push", serviceConfig.buildTimeoutSeconds); - const { logId } = await withSpan( - "image.build_push", - { "image.registry": registry, "image.name": name, "image.tag": tag, "devcontainer.instance.id": id, "devcontainer.config.path": configPath }, - () => - withCommandLog("docker", "image.build_push.output_captured", async (logStream) => { - const { signal } = buildTimeout; - // Before withRegistryAuthEnv copies the ambient DOCKER_CONFIG, so - // the copy already has the builder in it. - await ensureRemoteBuilder(logStream, signal); - logStream.write(`using remote buildx builder ${serviceConfig.buildxBuilderName}\n`); - logStream.write(`building "${id}" from ${configPath}\n`); - await (opts.registryCredentials - ? withRegistryAuthEnv(opts.registryCredentials, (env) => runBuild(env, logStream, signal)) - : runBuild(process.env, logStream, signal)); - }), - ).finally(buildTimeout.dispose); - return logId; -} diff --git a/service/src/config-discovery.test.ts b/service/src/config-discovery.test.ts deleted file mode 100644 index bb90106..0000000 --- a/service/src/config-discovery.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Finding every devcontainer.json in a clone, and turning it into the -// response's images list (ADR-0016) - plain temp directories, no git. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { BuildRequestError } from "./errors.js"; -import { discoverConfigs, instanceIdFor, instanceImageName, isRootConfigPath, selectInstances } from "./config-discovery.js"; - -test("root config paths are the two the CLI finds without --config", () => { - assert.ok(isRootConfigPath(".devcontainer/devcontainer.json")); - assert.ok(isRootConfigPath(".devcontainer.json")); - assert.ok(!isRootConfigPath(".devcontainer/main/devcontainer.json")); -}); - -function repoWith(...paths: string[]): string { - const dir = mkdtempSync(join(tmpdir(), "config-discovery-")); - for (const path of paths) { - mkdirSync(dirname(join(dir, path)), { recursive: true }); - writeFileSync(join(dir, path), '{ "image": "mcr.microsoft.com/devcontainers/base:alpine-3.20" }\n'); - } - return dir; -} - -test("discovery: a root .devcontainer.json alone is main", async () => { - assert.deepEqual(await discoverConfigs(repoWith(".devcontainer.json")), [{ id: "main", configPath: ".devcontainer.json" }]); -}); - -test("discovery: .devcontainer/devcontainer.json alone is main", async () => { - assert.deepEqual(await discoverConfigs(repoWith(".devcontainer/devcontainer.json")), [ - { id: "main", configPath: ".devcontainer/devcontainer.json" }, - ]); -}); - -test("discovery: .devcontainer/devcontainer.json wins over .devcontainer.json, as in the CLI", async () => { - assert.deepEqual(await discoverConfigs(repoWith(".devcontainer.json", ".devcontainer/devcontainer.json")), [ - { id: "main", configPath: ".devcontainer/devcontainer.json" }, - ]); -}); - -test("discovery: no config anywhere is an empty list", async () => { - assert.deepEqual(await discoverConfigs(repoWith("README.md")), []); - assert.deepEqual(await discoverConfigs(repoWith(".devcontainer/Dockerfile")), []); -}); - -test("discovery: sub-folders only, sorted by id, no main", async () => { - assert.deepEqual( - await discoverConfigs(repoWith(".devcontainer/frontend/devcontainer.json", ".devcontainer/backend/devcontainer.json")), - [ - { id: "backend", configPath: ".devcontainer/backend/devcontainer.json" }, - { id: "frontend", configPath: ".devcontainer/frontend/devcontainer.json" }, - ], - ); -}); - -test("discovery: root config first, then the sub-folders", async () => { - assert.deepEqual( - await discoverConfigs(repoWith(".devcontainer/zeta/devcontainer.json", ".devcontainer.json", ".devcontainer/alpha/devcontainer.json")), - [ - { id: "main", configPath: ".devcontainer.json" }, - { id: "alpha", configPath: ".devcontainer/alpha/devcontainer.json" }, - { id: "zeta", configPath: ".devcontainer/zeta/devcontainer.json" }, - ], - ); -}); - -test("discovery: only one level deep, only devcontainer.json, and folders without one are skipped", async () => { - assert.deepEqual( - await discoverConfigs( - repoWith( - ".devcontainer/a/b/devcontainer.json", - ".devcontainer/c/.devcontainer.json", - ".devcontainer/d/Dockerfile", - ".devcontainer/e/devcontainer.json", - ), - ), - [{ id: "e", configPath: ".devcontainer/e/devcontainer.json" }], - ); -}); - -test("discovery: symlinked sub-folders and files are ignored", async () => { - const outside = repoWith("elsewhere/devcontainer.json"); - const dir = repoWith(".devcontainer/real/devcontainer.json"); - symlinkSync(join(outside, "elsewhere"), join(dir, ".devcontainer", "linked")); - mkdirSync(join(dir, ".devcontainer", "filelink")); - symlinkSync(join(outside, "elsewhere", "devcontainer.json"), join(dir, ".devcontainer", "filelink", "devcontainer.json")); - assert.deepEqual(await discoverConfigs(dir), [{ id: "real", configPath: ".devcontainer/real/devcontainer.json" }]); -}); - -test("instance ids: lower-cased, [a-z0-9-] only, no leading/trailing dashes", () => { - assert.equal(instanceIdFor("backend"), "backend"); - assert.equal(instanceIdFor("Back_End"), "back-end"); - assert.equal(instanceIdFor("node.js 22"), "node-js-22"); - assert.equal(instanceIdFor(".hidden_"), "hidden"); - assert.equal(instanceIdFor("___"), ""); -}); - -test("discovery: two folders with the same id are a 400 naming both", async () => { - await assert.rejects( - discoverConfigs(repoWith(".devcontainer/Back_End/devcontainer.json", ".devcontainer/back-end/devcontainer.json")), - (err: Error) => - err instanceof BuildRequestError && - /"back-end"/.test(err.message) && - err.message.includes(".devcontainer/Back_End/devcontainer.json") && - err.message.includes(".devcontainer/back-end/devcontainer.json"), - ); -}); - -test("discovery: a folder named main collides with the root config", async () => { - await assert.rejects( - discoverConfigs(repoWith(".devcontainer.json", ".devcontainer/Main/devcontainer.json")), - (err: Error) => err instanceof BuildRequestError && /"main" \(\.devcontainer\.json, \.devcontainer\/Main\/devcontainer\.json\)/.test(err.message), - ); -}); - -test("discovery: a folder named main without a root config is just main", async () => { - assert.deepEqual(await discoverConfigs(repoWith(".devcontainer/main/devcontainer.json")), [ - { id: "main", configPath: ".devcontainer/main/devcontainer.json" }, - ]); -}); - -test("discovery: a folder name with no usable characters is a 400", async () => { - await assert.rejects( - discoverConfigs(repoWith(".devcontainer/___/devcontainer.json")), - (err: Error) => err instanceof BuildRequestError && err.message.includes(".devcontainer/___"), - ); -}); - -const items = [ - { id: "main", configPath: ".devcontainer/devcontainer.json" }, - { id: "backend", configPath: ".devcontainer/backend/devcontainer.json" }, - { id: "frontend", configPath: ".devcontainer/frontend/devcontainer.json" }, -]; - -test("instances filter: omitted or null selects everything", () => { - assert.deepEqual(selectInstances(items, undefined), items); - assert.deepEqual(selectInstances(items, null), items); -}); - -test("instances filter: keeps discovery order, ignores duplicates", () => { - assert.deepEqual( - selectInstances(items, ["frontend", "main", "frontend"]).map((i) => i.id), - ["main", "frontend"], - ); -}); - -test("instances filter: an unknown id is a 400 listing the valid ones", () => { - assert.throws( - () => selectInstances(items, ["backend", "nope"]), - (err: Error) => - err instanceof BuildRequestError && err.message === 'unknown instance id(s) "nope" - this repository has: "main", "backend", "frontend"', - ); -}); - -test("image names: main keeps the name, other items get -", () => { - assert.equal(instanceImageName("my-repo", "main"), "my-repo"); - assert.equal(instanceImageName("my-repo", "backend"), "my-repo-backend"); - assert.equal(instanceImageName("team/custom", "back-end"), "team/custom-back-end"); -}); diff --git a/service/src/config-discovery.ts b/service/src/config-discovery.ts deleted file mode 100644 index 64f1fe8..0000000 --- a/service/src/config-discovery.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { lstat, readdir, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { BuildRequestError } from "./errors.js"; - -// Every devcontainer.json a cloned repository has, as the list of images one -// `POST /build` produces (ADR-0016). The containers.dev spec allows three -// locations: `.devcontainer/devcontainer.json`, `.devcontainer.json`, and -// `.devcontainer//devcontainer.json`. The first two are "the" root -// config - at most one counts, `.devcontainer/devcontainer.json` first, the -// Dev Containers CLI's own order - and become the `main` item. Each -// sub-folder config is an item of its own, its id derived from the folder. - -export const MAIN_INSTANCE_ID = "main"; - -export interface DiscoveredConfig { - /** `main` for the root config, else the sanitized folder name. */ - id: string; - /** Repository-relative, `/`-separated. */ - configPath: string; -} - -const ROOT_CONFIG_PATHS = [".devcontainer/devcontainer.json", ".devcontainer.json"]; - -// Whether the Dev Containers CLI finds this config on its own (without -// `--config`). -export function isRootConfigPath(configPath: string): boolean { - return ROOT_CONFIG_PATHS.includes(configPath); -} - -// A folder name as an item id: lower-cased, anything outside [a-z0-9-] → `-`, -// leading/trailing dashes dropped. The id ends up in an image name -// (`-`, where a trailing dash isn't valid OCI) and, in the Coder -// template, in Kubernetes object names and the agent name (DNS labels, -// which must start and end alphanumeric). Empty when nothing usable is left. -export function instanceIdFor(folder: string): string { - return folder - .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") - .replace(/^-+|-+$/g, ""); -} - -async function isFile(path: string, followSymlinks: boolean): Promise { - try { - return (await (followSymlinks ? stat(path) : lstat(path))).isFile(); - } catch { - return false; - } -} - -// Root configs are read the way the CLI reads them (symlinks followed, as -// before). Sub-folders are enumerated without following symlinks - a -// symlinked folder or file in an untrusted clone could otherwise point the -// build at something outside the repository. -export async function discoverConfigs(repoDir: string): Promise { - const items: DiscoveredConfig[] = []; - for (const configPath of ROOT_CONFIG_PATHS) { - if (await isFile(join(repoDir, configPath), true)) { - items.push({ id: MAIN_INSTANCE_ID, configPath }); - break; - } - } - - let entries: import("node:fs").Dirent[] = []; - try { - entries = await readdir(join(repoDir, ".devcontainer"), { withFileTypes: true }); - } catch { - // no .devcontainer directory (or it's a file): no sub-folder configs - } - - const folders: { folder: string; id: string; configPath: string }[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const configPath = `.devcontainer/${entry.name}/devcontainer.json`; - if (!(await isFile(join(repoDir, configPath), false))) continue; - folders.push({ folder: entry.name, id: instanceIdFor(entry.name), configPath }); - } - - const unusable = folders.filter((f) => f.id === ""); - if (unusable.length > 0) { - throw new BuildRequestError( - `devcontainer.json sub-folder name(s) leave no usable id (need at least one of [a-z0-9]): ${unusable.map((f) => `.devcontainer/${f.folder}`).join(", ")}`, - ); - } - - const byId = new Map(); - if (items.length > 0) byId.set(MAIN_INSTANCE_ID, [items[0]!.configPath]); - for (const f of folders) byId.set(f.id, [...(byId.get(f.id) ?? []), f.configPath]); - const collisions = [...byId].filter(([, paths]) => paths.length > 1); - if (collisions.length > 0) { - throw new BuildRequestError( - `devcontainer.json locations map to the same instance id: ${collisions.map(([id, paths]) => `"${id}" (${paths.join(", ")})`).join("; ")} - rename a folder`, - ); - } - - folders.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); - return [...items, ...folders.map(({ id, configPath }) => ({ id, configPath }))]; -} - -// The items a request asked for, in discovery order. No filter (or null): -// all of them. -export function selectInstances(items: DiscoveredConfig[], filter: string[] | null | undefined): DiscoveredConfig[] { - if (filter === undefined || filter === null) return items; - const valid = new Set(items.map((i) => i.id)); - const unknown = [...new Set(filter)].filter((id) => !valid.has(id)); - if (unknown.length > 0) { - throw new BuildRequestError( - `unknown instance id(s) ${unknown.map((id) => `"${id}"`).join(", ")} - this repository has: ${items.map((i) => `"${i.id}"`).join(", ")}`, - ); - } - const wanted = new Set(filter); - return items.filter((i) => wanted.has(i.id)); -} - -// `main` keeps the name a single-config repository always had; every other -// item gets `-` appended, whether the name was derived or given. -export function instanceImageName(name: string, id: string): string { - return id === MAIN_INSTANCE_ID ? name : `${name}-${id}`; -} diff --git a/service/src/schemas.ts b/service/src/schemas.ts index 5f5a9f6..187940e 100644 --- a/service/src/schemas.ts +++ b/service/src/schemas.ts @@ -116,18 +116,6 @@ export const BuildRequestSchema = Type.Object( }), ), buildOptions: Type.Optional(BuildOptionsSchema), - instances: Type.Optional( - Type.Union([Type.Null(), Type.Array(Type.String({ minLength: 1 }), { minItems: 1 })], { - description: - "Build only these items of the repository's `images` list, by `id` (`main` for the root devcontainer.json, else the sub-folder's id). Omitted or null: every devcontainer.json found. Non-empty if given. An id the repository doesn't have is rejected with 400, listing the ids it does have. The response lists the selected items in discovery order, not in this order.", - }), - ), - dryRun: Type.Optional( - Type.Boolean({ - description: - "Clone, find the devcontainer.json files and resolve every item's image reference, but build and push nothing - the response has the same shape, without `imageBuildLogId`. For a caller that needs the list (e.g. Terraform at plan time) before building it. The same 400s apply.", - }), - ), }, { description: @@ -157,32 +145,9 @@ export const BuildRequestSchema = Type.Object( ); export type BuildRequestBody = Static; -export const BuildImageSchema = Type.Object({ - id: Type.String({ - description: - "`main` for the root config (`.devcontainer/devcontainer.json`, else `.devcontainer.json`, the Dev Containers CLI's order); for `.devcontainer//devcontainer.json`, the folder name lower-cased, every character outside `[a-z0-9-]` replaced by `-`, leading/trailing `-` dropped. Unique within a response.", - }), - configPath: Type.String({ - description: - "The devcontainer.json this image is built from, relative to the repository root. `.devcontainer/devcontainer.json` for a fallback-image build (the repository has no config; that file exists only in the service's scratch clone).", - }), - image: Type.String({ description: "`/:`." }), - registry: Type.String(), - name: Type.String({ - description: "`main`: the request's `image.name`, else the derived default. Any other item: that same name with `-` appended.", - }), - tag: Type.String({ description: "The same for every item." }), - imageBuildLogId: Type.Optional( - Type.String({ description: "This item's `devcontainer build --push` output, via `GET /logs/{id}`. Absent in a `dryRun`." }), - ), -}); - export const BuildResponseSchema = Type.Object( { - image: Type.String({ - description: - "`/:`, the same value pushed. This and `registry`/`name`/`tag`/`imageBuildLogId` describe `images[0]`: the `main` item when the repository has a root devcontainer.json (and `instances` didn't leave it out), else the first sub-folder item.", - }), + image: Type.String({ description: "`/:`, the same value pushed." }), registry: Type.String({ description: "Decomposed from `image` - kept separate since re-parsing it generically is ambiguous (registry ports, default-registry conventions, tag-vs-digest forms)." }), name: Type.String(), tag: Type.String(), @@ -190,51 +155,10 @@ export const BuildResponseSchema = Type.Object( commit: Type.String({ description: "Full SHA of the commit the image was built from - check this out to get the working copy that matches the image." }), gitCloneLogId: Type.Optional(Type.String({ description: "Fetch the full `git clone` output via `GET /logs/{id}`." })), imageBuildLogId: Type.Optional( - Type.String({ - description: "Fetch the full `devcontainer build --push` output (and remote builder setup) via `GET /logs/{id}`. Absent in a `dryRun`.", - }), + Type.String({ description: "Fetch the full `devcontainer build --push` output (and remote builder setup) via `GET /logs/{id}`." }), ), - images: Type.Array(BuildImageSchema, { - minItems: 1, - description: - "Every image this request built (or, with `dryRun`, would build): one per devcontainer.json found, filtered by `instances` - `main` first, then the sub-folder items sorted by `id`. Built one after another; if one fails, the request fails (500) and its error names the images already pushed. See [ADR-0016](https://github.com/DeepSpaceCartel/devcontainer-builder/blob/main/docs/decisions/0016-one-image-per-devcontainer-json.md).", - }), - }, - { - description: "The clone and every build+push succeeded (with `dryRun`: the clone and name resolution did).", - examples: [ - { - image: "ghcr.io/deepspacecartel/devcontainer-builder-examples:sha-a1b2c3d", - registry: "ghcr.io/deepspacecartel", - name: "devcontainer-builder-examples", - tag: "sha-a1b2c3d", - branch: "main", - commit: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", - gitCloneLogId: "git-0b0e4d2c-6f1a-4c55-9a4e-3f2b1c0d9e8f", - imageBuildLogId: "docker-5d6e7f80-1a2b-4c3d-8e9f-0a1b2c3d4e5f", - images: [ - { - id: "main", - configPath: ".devcontainer/devcontainer.json", - image: "ghcr.io/deepspacecartel/devcontainer-builder-examples:sha-a1b2c3d", - registry: "ghcr.io/deepspacecartel", - name: "devcontainer-builder-examples", - tag: "sha-a1b2c3d", - imageBuildLogId: "docker-5d6e7f80-1a2b-4c3d-8e9f-0a1b2c3d4e5f", - }, - { - id: "backend", - configPath: ".devcontainer/backend/devcontainer.json", - image: "ghcr.io/deepspacecartel/devcontainer-builder-examples-backend:sha-a1b2c3d", - registry: "ghcr.io/deepspacecartel", - name: "devcontainer-builder-examples-backend", - tag: "sha-a1b2c3d", - imageBuildLogId: "docker-9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", - }, - ], - }, - ], }, + { description: "The clone and build+push both succeeded." }, ); // Fastify lower-cases header names before schema validation, so these keys diff --git a/service/src/server.ts b/service/src/server.ts index 81b7648..5de9202 100644 --- a/service/src/server.ts +++ b/service/src/server.ts @@ -337,7 +337,7 @@ export async function buildApp(): Promise { "Prometheus text-format metrics (`Content-Type: text/plain`), for a `ServiceMonitor`/node-exporter-style scrape.", "Node.js process/runtime defaults (`prom-client`'s `collectDefaultMetrics`) plus:", "", - "- **`devcontainer_builder_builds_total`** (counter, label `status`: `success`|`dry_run`|`failure`|`invalid_request`|`busy`) - every `POST /build` attempt (`dry_run`: a successful `dryRun: true` request).", + "- **`devcontainer_builder_builds_total`** (counter, label `status`: `success`|`failure`|`invalid_request`|`busy`) - every `POST /build` attempt.", "- **`devcontainer_builder_build_duration_seconds`** (histogram, label `status`) - real build wall-clock time, bucketed toward minutes (not the sub-second defaults), since a build is a clone + image build + push.", "- **`devcontainer_builder_image_checks_total`** (counter, label `result`: `exists`|`absent`|`error`) - every `GET /image` call.", "- **`devcontainer_builder_image_deletes_total`** (counter, label `result`: `deleted`|`unsupported`|`error`) - every `DELETE /image` call.", @@ -368,11 +368,6 @@ export async function buildApp(): Promise { '- `{"error": "unable to derive an image name from repository path ...: provide image.name"}`.', '- `{"error": "no registry resolved for repository : provide image.registry or configure a matching registry mapping rule"}` - no `image.registry` given and no registry mapping rule matched.', '- `{"error": "SSH host key policy is \\"pinned\\" but no pinned key configured for host "}` - the resolved SSH credential has no `pinnedHostKey` under `sshHostKeyPolicy: pinned`.', - '- `{"error": "no devcontainer.json found in repository (...)"}` - the clone has no devcontainer.json at any location the spec allows, and the server has no `fallbackImage`.', - '- `{"error": "unknown instance id(s) \\"\\" - this repository has: \\"main\\", \\"\\", ..."}` - `instances` names an item the repository doesn\'t have.', - '- `{"error": "devcontainer.json locations map to the same instance id: ..."}` (or `"... leave no usable id ..."`) - two sub-folders (or a sub-folder named `main` and the root config) give the same `id`, or a sub-folder name has no `[a-z0-9]` in it.', - "", - "A repository can have several devcontainer.json files: the root one (`main`) and one per `.devcontainer//`. Each becomes its own image, built one after another and listed in `images` - see [ADR-0016](https://github.com/DeepSpaceCartel/devcontainer-builder/blob/main/docs/decisions/0016-one-image-per-devcontainer-json.md). `dryRun: true` returns that list without building it.", "", "", "`429` means the instance is already running `build.maxConcurrent` builds; retry after `Retry-After` seconds.", @@ -404,24 +399,19 @@ export async function buildApp(): Promise { "image.registry": request.body.image?.registry, "image.name": request.body.image?.name, "image.tag": request.body.image?.tag, - instances: request.body.instances ?? undefined, - dryRun: request.body.dryRun, }); const stopTimer = buildDurationSeconds.startTimer(); activeBuilds++; try { const result = await buildDevcontainer(request.body); - const status = request.body.dryRun ? "dry_run" : "success"; - buildsTotal.inc({ status }); - stopTimer({ status }); + buildsTotal.inc({ status: "success" }); + stopTimer({ status: "success" }); request.log.info({ event: "build.completed", "image.registry": result.registry, "image.name": result.name, "image.tag": result.tag, - instances: result.images.map((i) => i.id), - dryRun: request.body.dryRun, branch: result.branch, }); return result; diff --git a/service/src/types.ts b/service/src/types.ts index 64e5afc..12f2ebd 100644 --- a/service/src/types.ts +++ b/service/src/types.ts @@ -34,25 +34,8 @@ export interface BuildRequest { registryCredentials?: RegistryCredentials; platforms?: string[]; buildOptions?: BuildOptions; - /** Build only these items (ids from the response's `images[].id`); omitted or null: all. */ - instances?: string[] | null; - /** Clone, discover and name only - no build, no push. */ - dryRun?: boolean; } -// One devcontainer.json's image (ADR-0016). -export interface BuildImage { - id: string; - configPath: string; - image: string; - registry: string; - name: string; - tag: string; - imageBuildLogId?: string; -} - -// The top-level image/registry/name/tag/imageBuildLogId describe images[0], -// the shape every 1.x caller already reads. export interface BuildResponse { image: string; registry: string; @@ -62,7 +45,6 @@ export interface BuildResponse { commit: string; gitCloneLogId?: string; imageBuildLogId?: string; - images: BuildImage[]; } export interface ImageExistsResponse {