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 8, 2023
1 parent 4e3f96e commit 0ffd955
Show file tree
Hide file tree
Showing 13 changed files with 638 additions and 403 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
38 changes: 36 additions & 2 deletions docs/spec/v1beta2/imagepolicies.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ In the above example:
are then used to select the latest tag based on the policy defined in
`.spec.policy`.
- The latest image is constructed with the ImageRepository image and the
selected tag, and reported in the `.status.latestImage`.
selected tag, and reported in the `.status.latestImage` field.
- The selected tag's digest is reported in the `.status.latestDigest` field.

This example can be run by saving the manifest into `imagepolicy.yaml`.

Expand Down Expand Up @@ -65,6 +66,7 @@ Status:
Reason: Succeeded
Status: True
Type: Ready
Latest Digest: sha256:2d9a00b3981628a533ff43352193b1838b0a4bf6b0033444286f563205e51a2c
Latest Image: ghcr.io/stefanprodan/podinfo:5.1.4
Observed Generation: 1
Events:
Expand Down Expand Up @@ -331,7 +333,7 @@ specific ImagePolicy, e.g.

### Latest Image

The ImagePolicy reports the latest select image from the ImageRepository tags in
The ImagePolicy reports the latest selected image from the ImageRepository tags in
`.status.latestImage` for the resource.

Example:
Expand All @@ -344,8 +346,40 @@ metadata:
name: <policy-name>
status:
latestImage: ghcr.io/stefanprodan/podinfo:5.1.4
[...]
```

### Latest Digest

The ImagePolicy reports the digest value of the latest selected image from the ImageRepoistory tags
in `.status.latestDigest` for the resource. Image digests are an immutable reference to a certain image
and allow for a stricter policy to be applied in comparison to tags which are mutable.

Example:

```yaml
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
name: <policy-name>
status:
latestDigest: sha256:2d9a00b3981628a533ff43352193b1838b0a4bf6b0033444286f563205e51a2c
[...]
```

{{% alert color="warning" %}}
:warning: Note that image-reflector-controller will not update the digest in case the tag is mutated to point to a
different image version. If you really need to update the latest digest, set the field to an empty value and trigger
another reconciliation:

```sh
$ k patch imagepolicy podinfo --subresource=status --type=merge -p '{"status":{"latestDigest": null}}'
imagepolicy.image.toolkit.fluxcd.io/podinfo patched
$ flux reconcile image repository -n default podinfo
```
{{% /alert %}}

### Observed Previous Image

The ImagePolicy reports the previously observed latest image in
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)
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
138 changes: 6 additions & 132 deletions internal/controllers/imagerepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,14 @@ import (
"fmt"
"regexp"
"sort"
"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 +41,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 +106,8 @@ type ImageRepositoryReconciler struct {
DatabaseWriter
DatabaseReader
}
DeprecatedLoginOpts login.ProviderOptions

RegistryHelper registry.Helper

patchOptions []patch.Option
}
Expand Down Expand Up @@ -253,15 +248,15 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser
}

// Parse image reference.
ref, err := parseImageReference(obj.Spec.Image)
ref, err := registry.ParseImageReference(obj.Spec.Image)
if err != nil {
conditions.MarkStalled(obj, imagev1.ImageURLInvalidReason, err.Error())
result, retErr = ctrl.Result{}, nil
return
}
conditions.Delete(obj, meta.StalledCondition)

opts, err := r.setAuthOptions(ctx, obj, ref)
opts, err := r.RegistryHelper.GetAuthOptions(ctx, *obj)
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 +322,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 Expand Up @@ -462,7 +356,7 @@ func (r *ImageRepositoryReconciler) shouldScan(obj imagev1.ImageRepository, now

// If the canonical image name of the image is different from the last
// observed name, scan now.
ref, err := parseImageReference(obj.Spec.Image)
ref, err := registry.ParseImageReference(obj.Spec.Image)
if err != nil {
return false, scanInterval, "", err
}
Expand Down Expand Up @@ -563,26 +457,6 @@ func eventLogf(ctx context.Context, r kuberecorder.EventRecorder, obj runtime.Ob
r.Eventf(obj, eventType, reason, msg)
}

// parseImageReference parses the given URL into a container registry repository
// reference.
func parseImageReference(url string) (name.Reference, error) {
if s := strings.Split(url, "://"); len(s) > 1 {
return nil, fmt.Errorf(".spec.image value should not start with URL scheme; remove '%s://'", s[0])
}

ref, err := name.ParseReference(url)
if err != nil {
return nil, err
}

imageName := strings.TrimPrefix(url, ref.Context().RegistryStr())
if s := strings.Split(imageName, ":"); len(s) > 1 {
return nil, fmt.Errorf(".spec.image value should not contain a tag; remove ':%s'", s[1])
}

return ref, nil
}

// filterOutTags filters the given tags through the given regular expression
// patterns and returns a list of tags that don't match with the pattern.
func filterOutTags(tags []string, patterns []string) ([]string, error) {
Expand Down
Loading

0 comments on commit 0ffd955

Please sign in to comment.