Status: Draft for review
Date: 2026-05-06
Scope: the feature pipeline added in M3 — reference resolution
(OCI/HTTPS/Local), the FeatureStore interface, option processing, DAG
ordering, generated feature-dockerfile structure, the
devcontainer.metadata image label (write + read + pre-baked skip),
and the on-disk cache.
Companion to design/resolved-config.md (where ResolvedFeature is
typed) and design/runtime.md (where BuildImage lives). This doc owns
the spec-rich middle layer between them.
devcontainer.json (features map)
│
▼
┌─────────────────────────────────────────┐
│ feature.Resolve(refs, options) │
│ - process each ref → fetch → unpack │
│ - parse devcontainer-feature.json │
│ - apply user options + defaults │
│ - return []ResolvedFeature │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ feature.Order(features, override) │
│ - DAG: dependsOn + installsAfter │
│ - topo sort │
│ - cycle detection │
│ - applies overrideFeatureInstallOrder │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ feature.GenerateDockerfile( │
│ baseImage, orderedFeatures) │
│ - emit feature dockerfile + context │
│ - include devcontainer.metadata LABEL │
│ - returns BuildSpec for runtime │
└─────────────────────────────────────────┘
│
▼
runtime.BuildImage (Docker / future buildkit)
│
▼
ResolvedConfig is then "feature-aware":
- Features[i].AlreadyInstalled = true if base image label
declared the feature was already baked in
- the engine skips OCI fetch + dockerfile-gen entirely for
those features (the pre-baked-image hot path)
Three reference types, derived from the feature key in devcontainer.json:
| Form | Kind | Cache key |
|---|---|---|
ghcr.io/devcontainers/features/node:1 |
FeatureSourceOCI |
resolved digest |
https://example.com/feature.tgz |
FeatureSourceHTTPS |
sha256 of body |
./local-features/myfeature or ../path |
FeatureSourceLocal |
absolute path (no copy) |
Resolution returns an extracted directory containing the feature's
devcontainer-feature.json and install.sh. Local refs return their
own path; OCI/HTTPS extract into the cache (see §8).
OCI mechanics: parse ref via go-containerregistry/pkg/name, fetch
via remote.Image with default keychain (auth.NewMultiKeychain over
ambient docker creds), require manifest config media type
application/vnd.devcontainers, download the first layer, extract.
Resolved digest is recorded on ResolvedFeature.ResolvedRef so an image
rebuild months later uses the exact same bytes even if the tag has moved.
HTTPS mechanics: GET the URL, validate filename matches
^devcontainer-feature-[A-Za-z0-9_-]+\.tgz$, stream body to disk, extract.
Cache key is the sha256 of the response body so the same content at two
URLs dedupes. Custom headers (auth) supplied by callers via
EngineOptions.FeatureDownloadHeaders map[string]string.
Security posture for the HTTPS client:
- TLS certificates validated against system root CAs;
EngineOptions.HTTPSRootCAs *x509.CertPoollets callers add private CAs for internal mirrors.InsecureSkipVerifyis never set. - Redirects: follow up to 5 hops; reject
http://targets even on redirect. Go'snet/httpstripsAuthorization/Cookie/Proxy-Authorizationon cross-host redirects but forwards arbitrary custom headers unchanged — so we install aCheckRedirectthat drops every caller-suppliedFeatureDownloadHeadersentry when the redirect crosses hosts. Callers needing cross-host auth pass an explicit host allowlist viaEngineOptions.FeatureDownloadHeaderHosts. - Response size cap: 100 MB by default (
EngineOptions.FeatureDownloadMaxBytes). Body is streamed with aLimitReader; overflow returns*FeatureTooLargeError. - Timeouts: 30s connect, 5min overall (
EngineOptions.FeatureDownloadTimeout). No retry — features are large enough that retries mostly mask deeper problems; callers can re-run. - All network failures surface as
*FeatureFetchError{Ref, Cause}; the cache write is atomic (extract to tmp dir, rename on success) so a partial download never poisons the cache.
Local mechanics: filepath.Abs(filepath.Join(configDir, ref)). No
caching (the directory is the cache).
The pluggable boundary between resolution and dockerfile generation.
package feature
type Store interface {
// Resolve fetches one feature reference and returns the path to a
// directory containing devcontainer-feature.json + install.sh.
// Pulled artifacts are cached; repeat calls for the same ref are
// serviced from disk.
Resolve(ctx context.Context, ref string) (*Resolved, error)
}
type Resolved struct {
Ref string // as written in devcontainer.json
ResolvedRef string // pinned (digest for OCI, sha256 for HTTPS, abs path for Local)
Dir string // extracted directory on disk
Metadata config.FeatureMetadata
SourceKind config.FeatureSourceKind
}Default impl: feature.NewDiskStore(dir, headers). In-memory impl for
tests: feature.NewMemoryStore(map[string]Resolved) so unit tests don't
hit a registry.
Per-feature options merged in three layers: feature defaults
(from devcontainer-feature.json) → user values (from
devcontainer.json's features map) → final.
User value forms (per spec):
"feature": {}→ all defaults"feature": { "version": "1.2.3" }→ object merge"feature": "1.2.3"→ shorthand: applied as theversionoption if the feature declares it
After merge, options are emitted to a per-feature devcontainer-features.env
file as UPPERCASED_KEY="value", sorted lexicographically. Key transform:
- non-word chars →
_ - leading digit → prefix with
_ - uppercase
We validate enum/proposals at fetch time — specifically, inside
feature.Store.Resolve() once devcontainer-feature.json has been
fetched and Metadata is populated (see §10 decision #5 for the
two-layer Resolve split). Devpod defers this to install.sh; rejecting
typos before image build is worth the small duplication.
Engine.Up performs the same check as a backstop for any feature whose
metadata wasn't resolved earlier (e.g. injected mutations). On the
pre-baked-image hot path, features marked AlreadyInstalled from the
base image's devcontainer.metadata label skip resolution entirely;
their options were validated when the image was originally built, so
re-validating without remote metadata would force unnecessary fetches.
Ordered []ResolvedFeature per spec rules:
- Features named in
overrideFeatureInstallOrderfirst, in declaration order. (Hard override; not topo-sorted.) - Remaining features: topo-sorted via leaf-extraction over a graph built
from
installsAfteranddependsOn.
dependsOn is recursive (transitively pulls in unlisted features);
installsAfter is a soft "if both listed, this one waits" hint.
Cycle detection at edge insertion: DFS for back-edge before adding.
Returns a typed *FeatureCycleError{Path []string} so callers can show
the cycle to users.
Implementation: a small graph package internal to feature (~150 LOC).
We do not vendor or copy devpod's pkg/devcontainer/graph — same
algorithm, our own implementation.
The dockerfile prepended to the base image when features are present:
# syntax=docker/dockerfile:1.4
ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
FROM $_DEV_CONTAINERS_BASE_IMAGE AS devcontainer_target
USER root
COPY ./build-context/ /tmp/dc-features/
RUN chmod -R 0755 /tmp/dc-features
ARG _DEV_CONTAINERS_IMAGE_USER=root
# --- per-feature layers (in install order) ---
RUN \
echo "_CONTAINER_USER_HOME=$(getent passwd root | cut -d: -f6)" \
>> /tmp/dc-features/builtin.env && \
echo "_REMOTE_USER_HOME=$(getent passwd ${_DEV_CONTAINERS_IMAGE_USER:-root} | cut -d: -f6)" \
>> /tmp/dc-features/builtin.env
# Feature 0: ghcr.io/devcontainers/features/node:1
ENV NODE_VERSION="lts" NVM_VERSION="0.39.5"
RUN cd /tmp/dc-features/0 \
&& chmod +x ./run.sh && ./run.sh
# Feature 1: ghcr.io/devcontainers/features/git:1
RUN cd /tmp/dc-features/1 \
&& chmod +x ./run.sh && ./run.sh
# --- accumulated metadata ---
LABEL devcontainer.metadata='[<merged JSON, see §7>]'
USER $_DEV_CONTAINERS_IMAGE_USERrun.sh is a small wrapper we generate per feature:
#!/bin/sh
set -e
trap 'rc=$?; [ $rc -ne 0 ] && echo "ERROR: feature \"FEATURE_REF\" failed: exit $rc" >&2' EXIT
set -a
. ../builtin.env
. ./feature.env
set +a
chmod +x ./install.sh
./install.shBuild context layout (passed as BuildSpec.ContextPath):
<engine-managed tmp dir>/
Dockerfile # the file above
build-context/
builtin.env # _CONTAINER_USER_HOME etc. (filled at RUN time)
0/ # first feature in install order
run.sh # generated wrapper
feature.env # UPPERCASED_KEY="value" lines
install.sh # from the feature's tarball
... # everything else from the feature dir
1/
...
run.sh (vs devpod's devcontainer-features-install.sh) and
feature.env (vs devcontainer-features.env) are intentionally shorter
filenames — easier to type when debugging from an exec shell.
The cornerstone of incremental rebuilds and the pre-baked-image hot path that downstream consumers exercise.
Format: a JSON array on the image. Each element is a per-source layer of config — base image's prior label (carried through), one entry per feature, and the resolved devcontainer.json itself. Order matches build order; merge semantics are "later overrides earlier" for scalars, "concat + dedupe" for lists.
[
{ "remoteUser": "node",
"containerEnv": { "PATH": "/opt/node/bin:..." } },
{ "id": "ghcr.io/devcontainers/features/node",
"version": "1.5.0",
"containerEnv": { "NODE_OPTIONS": "..." },
"mounts": [...] },
{ "id": "ghcr.io/devcontainers/features/git", "version": "1.2.0" },
{ "remoteUser": "node",
"customizations": { "dap": {...} } }
]Write path: assembled during dockerfile generation, marshalled,
emitted as the final LABEL devcontainer.metadata=... instruction.
Read path: when Engine.Up starts, we Inspect the base image (the
one named in devcontainer.json's image field, or the result of
build) and parse its devcontainer.metadata label. For each feature in
the user's request, we check the array for a matching id + version-
compatible entry; if present, mark ResolvedFeature.AlreadyInstalled = true and skip OCI fetch + dockerfile-gen entirely.
This is the observation that makes the consumer hot path cheap: a base image is pre-built with all features installed; every workspace start is a label read + run, with zero feature work.
Version compatibility: match by id, then require the cached version
to be >= the requested. (Spec is silent on this; we adopt
permissive-newer-wins as the friendliest default. Caller can opt into
strict pinning via EngineOptions.StrictFeatureVersionMatch: true.)
Divergence from devpod: we record the resolved digest on the
metadata entry, not just the version string. Lets StrictFeatureVersionMatch
do byte-level identity comparison without needing the registry to be
online. Devpod stores Version only; downstream tooling can't tell if
two builds tagged :1 actually have the same bytes.
os.UserCacheDir()/devcontainer-go/features/ (overridable via
EngineOptions.FeatureCacheDir):
features/
oci/
sha256-<digest>/
manifest.json # OCI manifest (for verification on cache hit)
blob.tgz # the artifact
extracted/
devcontainer-feature.json
install.sh
...
https/
sha256-<contenthash>/
blob.tgz
extracted/...
index.json # ref → digest map with TTL hints
Cache key is content-addressed: OCI digest or HTTPS body sha256. A
mutable tag (:1) resolves through index.json to the digest, then
hits the disk cache. This is intentionally different from devpod's
"hash-the-ref-string" scheme — content-addressed dedupes across mirror
URLs and prevents stale-ref bugs.
Concurrency: a small lockfile (extracted/.lock) per cache entry so
parallel Resolve calls for the same ref don't race.
GC: out of scope for v1. Document rm -rf $cache as the recovery path.
Suggested PR breakdown:
- PR6 — feature.Store + Local + DAG. Interface, in-memory store, on-disk cache shell (no OCI yet), local-path resolver, DAG with cycle detection. Unit tests on synthetic features (no network).
- PR7 — OCI + HTTPS resolvers. Pull
go-containerregistry. Real feature fetch, real tarball extract. Integration test pullsghcr.io/devcontainers/features/git:1(small, stable) and verifies resolution + caching. - PR8 — Build-source path + dockerfile-gen.
Engine.Upfor*BuildSourceworks (without features). Then layer feature dockerfile-gen on top. Integration test builds an image with one local feature, verifies install ran. - PR9 —
devcontainer.metadataread + skip-already-installed. Inspect base image label on Up, markAlreadyInstalledfeatures, skip dockerfile-gen for them. Integration test uses an image pre-built by PR8 and asserts a no-op rebuild.
After M3, build source is fully functional with features. Compose remains M4.
Resolved during feature design review (2026-05-06):
-
OCI auth: default keychain + caller hook.
authn.DefaultKeychainused unlessEngineOptions.OCIKeychain authn.Keychainis supplied. Hook lets a consumer plug in short-lived ECR token auth without writing creds to disk. -
Buildkit: prefer if available, fall back to classic. Probe via
Infoatdocker.New()and cachedaemonHasBuildkit. Classic fallback is fine for v1's generated dockerfile (no cache mounts); revisit when we add buildkit-only constructs. -
Version match: permissive-newer-wins by default; opt-in strict. Default: feature is "already installed" if
idmatches and baked version>=requested via semver. Opt-in:EngineOptions.StrictFeatureVersionMatch: truerequires byte-levelResolvedRef(digest) equality. Non-semver versions are always treated as strict-match-only — safer than guessing. -
DAG depth: warn at 16, error at 64. Two-tier guardrail in
feature.Order. Warning surfaces asWarnDeepFeatureChain; error surfaces as*FeatureDAGTooDeepError{Depth, Path}. -
Two layers of "Resolve";
Engine.Uporchestrates them. Two distinct operations share the name and should not be conflated:- Config-level
devcontainer.Resolveparses the features map, appliesoverrideFeatureInstallOrder, and merges user options with what's defaultable without fetching. It intentionally leaves eachResolvedFeature'sMetadata,Dir,ResolvedRefempty andAlreadyInstalledfalse. - Feature-level
feature.Store.Resolve(§3) performs the actual fetch for one ref and returns a*ResolvedwithMetadata,Dir, andResolvedRefpopulated.
Engine.Upreads the base image'sdevcontainer.metadatalabel, marksAlreadyInstalledfor matching features, and then callsfeature.Store.Resolveonly for the rest — preserving the consumer's pre-baked-image hot path. M1's stubbedWarnUnsupportedFeatureFieldwarning goes away in PR6. - Config-level