diff --git a/.github/wiki/Image-Lock.md b/.github/wiki/Image-Lock.md index 6f90e899b..43eac7ee0 100644 --- a/.github/wiki/Image-Lock.md +++ b/.github/wiki/Image-Lock.md @@ -12,7 +12,7 @@ What the scan does not see: literals assembled from parts (for example `"postgre Each entry records `name`, `role` (`core`, `optional`, `monitoring`, `tool`, `plugin`), the fully qualified `repository`, `version`, the `index_digest` of the multi-arch index, the per-platform digests (`linux/amd64`, `linux/arm64`, `linux/arm/v7` when upstream publishes them), `legacy_ref`, `license`, `source` and `mirror`. -The first lock records what each effective reference resolved to on 2026-10-04. No version moved: `legacy_ref` is the exact string the CLI emitted before the lock existed. +The first lock records what each effective reference resolved to on 2026-10-04. No version moved: `legacy_ref` is the exact string the CLI emitted before the lock existed. `legacy_ref` stays frozen: a pin bump moves `version` (and the digests) only, so legacy mode keeps emitting the pre-lock string. The lock test checks that `legacy_ref` names the same repository with a tag; it does not require `legacy_ref` to equal `repository:version`. ## Pinning mode: `IMAGE_PINNING` @@ -25,11 +25,11 @@ Set `IMAGE_PINNING=lock` in `.env` to opt a project in early. Moving to `lock` c ## Overrides -A `*_VERSION` key in the env cascade is an explicit override and is used as given, unpinned, unless it equals the lock version. +A `*_VERSION` key in the env cascade is an explicit override and is used as given, unpinned, unless it equals the lock version or the `legacy_ref` tag. The `legacy_ref` tag is what the config defaults and the `.env` files written by `nself setup` carry (`HASURA_VERSION=v2.44.0`), so it counts as "the default" and stays pinned after a bump. To run the pre-lock version itself, use `IMAGE_PINNING=legacy`. | Setting | `IMAGE_PINNING=lock` | `IMAGE_PINNING=legacy` | |---|---|---| -| unset, or equal to the lock version (`REDIS_VERSION=7-alpine`) | `docker.io/library/redis:7-alpine@sha256:...` | `redis:7-alpine` | +| unset, or equal to the lock version or the `legacy_ref` tag (`REDIS_VERSION=7-alpine`) | `docker.io/library/redis:7-alpine@sha256:...` | `redis:7-alpine` | | any other value (`REDIS_VERSION=6.2-alpine`) | `redis:6.2-alpine` (no digest) | `redis:6.2-alpine` | | `POSTGRES_IMAGE` / `CS_N_IMAGE` | used as given | used as given | @@ -39,7 +39,7 @@ A project digest file written by `nself update images` (`.nself-image-digests.js ## How pins move -1. Edit the entry in `internal/compose/images.yaml` (a version bump is its own reviewed change). +1. Edit the entry's `version` in `internal/compose/images.yaml` (a version bump is its own reviewed change). Leave `legacy_ref` alone. The weekly bump (`tools/imagebump`, `.github/workflows/image-pin-bump.yml`) does the same. 2. `go run ./tools/imagelock -resolve` reads each index through the docker CLI (network), proves each digest addresses the index it came from, and rewrites the lock. 3. `go run ./tools/imagelock -check` verifies the lock offline: header, sorted, every entry has an index digest and `linux/amd64`, and the lock covers `images.yaml`. Adding an entry without resolving fails it. 4. `go run ./tools/imagelock -resolve -plugins images.json` also merges the plugins repository's `images.json` v1: `kind: plugin` entries become `plugin/`, `kind: upstream` entries keep their name, `kind: ci` entries are ignored. diff --git a/internal/compose/images_lock.go b/internal/compose/images_lock.go index 2b1d64c72..a86094823 100644 --- a/internal/compose/images_lock.go +++ b/internal/compose/images_lock.go @@ -86,6 +86,16 @@ func (r Ref) LegacyRepository() string { return r.LegacyRef } +// LegacyVersion is the tag of legacy_ref: the version the CLI ran before the +// lock, which config defaults and `nself setup` .env files still carry. It +// stays frozen when a pin bump moves Version. +func (r Ref) LegacyVersion() string { + if repo := r.LegacyRepository(); repo != r.LegacyRef { + return r.LegacyRef[len(repo)+1:] + } + return "" +} + var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) // MarshalLock renders a lock deterministically: 2-space indent, trailing @@ -209,19 +219,26 @@ func PinningMode() string { } // ImageRef returns the image string for lock entry name at the requested -// version. An empty version, or one equal to the lock version, means "the -// default": legacy_ref in legacy mode, repository:version@digest in lock mode. -// Any other version is an explicit user override and is used as given, -// unpinned, on the entry's pre-lock repository spelling. An unknown name yields -// "" (the tests assert every generator name exists). +// version. An empty version, the lock version, or the frozen legacy_ref version +// (the pre-lock default that config and .env files carry) means "the default": +// legacy_ref in legacy mode, repository:version@digest in lock mode. That keeps +// a pin bump from turning the shipped default into an unpinned override. Any +// other version is an explicit user override and is used as given, unpinned, +// on the entry's pre-lock repository spelling. An unknown name yields "" (the +// tests assert every generator name exists). func ImageRef(name, version string) string { r, ok := LockedRef(name) if !ok { return "" } + return r.imageFor(PinningMode(), version) +} + +// imageFor is ImageRef for one entry and mode. +func (r Ref) imageFor(mode, version string) string { v := strings.TrimSpace(version) - if PinningMode() == PinningLock { - if v == "" || v == r.Version { + if mode == PinningLock { + if v == "" || v == r.Version || v == r.LegacyVersion() { return r.String() } return r.LegacyRepository() + ":" + v diff --git a/internal/compose/images_lock_test.go b/internal/compose/images_lock_test.go index 9c3171bdc..f5249a291 100644 --- a/internal/compose/images_lock_test.go +++ b/internal/compose/images_lock_test.go @@ -40,8 +40,10 @@ func TestLockIsSound(t *testing.T) { names := make([]string, len(imgs)) for i, r := range imgs { names[i] = r.Name - if r.Role != RolePlugin && r.LegacyRepository()+":"+r.Version != r.LegacyRef { - t.Errorf("%s: legacy_ref %q is not : (%s:%s)", r.Name, r.LegacyRef, r.LegacyRepository(), r.Version) + // legacy_ref is frozen at the pre-lock string (a pin bump moves only + // version), so it must name the same repository and carry a tag. + if r.Role != RolePlugin && (r.LegacyVersion() == "" || shortRepo(r.LegacyRepository()) != shortRepo(r.Repository)) { + t.Errorf("%s: legacy_ref %q is not : for %s", r.Name, r.LegacyRef, r.Repository) } if !lockedRefRE.MatchString(r.String()) { t.Errorf("%s: %q is not repository:version@sha256:<64 hex>", r.Name, r.String()) @@ -60,6 +62,72 @@ func TestLockIsSound(t *testing.T) { } } +// shortRepo drops the docker.io/ and library/ prefixes Docker implies. +func shortRepo(repo string) string { + return strings.TrimPrefix(strings.TrimPrefix(repo, "docker.io/"), "library/") +} + +// TestImageForAfterPinBump: once a bump moves version past the frozen +// legacy_ref, the pre-lock default (what config defaults and .env files carry) +// must still resolve to the locked digest in lock mode and to legacy_ref in +// legacy mode; only another version is an unpinned override. +func TestImageForAfterPinBump(t *testing.T) { + d := "sha256:" + strings.Repeat("b", 64) + r := Ref{Name: "hasura", Repository: "docker.io/hasura/graphql-engine", Version: "v2.44.1", IndexDigest: d, + LegacyRef: "hasura/graphql-engine:v2.44.0"} + locked := "docker.io/hasura/graphql-engine:v2.44.1@" + d + for _, tc := range []struct{ mode, version, want string }{ + {PinningLock, "", locked}, + {PinningLock, "v2.44.0", locked}, + {PinningLock, " v2.44.1 ", locked}, + {PinningLock, "v2.43.0", "hasura/graphql-engine:v2.43.0"}, + {PinningLegacy, "", "hasura/graphql-engine:v2.44.0"}, + {PinningLegacy, "v2.44.0", "hasura/graphql-engine:v2.44.0"}, + {PinningLegacy, "v2.44.1", "hasura/graphql-engine:v2.44.1"}, + } { + if got := r.imageFor(tc.mode, tc.version); got != tc.want { + t.Errorf("%s %q -> %q, want %q", tc.mode, tc.version, got, tc.want) + } + } + if v := (Ref{LegacyRef: "busybox"}).LegacyVersion(); v != "" { + t.Errorf("untagged legacy_ref has version %q", v) + } +} + +// TestConfigDefaultVersionsStayPinned: a documented *_VERSION default that is +// neither the lock version nor the legacy_ref version would make ImageRef emit +// an unpinned tag in lock mode. Guards the config literals against pin bumps. +func TestConfigDefaultVersionsStayPinned(t *testing.T) { + setMode(t, PinningLock) + keys := map[string]string{"elasticsearch": "ELASTICSEARCH_VERSION"} + for _, e := range serviceCatalog { + if e.VersionEnv != "" { + name := e.Name + if e.ImageKey != "" { + name = e.ImageKey + } + keys[name] = e.VersionEnv + } + } + for name, key := range keys { + r, ok := LockedRef(name) + if !ok { + continue + } + if def := config.DefaultFor(key); def != "" && ImageRef(name, def) != r.String() { + t.Errorf("%s=%q (config default) is unpinned in lock mode: %s", key, def, ImageRef(name, def)) + } + } + for name, cfg := range fixtureConfigs(t) { + for svc, v := range map[string]string{"hasura": cfg.Hasura.Version, "auth": cfg.Auth.Version, + "elasticsearch": cfg.Search.Elasticsearch.Version} { + if r, _ := LockedRef(svc); ImageRef(svc, v) != r.String() { + t.Errorf("%s: ApplyDefaults %s version %q is unpinned in lock mode", name, svc, v) + } + } + } +} + func TestLockedImagesIsACopy(t *testing.T) { a := LockedImages() a[0].Name = "mutated"