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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions config/resolved.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,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".
Expand Down
25 changes: 20 additions & 5 deletions design/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,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:
Expand All @@ -382,10 +384,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
Expand Down
2 changes: 1 addition & 1 deletion engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]`,
},
}
eng, _ := New(EngineOptions{Runtime: rt})
Expand Down
27 changes: 27 additions & 0 deletions feature/dockerfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,33 @@ 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)
}
}

func TestParseMetadataLabel_PreservesFinalEntryFields(t *testing.T) {
// Reference behavior: the final no-ID resolved-config entry carries
// remoteUser/containerUser. ParseMetadataLabel must round-trip them
Expand Down
4 changes: 3 additions & 1 deletion feature/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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),
Expand Down Expand Up @@ -205,7 +206,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 {
Expand All @@ -226,6 +227,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),
Expand Down
53 changes: 47 additions & 6 deletions lockfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,45 @@ func (l *featureLock) pin(f config.ResolvedFeature) (feature.LockedFeature, bool
return l.existing.Entry(f.Ref)
}

// acceptsBaked reports whether the base image's installation of f,
// recorded by the label entry baked, may stand in for fetching f. With
// the lockfile disabled the label match alone decides. Otherwise the
// baked artifact must be the one the lockfile pins, or, with no pin,
// one the label records, so that it can be locked. Upgrade mode accepts
// none: it resolves every reference afresh, and locking the baked
// artifact could replace a newer pin with an older one. A baked feature
// that is not accepted is fetched and installed like any other; the
// label records no artifact when another tool built the image.
func (l *featureLock) acceptsBaked(f config.ResolvedFeature, baked config.FeatureMetadata) bool {
switch l.mode {
case LockfileDisabled:
return true
case LockfileUpgrade:
return false
}
if baked.ResolvedRef == "" {
return false
}
pin, pinned := l.pin(f)
return !pinned || feature.CheckIntegrity(f, feature.Fetched{ResolvedRef: baked.ResolvedRef}, pin) == nil
Comment thread
dap-code-review-by-crunchloop[bot] marked this conversation as resolved.
Resolved
Comment on lines +87 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · core/correctness

Unpinned baked features skip the lockfile's dependency metadata

The serialized lockfile is consumed by the reference-compatible feature lockfile path: at devcontainers/cli v0.87.0 src/spec-configuration/lockfile.ts:26-35 writes each feature's declared dependsOn, and containerFeaturesOrder.ts:414-435 resolves those dependencies while preparing a future feature install. In this repository feature.Lock at feature/lockfile.go:155-164 serializes only f.Metadata.DependsOn; the accepted baked metadata has no such field, and Lockfile.Equal/Changed compare it at feature/lockfile.go:113-124.

Fix — When an unpinned baked feature is accepted, retain its declared dependency list from its feature manifest (for example by resolving the manifest or preserving the relevant dependency data in the image label) before generating the lock entry; alternatively fetch the feature artifact to build a complete lock entry.

Witness

At lockfile.go:87-88 acceptsBaked returns true for a baked artifact when no lock pin exists. markAlreadyInstalled assigns the parsed label entry to f.Metadata; metadataEntry does not include dependsOn, while fetched feature metadata does (feature/metadata.go:23-38, feature/parse.go:63-65,92-98). commit then passes this metadata to feature.Lock, whose DependsOn is constructed from f.Metadata.DependsOn (feature/lockfile.go:155-164), and writes the resulting entry. A real feature declaring dependsOn thus gets an empty dependsOn in newly generated lockfile after its first baked-image acceptance without a prior pin. CLI v0.87.0 lockfile generation writes keys from the manifest (/tmp/devcontainers-cli/src/spec-configuration/lockfile.ts:25-35), and its ordering resolver reads those declarations (containerFeaturesOrder.ts:414-435).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid for this commit taken alone. It's resolved by #151, which is stacked on this PR and merges right after it: there, layerFeatures fills in a baked feature's dependsOn before lock.commit runs. It comes from the label, which now records dependsOn, or for a label written before that, from the feature's metadata fetched at its recorded artifact. So feature.Lock builds the entry from the full list. The lockfile is unreleased, so main won't ship with the gap. I'm adding a test on #151 that asserts an unpinned baked feature's lock entry carries its dependsOn.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't settle this from the review record alone — Settling this requires reading the stacked branch #151 — layerFeatures, the label schema, and the new test — which is not in this record; the finding stands on the sequence this diff shows.

To have me read the code and take another look, comment @dap-code-review-by-crunchloop review. I also understand @dap-code-review-by-crunchloop pause and @dap-code-review-by-crunchloop resume.

}

// commit records what features resolved to: written in default and
// upgrade modes when it differs from the existing lockfile, compared
// in frozen mode.
//
// Features the base image already carries were not fetched, so there
// is nothing new to record for them: their existing entry is kept.
// A feature the base image carries keeps its pin, which acceptsBaked
// checked it against; the label does not record dependsOn, so an entry
// rebuilt from it could differ. Without a pin it is locked from the
// artifact the label records.
func (l *featureLock) commit(features []config.ResolvedFeature) error {
if l.mode == LockfileDisabled {
return nil
}
next := &feature.Lockfile{Features: map[string]feature.LockedFeature{}}
for _, f := range features {
if f.AlreadyInstalled {
if entry, ok := l.existing.Entry(f.Ref); ok {
next.Features[f.Ref] = entry
}
if pin, pinned := l.pin(f); f.AlreadyInstalled && pinned {
next.Features[f.Ref] = pin
continue
}
if entry, ok := feature.Lock(f); ok {
Expand All @@ -95,3 +118,21 @@ func (l *featureLock) commit(features []config.ResolvedFeature) error {
}
return feature.WriteLockfile(l.path, next)
}

// commitNoFeatures reconciles the lockfile of a configuration that
// declares no features. Nothing is pinned, but entries left by removed
// features are stale: default and upgrade modes empty the lockfile and
// frozen mode reports them. Without a lockfile there is nothing to do
// in any mode, as with the reference CLI.
func commitNoFeatures(mode LockfileMode, configPath string) error {
if mode == LockfileDisabled {
return nil
}
l := &featureLock{mode: mode, path: feature.LockfilePath(configPath)}
existing, err := feature.ReadLockfile(l.path)
if err != nil || existing == nil {
return err
}
l.existing = existing
return l.commit(nil)
}
120 changes: 106 additions & 14 deletions lockfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,26 +244,118 @@ func TestLockfile_HTTPSIntegrityMismatchFails(t *testing.T) {
}
}

// TestLockfile_KeepsEntryForFeatureBakedIntoBaseImage: a feature the
// base image's metadata label satisfies is not fetched, so its entry
// carries over rather than being dropped (which frozen mode would
// then reject on every run).
func TestLockfile_KeepsEntryForFeatureBakedIntoBaseImage(t *testing.T) {
f := newLockFixture(t, `{"ghcr.io/x/a:1":{},"ghcr.io/x/b:1":{}}`)
f.rt.imagesByRef["alpine:3.20"] = &runtime.ImageDetails{Labels: map[string]string{
feature.MetadataLabel: `[{"id":"a","version":"1.0.0"}]`,
// TestLockfile_BakedFeature: a feature the base image's metadata label
// carries stands in for a fetch only when the lockfile accepts the
// artifact the label records: the pinned one, or with no pin a known
// one, which is then locked. Anything else is fetched (at the pin).
func TestLockfile_BakedFeature(t *testing.T) {
baked := func(resolvedRef string) string {
return `[{"id":"a","version":"1.0.0","resolvedRef":"` + resolvedRef + `"}]`
}
pinned := &feature.Lockfile{Features: map[string]feature.LockedFeature{
"ghcr.io/x/a:1": ociEntry("ghcr.io/x/a", lockDigestOld),
}}
locked := &feature.Lockfile{Features: map[string]feature.LockedFeature{
cases := []struct {
name string
label string
lock *feature.Lockfile
mode LockfileMode
wantFetched []string
wantErr bool
wantLock *feature.Lockfile // nil: not checked
}{
{
name: "pinned artifact", label: baked("ghcr.io/x/a@" + lockDigestOld), lock: pinned,
mode: LockfileFrozen, wantLock: pinned,
},
{
name: "other artifact", label: baked("ghcr.io/x/a@" + lockDigestNew), lock: pinned,
mode: LockfileFrozen, wantFetched: []string{"ghcr.io/x/a@" + lockDigestOld}, wantLock: pinned,
},
{
name: "unrecorded artifact", label: `[{"id":"a","version":"1.0.0"}]`, lock: pinned,
mode: LockfileFrozen, wantFetched: []string{"ghcr.io/x/a@" + lockDigestOld}, wantLock: pinned,
},
{
name: "no pin locks the baked artifact", label: baked("ghcr.io/x/a@" + lockDigestOld),
mode: LockfileDefault, wantLock: pinned,
},
{
name: "no pin in frozen mode", label: baked("ghcr.io/x/a@" + lockDigestOld),
lock: &feature.Lockfile{Features: map[string]feature.LockedFeature{}}, mode: LockfileFrozen, wantErr: true,
},
{
name: "disabled trusts the label", label: `[{"id":"a","version":"1.0.0"}]`, mode: LockfileDisabled,
},
{
// The tag now resolves to lockDigestNew; an older baked
// artifact must not be locked in its place.
name: "upgrade resolves afresh", label: baked("ghcr.io/x/a@" + lockDigestOld),
lock: &feature.Lockfile{Features: map[string]feature.LockedFeature{
"ghcr.io/x/a:1": ociEntry("ghcr.io/x/a", lockDigestNew),
}},
mode: LockfileUpgrade, wantFetched: []string{"ghcr.io/x/a:1"},
wantLock: &feature.Lockfile{Features: map[string]feature.LockedFeature{
"ghcr.io/x/a:1": ociEntry("ghcr.io/x/a", lockDigestNew),
}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newLockFixture(t, `{"ghcr.io/x/a:1":{}}`)
f.rt.imagesByRef["alpine:3.20"] = &runtime.ImageDetails{Labels: map[string]string{feature.MetadataLabel: tc.label}}
if tc.lock != nil {
f.writeLock(tc.lock)
}
err := f.build(tc.mode)
var me *feature.LockfileMismatchError
if tc.wantErr != errors.As(err, &me) || (!tc.wantErr && err != nil) {
t.Fatalf("build: %v, want mismatch error %v", err, tc.wantErr)
}
if !reflect.DeepEqual(f.store.fetched, tc.wantFetched) {
t.Errorf("fetched %v, want %v", f.store.fetched, tc.wantFetched)
}
if tc.wantLock != nil {
if got := f.readLock(); !got.Equal(tc.wantLock) {
t.Errorf("lockfile differs in %v", got.Changed(tc.wantLock))
}
}
})
}
}

// TestLockfile_NoFeatures: removing the last feature leaves entries
// that are stale. Default mode empties the lockfile and frozen mode
// reports them; without a lockfile none is created.
func TestLockfile_NoFeatures(t *testing.T) {
stale := &feature.Lockfile{Features: map[string]feature.LockedFeature{
"ghcr.io/x/a:1": ociEntry("ghcr.io/x/a", lockDigestOld),
"ghcr.io/x/b:1": ociEntry("ghcr.io/x/b", lockDigestOld),
}}
f.writeLock(locked)

if err := f.build(LockfileFrozen); err != nil {
f := newLockFixture(t, `{}`)
f.writeLock(stale)
var me *feature.LockfileMismatchError
if err := f.build(LockfileFrozen); !errors.As(err, &me) || !reflect.DeepEqual(me.Changed, []string{"ghcr.io/x/a:1"}) {
t.Fatalf("frozen: got %v, want a mismatch naming ghcr.io/x/a:1", err)
}
if err := f.build(LockfileDefault); err != nil {
t.Fatal(err)
}
if want := []string{"ghcr.io/x/b@" + lockDigestOld}; !reflect.DeepEqual(f.store.fetched, want) {
t.Errorf("fetched %v, want only b", f.store.fetched)
if got := f.readLock(); got == nil || len(got.Features) != 0 {
t.Errorf("default: lockfile = %+v, want no entries", got)
}
if err := f.build(LockfileFrozen); err != nil {
t.Errorf("frozen after default emptied the lockfile: %v", err)
}

f = newLockFixture(t, `{}`)
for _, mode := range []LockfileMode{LockfileDefault, LockfileFrozen} {
if err := f.build(mode); err != nil {
t.Errorf("%q without a lockfile: %v", mode, err)
}
}
if _, err := os.Stat(f.lockPath); !errors.Is(err, os.ErrNotExist) {
t.Errorf("a lockfile was created for a config without features: %v", err)
}
}

Expand Down
Loading
Loading