diff --git a/controllers/ocirepository_controller.go b/controllers/ocirepository_controller.go index fb7ad29c..0e441f8a 100644 --- a/controllers/ocirepository_controller.go +++ b/controllers/ocirepository_controller.go @@ -25,10 +25,14 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/authn/k8schain" "github.com/google/go-containerregistry/pkg/crane" gcrv1 "github.com/google/go-containerregistry/pkg/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/uuid" kuberecorder "k8s.io/client-go/tools/record" @@ -280,8 +284,16 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration) defer cancel() + // Generates registry credential keychain + keychain, err := r.keychain(ctx, obj) + if err != nil { + e := &serror.Event{Err: err, Reason: sourcev1.OCIOperationFailedReason} + conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error()) + return sreconcile.ResultEmpty, e + } + // Determine which artifact revision to pull - url, err := r.getArtifactURL(ctxTimeout, obj) + url, err := r.getArtifactURL(ctxTimeout, obj, keychain) if err != nil { e := &serror.Event{Err: err, Reason: sourcev1.OCIOperationFailedReason} conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error()) @@ -289,7 +301,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour } // Pull artifact from the remote container registry - img, err := crane.Pull(url, r.craneOptions(ctxTimeout)...) + img, err := crane.Pull(url, r.craneOptions(ctxTimeout, keychain)...) if err != nil { e := &serror.Event{Err: err, Reason: sourcev1.OCIOperationFailedReason} conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error()) @@ -352,7 +364,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour } // getArtifactURL determines which tag or digest should be used and returns the OCI artifact FQN. -func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourcev1.OCIRepository) (string, error) { +func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourcev1.OCIRepository, keychain authn.Keychain) (string, error) { url := obj.Spec.URL if obj.Spec.Reference != nil { if obj.Spec.Reference.Digest != "" { @@ -360,7 +372,7 @@ func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourc } if obj.Spec.Reference.SemVer != "" { - tag, err := r.getTagBySemver(ctx, url, obj.Spec.Reference.SemVer) + tag, err := r.getTagBySemver(ctx, url, obj.Spec.Reference.SemVer, keychain) if err != nil { return "", err } @@ -377,8 +389,8 @@ func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourc // getTagBySemver call the remote container registry, fetches all the tags from the repository, // and returns the latest tag according to the semver expression. -func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context, url, exp string) (string, error) { - tags, err := crane.ListTags(url, r.craneOptions(ctx)...) +func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context, url, exp string, keychain authn.Keychain) (string, error) { + tags, err := crane.ListTags(url, r.craneOptions(ctx, keychain)...) if err != nil { return "", err } @@ -408,11 +420,56 @@ func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context, url, exp s return matchingVersions[0].Original(), nil } +// keychain generates the credential keychain based on the resource +// configuration. If no auth is specified a default keychain with +// anonymous access is returned +func (r *OCIRepositoryReconciler) keychain(ctx context.Context, obj *sourcev1.OCIRepository) (authn.Keychain, error) { + pullSecretNames := sets.NewString() + + // lookup auth secret + if obj.Spec.SecretRef != nil { + pullSecretNames.Insert(obj.Spec.SecretRef.Name) + } + + // lookup service account + if obj.Spec.ServiceAccountName != "" { + serviceAccountName := obj.Spec.ServiceAccountName + serviceAccount := corev1.ServiceAccount{} + err := r.Get(ctx, types.NamespacedName{Namespace: obj.Namespace, Name: serviceAccountName}, &serviceAccount) + if err != nil { + return nil, err + } + for _, ips := range serviceAccount.ImagePullSecrets { + pullSecretNames.Insert(ips.Name) + } + } + + // if no pullsecrets available return DefaultKeyChain + if len(pullSecretNames) == 0 { + return authn.DefaultKeychain, nil + } + + // lookup image pull secrets + imagePullSecrets := make([]corev1.Secret, len(pullSecretNames)) + for i, imagePullSecretName := range pullSecretNames.List() { + imagePullSecret := corev1.Secret{} + err := r.Get(ctx, types.NamespacedName{Namespace: obj.Namespace, Name: imagePullSecretName}, &imagePullSecret) + if err != nil { + r.eventLogf(ctx, obj, events.EventSeverityTrace, "secret %q not found", imagePullSecretName) + return nil, err + } + imagePullSecrets[i] = imagePullSecret + } + + return k8schain.NewFromPullSecrets(ctx, imagePullSecrets) +} + // craneOptions sets the timeout and user agent for all operations against remote container registries. -func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context) []crane.Option { +func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context, keychain authn.Keychain) []crane.Option { return []crane.Option{ crane.WithContext(ctx), crane.WithUserAgent("flux/v2"), + crane.WithAuthFromKeychain(keychain), } } diff --git a/controllers/ocirepository_controller_test.go b/controllers/ocirepository_controller_test.go index 044d8666..bcae3ad1 100644 --- a/controllers/ocirepository_controller_test.go +++ b/controllers/ocirepository_controller_test.go @@ -1,6 +1,27 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ package controllers import ( + "fmt" + "net/http/httptest" + "net/url" + "os" + "path" + "path/filepath" "testing" "time" @@ -8,8 +29,14 @@ import ( "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/patch" + "github.com/fluxcd/pkg/untar" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status" @@ -17,24 +44,60 @@ import ( ) func TestOCIRepository_Reconcile(t *testing.T) { + g := NewWithT(t) + + // Registry server with public images + regServer := httptest.NewServer(registry.New()) + versions := []string{"6.1.4", "6.1.5", "6.1.6"} + podinfoVersions := make(map[string]podinfoImage) + + for i := 0; i < len(versions); i++ { + pi, err := createPodinfoImageFromTar(fmt.Sprintf("podinfo-%s.tar", versions[i]), versions[i], regServer) + g.Expect(err).ToNot(HaveOccurred()) + + podinfoVersions[versions[i]] = *pi + + } + tests := []struct { - name string - url string - tag string - semver string - digest string + name string + url string + tag string + semver string + digest string + assertArtifact []artifactFixture }{ { name: "public tag", - url: "ghcr.io/stefanprodan/manifests/podinfo", - tag: "6.1.6", - digest: "3b6cdcc7adcc9a84d3214ee1c029543789d90b5ae69debe9efa3f66e982875de", + url: podinfoVersions["6.1.6"].url, + tag: podinfoVersions["6.1.6"].tag, + digest: podinfoVersions["6.1.6"].digest.Hex, + assertArtifact: []artifactFixture{ + { + expectedPath: "kustomize/deployment.yaml", + expectedChecksum: "6fd625effe6bb805b6a78943ee082a4412e763edb7fcaed6e8fe644d06cbf423", + }, + { + expectedPath: "kustomize/hpa.yaml", + expectedChecksum: "d20e92e3b2926ebfee1644be0f4d0abadebfa95a8005c12f71bfd534a4be4ff9", + }, + }, }, { name: "public semver", - url: "ghcr.io/stefanprodan/manifests/podinfo", + url: podinfoVersions["6.1.5"].url, semver: ">= 6.1 <= 6.1.5", - digest: "1d1bf6980fc86f69481bd8c875c531aa23d761ac890ce2594d4df2b39ecd8713", + digest: podinfoVersions["6.1.5"].digest.Hex, + assertArtifact: []artifactFixture{ + { + expectedPath: "kustomize/deployment.yaml", + expectedChecksum: "dce4f5f780a8e8994b06031e5b567bf488ceaaaabd9bd3fc278b4f3bfc8c577b", + }, + { + expectedPath: "kustomize/hpa.yaml", + expectedChecksum: "d20e92e3b2926ebfee1644be0f4d0abadebfa95a8005c12f71bfd534a4be4ff9", + }, + }, }, } @@ -95,6 +158,36 @@ func TestOCIRepository_Reconcile(t *testing.T) { // Check if the revision matches the expected digest g.Expect(obj.Status.Artifact.Revision).To(Equal(tt.digest)) + // Check if the artifact storage path matches the expected file path + localPath := testStorage.LocalPath(*obj.Status.Artifact) + t.Logf("artifact local path: %s", localPath) + + f, err := os.Open(localPath) + g.Expect(err).ToNot(HaveOccurred()) + defer f.Close() + + // create a tmp directory to extract artifact + tmp, err := os.MkdirTemp("", "ocirepository-test-") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(tmp) + + ep, err := untar.Untar(f, tmp) + g.Expect(err).ToNot(HaveOccurred()) + t.Logf("extracted summary: %s", ep) + + for _, af := range tt.assertArtifact { + expectedFile := filepath.Join(tmp, af.expectedPath) + g.Expect(expectedFile).To(BeAnExistingFile()) + + f2, err := os.Open(expectedFile) + g.Expect(err).ToNot(HaveOccurred()) + defer f2.Close() + + h := testStorage.Checksum(f2) + t.Logf("file %q hash: %q", expectedFile, h) + g.Expect(h).To(Equal(af.expectedChecksum)) + } + // Check if the object status is valid condns := &status.Conditions{NegativePolarity: ociRepositoryReadyCondition.NegativePolarity} checker := status.NewChecker(testEnv.Client, condns) @@ -133,3 +226,405 @@ func TestOCIRepository_Reconcile(t *testing.T) { }) } } + +func TestOCIRepository_SecretRef(t *testing.T) { + g := NewWithT(t) + + // Instantiate Authenticated Registry Server + regServer, err := setupRegistryServer(ctx) + g.Expect(err).ToNot(HaveOccurred()) + + // Create Test Image + image, err := crane.Load(path.Join("testdata", "podinfo", "podinfo-6.1.6.tar")) + g.Expect(err).ToNot(HaveOccurred()) + + repositoryURL := fmt.Sprintf("%s/podinfo", regServer.registryHost) + + // Push Test Image + err = crane.Push(image, repositoryURL, crane.WithAuth(&authn.Basic{ + Username: testRegistryUsername, + Password: testRegistryPassword, + })) + g.Expect(err).ToNot(HaveOccurred()) + + // Test Image digest + podinfoImageDigest, err := image.Digest() + g.Expect(err).ToNot(HaveOccurred()) + + tests := []struct { + name string + url string + digest v1.Hash + includeSecretRef bool + includeServiceAccount bool + }{ + { + name: "private-registry-access-via-secretref", + url: repositoryURL, + digest: podinfoImageDigest, + includeSecretRef: true, + includeServiceAccount: false, + }, + { + name: "private-registry-access-via-serviceaccount", + url: repositoryURL, + digest: podinfoImageDigest, + includeSecretRef: false, + includeServiceAccount: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + ns, err := testEnv.CreateNamespace(ctx, "ocirepository-test") + g.Expect(err).ToNot(HaveOccurred()) + defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "auth-secretref", + Namespace: ns.Name, + }, + Type: corev1.SecretTypeDockerConfigJson, + StringData: map[string]string{ + ".dockerconfigjson": fmt.Sprintf(`{"auths": {%q: {"username": %q, "password": %q}}}`, tt.url, testRegistryUsername, testRegistryPassword), + }, + } + g.Expect(testEnv.CreateAndWait(ctx, secret)).To(Succeed()) + defer func() { g.Expect(testEnv.Delete(ctx, secret)).To(Succeed()) }() + + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "sa-ocitest", + Namespace: ns.Name, + }, + ImagePullSecrets: []corev1.LocalObjectReference{{Name: secret.Name}}, + } + g.Expect(testEnv.CreateAndWait(ctx, serviceAccount)).To(Succeed()) + defer func() { g.Expect(testEnv.Delete(ctx, serviceAccount)).To(Succeed()) }() + + obj := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ocirepository-test-resource", + Namespace: ns.Name, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: tt.url, + Interval: metav1.Duration{Duration: 60 * time.Minute}, + Reference: &sourcev1.OCIRepositoryRef{Digest: tt.digest.String()}, + }, + } + + if tt.includeSecretRef { + obj.Spec.SecretRef = &meta.LocalObjectReference{Name: secret.Name} + } + + if tt.includeServiceAccount { + obj.Spec.ServiceAccountName = serviceAccount.Name + } + + g.Expect(testEnv.Create(ctx, obj)).To(Succeed()) + + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + // Wait for the finalizer to be set + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + return len(obj.Finalizers) > 0 + }, timeout).Should(BeFalse()) + + // Wait for the object to be Ready + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + if !conditions.IsReady(obj) { + return false + } + readyCondition := conditions.Get(obj, meta.ReadyCondition) + return obj.Generation == readyCondition.ObservedGeneration && + obj.Generation == obj.Status.ObservedGeneration + }, timeout).Should(BeTrue()) + + t.Log(obj.Status.Artifact.Revision) + + // Check if the revision matches the expected digest + g.Expect(obj.Status.Artifact.Revision).To(Equal(tt.digest.Hex)) + + // Check if the artifact storage path matches the expected file path + localPath := testStorage.LocalPath(*obj.Status.Artifact) + t.Logf("artifact local path: %s", localPath) + + f, err := os.Open(localPath) + g.Expect(err).ToNot(HaveOccurred()) + defer f.Close() + + // create a tmp directory to extract artifact + tmp, err := os.MkdirTemp("", "ocirepository-test-") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(tmp) + + ep, err := untar.Untar(f, tmp) + g.Expect(err).ToNot(HaveOccurred()) + t.Logf("extracted summary: %s", ep) + + expectedFile := filepath.Join(tmp, `kustomize/deployment.yaml`) + g.Expect(expectedFile).To(BeAnExistingFile()) + + f2, err := os.Open(expectedFile) + g.Expect(err).ToNot(HaveOccurred()) + defer f2.Close() + + h := testStorage.Checksum(f2) + t.Logf("hash: %q", h) + g.Expect(h).To(Equal("6fd625effe6bb805b6a78943ee082a4412e763edb7fcaed6e8fe644d06cbf423")) + + // Check if the object status is valid + condns := &status.Conditions{NegativePolarity: ociRepositoryReadyCondition.NegativePolarity} + checker := status.NewChecker(testEnv.Client, condns) + checker.CheckErr(ctx, obj) + + // kstatus client conformance check + u, err := patch.ToUnstructured(obj) + g.Expect(err).ToNot(HaveOccurred()) + res, err := kstatus.Compute(u) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(res.Status).To(Equal(kstatus.CurrentStatus)) + + // Patch the object with reconcile request annotation. + patchHelper, err := patch.NewHelper(obj, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + annotations := map[string]string{ + meta.ReconcileRequestAnnotation: "now", + } + obj.SetAnnotations(annotations) + g.Expect(patchHelper.Patch(ctx, obj)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + return obj.Status.LastHandledReconcileAt == "now" + }, timeout).Should(BeTrue()) + + // Wait for the object to be deleted + g.Expect(testEnv.Delete(ctx, obj)).To(Succeed()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return apierrors.IsNotFound(err) + } + return false + }, timeout).Should(BeTrue()) + + }) + } +} + +func TestOCIRepository_FailedAuth(t *testing.T) { + g := NewWithT(t) + + // Instantiate Authenticated Registry Server + regServer, err := setupRegistryServer(ctx) + g.Expect(err).ToNot(HaveOccurred()) + + // Create Test Image + image, err := crane.Load(path.Join("testdata", "podinfo", "podinfo-6.1.6.tar")) + g.Expect(err).ToNot(HaveOccurred()) + + repositoryURL := fmt.Sprintf("%s/podinfo", regServer.registryHost) + + // Push Test Image + err = crane.Push(image, repositoryURL, crane.WithAuth(&authn.Basic{ + Username: testRegistryUsername, + Password: testRegistryPassword, + })) + g.Expect(err).ToNot(HaveOccurred()) + + // Test Image digest + podinfoImageDigest, err := image.Digest() + g.Expect(err).ToNot(HaveOccurred()) + + tests := []struct { + name string + url string + digest v1.Hash + repoUsername string + repoPassword string + includeSecretRef bool + includeServiceAccount bool + }{ + { + name: "missing-auth", + url: repositoryURL, + repoUsername: "", + repoPassword: "", + digest: podinfoImageDigest, + includeSecretRef: false, + includeServiceAccount: false, + }, + { + name: "invalid-auth-via-secret", + url: repositoryURL, + repoUsername: "InvalidUser", + repoPassword: "InvalidPassword", + digest: podinfoImageDigest, + includeSecretRef: true, + includeServiceAccount: false, + }, + { + name: "invalid-auth-via-service-account", + url: repositoryURL, + repoUsername: "InvalidUser", + repoPassword: "InvalidPassword", + digest: podinfoImageDigest, + includeSecretRef: false, + includeServiceAccount: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + ns, err := testEnv.CreateNamespace(ctx, "ocirepository-test") + g.Expect(err).ToNot(HaveOccurred()) + defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "auth-secretref", + Namespace: ns.Name, + }, + Type: corev1.SecretTypeDockerConfigJson, + StringData: map[string]string{ + ".dockerconfigjson": fmt.Sprintf(`{"auths": {%q: {"username": %q, "password": %q}}}`, tt.url, tt.repoUsername, tt.repoPassword), + }, + } + g.Expect(testEnv.CreateAndWait(ctx, secret)).To(Succeed()) + defer func() { g.Expect(testEnv.Delete(ctx, secret)).To(Succeed()) }() + + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "sa-ocitest", + Namespace: ns.Name, + }, + ImagePullSecrets: []corev1.LocalObjectReference{{Name: secret.Name}}, + } + g.Expect(testEnv.CreateAndWait(ctx, serviceAccount)).To(Succeed()) + defer func() { g.Expect(testEnv.Delete(ctx, serviceAccount)).To(Succeed()) }() + + obj := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ocirepository-test-resource", + Namespace: ns.Name, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: tt.url, + Interval: metav1.Duration{Duration: 60 * time.Minute}, + Reference: &sourcev1.OCIRepositoryRef{Digest: tt.digest.String()}, + }, + } + + if tt.includeSecretRef { + obj.Spec.SecretRef = &meta.LocalObjectReference{Name: secret.Name} + } + + if tt.includeServiceAccount { + obj.Spec.ServiceAccountName = serviceAccount.Name + } + + g.Expect(testEnv.Create(ctx, obj)).To(Succeed()) + + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + failedObj := sourcev1.OCIRepository{} + + // Wait for the finalizer to be set + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, &failedObj); err != nil { + return false + } + return len(failedObj.Finalizers) > 0 + }, timeout).Should(BeTrue()) + + // Wait for the object to fail + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, &failedObj); err != nil { + return false + } + readyCondition := conditions.Get(&failedObj, meta.ReadyCondition) + if readyCondition == nil { + return false + } + return obj.Generation == readyCondition.ObservedGeneration && + !conditions.IsReady(&failedObj) + }, timeout).Should(BeTrue()) + + g.Expect(testEnv.Get(ctx, key, &failedObj)).To(Succeed()) + readyCondition := conditions.Get(&failedObj, meta.ReadyCondition) + g.Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(readyCondition.Message).Should(ContainSubstring("UNAUTHORIZED: authentication required; [map[Action:pull Class: Name:podinfo Type:repository]]")) + + // Wait for the object to be deleted + g.Expect(testEnv.Delete(ctx, &failedObj)).To(Succeed()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, &failedObj); err != nil { + return apierrors.IsNotFound(err) + } + return false + }, timeout).Should(BeTrue()) + }) + } +} + +type artifactFixture struct { + expectedPath string + expectedChecksum string +} +type podinfoImage struct { + url string + tag string + digest v1.Hash +} + +func createPodinfoImageFromTar(tarFileName, tag string, imageServer *httptest.Server) (*podinfoImage, error) { + + // Create Image + image, err := crane.Load(path.Join("testdata", "podinfo", tarFileName)) + if err != nil { + return nil, err + } + + url, err := url.Parse(imageServer.URL) + if err != nil { + return nil, err + } + repositoryURL := fmt.Sprintf("%s/podinfo", url.Host) + + // Image digest + podinfoImageDigest, err := image.Digest() + if err != nil { + return nil, err + } + + // Push image + err = crane.Push(image, repositoryURL) + if err != nil { + return nil, err + } + + // Tag the image + err = crane.Tag(repositoryURL, tag) + if err != nil { + return nil, err + } + + return &podinfoImage{ + url: repositoryURL, + tag: tag, + digest: podinfoImageDigest, + }, nil +} diff --git a/controllers/testdata/podinfo/podinfo-6.1.4.tar b/controllers/testdata/podinfo/podinfo-6.1.4.tar new file mode 100644 index 00000000..dbc58051 Binary files /dev/null and b/controllers/testdata/podinfo/podinfo-6.1.4.tar differ diff --git a/controllers/testdata/podinfo/podinfo-6.1.5.tar b/controllers/testdata/podinfo/podinfo-6.1.5.tar new file mode 100644 index 00000000..335d6a5a Binary files /dev/null and b/controllers/testdata/podinfo/podinfo-6.1.5.tar differ diff --git a/controllers/testdata/podinfo/podinfo-6.1.6.tar b/controllers/testdata/podinfo/podinfo-6.1.6.tar new file mode 100644 index 00000000..09616c2d Binary files /dev/null and b/controllers/testdata/podinfo/podinfo-6.1.6.tar differ