Skip to content

Commit

Permalink
Store digest of latest image in ImagePolicy's status
Browse files Browse the repository at this point in the history
The new API field `.status.latestDigest` in the `ImagePolicy` kind
stores the digest of the image referred to by the the
`.status.latestImage` field.

This new field can be used to pin an image to an immutable descriptor
rather than to a potentially moving tag, increasing the security of
workloads deployed on a cluster.

The goal is to make use of the digest in IAC so that manifests can be
updated with the actual image digest.

Signed-off-by: Max Jonas Werner <[email protected]>
  • Loading branch information
Max Jonas Werner committed May 5, 2023
1 parent e6d17ce commit b65f6b9
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 116 deletions.
4 changes: 4 additions & 0 deletions api/v1beta2/imagepolicy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/api/image-reflector.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,19 @@ the policy.</p>
</tr>
<tr>
<td>
<code>latestDigest</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>LatestDigest is the digest of the latest image stored in the
accompanying LatestImage field.</p>
</td>
</tr>
<tr>
<td>
<code>observedPreviousImage</code><br>
<em>
string
Expand Down
31 changes: 30 additions & 1 deletion internal/controllers/imagepolicy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -110,6 +113,7 @@ type ImagePolicyReconciler struct {
ControllerName string
Database DatabaseReader
ACLOptions acl.Options
RegistryHelper registry.Helper

patchOptions []patch.Option
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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) {
Expand Down
113 changes: 4 additions & 109 deletions internal/controllers/imagerepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -45,16 +42,14 @@ 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"
"github.com/fluxcd/pkg/runtime/predicates"
"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.
Expand Down Expand Up @@ -112,7 +107,8 @@ type ImageRepositoryReconciler struct {
DatabaseWriter
DatabaseReader
}
DeprecatedLoginOpts login.ProviderOptions

RegistryHelper registry.Helper

patchOptions []patch.Option
}
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/controllers/imagerepository_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
Expand Down
29 changes: 29 additions & 0 deletions internal/registry/helper.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading

0 comments on commit b65f6b9

Please sign in to comment.