From 1477468e6432dd5a04da5d0114529bc0f905d735 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Thu, 11 Jun 2026 11:51:25 +0200 Subject: [PATCH 1/2] oc adm release new: Include base image's image-references in pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CVO image is the release base image but lacks the io.openshift.release.operator=true label, so its manifests are not extracted during release building. This means any images declared in the CVO's install/image-references (like cluster-update-console-plugin) are not seen by pruneUnreferencedImageStreams and get dropped from the release payload. When the base image has no operator label, still extract its manifests so the image-references file is available during pruning. Exclude the base image from the ordered list so its manifests are not duplicated into release-manifests/ — they already live in /manifests/ from the base layer. --- pkg/cli/admin/release/new.go | 14 +++ pkg/cli/admin/release/new_test.go | 166 ++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/pkg/cli/admin/release/new.go b/pkg/cli/admin/release/new.go index b2f0858100..87faa4e04e 100644 --- a/pkg/cli/admin/release/new.go +++ b/pkg/cli/admin/release/new.go @@ -15,6 +15,7 @@ import ( "path" "path/filepath" "regexp" + "slices" "sort" "strings" "sync" @@ -711,6 +712,12 @@ func (o *NewOptions) Run(ctx context.Context) error { } } ordered = filteredNames + + if len(o.ToImageBaseTag) > 0 { + ordered = slices.DeleteFunc(ordered, func(s string) bool { + return s == o.ToImageBaseTag + }) + } } if len(o.Mirror) > 0 { @@ -1035,6 +1042,13 @@ func (o *NewOptions) extractManifests(is *imageapi.ImageStream, name string, met } if len(labels[annotationReleaseOperator]) == 0 { + if tag.Name == o.ToImageBaseTag { + if err := os.MkdirAll(dstDir, 0777); err != nil { + return false, err + } + klog.V(2).Infof("Image %s is the release base image, extracting for image-references", m.ImageRef) + return true, nil + } klog.V(2).Infof("Image %s has no %s label, skipping", m.ImageRef, annotationReleaseOperator) return false, nil } diff --git a/pkg/cli/admin/release/new_test.go b/pkg/cli/admin/release/new_test.go index 8391747a09..928e78a59b 100644 --- a/pkg/cli/admin/release/new_test.go +++ b/pkg/cli/admin/release/new_test.go @@ -1,7 +1,12 @@ package release import ( + "bytes" "context" + "encoding/json" + "os" + "path/filepath" + "slices" "strings" "testing" @@ -12,6 +17,167 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func writeImageReferences(t *testing.T, dir string, tagNames []string) { + t.Helper() + is := &imageapi.ImageStream{ + TypeMeta: metav1.TypeMeta{ + Kind: "ImageStream", + APIVersion: "image.openshift.io/v1", + }, + } + for _, name := range tagNames { + is.Spec.Tags = append(is.Spec.Tags, imageapi.TagReference{ + Name: name, + From: &corev1.ObjectReference{ + Kind: "DockerImage", + Name: "example.com/" + name + ":latest", + }, + }) + } + data, err := json.Marshal(is) + if err != nil { + t.Fatalf("failed to marshal image-references: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "image-references"), data, 0644); err != nil { + t.Fatalf("failed to write image-references: %v", err) + } +} + +func tagNames(is *imageapi.ImageStream) []string { + var names []string + for _, tag := range is.Spec.Tags { + names = append(names, tag.Name) + } + return names +} + +func TestPruneUnreferencedImageStreams(t *testing.T) { + makeIS := func(names ...string) *imageapi.ImageStream { + is := &imageapi.ImageStream{} + for _, name := range names { + is.Spec.Tags = append(is.Spec.Tags, imageapi.TagReference{ + Name: name, + From: &corev1.ObjectReference{Kind: "DockerImage", Name: "example.com/" + name + ":latest"}, + }) + } + return is + } + + t.Run("images referenced by operator image-references are kept", func(t *testing.T) { + dir := t.TempDir() + operatorDir := filepath.Join(dir, "my-operator") + if err := os.MkdirAll(operatorDir, 0777); err != nil { + t.Fatal(err) + } + writeImageReferences(t, operatorDir, []string{"helper-image"}) + + is := makeIS("my-operator", "helper-image", "unreferenced-image") + metadata := map[string]imageData{ + "my-operator": {Directory: operatorDir}, + } + + if err := pruneUnreferencedImageStreams(&bytes.Buffer{}, is, metadata, []string{"my-operator"}); err != nil { + t.Fatal(err) + } + + names := tagNames(is) + if !slices.Contains(names, "my-operator") { + t.Error("expected my-operator to be kept (in include list)") + } + if !slices.Contains(names, "helper-image") { + t.Error("expected helper-image to be kept (referenced by operator image-references)") + } + if slices.Contains(names, "unreferenced-image") { + t.Error("expected unreferenced-image to be pruned") + } + }) + + t.Run("base image image-references prevents pruning", func(t *testing.T) { + dir := t.TempDir() + + operatorDir := filepath.Join(dir, "my-operator") + if err := os.MkdirAll(operatorDir, 0777); err != nil { + t.Fatal(err) + } + writeImageReferences(t, operatorDir, []string{"operator-dep"}) + + baseDir := filepath.Join(dir, "cluster-version-operator") + if err := os.MkdirAll(baseDir, 0777); err != nil { + t.Fatal(err) + } + writeImageReferences(t, baseDir, []string{"cluster-update-console-plugin"}) + + is := makeIS("cluster-version-operator", "my-operator", "operator-dep", "cluster-update-console-plugin") + metadata := map[string]imageData{ + "my-operator": {Directory: operatorDir}, + "cluster-version-operator": {Directory: baseDir}, + } + + if err := pruneUnreferencedImageStreams(&bytes.Buffer{}, is, metadata, []string{"cluster-version-operator", "my-operator"}); err != nil { + t.Fatal(err) + } + + names := tagNames(is) + if !slices.Contains(names, "cluster-update-console-plugin") { + t.Error("expected cluster-update-console-plugin to be kept (referenced by base image image-references)") + } + if !slices.Contains(names, "operator-dep") { + t.Error("expected operator-dep to be kept (referenced by operator image-references)") + } + }) + + t.Run("without base image image-references the image is pruned", func(t *testing.T) { + dir := t.TempDir() + + operatorDir := filepath.Join(dir, "my-operator") + if err := os.MkdirAll(operatorDir, 0777); err != nil { + t.Fatal(err) + } + writeImageReferences(t, operatorDir, []string{"operator-dep"}) + + is := makeIS("cluster-version-operator", "my-operator", "operator-dep", "cluster-update-console-plugin") + metadata := map[string]imageData{ + "my-operator": {Directory: operatorDir}, + } + + if err := pruneUnreferencedImageStreams(&bytes.Buffer{}, is, metadata, []string{"cluster-version-operator", "my-operator"}); err != nil { + t.Fatal(err) + } + + names := tagNames(is) + if slices.Contains(names, "cluster-update-console-plugin") { + t.Error("expected cluster-update-console-plugin to be pruned (not referenced by any image-references)") + } + }) +} + +func TestBaseImageExcludedFromOrdered(t *testing.T) { + baseTag := "cluster-version-operator" + + t.Run("base image tag is removed from ordered", func(t *testing.T) { + ordered := []string{"my-operator", "cluster-version-operator", "another-operator"} + ordered = slices.DeleteFunc(ordered, func(s string) bool { + return s == baseTag + }) + if slices.Contains(ordered, baseTag) { + t.Errorf("expected %s to be removed from ordered", baseTag) + } + if len(ordered) != 2 { + t.Errorf("expected 2 entries, got %d", len(ordered)) + } + }) + + t.Run("ordered unchanged when base image not present", func(t *testing.T) { + ordered := []string{"my-operator", "another-operator"} + ordered = slices.DeleteFunc(ordered, func(s string) bool { + return s == baseTag + }) + if len(ordered) != 2 { + t.Errorf("expected 2 entries, got %d", len(ordered)) + } + }) +} + func TestMirrorImages(t *testing.T) { ctx := context.Background() From 6af748681d533ffc060db85fc2e2f226ddd9fe66 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Fri, 12 Jun 2026 10:59:22 +0200 Subject: [PATCH 2/2] fixup: Address test review feedback - Drop TestBaseImageExcludedFromOrdered (just exercised stdlib slices) - Extract createImageStream helper shared by writeImageReferences and tests --- pkg/cli/admin/release/new_test.go | 57 ++++++++----------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/pkg/cli/admin/release/new_test.go b/pkg/cli/admin/release/new_test.go index 928e78a59b..0103768885 100644 --- a/pkg/cli/admin/release/new_test.go +++ b/pkg/cli/admin/release/new_test.go @@ -17,15 +17,15 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func writeImageReferences(t *testing.T, dir string, tagNames []string) { - t.Helper() +// createImageStream builds an ImageStream with tags for the given names. +func createImageStream(names ...string) *imageapi.ImageStream { is := &imageapi.ImageStream{ TypeMeta: metav1.TypeMeta{ Kind: "ImageStream", APIVersion: "image.openshift.io/v1", }, } - for _, name := range tagNames { + for _, name := range names { is.Spec.Tags = append(is.Spec.Tags, imageapi.TagReference{ Name: name, From: &corev1.ObjectReference{ @@ -34,6 +34,13 @@ func writeImageReferences(t *testing.T, dir string, tagNames []string) { }, }) } + return is +} + +// writeImageReferences creates an image-references file in dir with the given tag names. +func writeImageReferences(t *testing.T, dir string, names []string) { + t.Helper() + is := createImageStream(names...) data, err := json.Marshal(is) if err != nil { t.Fatalf("failed to marshal image-references: %v", err) @@ -43,6 +50,7 @@ func writeImageReferences(t *testing.T, dir string, tagNames []string) { } } +// tagNames returns the names of all tags in the image stream. func tagNames(is *imageapi.ImageStream) []string { var names []string for _, tag := range is.Spec.Tags { @@ -52,16 +60,6 @@ func tagNames(is *imageapi.ImageStream) []string { } func TestPruneUnreferencedImageStreams(t *testing.T) { - makeIS := func(names ...string) *imageapi.ImageStream { - is := &imageapi.ImageStream{} - for _, name := range names { - is.Spec.Tags = append(is.Spec.Tags, imageapi.TagReference{ - Name: name, - From: &corev1.ObjectReference{Kind: "DockerImage", Name: "example.com/" + name + ":latest"}, - }) - } - return is - } t.Run("images referenced by operator image-references are kept", func(t *testing.T) { dir := t.TempDir() @@ -71,7 +69,7 @@ func TestPruneUnreferencedImageStreams(t *testing.T) { } writeImageReferences(t, operatorDir, []string{"helper-image"}) - is := makeIS("my-operator", "helper-image", "unreferenced-image") + is := createImageStream("my-operator", "helper-image", "unreferenced-image") metadata := map[string]imageData{ "my-operator": {Directory: operatorDir}, } @@ -107,7 +105,7 @@ func TestPruneUnreferencedImageStreams(t *testing.T) { } writeImageReferences(t, baseDir, []string{"cluster-update-console-plugin"}) - is := makeIS("cluster-version-operator", "my-operator", "operator-dep", "cluster-update-console-plugin") + is := createImageStream("cluster-version-operator", "my-operator", "operator-dep", "cluster-update-console-plugin") metadata := map[string]imageData{ "my-operator": {Directory: operatorDir}, "cluster-version-operator": {Directory: baseDir}, @@ -135,7 +133,7 @@ func TestPruneUnreferencedImageStreams(t *testing.T) { } writeImageReferences(t, operatorDir, []string{"operator-dep"}) - is := makeIS("cluster-version-operator", "my-operator", "operator-dep", "cluster-update-console-plugin") + is := createImageStream("cluster-version-operator", "my-operator", "operator-dep", "cluster-update-console-plugin") metadata := map[string]imageData{ "my-operator": {Directory: operatorDir}, } @@ -151,33 +149,6 @@ func TestPruneUnreferencedImageStreams(t *testing.T) { }) } -func TestBaseImageExcludedFromOrdered(t *testing.T) { - baseTag := "cluster-version-operator" - - t.Run("base image tag is removed from ordered", func(t *testing.T) { - ordered := []string{"my-operator", "cluster-version-operator", "another-operator"} - ordered = slices.DeleteFunc(ordered, func(s string) bool { - return s == baseTag - }) - if slices.Contains(ordered, baseTag) { - t.Errorf("expected %s to be removed from ordered", baseTag) - } - if len(ordered) != 2 { - t.Errorf("expected 2 entries, got %d", len(ordered)) - } - }) - - t.Run("ordered unchanged when base image not present", func(t *testing.T) { - ordered := []string{"my-operator", "another-operator"} - ordered = slices.DeleteFunc(ordered, func(s string) bool { - return s == baseTag - }) - if len(ordered) != 2 { - t.Errorf("expected 2 entries, got %d", len(ordered)) - } - }) -} - func TestMirrorImages(t *testing.T) { ctx := context.Background()