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
8 changes: 4 additions & 4 deletions .github/wiki/Image-Lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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 |

Expand All @@ -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/<slug>`, `kind: upstream` entries keep their name, `kind: ci` entries are ignored.
Expand Down
31 changes: 24 additions & 7 deletions internal/compose/images_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 70 additions & 2 deletions internal/compose/images_lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo>:<version> (%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 <repository>:<tag> 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())
Expand All @@ -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"
Expand Down
Loading