diff --git a/api/v1beta2/imagepolicy_types.go b/api/v1beta2/imagepolicy_types.go index a7369a77..672d3665 100644 --- a/api/v1beta2/imagepolicy_types.go +++ b/api/v1beta2/imagepolicy_types.go @@ -105,6 +105,10 @@ type ImagePolicyStatus struct { // the image repository, when filtered and ordered according to // the policy. LatestImage string `json:"latestImage,omitempty"` + // LatestDigest is the digest of the latest image stored in the + // accompanying LatestImage field. + // +optional + LatestDigest string `json:"latestDigest,omitempty"` // ObservedPreviousImage is the observed previous LatestImage. It is used // to keep track of the previous and current images. // +optional diff --git a/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml b/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml index 4e6ec8af..4c09094a 100644 --- a/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml +++ b/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml @@ -383,6 +383,10 @@ spec: - type type: object type: array + latestDigest: + description: LatestDigest is the digest of the latest image stored + in the accompanying LatestImage field. + type: string latestImage: description: LatestImage gives the first in the list of images scanned by the image repository, when filtered and ordered according to diff --git a/docs/api/image-reflector.md b/docs/api/image-reflector.md index f4eeceeb..b0b81091 100644 --- a/docs/api/image-reflector.md +++ b/docs/api/image-reflector.md @@ -313,6 +313,19 @@ the policy.

+latestDigest
+ +string + + + +(Optional) +

LatestDigest is the digest of the latest image stored in the +accompanying LatestImage field.

+ + + + observedPreviousImage
string diff --git a/internal/controllers/imagepolicy_controller.go b/internal/controllers/imagepolicy_controller.go index 4e4b7f77..9cb11912 100644 --- a/internal/controllers/imagepolicy_controller.go +++ b/internal/controllers/imagepolicy_controller.go @@ -20,9 +20,11 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -49,6 +51,7 @@ import ( imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" "github.com/fluxcd/image-reflector-controller/internal/policy" + "github.com/fluxcd/image-reflector-controller/internal/registry" ) // errAccessDenied is returned when an ImageRepository reference in ImagePolicy @@ -110,6 +113,7 @@ type ImagePolicyReconciler struct { ControllerName string Database DatabaseReader ACLOptions acl.Options + RegistryHelper registry.Helper patchOptions []patch.Option } @@ -258,7 +262,7 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP } // Cleanup the last result. - obj.Status.LatestImage = "" + obj.Status.LatestImage, obj.Status.LatestDigest = "", "" // Get ImageRepository from reference. repo, err := r.getImageRepository(ctx, obj) @@ -327,6 +331,14 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP if oldObj.Status.LatestImage != obj.Status.LatestImage { obj.Status.ObservedPreviousImage = oldObj.Status.LatestImage } + + if oldObj.Status.LatestImage != obj.Status.LatestImage || obj.Status.LatestDigest == "" { + obj.Status.LatestDigest, err = r.fetchDigest(ctx, repo, latest, obj) + if err != nil { + result, retErr = ctrl.Result{}, fmt.Errorf("failed fetching digest of %s: %w", obj.Status.LatestImage, err) + return + } + } // Parse the observed previous image if any and extract previous tag. This // is used to determine image tag update path. if obj.Status.ObservedPreviousImage != "" { @@ -348,6 +360,23 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP return } +func (r *ImagePolicyReconciler) fetchDigest(ctx context.Context, repo *imagev1.ImageRepository, latest string, obj *imagev1.ImagePolicy) (string, error) { + ref := strings.Join([]string{repo.Spec.Image, latest}, ":") + tagRef, err := name.ParseReference(ref) + if err != nil { + return "", fmt.Errorf("failed parsing reference %q: %w", ref, err) + } + opts, err := r.RegistryHelper.GetAuthOptions(ctx, repo, tagRef) + if err != nil { + return "", fmt.Errorf("failed to configure authentication options: %w", err) + } + desc, err := remote.Head(tagRef, opts...) + if err != nil { + return "", fmt.Errorf("failed fetching descriptor for %q: %w", tagRef.String(), err) + } + return desc.Digest.String(), nil +} + // getImageRepository tries to fetch an ImageRepository referenced by the given // ImagePolicy if it's accessible. func (r *ImagePolicyReconciler) getImageRepository(ctx context.Context, obj *imagev1.ImagePolicy) (*imagev1.ImageRepository, error) { diff --git a/internal/controllers/imagerepository_controller.go b/internal/controllers/imagerepository_controller.go index b19ed92a..d5611496 100644 --- a/internal/controllers/imagerepository_controller.go +++ b/internal/controllers/imagerepository_controller.go @@ -25,15 +25,12 @@ import ( "strings" "time" - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/authn/k8schain" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" kerrors "k8s.io/apimachinery/pkg/util/errors" kuberecorder "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" @@ -45,8 +42,6 @@ import ( eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/oci" - "github.com/fluxcd/pkg/oci/auth/login" "github.com/fluxcd/pkg/runtime/conditions" helper "github.com/fluxcd/pkg/runtime/controller" "github.com/fluxcd/pkg/runtime/patch" @@ -54,7 +49,7 @@ import ( "github.com/fluxcd/pkg/runtime/reconcile" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" - "github.com/fluxcd/image-reflector-controller/internal/secret" + "github.com/fluxcd/image-reflector-controller/internal/registry" ) // latestTagsCount is the number of tags to use as latest tags. @@ -112,7 +107,8 @@ type ImageRepositoryReconciler struct { DatabaseWriter DatabaseReader } - DeprecatedLoginOpts login.ProviderOptions + + RegistryHelper registry.Helper patchOptions []patch.Option } @@ -261,7 +257,7 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser } conditions.Delete(obj, meta.StalledCondition) - opts, err := r.setAuthOptions(ctx, obj, ref) + opts, err := r.RegistryHelper.GetAuthOptions(ctx, obj, ref) if err != nil { e := fmt.Errorf("failed to configure authentication options: %w", err) conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.AuthenticationFailedReason, e.Error()) @@ -327,107 +323,6 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser return } -// setAuthOptions returns authentication options required to scan a repository. -func (r *ImageRepositoryReconciler) setAuthOptions(ctx context.Context, obj *imagev1.ImageRepository, ref name.Reference) ([]remote.Option, error) { - timeout := obj.GetTimeout() - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Configure authentication strategy to access the registry. - var options []remote.Option - var authSecret corev1.Secret - var auth authn.Authenticator - var authErr error - - if obj.Spec.SecretRef != nil { - if err := r.Get(ctx, types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.Spec.SecretRef.Name, - }, &authSecret); err != nil { - return nil, err - } - auth, authErr = secret.AuthFromSecret(authSecret, ref) - } else { - // Build login provider options and use it to attempt registry login. - opts := login.ProviderOptions{} - switch obj.GetProvider() { - case "aws": - opts.AwsAutoLogin = true - case "azure": - opts.AzureAutoLogin = true - case "gcp": - opts.GcpAutoLogin = true - default: - opts = r.DeprecatedLoginOpts - } - auth, authErr = login.NewManager().Login(ctx, obj.Spec.Image, ref, opts) - } - if authErr != nil { - // If it's not unconfigured provider error, abort reconciliation. - // Continue reconciliation if it's unconfigured providers for scanning - // public repositories. - if !errors.Is(authErr, oci.ErrUnconfiguredProvider) { - return nil, authErr - } - } - if auth != nil { - options = append(options, remote.WithAuth(auth)) - } - - // Load any provided certificate. - if obj.Spec.CertSecretRef != nil { - var certSecret corev1.Secret - if obj.Spec.SecretRef != nil && obj.Spec.SecretRef.Name == obj.Spec.CertSecretRef.Name { - certSecret = authSecret - } else { - if err := r.Get(ctx, types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.Spec.CertSecretRef.Name, - }, &certSecret); err != nil { - return nil, err - } - } - - tr, err := secret.TransportFromSecret(&certSecret) - if err != nil { - return nil, err - } - options = append(options, remote.WithTransport(tr)) - } - - if obj.Spec.ServiceAccountName != "" { - serviceAccount := corev1.ServiceAccount{} - // Lookup service account - if err := r.Get(ctx, types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.Spec.ServiceAccountName, - }, &serviceAccount); err != nil { - return nil, err - } - - if len(serviceAccount.ImagePullSecrets) > 0 { - imagePullSecrets := make([]corev1.Secret, len(serviceAccount.ImagePullSecrets)) - for i, ips := range serviceAccount.ImagePullSecrets { - var saAuthSecret corev1.Secret - if err := r.Get(ctx, types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: ips.Name, - }, &saAuthSecret); err != nil { - return nil, err - } - imagePullSecrets[i] = saAuthSecret - } - keychain, err := k8schain.NewFromPullSecrets(ctx, imagePullSecrets) - if err != nil { - return nil, err - } - options = append(options, remote.WithAuthFromKeychain(keychain)) - } - } - - return options, nil -} - // shouldScan takes an image repo and the time now, and returns whether // the repository should be scanned now, and how long to wait for the // next scan. It also returns the reason for the scan. diff --git a/internal/controllers/imagerepository_controller_test.go b/internal/controllers/imagerepository_controller_test.go index 4c3087a5..32f32d9e 100644 --- a/internal/controllers/imagerepository_controller_test.go +++ b/internal/controllers/imagerepository_controller_test.go @@ -264,7 +264,7 @@ func TestImageRepositoryReconciler_setAuthOptions(t *testing.T) { ref, err := name.ParseReference(obj.Spec.Image) g.Expect(err).ToNot(HaveOccurred()) - _, err = r.setAuthOptions(ctx, obj, ref) + _, err = r.RegistryHelper.GetAuthOptions(ctx, obj, ref) g.Expect(err != nil).To(Equal(tt.wantErr)) }) } diff --git a/internal/registry/helper.go b/internal/registry/helper.go new file mode 100644 index 00000000..7c05d90c --- /dev/null +++ b/internal/registry/helper.go @@ -0,0 +1,29 @@ +package registry + +import ( + "context" + + imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" + "github.com/fluxcd/pkg/oci/auth/login" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Helper interface { + GetAuthOptions(ctx context.Context, obj *imagev1.ImageRepository, ref name.Reference) ([]remote.Option, error) +} + +type DefaultHelper struct { + k8sClient client.Client + DeprecatedLoginOpts login.ProviderOptions +} + +var _ Helper = DefaultHelper{} + +func NewDefaultHelper(c client.Client, deprecatedLoginOpts login.ProviderOptions) DefaultHelper { + return DefaultHelper{ + k8sClient: c, + DeprecatedLoginOpts: deprecatedLoginOpts, + } +} diff --git a/internal/registry/options.go b/internal/registry/options.go new file mode 100644 index 00000000..4ea9c905 --- /dev/null +++ b/internal/registry/options.go @@ -0,0 +1,118 @@ +package registry + +import ( + "context" + "errors" + + imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" + "github.com/fluxcd/image-reflector-controller/internal/secret" + "github.com/fluxcd/pkg/oci" + "github.com/fluxcd/pkg/oci/auth/login" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/authn/k8schain" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +// GetAuthOptions returns authentication options required to scan a repository. +func (h DefaultHelper) GetAuthOptions(ctx context.Context, obj *imagev1.ImageRepository, ref name.Reference) ([]remote.Option, error) { + timeout := obj.GetTimeout() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Configure authentication strategy to access the registry. + var options []remote.Option + var authSecret corev1.Secret + var auth authn.Authenticator + var authErr error + + if obj.Spec.SecretRef != nil { + if err := h.k8sClient.Get(ctx, types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.Spec.SecretRef.Name, + }, &authSecret); err != nil { + return nil, err + } + auth, authErr = secret.AuthFromSecret(authSecret, ref) + } else { + // Build login provider options and use it to attempt registry login. + opts := login.ProviderOptions{} + switch obj.GetProvider() { + case "aws": + opts.AwsAutoLogin = true + case "azure": + opts.AzureAutoLogin = true + case "gcp": + opts.GcpAutoLogin = true + default: + opts = h.DeprecatedLoginOpts + } + auth, authErr = login.NewManager().Login(ctx, obj.Spec.Image, ref, opts) + } + if authErr != nil { + // If it's not unconfigured provider error, abort reconciliation. + // Continue reconciliation if it's unconfigured providers for scanning + // public repositories. + if !errors.Is(authErr, oci.ErrUnconfiguredProvider) { + return nil, authErr + } + } + if auth != nil { + options = append(options, remote.WithAuth(auth)) + } + + // Load any provided certificate. + if obj.Spec.CertSecretRef != nil { + var certSecret corev1.Secret + if obj.Spec.SecretRef != nil && obj.Spec.SecretRef.Name == obj.Spec.CertSecretRef.Name { + certSecret = authSecret + } else { + if err := h.k8sClient.Get(ctx, types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.Spec.CertSecretRef.Name, + }, &certSecret); err != nil { + return nil, err + } + } + + tr, err := secret.TransportFromSecret(&certSecret) + if err != nil { + return nil, err + } + options = append(options, remote.WithTransport(tr)) + } + + if obj.Spec.ServiceAccountName != "" { + serviceAccount := corev1.ServiceAccount{} + // Lookup service account + if err := h.k8sClient.Get(ctx, types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.Spec.ServiceAccountName, + }, &serviceAccount); err != nil { + return nil, err + } + + if len(serviceAccount.ImagePullSecrets) > 0 { + imagePullSecrets := make([]corev1.Secret, len(serviceAccount.ImagePullSecrets)) + for i, ips := range serviceAccount.ImagePullSecrets { + var saAuthSecret corev1.Secret + if err := h.k8sClient.Get(ctx, types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: ips.Name, + }, &saAuthSecret); err != nil { + return nil, err + } + imagePullSecrets[i] = saAuthSecret + } + keychain, err := k8schain.NewFromPullSecrets(ctx, imagePullSecrets) + if err != nil { + return nil, err + } + options = append(options, remote.WithAuthFromKeychain(keychain)) + } + } + + return options, nil +} diff --git a/main.go b/main.go index aae1c762..12239a03 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,7 @@ import ( "github.com/fluxcd/image-reflector-controller/internal/controllers" "github.com/fluxcd/image-reflector-controller/internal/database" "github.com/fluxcd/image-reflector-controller/internal/features" + "github.com/fluxcd/image-reflector-controller/internal/registry" ) const controllerName = "image-reflector-controller" @@ -196,17 +197,20 @@ func main() { metricsH := helper.MustMakeMetrics(mgr) + deprecatedLoginOptions := login.ProviderOptions{ + AwsAutoLogin: awsAutoLogin, + AzureAutoLogin: azureAutoLogin, + GcpAutoLogin: gcpAutoLogin, + } + registryHelper := registry.NewDefaultHelper(mgr.GetClient(), deprecatedLoginOptions) + if err := (&controllers.ImageRepositoryReconciler{ Client: mgr.GetClient(), EventRecorder: eventRecorder, Metrics: metricsH, Database: db, ControllerName: controllerName, - DeprecatedLoginOpts: login.ProviderOptions{ - AwsAutoLogin: awsAutoLogin, - AzureAutoLogin: azureAutoLogin, - GcpAutoLogin: gcpAutoLogin, - }, + RegistryHelper: registryHelper, }).SetupWithManager(mgr, controllers.ImageRepositoryReconcilerOptions{ MaxConcurrentReconciles: concurrent, RateLimiter: helper.GetRateLimiter(rateLimiterOptions), @@ -221,6 +225,7 @@ func main() { Database: db, ACLOptions: aclOptions, ControllerName: controllerName, + RegistryHelper: registryHelper, }).SetupWithManager(mgr, controllers.ImagePolicyReconcilerOptions{ MaxConcurrentReconciles: concurrent, RateLimiter: helper.GetRateLimiter(rateLimiterOptions),