diff --git a/CHANGELOG.md b/CHANGELOG.md index 68adca8..1d86cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (mutually exclusive), named as in the reference CLI. - The file is byte-compatible with the CLI's, and a `.devcontainer.json` gets a `.devcontainer-lock.json`. Local features are not recorded. - - Features baked into the base image keep their existing entry. + - A feature baked into the base image is used only if the artifact its + `devcontainer.metadata` label entry records is the locked one (or, + with no pin, is recorded at all, and is then locked); otherwise it + is fetched at its pin and installed. `LockfileUpgrade` always + re-resolves baked features. The label's `resolvedRef` now + survives parsing and rebuilds on top of the image. + - Removing the last feature empties the lockfile; frozen mode reports + the stale entries. - Compose reattach does not consult the lockfile. - See design/features.md §8.2. @@ -45,6 +52,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 discovered `devcontainer.json`'s directory for local features and the compose working directory. Previously they always assumed `.devcontainer/`, which was wrong for a root `.devcontainer.json`. +- **runtime/docker** — a user Dockerfile that names a frontend with + `# syntax=` (or the `BUILDKIT_SYNTAX` build arg) no longer fails with + `failed to resolve source metadata for docker.io/docker/dockerfile:1: + no active sessions` on Docker Engine before 29.6. `BuildImage` builds + without a BuildKit session, and BuildKit before v0.31 refuses registry + access without one. The frontend image is now pre-pulled through the + classic pull API, as `FROM` images already were. The directive is read + the way BuildKit's parser reads it. (#148) +- **features** — features that another feature's `dependsOn` requires + are now installed even when `devcontainer.json` does not declare them, + as the spec requires. (#147) + - Previously `dependsOn` only ordered features that were declared + anyway. A feature that relied on an undeclared one installed without + it. + - Dependencies are fetched recursively, get their options validated + and defaults applied, and are locked and pinned in + `devcontainer-lock.json` under their `dependsOn` key, as the + reference CLI does. + - Duplicates are collapsed by spec equality: same content and same + options once defaults apply. One feature requested with different + options is installed once per set of options. + - Declared features that resolve to the same content with the same + options are now installed once rather than twice. + - Dependencies are always installed, even when the base image's label + lists them: the pre-baked match does not compare options yet + (#154). A pre-baked image whose features have dependencies + therefore reinstalls those on every build until then. + - A declared feature the base image carries is not reinstalled, but + its dependencies are. Image labels now record each feature's + `dependsOn`; for a label written before that, the baked feature's + metadata is fetched at its recorded artifact to find them. + - A dependency dropped as a duplicate of another reference is still + locked under its own key. + - A local (path) dependency is followed only from a local feature, + and only when it is a relative path that resolves, symlinks + included, inside the directory of `devcontainer.json`; otherwise + `Up`/`Build` fail. A published feature's metadata cannot make the + build copy and run a host directory. + - A local dependency's content is part of the compose inputs hash. + - See design/features.md §5.1. +- **features** — OCI feature references are lowercased before they are + parsed, as the reference CLI does. References with uppercase + characters, such as `ghcr.io/codspace/dependson/A:2`, failed to parse. ## [0.5.3] - 2026-10-28 diff --git a/README.md b/README.md index d3f1c54..9d0a80b 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,9 @@ field/behavior the library covers. Legend: ✅ acted on · ⚠️ parsed but not | Field | Status | Notes | | --- | --- | --- | | `features` (OCI / HTTPS / local) | ✅ | DAG ordering, options validation, content-addressed cache (local-first for digests and exact `X.Y.Z` versions; floating tags fall back to the last resolution when the registry is unreachable) | -| `dependsOn` / `installsAfter` / `overrideFeatureInstallOrder` | ✅ | | +| `dependsOn` / `installsAfter` / `overrideFeatureInstallOrder` | ✅ | `dependsOn` features are installed whether declared or not, deduplicated by content and options per the spec | | Pre-baked-image short-circuit | ✅ | `devcontainer.metadata` label read on Up | -| `devcontainer-lock.json` | ✅ | Written and pinned by default (`UpOptions.Lockfile` / `BuildOptions.Lockfile`: frozen, upgrade, disabled), CLI-compatible format. Records declared features only: features reachable only through `dependsOn` are not installed | +| `devcontainer-lock.json` | ✅ | Written and pinned by default (`UpOptions.Lockfile` / `BuildOptions.Lockfile`: frozen, upgrade, disabled), CLI-compatible format, including features pulled in through `dependsOn` | **Lifecycle** diff --git a/config/resolve.go b/config/resolve.go index 0bc7363..ff44fae 100644 --- a/config/resolve.go +++ b/config/resolve.go @@ -227,7 +227,7 @@ func buildPartialFeatures(rawFeatures map[string]json.RawMessage, overrideOrder feature: ResolvedFeature{ Ref: ref, Options: opts, - SourceKind: classifyFeatureRef(ref), + SourceKind: ClassifyFeatureRef(ref), }, }) } @@ -289,12 +289,12 @@ func parseFeatureOptions(raw json.RawMessage, ref, source string) (map[string]an }} } -// classifyFeatureRef maps a features-map key to its source kind based -// on the ref shape. Matches the spec's rules: +// ClassifyFeatureRef maps a features-map or dependsOn key to its source +// kind based on the ref shape. Matches the spec's rules: // - "./..." or "../..." or absolute path → Local // - "https://..." → HTTPS // - everything else → OCI (the canonical "ghcr.io/..." form) -func classifyFeatureRef(ref string) FeatureSourceKind { +func ClassifyFeatureRef(ref string) FeatureSourceKind { switch { case strings.HasPrefix(ref, "./") || strings.HasPrefix(ref, "../") || strings.HasPrefix(ref, "/"): return FeatureSourceLocal diff --git a/config/resolved.go b/config/resolved.go index a5f91e1..a84d67f 100644 --- a/config/resolved.go +++ b/config/resolved.go @@ -248,7 +248,10 @@ const ( // // ID/Version/Name/Description/DocumentationURL/LicenseURL/Options/ // InstallsAfter/DependsOn are feature-only — they have no merge semantics -// and are ignored when the layer originates from a label entry. +// and are ignored when the layer originates from a label entry. A label +// entry's DependsOn says which dependencies the image was built with: +// non-nil (possibly empty) when the label records them, nil when it was +// written before they were recorded. type FeatureMetadata struct { ID string Version string @@ -257,6 +260,12 @@ type FeatureMetadata struct { DocumentationURL string LicenseURL string + // ResolvedRef is the pinned artifact a label entry records for the + // feature it installed (ResolvedFeature.ResolvedRef at build time). + // Label-only, and empty when the image was built by a tool that does + // not record it. + ResolvedRef string + Options map[string]FeatureOption // Mergeable surface (devpod parity). Empty / nil means "unset". diff --git a/depends_on_test.go b/depends_on_test.go new file mode 100644 index 0000000..ad84154 --- /dev/null +++ b/depends_on_test.go @@ -0,0 +1,462 @@ +package devcontainer + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/crunchloop/devcontainer/config" + "github.com/crunchloop/devcontainer/feature" + "github.com/crunchloop/devcontainer/runtime" +) + +// depStore is a feature.Store serving OCI features from a table keyed +// by repository. A tag resolves to the entry's digest; repo@digest is +// served when it is that digest. +type depStore struct { + dir string + features map[string]depEntry // repo → entry + fetched []string +} + +type depEntry struct { + digest string + meta config.FeatureMetadata +} + +func (s *depStore) Fetch(_ context.Context, ref string, _ config.FeatureSourceKind) (feature.Fetched, error) { + s.fetched = append(s.fetched, ref) + repo := ref + if i := strings.LastIndex(repo, "@"); i >= 0 { + repo = repo[:i] + } else if i := strings.LastIndex(repo, ":"); i > strings.LastIndex(repo, "/") { + repo = repo[:i] + } + e, ok := s.features[repo] + if !ok { + return feature.Fetched{}, errors.New("not found: " + ref) + } + return feature.Fetched{Dir: s.dir, ResolvedRef: repo + "@" + e.digest, Metadata: e.meta}, nil +} + +func digestOf(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) } + +// meta returns feature metadata with id, a "flavor" option defaulting +// to "plain", and the given dependsOn. +func meta(id string, dependsOn map[string]map[string]any) config.FeatureMetadata { + return config.FeatureMetadata{ + ID: id, + Version: "1.0.0", + Options: map[string]config.FeatureOption{"flavor": {Type: "string", Default: "plain"}}, + DependsOn: dependsOn, + } +} + +type depFixture struct { + t *testing.T + eng *Engine + rt *fakeRuntime + store *depStore + ws string +} + +func newDepFixture(t *testing.T, features string, table map[string]depEntry) *depFixture { + t.Helper() + ws := writeImageDevcontainer(t, `{"image":"alpine:3.20","features":`+features+`}`) + rt := newFakeRuntime() + store := &depStore{dir: t.TempDir(), features: table} + eng, err := New(EngineOptions{Runtime: rt, FeatureStore: store}) + if err != nil { + t.Fatal(err) + } + return &depFixture{t: t, eng: eng, rt: rt, store: store, ws: ws} +} + +// layer resolves the workspace and runs layerFeatures, returning the +// config it fetched and ordered features into. The fake runtime cannot +// build, so a run that got that far ends in ErrNotImplemented, which is +// swallowed. +func (f *depFixture) layer(mode LockfileMode) (*config.ResolvedConfig, error) { + f.t.Helper() + configPath := filepath.Join(f.ws, ".devcontainer", "devcontainer.json") + cfg, err := Resolve(context.Background(), ResolveOptions{LocalWorkspaceFolder: f.ws, ConfigPath: configPath}) + if err != nil { + f.t.Fatal(err) + } + opts := UpOptions{LocalWorkspaceFolder: f.ws, ConfigPath: configPath, Lockfile: mode} + opts.bus = newEventBus(f.eng.emitter, nil) + defer opts.bus.Close() + _, _, err = f.eng.layerFeatures(context.Background(), cfg, "alpine:3.20", opts) + if errors.Is(err, runtime.ErrNotImplemented) { + err = nil + } + return cfg, err +} + +// installed renders cfg.Features in install order as ref or +// ref{flavor} when installed with a non-default flavor; skipped ones +// are suffixed "(baked)". +func installed(cfg *config.ResolvedConfig) []string { + var out []string + for _, f := range cfg.Features { + s := f.Ref + if fl, _ := f.Options["flavor"].(string); fl != "" && fl != "plain" { + s += "{" + fl + "}" + } + if f.AlreadyInstalled { + s += "(baked)" + } + out = append(out, s) + } + return out +} + +func TestDependsOn_PullsInChainInDependencyOrder(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/a:1":{}}`, map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"ghcr.io/x/b:1": {}})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", map[string]map[string]any{"ghcr.io/x/c:1": {}})}, + "ghcr.io/x/c": {digestOf('c'), meta("c", nil)}, + }) + cfg, err := f.layer(LockfileDefault) + if err != nil { + t.Fatal(err) + } + if got, want := installed(cfg), []string{"ghcr.io/x/c:1", "ghcr.io/x/b:1", "ghcr.io/x/a:1"}; !reflect.DeepEqual(got, want) { + t.Errorf("installed %v, want %v", got, want) + } + // Pulled-in features get spec defaults like declared ones. + if fl := cfg.Features[0].Options["flavor"]; fl != "plain" { + t.Errorf("pulled-in feature options = %v, want defaults applied", cfg.Features[0].Options) + } +} + +func TestDependsOn_DiamondInstallsSharedDependencyOnce(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/a:1":{}}`, map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"ghcr.io/x/b:1": {}, "ghcr.io/x/c:1": {}})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", map[string]map[string]any{"ghcr.io/x/d:1": {}})}, + "ghcr.io/x/c": {digestOf('c'), meta("c", map[string]map[string]any{"ghcr.io/x/d:1": {}})}, + "ghcr.io/x/d": {digestOf('d'), meta("d", nil)}, + }) + cfg, err := f.layer(LockfileDefault) + if err != nil { + t.Fatal(err) + } + if got, want := installed(cfg), []string{"ghcr.io/x/d:1", "ghcr.io/x/b:1", "ghcr.io/x/c:1", "ghcr.io/x/a:1"}; !reflect.DeepEqual(got, want) { + t.Errorf("installed %v, want %v", got, want) + } + if n := strings.Count(strings.Join(f.store.fetched, " "), "ghcr.io/x/d"); n != 1 { + t.Errorf("fetched d %d times: %v", n, f.store.fetched) + } +} + +// TestDependsOn_DeclaredFeatureSatisfiesDependency covers spec +// equality: a dependency is a declared feature when both are the same +// content with the same options once defaults apply, however the +// reference is spelled, and a different feature (installed again) +// when the options differ. +func TestDependsOn_DeclaredFeatureSatisfiesDependency(t *testing.T) { + table := func(depOpts map[string]any, depKey string) map[string]depEntry { + return map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{depKey: depOpts})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", nil)}, + } + } + for _, tc := range []struct { + name string + depKey string + depOpts map[string]any + want []string + }{ + {"same ref and options", "ghcr.io/x/b:1", map[string]any{}, + []string{"ghcr.io/x/b:1", "ghcr.io/x/a:1"}}, + {"explicit default", "ghcr.io/x/b:1", map[string]any{"flavor": "plain"}, + []string{"ghcr.io/x/b:1", "ghcr.io/x/a:1"}}, + {"other tag, same digest", "ghcr.io/x/b:1.0", map[string]any{}, + []string{"ghcr.io/x/b:1", "ghcr.io/x/a:1"}}, + {"different options", "ghcr.io/x/b:1", map[string]any{"flavor": "spicy"}, + []string{"ghcr.io/x/b:1", "ghcr.io/x/b:1{spicy}", "ghcr.io/x/a:1"}}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/a:1":{},"ghcr.io/x/b:1":{}}`, table(tc.depOpts, tc.depKey)) + cfg, err := f.layer(LockfileDefault) + if err != nil { + t.Fatal(err) + } + if got := installed(cfg); !reflect.DeepEqual(got, tc.want) { + t.Errorf("installed %v, want %v", got, tc.want) + } + }) + } +} + +func TestDependsOn_CycleThroughPulledInFeature(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/a:1":{}}`, map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"ghcr.io/x/b:1": {}})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", map[string]map[string]any{"ghcr.io/x/a:1": {}})}, + }) + _, err := f.layer(LockfileDefault) + var ce *feature.FeatureCycleError + if !errors.As(err, &ce) { + t.Fatalf("got %v, want *FeatureCycleError", err) + } + if n := strings.Count(strings.Join(f.store.fetched, " "), "ghcr.io/x/a"); n != 1 { + t.Errorf("fetched a %d times: %v", n, f.store.fetched) + } +} + +func TestDependsOn_PulledInFeaturesAreLocked(t *testing.T) { + table := map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"ghcr.io/x/b:1": {}})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", nil)}, + } + f := newDepFixture(t, `{"ghcr.io/x/a:1":{}}`, table) + if _, err := f.layer(LockfileDefault); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(f.ws, ".devcontainer", "devcontainer-lock.json") + lf, err := feature.ReadLockfile(lockPath) + if err != nil { + t.Fatal(err) + } + want := &feature.Lockfile{Features: map[string]feature.LockedFeature{ + "ghcr.io/x/a:1": {Version: "1.0.0", Resolved: "ghcr.io/x/a@" + digestOf('a'), Integrity: digestOf('a'), DependsOn: []string{"ghcr.io/x/b:1"}}, + "ghcr.io/x/b:1": {Version: "1.0.0", Resolved: "ghcr.io/x/b@" + digestOf('b'), Integrity: digestOf('b')}, + }} + if !lf.Equal(want) { + t.Errorf("lockfile differs in %v", lf.Changed(want)) + } + + // The pulled-in feature is pinned like a declared one. + f.store.fetched = nil + if _, err := f.layer(LockfileFrozen); err != nil { + t.Fatal(err) + } + if want := []string{"ghcr.io/x/a@" + digestOf('a'), "ghcr.io/x/b@" + digestOf('b')}; !reflect.DeepEqual(f.store.fetched, want) { + t.Errorf("frozen fetched %v, want %v", f.store.fetched, want) + } + + // A lockfile without the pulled-in feature is out of date. + delete(lf.Features, "ghcr.io/x/b:1") + if err := feature.WriteLockfile(lockPath, lf); err != nil { + t.Fatal(err) + } + var me *feature.LockfileMismatchError + if _, err := f.layer(LockfileFrozen); !errors.As(err, &me) || !reflect.DeepEqual(me.Changed, []string{"ghcr.io/x/b:1"}) { + t.Fatalf("got %v, want *LockfileMismatchError naming ghcr.io/x/b:1", err) + } +} + +// TestDependsOn_BakedDependency: a dependency is fetched and installed +// even when the base image's label lists it, since the label match does +// not compare the options its dependsOn entry asks for (#154). A +// declared feature the image carries is not installed again, but its +// dependencies are: named by a label that records them, or for a label +// written before that, by its metadata read at its recorded artifact. +func TestDependsOn_BakedDependency(t *testing.T) { + table := map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"ghcr.io/x/b:1": {}})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", map[string]map[string]any{"ghcr.io/x/c:1": {}})}, + "ghcr.io/x/c": {digestOf('c'), meta("c", nil)}, + } + bakedB := `{"id":"b","version":"1.0.0","resolvedRef":"ghcr.io/x/b@` + digestOf('b') + `"` + bakedC := `{"id":"c","version":"1.0.0","resolvedRef":"ghcr.io/x/c@` + digestOf('c') + `","dependsOn":{}}` + for _, tc := range []struct { + name, declared, label string + wantFetched, want []string + }{ + { + name: "baked dependency is installed anyway", + declared: `{"ghcr.io/x/a:1":{}}`, + label: `[` + bakedB + `,"dependsOn":{"ghcr.io/x/c:1":{}}},` + bakedC + `]`, + wantFetched: []string{"ghcr.io/x/a:1", "ghcr.io/x/b:1", "ghcr.io/x/c:1"}, + want: []string{"ghcr.io/x/c:1", "ghcr.io/x/b:1", "ghcr.io/x/a:1"}, + }, + { + name: "baked declared feature, label records its dependencies", + declared: `{"ghcr.io/x/b:1":{}}`, + label: `[` + bakedB + `,"dependsOn":{"ghcr.io/x/c:1":{}}},` + bakedC + `]`, + wantFetched: []string{"ghcr.io/x/c:1"}, + want: []string{"ghcr.io/x/c:1", "ghcr.io/x/b:1(baked)"}, + }, + { + name: "baked declared feature, label written before dependencies were recorded", + declared: `{"ghcr.io/x/b:1":{}}`, + label: `[` + bakedB + `}]`, + wantFetched: []string{"ghcr.io/x/b@" + digestOf('b'), "ghcr.io/x/c:1"}, + want: []string{"ghcr.io/x/c:1", "ghcr.io/x/b:1(baked)"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + f := newDepFixture(t, tc.declared, table) + f.rt.imagesByRef["alpine:3.20"] = &runtime.ImageDetails{Labels: map[string]string{feature.MetadataLabel: tc.label}} + cfg, err := f.layer(LockfileDefault) + if err != nil { + t.Fatal(err) + } + if got := installed(cfg); !reflect.DeepEqual(got, tc.want) { + t.Errorf("installed %v, want %v", got, tc.want) + } + if !reflect.DeepEqual(f.store.fetched, tc.wantFetched) { + t.Errorf("fetched %v, want %v", f.store.fetched, tc.wantFetched) + } + }) + } +} + +// TestDependsOn_DuplicateKeyIsLocked: a dependency dropped as the same +// content as a declared feature under another reference is still a +// requested key, so it is locked (to that content) and pinned next time. +func TestDependsOn_DuplicateKeyIsLocked(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/a:1":{},"ghcr.io/x/b:1":{}}`, map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"ghcr.io/x/b:1.0": {}})}, + "ghcr.io/x/b": {digestOf('b'), meta("b", nil)}, + }) + cfg, err := f.layer(LockfileDefault) + if err != nil { + t.Fatal(err) + } + if got, want := installed(cfg), []string{"ghcr.io/x/b:1", "ghcr.io/x/a:1"}; !reflect.DeepEqual(got, want) { + t.Errorf("installed %v, want %v", got, want) + } + lf, err := feature.ReadLockfile(filepath.Join(f.ws, ".devcontainer", "devcontainer-lock.json")) + if err != nil { + t.Fatal(err) + } + entry, ok := lf.Entry("ghcr.io/x/b:1.0") + if !ok || entry.Integrity != digestOf('b') { + t.Fatalf("lock entry for the dependency key = (%+v, %v), want integrity %s", entry, ok, digestOf('b')) + } + if _, err := f.layer(LockfileFrozen); err != nil { + t.Errorf("frozen rerun: %v", err) + } +} + +// TestDependsOn_LocalDependencyOfLocalFeature: a local feature's +// dependsOn on another local feature resolves relative to +// devcontainer.json, as a declared local feature does. +func TestDependsOn_LocalDependencyOfLocalFeature(t *testing.T) { + ws := writeImageDevcontainer(t, `{"image":"alpine:3.20","features":{"./a":{}}}`) + writeLocalFeature(t, filepath.Join(ws, ".devcontainer", "a"), `{"id":"a","version":"1.0.0","dependsOn":{"./b":{}}}`) + writeLocalFeature(t, filepath.Join(ws, ".devcontainer", "b"), `{"id":"b","version":"1.0.0"}`) + eng, err := New(EngineOptions{Runtime: newFakeRuntime()}) + if err != nil { + t.Fatal(err) + } + f := &depFixture{t: t, eng: eng, ws: ws} + cfg, err := f.layer(LockfileDisabled) + if err != nil { + t.Fatal(err) + } + if got, want := installed(cfg), []string{"./b", "./a"}; !reflect.DeepEqual(got, want) { + t.Errorf("installed %v, want %v", got, want) + } + if want := filepath.Join(ws, ".devcontainer", "b"); cfg.Features[0].Dir != want { + t.Errorf("./b resolved to %s, want %s", cfg.Features[0].Dir, want) + } +} + +func writeLocalFeature(t *testing.T, dir, metadata string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "devcontainer-feature.json"), []byte(metadata), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +// TestDependsOn_BakedFeatureLockedWithItsDependsOn: a baked feature the +// lockfile does not pin yet is locked from the artifact its label +// records, and its entry carries its dependsOn like a fetched one's: +// from the label, or for an older label from its metadata. +func TestDependsOn_BakedFeatureLockedWithItsDependsOn(t *testing.T) { + table := map[string]depEntry{ + "ghcr.io/x/b": {digestOf('b'), meta("b", map[string]map[string]any{"ghcr.io/x/c:1": {}})}, + "ghcr.io/x/c": {digestOf('c'), meta("c", nil)}, + } + bakedB := `{"id":"b","version":"1.0.0","resolvedRef":"ghcr.io/x/b@` + digestOf('b') + `"` + bakedC := `{"id":"c","version":"1.0.0","resolvedRef":"ghcr.io/x/c@` + digestOf('c') + `","dependsOn":{}}` + for _, tc := range []struct{ name, label string }{ + {"label records dependsOn", `[` + bakedB + `,"dependsOn":{"ghcr.io/x/c:1":{}}},` + bakedC + `]`}, + {"older label", `[` + bakedB + `},` + bakedC + `]`}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/b:1":{}}`, table) + f.rt.imagesByRef["alpine:3.20"] = &runtime.ImageDetails{Labels: map[string]string{feature.MetadataLabel: tc.label}} + if _, err := f.layer(LockfileDefault); err != nil { + t.Fatal(err) + } + lf, err := feature.ReadLockfile(filepath.Join(f.ws, ".devcontainer", "devcontainer-lock.json")) + if err != nil { + t.Fatal(err) + } + entry, ok := lf.Entry("ghcr.io/x/b:1") + if !ok || entry.Integrity != digestOf('b') || !reflect.DeepEqual(entry.DependsOn, []string{"ghcr.io/x/c:1"}) { + t.Errorf("lock entry for b = (%+v, %v), want integrity %s and dependsOn [ghcr.io/x/c:1]", entry, ok, digestOf('b')) + } + }) + } +} + +// TestDependsOn_LocalDependencyConfined: a local dependsOn key is +// followed only from a local feature, only as a relative path, and only +// when it resolves (symlinks included) inside the directory of +// devcontainer.json; otherwise feature metadata could make the build +// copy and run any host directory. +func TestDependsOn_LocalDependencyConfined(t *testing.T) { + t.Run("from a remote feature", func(t *testing.T) { + f := newDepFixture(t, `{"ghcr.io/x/a:1":{}}`, map[string]depEntry{ + "ghcr.io/x/a": {digestOf('a'), meta("a", map[string]map[string]any{"./b": {}})}, + }) + writeLocalFeature(t, filepath.Join(f.ws, ".devcontainer", "b"), `{"id":"b","version":"1.0.0"}`) + if _, err := f.layer(LockfileDisabled); err == nil || !strings.Contains(err.Error(), "only a local feature") { + t.Fatalf("got %v, want a refusal of the local dependency", err) + } + }) + + outside := t.TempDir() + writeLocalFeature(t, filepath.Join(outside, "evil"), `{"id":"evil","version":"1.0.0"}`) + for _, tc := range []struct { + name, key, want string + traverse, link bool + }{ + {name: "absolute path", key: filepath.Join(outside, "evil"), want: "relative path"}, + {name: "parent traversal", traverse: true, want: "outside"}, + {name: "symlink out", key: "./link", link: true, want: "outside"}, + } { + t.Run(tc.name, func(t *testing.T) { + ws := writeImageDevcontainer(t, `{"image":"alpine:3.20","features":{"./a":{}}}`) + dc := filepath.Join(ws, ".devcontainer") + key := tc.key + if tc.traverse { + // Reach the outside directory from .devcontainer with "..". + rel, err := filepath.Rel(dc, filepath.Join(outside, "evil")) + if err != nil { + t.Fatal(err) + } + key = rel + } + if tc.link { + if err := os.Symlink(filepath.Join(outside, "evil"), filepath.Join(dc, "link")); err != nil { + t.Fatal(err) + } + } + writeLocalFeature(t, filepath.Join(dc, "a"), `{"id":"a","version":"1.0.0","dependsOn":{"`+key+`":{}}}`) + eng, err := New(EngineOptions{Runtime: newFakeRuntime()}) + if err != nil { + t.Fatal(err) + } + f := &depFixture{t: t, eng: eng, ws: ws} + if _, err := f.layer(LockfileDisabled); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("dependsOn %q: got %v, want an error containing %q", key, err, tc.want) + } + }) + } +} diff --git a/design/features.md b/design/features.md index 3085114..e8f91e2 100644 --- a/design/features.md +++ b/design/features.md @@ -162,8 +162,99 @@ Ordered `[]ResolvedFeature` per spec rules: 2. Remaining features: topo-sorted via leaf-extraction over a graph built from `installsAfter` and `dependsOn`. -`dependsOn` is recursive (transitively pulls in unlisted features); -`installsAfter` is a soft "if both listed, this one waits" hint. +`dependsOn` is a hard dependency: the engine installs it whether or not +`devcontainer.json` declares it. `installsAfter` is a soft "if both +listed, this one waits" hint. See §5.1 for how dependencies are pulled +in. + +### 5.1 Pulling in `dependsOn` + +The spec and the reference CLI agree on what counts as one feature. Two +features are equal when they have the same content (manifest digest for +OCI, body sha256 for HTTPS) **and** the same options. So one feature +requested with two sets of options is installed twice. `layerFeatures` +already fetches each feature in turn. That loop now also appends the +features a fetched feature's `dependsOn` requires, which the loop then +fetches like declared ones: pinned through the lockfile, with defaults +applied and options validated. No new graph structure is needed. + +Duplicates are recognised at two points. Both look at the feature list +built so far: + +- **Before fetching**, a dependency already requested with the same + key and options is skipped. This is what stops a cycle and avoids + refetching a shared dependency. +- **After fetching**, a feature whose content and merged options match + an earlier one is dropped. This covers the same feature reached + under a different reference (`:1` and `:1.0` resolving to the same + digest). + +`topoSort` maps each id to all of its entries. A `dependsOn` edge goes +to the entries installed with the options the dependency asks for. +`installsAfter` edges go to every entry for the id. Ties are broken by +ref, then by options. + +Decisions: + +1. **Options are compared after defaults are applied.** The spec + compares "the options executed against the Feature"; the CLI + compares the options as written. Comparing merged options means + `{}` and `{"version": "latest"}` (when `latest` is the default) are + one install, not two of the same thing. No file format depends on + this, so following the spec costs no compatibility. +2. **Declared features are deduplicated by content too.** If + `devcontainer.json` lists two references that resolve to the same + digest with the same options, the feature is installed once. + Previously it was installed twice. This is the CLI's behaviour as + well. +3. **Local `dependsOn` paths resolve against `devcontainer.json`'s + directory**, as declared local features do and as the CLI does. They + are not resolved against the declaring feature's directory. They are + also **confined**, because feature metadata, not the user, chooses + them, and the directory they name is copied into the build and its + `install.sh` run: + - Only a local feature may declare one. A published feature cannot + know the consumer's files; a local key from it is refused. This is + stricter than the CLI, which follows it if it stays inside + `.devcontainer/`. + - The key must be relative and resolve, symlinks included, inside + the directory of `devcontainer.json`, as the CLI requires of local + features (`containerFeaturesConfiguration.ts`: no absolute paths, + no escape from `.devcontainer/`). + + Declared local features keep their existing, unconfined resolution: + the user wrote those paths, and confining them would break configs + that point outside the config directory. +4. **A dependency is never taken from the base image**, even when its + label lists it: it is fetched and installed like any other feature. + The pre-baked match compares id and version only, and a `dependsOn` + entry's options are chosen by the feature's author, not the user, so + an image built with other options would silently stand in. #154 adds + option matching to the pre-baked match; dependencies can reuse the + image once it lands. The cost until then: a pre-baked image whose + features have dependencies reinstalls those dependencies on every + build. + + A declared feature the image carries is still not reinstalled, but + its dependencies are installed, so they must be known. Labels now + record each installed feature's `dependsOn` (as `{}` when it has + none), so for an image built from now on they come from the label + without a fetch. For a label written before that, the baked + feature's metadata is fetched at the artifact the label records, a + digest reference the feature cache serves locally once seen. +5. **A dependency dropped as a duplicate is still locked.** When a + `dependsOn` key resolves to the same content and options as a + feature already in the set, it is not installed twice, but its key + still gets a lockfile entry for that content, so the reference it was + requested by stays pinned. +6. **OCI references are lowercased before parsing**, as the CLI's + `getRef` does. Published `dependsOn` keys such as + `ghcr.io/codspace/dependson/A` are written by feature authors, not + the user, and go-containerregistry rejects uppercase repository + names. + +Cycles that run through a pulled-in feature are reported the same way +as any other cycle, as `*FeatureCycleError`. Cycle detection at edge insertion: DFS for back-edge before adding. Returns a typed `*FeatureCycleError{Path []string}` so callers can show @@ -369,7 +460,9 @@ sha256. So the lockfile needs no store change: - **HTTPS pin:** the URL is fetched as before and its digest is compared with `integrity` (`*feature.FeatureIntegrityError` on mismatch). - **Write:** after the fetch loop and before the build, as the CLI does. - The lockfile is regenerated from scratch, so stale entries drop. It is + The lockfile is regenerated from scratch, so stale entries drop, + including when the last feature is removed (frozen mode reports them; + a config without features and without a lockfile gets none). It is compared by content, not bytes, and written only when entries changed. Decisions: @@ -382,10 +475,23 @@ Decisions: fixtures). The one divergence is key order for keys where bytewise order and `localeCompare` disagree. Comparing by content keeps either tool from rewriting the other's file over it. -2. **Features baked into the base image keep their existing entry.** - They are not fetched, so they produce no new entry. Dropping theirs - would churn the file on every pre-baked run and always fail frozen - mode. +2. **A baked feature stands in for a fetch only when the lockfile + accepts its artifact.** The base image's label records each + feature's `resolvedRef` (this library has written it since v0.1.0), + and the label match additionally requires that artifact to be the + pinned one, or, with no pin, a known one that is then locked. A + baked feature of a different or unrecorded artifact is fetched at + its pin and installed over the base image, so frozen mode never + accepts a base image the lockfile does not describe. An accepted + feature keeps its pin rather than being re-locked from the label, + which does not record `dependsOn`. Upgrade mode accepts no baked + feature: it resolves every reference afresh, and locking the baked + artifact could replace a newer pin with an older one. With the + lockfile disabled the label match alone decides, as before. + + Labels written by other tools record no artifact. The reference + CLI's never matched here anyway: their `id` is the full reference, + not the bare id the match compares. 3. **Compose reattach neither reads nor writes the lockfile, and the lockfile is not part of the compose inputs hash.** Reattach resolves nothing, matching the CLI, which does not re-resolve features when it @@ -398,11 +504,20 @@ Decisions: compose working directory in the wrong place for a root `.devcontainer.json`. -Known gap: the engine installs only the features declared in -`devcontainer.json`. It does not pull in features reachable only through -another feature's `dependsOn`. The CLI does, and locks them too. The -lockfile here therefore lists declared features only. Transitive -install is separate work. +5. **Features pulled in through `dependsOn` (§5.1) are locked and + pinned like declared ones.** Their key is the `dependsOn` key as + written, as in the CLI. A frozen lockfile that lacks one is out of + date. Verified against the CLI's own `lockfile-dependson` fixture. +6. **The compose inputs hash stays declared-only, with one + exception.** A pulled-in feature is implied by the declaring + feature's entry: a local feature's hashed content holds its + `dependsOn` keys and options, and an OCI feature is identified by + its reference as written. The exception is a *local* dependency's + own content, which nothing else covers, so it is hashed + transitively from the declared local features. That reaches every + local dependency, since only a local feature may declare one + (§5.1 decision 3). Projects without one hash exactly as before, so + there is no recreate on upgrade. Concurrency: a small lockfile (`extracted/.lock`) per cache entry so parallel `Resolve` calls for the same ref don't race. diff --git a/engine_test.go b/engine_test.go index e3cb445..ec3a53c 100644 --- a/engine_test.go +++ b/engine_test.go @@ -557,7 +557,7 @@ func TestUp_PrebakedImageSkipsFeatureFetch(t *testing.T) { ID: "sha256:base", Tags: []string{"alpine:3.20"}, Labels: map[string]string{ - "devcontainer.metadata": `[{"id":"pre-baked-feature","version":"1.5.0"}]`, + "devcontainer.metadata": `[{"id":"pre-baked-feature","version":"1.5.0","resolvedRef":"ghcr.io/x/pre-baked-feature@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","dependsOn":{}}]`, }, } eng, _ := New(EngineOptions{Runtime: rt}) diff --git a/feature/disk_store.go b/feature/disk_store.go index f0396d8..e7de247 100644 --- a/feature/disk_store.go +++ b/feature/disk_store.go @@ -116,7 +116,7 @@ func fetchLocal(absPath string) (Fetched, error) { return Fetched{}, fmt.Errorf("local feature %s: missing %s: %w", absPath, installScript, err) } - meta, err := parseMetadata(absPath) + meta, err := ParseMetadata(absPath) if err != nil { return Fetched{}, err } diff --git a/feature/dockerfile_test.go b/feature/dockerfile_test.go index 6f12f13..5cbec13 100644 --- a/feature/dockerfile_test.go +++ b/feature/dockerfile_test.go @@ -226,6 +226,76 @@ func TestParseMetadataLabel_RoundTrip(t *testing.T) { } } +// TestParseMetadataLabel_ResolvedRefSurvivesRebuild: the artifact a +// label entry records is what the lockfile checks a baked feature +// against, so it must survive parsing and being carried into the label +// of an image built on top. +func TestParseMetadataLabel_ResolvedRefSurvivesRebuild(t *testing.T) { + const ref = "ghcr.io/x/git@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + base, err := ParseMetadataLabel(`[{"id":"git","version":"1.0","resolvedRef":"` + ref + `"}]`) + if err != nil { + t.Fatal(err) + } + if base[0].ResolvedRef != ref { + t.Fatalf("parsed ResolvedRef = %q, want %q", base[0].ResolvedRef, ref) + } + raw, err := buildMetadataLabel(BuildPlan{BaseImage: "base", BaseImageMetadata: base, + Features: []config.ResolvedFeature{fixtureFeature(t, "node", "20")}}) + if err != nil { + t.Fatal(err) + } + rebuilt, err := ParseMetadataLabel(string(raw)) + if err != nil { + t.Fatal(err) + } + if rebuilt[0].ID != "git" || rebuilt[0].ResolvedRef != ref { + t.Errorf("carried entry = %+v, want git at %s", rebuilt[0], ref) + } +} + +// TestMetadataLabel_RecordsDependsOn: a newly installed feature's entry +// records its dependsOn, as {} when it has none, so a build on top +// knows the image carries those dependencies; an older label without +// it parses as unrecorded (nil), and both survive a rebuild as written. +func TestMetadataLabel_RecordsDependsOn(t *testing.T) { + git := fixtureFeature(t, "git", "1.0") + node := fixtureFeature(t, "node", "20") + node.Metadata.DependsOn = map[string]map[string]any{"ghcr.io/test/git:1": {}} + raw, err := buildMetadataLabel(BuildPlan{BaseImage: "base", Features: []config.ResolvedFeature{git, node}}) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseMetadataLabel(string(raw)) + if err != nil { + t.Fatal(err) + } + if parsed[0].DependsOn == nil || len(parsed[0].DependsOn) != 0 { + t.Errorf("git dependsOn = %#v, want recorded and empty", parsed[0].DependsOn) + } + if _, ok := parsed[1].DependsOn["ghcr.io/test/git:1"]; !ok { + t.Errorf("node dependsOn = %#v, want ghcr.io/test/git:1", parsed[1].DependsOn) + } + + old, err := ParseMetadataLabel(`[{"id":"git","version":"1.0"}]`) + if err != nil { + t.Fatal(err) + } + if old[0].DependsOn != nil { + t.Errorf("older label dependsOn = %#v, want nil (unrecorded)", old[0].DependsOn) + } + raw, err = buildMetadataLabel(BuildPlan{BaseImage: "base", BaseImageMetadata: append(old, parsed[0])}) + if err != nil { + t.Fatal(err) + } + rebuilt, err := ParseMetadataLabel(string(raw)) + if err != nil { + t.Fatal(err) + } + if rebuilt[0].DependsOn != nil || rebuilt[1].DependsOn == nil { + t.Errorf("rebuilt dependsOn = %#v, %#v; want nil, recorded", rebuilt[0].DependsOn, rebuilt[1].DependsOn) + } +} + func TestParseMetadataLabel_PreservesFinalEntryFields(t *testing.T) { // Reference behavior: the final no-ID resolved-config entry carries // remoteUser/containerUser. ParseMetadataLabel must round-trip them diff --git a/feature/graph.go b/feature/graph.go index 576d94b..2637b62 100644 --- a/feature/graph.go +++ b/feature/graph.go @@ -1,7 +1,9 @@ package feature import ( + "encoding/json" "fmt" + "reflect" "sort" "strings" @@ -53,12 +55,14 @@ func topoSort(features []config.ResolvedFeature) ([]config.ResolvedFeature, []co return features, nil, nil } - // Build a map from a feature's id (ignoring tag) to its index, so - // installsAfter / dependsOn entries can be resolved against the - // feature set even if their refs differ in tag specificity. - idx := make(map[string]int, len(features)) + // Map a feature's id (ignoring tag) to its indices, so installsAfter + // / dependsOn entries resolve against the feature set even if their + // refs differ in tag specificity. One id can have several entries: + // a feature installed once per distinct set of options. + idx := make(map[string][]int, len(features)) for i, f := range features { - idx[normalizeID(f.Ref)] = i + id := normalizeID(f.Ref) + idx[id] = append(idx[id], i) } // inDegree[i] = number of edges pointing TO features[i] (i.e. @@ -66,23 +70,23 @@ func topoSort(features []config.ResolvedFeature) ([]config.ResolvedFeature, []co inDegree := make([]int, len(features)) // adj[i] = features that depend on i (out-edges). adj := make([][]int, len(features)) + edge := func(from, to int) { + adj[from] = append(adj[from], to) + inDegree[to]++ + } for i, f := range features { + // installsAfter is satisfied by any entry for the id; unrelated + // ids are ignored per spec. for _, dep := range f.Metadata.InstallsAfter { - j, ok := idx[normalizeID(dep)] - if !ok { - continue // unrelated installsAfter — ignored per spec + for _, j := range idx[normalizeID(dep)] { + edge(j, i) } - adj[j] = append(adj[j], i) - inDegree[i]++ } - for dep := range f.Metadata.DependsOn { - j, ok := idx[normalizeID(dep)] - if !ok { - continue + for dep, opts := range f.Metadata.DependsOn { + for _, j := range dependencyEntries(features, idx[normalizeID(dep)], opts) { + edge(j, i) } - adj[j] = append(adj[j], i) - inDegree[i]++ } } @@ -97,7 +101,7 @@ func topoSort(features []config.ResolvedFeature) ([]config.ResolvedFeature, []co } } sort.Slice(ready, func(a, b int) bool { - return features[ready[a]].Ref < features[ready[b]].Ref + return featureLess(features[ready[a]], features[ready[b]]) }) out := make([]config.ResolvedFeature, 0, len(features)) @@ -117,7 +121,7 @@ func topoSort(features []config.ResolvedFeature) ([]config.ResolvedFeature, []co // Sort children alphabetically before processing for determinism. children := append([]int(nil), adj[i]...) sort.Slice(children, func(a, b int) bool { - return features[children[a]].Ref < features[children[b]].Ref + return featureLess(features[children[a]], features[children[b]]) }) for _, j := range children { if depth[i]+1 > depth[j] { @@ -150,6 +154,50 @@ func topoSort(features []config.ResolvedFeature) ([]config.ResolvedFeature, []co return out, warnings, nil } +// dependencyEntries returns the entries among candidates (indices of +// features sharing a dependsOn key's id) that satisfy the dependency: +// those installed with the options it asks for once their defaults are +// applied. If none do, every candidate is returned, so the edge still +// orders the feature after its dependency's id as before entries were +// told apart by options. +func dependencyEntries(features []config.ResolvedFeature, candidates []int, opts map[string]any) []int { + var out []int + for _, j := range candidates { + if SameOptions(features[j], opts) { + out = append(out, j) + } + } + if len(out) == 0 { + return candidates + } + return out +} + +// SameOptions reports whether f is installed with the options a +// request for it with opts resolves to, after f's defaults apply. +func SameOptions(f config.ResolvedFeature, opts map[string]any) bool { + merged, _, err := MergeOptions(f.Metadata, opts) + return err == nil && reflect.DeepEqual(merged, mergedOrEmpty(f.Options)) +} + +func mergedOrEmpty(opts map[string]any) map[string]any { + if opts == nil { + return map[string]any{} + } + return opts +} + +// featureLess orders features by ref, then by options, so entries for +// one ref installed with different options sort deterministically. +func featureLess(a, b config.ResolvedFeature) bool { + if a.Ref != b.Ref { + return a.Ref < b.Ref + } + ao, _ := json.Marshal(a.Options) + bo, _ := json.Marshal(b.Options) + return string(ao) < string(bo) +} + // buildDeepestPath reconstructs a chain to a node at `depth[target]` // by walking backward through predecessors. Used for diagnostics, not // hot-path; O(N*chain_len). diff --git a/feature/https.go b/feature/https.go index 70ea929..8887c33 100644 --- a/feature/https.go +++ b/feature/https.go @@ -84,7 +84,7 @@ func (s *DiskStore) fetchHTTPS(ctx context.Context, ref string) (Fetched, error) if _, err := os.Stat(filepath.Join(extractedDir, metadataFile)); err == nil { // Cache hit on a previous run. - meta, err := parseMetadata(extractedDir) + meta, err := ParseMetadata(extractedDir) if err == nil { return Fetched{Dir: extractedDir, ResolvedRef: digest, Metadata: meta}, nil } @@ -114,7 +114,7 @@ func (s *DiskStore) fetchHTTPS(ctx context.Context, ref string) (Fetched, error) if _, err := os.Stat(filepath.Join(extractedDir, installScript)); err != nil { return Fetched{}, fmt.Errorf("HTTPS feature %s: missing %s after extract: %w", ref, installScript, err) } - meta, err := parseMetadata(extractedDir) + meta, err := ParseMetadata(extractedDir) if err != nil { return Fetched{}, err } diff --git a/feature/lockfile.go b/feature/lockfile.go index aa5e307..4a8b1de 100644 --- a/feature/lockfile.go +++ b/feature/lockfile.go @@ -173,7 +173,7 @@ func PinnedRef(f config.ResolvedFeature, entry LockedFeature) (string, error) { if f.SourceKind != config.FeatureSourceOCI { return f.Ref, nil } - ref, err := name.ParseReference(f.Ref) + ref, err := name.ParseReference(strings.ToLower(f.Ref)) if err != nil { return "", fmt.Errorf("parse OCI ref %q: %w", f.Ref, err) } diff --git a/feature/lockfile_test.go b/feature/lockfile_test.go index 1c5dc34..ee4cf91 100644 --- a/feature/lockfile_test.go +++ b/feature/lockfile_test.go @@ -193,6 +193,7 @@ func TestPinnedRef(t *testing.T) { for ref, want := range map[string]string{ "ghcr.io/x/f:1": "ghcr.io/x/f@" + digestA, "ghcr.io/x/f": "ghcr.io/x/f@" + digestA, + "ghcr.io/X/F:1": "ghcr.io/x/f@" + digestA, "ghcr.io/x/f@" + digestB: "ghcr.io/x/f@" + digestA, } { got, err := PinnedRef(config.ResolvedFeature{Ref: ref, SourceKind: config.FeatureSourceOCI}, entry) diff --git a/feature/metadata.go b/feature/metadata.go index 2d6fd0c..ba0ec4d 100644 --- a/feature/metadata.go +++ b/feature/metadata.go @@ -24,6 +24,11 @@ type metadataEntry struct { ID string `json:"id,omitempty"` Version string `json:"version,omitempty"` ResolvedRef string `json:"resolvedRef,omitempty"` + // DependsOn records the dependsOn of the feature the entry installed, + // as {} when it has none, so a build on top knows the image already + // carries those dependencies. Absent from labels written before it + // was recorded. + DependsOn *map[string]map[string]any `json:"dependsOn,omitempty"` // Mergeable surface. RemoteUser string `json:"remoteUser,omitempty"` @@ -82,6 +87,11 @@ func buildMetadataLabel(plan BuildPlan) ([]byte, error) { } e := metadataFromFeatureMetadata(f.Metadata) e.ResolvedRef = f.ResolvedRef + deps := f.Metadata.DependsOn + if deps == nil { + deps = map[string]map[string]any{} + } + e.DependsOn = &deps entries = append(entries, e) } @@ -100,6 +110,7 @@ func metadataFromFeatureMetadata(m config.FeatureMetadata) metadataEntry { out := metadataEntry{ ID: m.ID, Version: m.Version, + ResolvedRef: m.ResolvedRef, RemoteUser: m.RemoteUser, ContainerUser: m.ContainerUser, UserEnvProbe: string(m.UserEnvProbe), @@ -116,6 +127,10 @@ func metadataFromFeatureMetadata(m config.FeatureMetadata) metadataEntry { Entrypoint: m.Entrypoint, Customizations: m.Customizations, } + if m.DependsOn != nil { + deps := m.DependsOn + out.DependsOn = &deps + } if m.HostRequirements != nil { out.HostRequirements = &rawHostReq{ CPUs: m.HostRequirements.CPUs, @@ -205,7 +220,7 @@ func ParseMetadataLabel(label string) ([]config.FeatureMetadata, error) { if err := json.Unmarshal([]byte(label), &single); err != nil { return nil, err } - return []config.FeatureMetadata{{ID: single.ID, Version: single.Version}}, nil + return []config.FeatureMetadata{{ID: single.ID, Version: single.Version, ResolvedRef: single.ResolvedRef}}, nil } var entries []metadataEntry if err := json.Unmarshal([]byte(label), &entries); err != nil { @@ -226,6 +241,7 @@ func (e metadataEntry) toFeatureMetadata() (config.FeatureMetadata, error) { out := config.FeatureMetadata{ ID: e.ID, Version: e.Version, + ResolvedRef: e.ResolvedRef, RemoteUser: e.RemoteUser, ContainerUser: e.ContainerUser, UserEnvProbe: config.UserEnvProbe(e.UserEnvProbe), @@ -242,6 +258,12 @@ func (e metadataEntry) toFeatureMetadata() (config.FeatureMetadata, error) { Entrypoint: e.Entrypoint, Customizations: e.Customizations, } + if e.DependsOn != nil { + out.DependsOn = *e.DependsOn + if out.DependsOn == nil { + out.DependsOn = map[string]map[string]any{} + } + } if e.HostRequirements != nil { out.HostRequirements = &config.HostRequirements{ CPUs: e.HostRequirements.CPUs, diff --git a/feature/oci.go b/feature/oci.go index f669a03..6df244d 100644 --- a/feature/oci.go +++ b/feature/oci.go @@ -46,7 +46,10 @@ const devContainerFeatureMediaType = "application/vnd.devcontainers" // successful online fetch. The recorded digest is only used when its // extracted content is still in the cache. func (s *DiskStore) fetchOCI(ctx context.Context, ref string) (Fetched, error) { - parsed, err := name.ParseReference(ref) + // OCI repository names are lowercase; the reference CLI lowercases + // the whole reference, so feature ids such as ghcr.io/owner/A:1 + // resolve. + parsed, err := name.ParseReference(strings.ToLower(ref)) if err != nil { return Fetched{}, fmt.Errorf("parse OCI ref %q: %w", ref, err) } @@ -143,7 +146,7 @@ func (s *DiskStore) cachedOCI(repo, digest string) (Fetched, bool) { if _, err := os.Stat(filepath.Join(extractedDir, installScript)); err != nil { return Fetched{}, false } - meta, err := parseMetadata(extractedDir) + meta, err := ParseMetadata(extractedDir) if err != nil { return Fetched{}, false } @@ -254,7 +257,7 @@ func (s *DiskStore) extractOCI(ref, repo string, digest v1.Hash, img v1.Image) ( if _, err := os.Stat(filepath.Join(tmpDir, installScript)); err != nil { return Fetched{}, fmt.Errorf("OCI feature %s: missing %s after extract: %w", ref, installScript, err) } - if _, err := parseMetadata(tmpDir); err != nil { + if _, err := ParseMetadata(tmpDir); err != nil { return Fetched{}, err } if err := os.RemoveAll(extractedDir); err != nil { @@ -263,7 +266,7 @@ func (s *DiskStore) extractOCI(ref, repo string, digest v1.Hash, img v1.Image) ( if err := os.Rename(tmpDir, extractedDir); err != nil { return Fetched{}, fmt.Errorf("install extracted feature %s: %w", ref, err) } - meta, err := parseMetadata(extractedDir) + meta, err := ParseMetadata(extractedDir) if err != nil { return Fetched{}, err } diff --git a/feature/parse.go b/feature/parse.go index 5428b8b..c229157 100644 --- a/feature/parse.go +++ b/feature/parse.go @@ -18,10 +18,10 @@ const metadataFile = "devcontainer-feature.json" // instead of opaque mid-build errors. const installScript = "install.sh" -// parseMetadata reads