diff --git a/pkg/images/load.go b/pkg/images/load.go index cd339fa..208ee1c 100644 --- a/pkg/images/load.go +++ b/pkg/images/load.go @@ -64,7 +64,7 @@ func Load(ctx context.Context, lr *cluster.LookupResult, archive io.Reader, logg before := listRefSet(ctx, lr) args := []string{"-n", "k8s.io", "image", "import", "-"} - logger.Info("loading image archive", + logger.Debug("loading image archive", zap.String("backend", string(lr.Backend)), zap.String("cluster", lr.ClusterName), ) @@ -74,7 +74,7 @@ func Load(ctx context.Context, lr *cluster.LookupResult, archive io.Reader, logg stdout.String(), stderr.String(), err) } if stdout.Len() > 0 { - logger.Info("ctr image import", + logger.Debug("ctr image import", zap.String("output", stdout.String()), ) } @@ -90,16 +90,15 @@ func Load(ctx context.Context, lr *cluster.LookupResult, archive io.Reader, logg logger.Warn("post-import snapshot failed; skipping digest-alias step") return nil } - aliased := 0 + created := map[string]string{} for _, p := range after { if before[p.ref] { continue // existed before this import } - if strings.Contains(p.ref, "@") { - continue // already digest-form; nothing to alias + alias := aliasFor(p.ref, p.digest) + if alias == "" { + continue } - nameOnly := stripTag(p.ref) - alias := nameOnly + "@" + p.digest if before[alias] { continue // alias already exists somehow; skip } @@ -109,24 +108,92 @@ func Load(ctx context.Context, lr *cluster.LookupResult, archive io.Reader, logg // Don't fail the whole Load -- the import already // landed. Log so the operator sees what didn't // alias and can do it manually if needed. - logger.Warn("ctr image tag (digest alias) failed", + logger.Warn("ctr image tag (alias) failed", zap.String("ref", p.ref), zap.String("alias", alias), zap.String("stderr", tagErr.String()), zap.Error(err)) continue } - aliased++ - logger.Info("digest alias created", + created[p.ref] = alias + logger.Debug("alias created", zap.String("ref", p.ref), zap.String("alias", alias)) } - if aliased == 0 { - logger.Info("no new digest aliases needed (re-import or already aliased)") + + // Happy-path summary: one INFO per new ref that names a real + // repo. ctr writes a bare config-digest row ("sha256:") + // alongside the canonical ref when importing; we filter it + // here so the operator only sees lines for things they pinned. + any := false + for _, p := range after { + if before[p.ref] { + continue + } + if strings.HasPrefix(p.ref, "sha256:") { + continue + } + any = true + var line string + if strings.Contains(p.ref, "@") { + // Digest-form ref already carries the digest; don't + // repeat it in the log line. + line = "imported " + p.ref + } else { + short := p.digest + if strings.HasPrefix(short, "sha256:") && len(short) > 19 { + short = short[:19] + } + line = fmt.Sprintf("imported %s @%s", p.ref, short) + } + if _, ok := created[p.ref]; ok { + if strings.Contains(p.ref, "@") { + line += " (+1 :latest alias)" + } else { + line += " (+1 digest alias)" + } + } + logger.Info(line) + } + if !any { + logger.Info("no new image refs (already loaded)") } return nil } +// aliasFor decides what alias (if any) to create for a new +// post-import ref. Two cases produce a useful alias; one (the +// bare config-digest row containerd writes alongside the +// canonical ref) produces "" so the caller skips. +// +// - Tag-form ref (":") -> "@". +// Required by deployments pinning images in name:tag@sha256: +// digest form; without it kubelet's digest lookup misses the +// tag-only row in containerd's image store and falls back to +// a registry pull. +// +// - Digest-form ref ("@") -> ":latest@ +// ". Required by kubelet's checkpoint-image check on +// containerd v2: it resolves images by config-digest and +// normalizes the bare config-digest row to "docker.io/library/ +// sha256@..." (not found). A tag-form alias gives the lookup +// a parseable repo to land on. +// +// - Bare "sha256:" (the config-digest row ctr emits as a +// side effect) -> "". Treating it as a tagged ref would +// mangle it through stripTag into the literal "sha256" and +// synthesize "sha256@sha256:..." -- a garbage entry that +// poisons the image store. +func aliasFor(ref, digest string) string { + if strings.HasPrefix(ref, "sha256:") { + return "" + } + if at := strings.Index(ref, "@"); at >= 0 { + return ref[:at] + ":latest" + ref[at:] + } + return stripTag(ref) + "@" + digest +} + // TarOCIDir streams a USTAR archive of an OCI v1 layout rooted // at dir into w. The entries are dir-relative (oci-layout, // index.json, blobs/sha256/*) -- the same shape `tar -cf - -C diff --git a/pkg/images/load_test.go b/pkg/images/load_test.go index cf5275b..02c8cf1 100644 --- a/pkg/images/load_test.go +++ b/pkg/images/load_test.go @@ -259,3 +259,43 @@ func TestStripTag(t *testing.T) { } } } + +// TestAliasFor pins the post-import alias policy. Each case +// captures one of the three shapes ctr writes into the image +// store after `image import`: tag-form, digest-form, and the +// bare config-digest row that mustn't be aliased. +func TestAliasFor(t *testing.T) { + const digest = "sha256:af91c49ce795f3b2c1a4e6d8b9c0e1f2a3b4c5d6e7f80112233445566778899aa" + cases := []struct { + name, ref, want string + }{ + { + name: "tag-form -> digest alias", + ref: "ghcr.io/yolean/echo:v1", + want: "ghcr.io/yolean/echo@" + digest, + }, + { + name: "digest-form -> :latest alias (kubelet checkpoint-image lookup)", + ref: "ghcr.io/yolean/minio-deduplication@" + digest, + want: "ghcr.io/yolean/minio-deduplication:latest@" + digest, + }, + { + name: "bare config-digest row -> no alias (would mangle to sha256@sha256:...)", + ref: "sha256:dc863b8391abb7c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70819253647586978a9", + want: "", + }, + { + name: "hostport tag stripped at correct colon", + ref: "registry.example:5000/foo/bar:tag", + want: "registry.example:5000/foo/bar@" + digest, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := aliasFor(c.ref, digest) + if got != c.want { + t.Errorf("aliasFor(%q) = %q, want %q", c.ref, got, c.want) + } + }) + } +}