Skip to content
Closed
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
89 changes: 86 additions & 3 deletions pkg/manifestpusher/manifestpusher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/estesp/manifest-tool/v2/pkg/registry"
"github.com/estesp/manifest-tool/v2/pkg/types"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"

corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/wait"
ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"

buildv1 "github.com/openshift/api/build/v1"
Expand Down Expand Up @@ -65,6 +69,72 @@ func (m manifestPusher) PushImageWithManifest(builds []buildv1.Build, targetImag
}
m.logger.WithField("digest", digest).Infof("Image %s created", targetImageRef)

if err := m.importManifestList(targetImageRef, digest); err != nil {
return fmt.Errorf("failed to import manifest list into ImageStreamTag: %w", err)
}

return nil
}

// importManifestList forces the ImageStreamTag to point at the manifest list
// we just pushed by creating an ImageStreamImport. The integrated registry's
// async reconciliation does not reliably pick up manifest lists pushed via
// the Docker V2 API (manifest-tool), so without this the IST can stay at a
// stale digest and downstream builds fail to find the expected architecture.
func (m manifestPusher) importManifestList(targetImageRef, digest string) error {
namespace, imageStreamName, tag, err := parseImageStreamRef(targetImageRef)
if err != nil {
return err
}

sourcePullSpec := fmt.Sprintf("%s/%s@%s", m.registryURL, targetImageRef, digest)
m.logger.WithFields(logrus.Fields{
"namespace": namespace,
"imageStream": imageStreamName,
"tag": tag,
"sourcePullSpec": sourcePullSpec,
}).Info("Importing manifest list into ImageStreamTag")

var lastErr error
if err := wait.ExponentialBackoff(wait.Backoff{Steps: 3, Duration: 1 * time.Second, Factor: 2}, func() (bool, error) {
streamImport := &imagev1.ImageStreamImport{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: imageStreamName,
},
Spec: imagev1.ImageStreamImportSpec{
Import: true,
Images: []imagev1.ImageImportSpec{
{
To: &corev1.LocalObjectReference{Name: tag},
From: corev1.ObjectReference{
Kind: "DockerImage",
Name: sourcePullSpec,
},
ImportPolicy: imagev1.TagImportPolicy{ImportMode: imagev1.ImportModePreserveOriginal, Insecure: true},
ReferencePolicy: imagev1.TagReferencePolicy{Type: imagev1.SourceTagReferencePolicy},
},
},
},
}
if err := m.client.Create(context.Background(), streamImport); err != nil {
if kerrors.IsConflict(err) || kerrors.IsForbidden(err) {
lastErr = err
return false, nil
}
return false, err
}
if len(streamImport.Status.Images) == 0 || streamImport.Status.Images[0].Image == nil {
lastErr = fmt.Errorf("import returned no image status")
return false, nil
}
Comment on lines +127 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider checking the import status for errors.

The code checks that Status.Images[0].Image is populated but doesn't verify that the import actually succeeded. The ImageImportStatus can contain a Status.Status field with an error message if the import failed. Without this check, you might silently treat a failed import as successful.

🛠️ Proposed fix to add status error check
 		if len(streamImport.Status.Images) == 0 || streamImport.Status.Images[0].Image == nil {
 			lastErr = fmt.Errorf("import returned no image status")
 			return false, nil
 		}
+		if status := streamImport.Status.Images[0].Status; status.Status == metav1.StatusFailure {
+			lastErr = fmt.Errorf("import failed: %s", status.Message)
+			return false, nil
+		}
 		return true, nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if len(streamImport.Status.Images) == 0 || streamImport.Status.Images[0].Image == nil {
lastErr = fmt.Errorf("import returned no image status")
return false, nil
}
if len(streamImport.Status.Images) == 0 || streamImport.Status.Images[0].Image == nil {
lastErr = fmt.Errorf("import returned no image status")
return false, nil
}
if status := streamImport.Status.Images[0].Status; status.Status == metav1.StatusFailure {
lastErr = fmt.Errorf("import failed: %s", status.Message)
return false, nil
}
return true, nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/manifestpusher/manifestpusher.go` around lines 127 - 130, The code
currently only checks for a non-nil Image (streamImport.Status.Images[0].Image)
but ignores the import result; inspect the corresponding
ImageImportStatus.Status (e.g., streamImport.Status.Images[0].Status.Status) and
if it indicates failure include that error text in lastErr and return an error
(or false plus an error) instead of treating it as success. Update the branch
that sets lastErr = fmt.Errorf("import returned no image status") to also check
the ImageImportStatus.Status field, populate lastErr with the Status message
when present, and return the error so callers know the import actually failed.

return true, nil
}); err != nil {
if lastErr != nil {
return fmt.Errorf("%w: %v", err, lastErr)
}
return err
}
return nil
}

Expand Down Expand Up @@ -126,14 +196,27 @@ func (m manifestPusher) manifestEntries(builds []buildv1.Build, targetImageRef s
}

func splitImageStreamTagRef(targetImageRef string) (string, string, error) {
ns, name, tag, err := parseImageStreamRef(targetImageRef)
if err != nil {
return "", "", err
}
return ns, fmt.Sprintf("%s:%s", name, tag), nil
}

// parseImageStreamRef splits a target image reference of the form
// "<namespace>/<imagestream>:<tag>" into its three components.
// splitImageStreamTagRef returns the colon-joined "name:tag" form needed for
// Kubernetes IST lookups; this function returns them separately for
// ImageStreamImport objects which need the imagestream name and tag apart.
func parseImageStreamRef(targetImageRef string) (namespace, imageStreamName, tag string, err error) {
parts := strings.SplitN(targetImageRef, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid target image reference %q, expected <namespace>/<name>:<tag>", targetImageRef)
return "", "", "", fmt.Errorf("invalid target image reference %q, expected <namespace>/<name>:<tag>", targetImageRef)
}

ref, err := util.ParseImageStreamTagReference(parts[1])
if err != nil {
return "", "", fmt.Errorf("invalid target image stream tag %q: %w", parts[1], err)
return "", "", "", fmt.Errorf("invalid target image stream tag %q: %w", parts[1], err)
}
return parts[0], fmt.Sprintf("%s:%s", ref.Name, ref.Tag), nil
return parts[0], ref.Name, ref.Tag, nil
}
166 changes: 166 additions & 0 deletions pkg/manifestpusher/manifestpusher_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package manifestpusher

import (
"context"
"testing"

"github.com/estesp/manifest-tool/v2/pkg/types"
Expand Down Expand Up @@ -193,6 +194,171 @@ func TestManifestEntries(t *testing.T) {
}
}

func TestImportManifestList(t *testing.T) {
scheme := runtime.NewScheme()
if err := imagev1.AddToScheme(scheme); err != nil {
t.Fatalf("failed to add imagev1 to scheme: %v", err)
}

var testCases = []struct {
name string
targetRef string
digest string
wantNamespace string
wantName string
wantTag string
wantPullSpec string
wantErr bool
}{
{
name: "creates ImageStreamImport with correct parameters",
targetRef: "ci-op-abc123/pipeline:src",
digest: "sha256:deadbeef",
wantNamespace: "ci-op-abc123",
wantName: "pipeline",
wantTag: "src",
wantPullSpec: "registry/ci-op-abc123/pipeline:src@sha256:deadbeef",
},
{
name: "returns error for invalid reference",
targetRef: "invalid",
digest: "sha256:deadbeef",
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var createdImport *imagev1.ImageStreamImport
client := &importCapturingClient{
Client: fakectrlruntimeclient.NewClientBuilder().WithScheme(scheme).Build(),
capturedImports: &createdImport,
}
pusher := manifestPusher{
logger: logrus.NewEntry(logrus.New()),
registryURL: "registry",
client: client,
}
err := pusher.importManifestList(tc.targetRef, tc.digest)
if tc.wantErr {
if err == nil {
t.Fatal("expected error but got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if createdImport == nil {
t.Fatal("expected ImageStreamImport to be created, but it was not")
}
if createdImport.Namespace != tc.wantNamespace {
t.Errorf("namespace: want %q, got %q", tc.wantNamespace, createdImport.Namespace)
}
if createdImport.Name != tc.wantName {
t.Errorf("name: want %q, got %q", tc.wantName, createdImport.Name)
}
if !createdImport.Spec.Import {
t.Error("expected Spec.Import to be true")
}
if len(createdImport.Spec.Images) != 1 {
t.Fatalf("expected 1 image spec, got %d", len(createdImport.Spec.Images))
}
img := createdImport.Spec.Images[0]
if img.To.Name != tc.wantTag {
t.Errorf("tag: want %q, got %q", tc.wantTag, img.To.Name)
}
if img.From.Name != tc.wantPullSpec {
t.Errorf("pullSpec: want %q, got %q", tc.wantPullSpec, img.From.Name)
}
if img.ImportPolicy.ImportMode != imagev1.ImportModePreserveOriginal {
t.Errorf("importMode: want %q, got %q", imagev1.ImportModePreserveOriginal, img.ImportPolicy.ImportMode)
}
if !img.ImportPolicy.Insecure {
t.Error("expected ImportPolicy.Insecure to be true")
}
})
}
}

// importCapturingClient wraps a fake client to capture ImageStreamImport
// creates and simulate the server populating the Status field.
type importCapturingClient struct {
ctrlruntimeclient.Client
capturedImports **imagev1.ImageStreamImport
}

func (c *importCapturingClient) Create(ctx context.Context, obj ctrlruntimeclient.Object, opts ...ctrlruntimeclient.CreateOption) error {
if isi, ok := obj.(*imagev1.ImageStreamImport); ok {
*c.capturedImports = isi.DeepCopy()
isi.Status = imagev1.ImageStreamImportStatus{
Images: []imagev1.ImageImportStatus{
{
Image: &imagev1.Image{
ObjectMeta: metav1.ObjectMeta{Name: "sha256:deadbeef"},
DockerImageReference: isi.Spec.Images[0].From.Name,
},
},
},
}
return nil
}
return c.Client.Create(ctx, obj, opts...)
}

func TestParseImageStreamRef(t *testing.T) {
var testCases = []struct {
name string
targetImageRef string
wantNamespace string
wantImageStream string
wantTag string
wantError bool
}{
{
name: "valid reference",
targetImageRef: "ci-op-abc123/pipeline:src",
wantNamespace: "ci-op-abc123",
wantImageStream: "pipeline",
wantTag: "src",
},
{
name: "invalid missing slash",
targetImageRef: "pipeline:src",
wantError: true,
},
{
name: "invalid missing tag",
targetImageRef: "ci/pipeline",
wantError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
namespace, imageStream, tag, err := parseImageStreamRef(tc.targetImageRef)
if tc.wantError {
if err == nil {
t.Fatal("expected error but got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff := cmp.Diff(tc.wantNamespace, namespace); diff != "" {
t.Fatalf("namespace mismatch (-want +got): %s", diff)
}
if diff := cmp.Diff(tc.wantImageStream, imageStream); diff != "" {
t.Fatalf("imageStream mismatch (-want +got): %s", diff)
}
if diff := cmp.Diff(tc.wantTag, tag); diff != "" {
t.Fatalf("tag mismatch (-want +got): %s", diff)
}
})
}
}

func TestSplitImageStreamTagRef(t *testing.T) {
var testCases = []struct {
name string
Expand Down