Skip to content

Commit

Permalink
Store digest of latest image in ImagePolicy 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.

This commit changes the format of the data stored in the caching
badger database from a list of strings to a list of `database.Tag`
objects where each tag carries a tag name and a digest value.

`ImageRepositoryReconciler` now fetched the digest of each image+tag
when it scans the registry for new tags. To accomplish this it issues
a HEAD request against the registry for each tag with the response
carrying the digest in the headers. Since this is a potentially
expensive operation involving network roundtrips for each tag, a
goroutine is spawned for each HEAD request to parallelize the fetching
process.

Migration from the old database format to the new one is taken care of
by the `badger.unmarshal` function which falls back to trying to
unmarshal the data into a string slice in case the attempt to
unmarshal it into a `database.Tag` slice fails. Subsequent `SetTags`
calls then store the data in the new format.

Because of its potential to significantly increase the amount of
network requests, the feature is disabled by default and can be
enabled using a feature flag for now.

closes #214

Signed-off-by: Max Jonas Werner <mail@makk.es>
  • Loading branch information
Max Jonas Werner committed Apr 6, 2023
1 parent 7a670e9 commit 3e9a5bf
Show file tree
Hide file tree
Showing 26 changed files with 583 additions and 253 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
6 changes: 3 additions & 3 deletions internal/controllers/controllers_fuzzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import (
fuzz "github.com/AdaLogics/go-fuzz-headers"

imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2"
"github.com/fluxcd/image-reflector-controller/internal/database"
ircbadger "github.com/fluxcd/image-reflector-controller/internal/database/badger"
"github.com/fluxcd/image-reflector-controller/internal/test"
)

Expand Down Expand Up @@ -252,7 +252,7 @@ func initFunc() {

imageRepoReconciler = &ImageRepositoryReconciler{
Client: k8sMgr.GetClient(),
Database: database.NewBadgerDatabase(badgerDB),
Database: ircbadger.NewBadgerDatabase(badgerDB),
EventRecorder: record.NewFakeRecorder(256),
patchOptions: getPatchOptions(imageRepositoryOwnedConditions, "irc"),
}
Expand All @@ -263,7 +263,7 @@ func initFunc() {

imagePolicyReconciler = &ImagePolicyReconciler{
Client: k8sMgr.GetClient(),
Database: database.NewBadgerDatabase(badgerDB),
Database: ircbadger.NewBadgerDatabase(badgerDB),
EventRecorder: record.NewFakeRecorder(256),
patchOptions: getPatchOptions(imagePolicyOwnedConditions, "irc"),
}
Expand Down
28 changes: 16 additions & 12 deletions internal/controllers/imagepolicy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import (
pkgreconcile "github.com/fluxcd/pkg/runtime/reconcile"

imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2"
"github.com/fluxcd/image-reflector-controller/internal/database"
"github.com/fluxcd/image-reflector-controller/internal/policy"
)

Expand Down Expand Up @@ -108,7 +109,7 @@ type ImagePolicyReconciler struct {
helper.Metrics

ControllerName string
Database DatabaseReader
Database database.DatabaseReader
ACLOptions acl.Options

patchOptions []patch.Option
Expand Down Expand Up @@ -259,6 +260,7 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP

// Cleanup the last result.
obj.Status.LatestImage = ""
obj.Status.LatestDigest = ""

// Get ImageRepository from reference.
repo, err := r.getImageRepository(ctx, obj)
Expand Down Expand Up @@ -317,7 +319,8 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP
}

// Write the observations on status.
obj.Status.LatestImage = repo.Spec.Image + ":" + latest
obj.Status.LatestImage = repo.Spec.Image + ":" + latest.Name
obj.Status.LatestDigest = latest.Digest
// If the old latest image and new latest image don't match, set the old
// image as the observed previous image.
// NOTE: The following allows the previous image to be set empty when
Expand All @@ -340,7 +343,7 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP
}

resultImage = repo.Spec.Image
resultTag = latest
resultTag = latest.Name

conditions.Delete(obj, meta.ReadyCondition)

Expand Down Expand Up @@ -386,36 +389,37 @@ func (r *ImagePolicyReconciler) getImageRepository(ctx context.Context, obj *ima

// applyPolicy reads the tags of the given repository from the internal database
// and applies the tag filters and constraints to return the latest image.
func (r *ImagePolicyReconciler) applyPolicy(ctx context.Context, obj *imagev1.ImagePolicy, repo *imagev1.ImageRepository) (string, error) {
func (r *ImagePolicyReconciler) applyPolicy(ctx context.Context, obj *imagev1.ImagePolicy, repo *imagev1.ImageRepository) (*database.Tag, error) {
policer, err := policy.PolicerFromSpec(obj.Spec.Policy)
if err != nil {
return "", errInvalidPolicy{err: fmt.Errorf("invalid policy: %w", err)}
return nil, errInvalidPolicy{err: fmt.Errorf("invalid policy: %w", err)}
}

// Read tags from database, apply and filter is configured and compute the
// result.
tags, err := r.Database.Tags(repo.Status.CanonicalImageName)
if err != nil {
return "", fmt.Errorf("failed to read tags from database: %w", err)
return nil, fmt.Errorf("failed to read tags from database: %w", err)
}

if len(tags) == 0 {
return "", errNoTagsInDatabase
return nil, errNoTagsInDatabase
}

// Apply tag filter.
if obj.Spec.FilterTags != nil {
filter, err := policy.NewRegexFilter(obj.Spec.FilterTags.Pattern, obj.Spec.FilterTags.Extract)
if err != nil {
return "", errInvalidPolicy{err: fmt.Errorf("failed to filter tags: %w", err)}
return nil, errInvalidPolicy{err: fmt.Errorf("failed to filter tags: %w", err)}
}
filter.Apply(tags)
tags = filter.Items()
latest, err := policer.Latest(tags)
tagNames := filter.Items()
latest, err := policer.Latest(tagNames)
if err != nil {
return "", err
return nil, err
}
return filter.GetOriginalTag(latest), nil
origTag := filter.GetOriginalTag(latest.Name)
return &origTag, nil
}
// Compute and return result.
return policer.Latest(tags)
Expand Down
38 changes: 26 additions & 12 deletions internal/controllers/imagepolicy_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"

imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2"
"github.com/fluxcd/image-reflector-controller/internal/database"
"github.com/fluxcd/image-reflector-controller/internal/policy"
)

Expand Down Expand Up @@ -231,7 +232,7 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) {
filter *imagev1.TagFilter
db *mockDatabase
wantErr bool
wantResult string
wantResult *database.Tag
}{
{
name: "invalid policy",
Expand All @@ -251,16 +252,21 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) {
wantErr: true,
},
{
name: "semver, no tag filter",
policy: imagev1.ImagePolicyChoice{SemVer: &imagev1.SemVerPolicy{Range: "1.0.x"}},
db: &mockDatabase{TagData: []string{"1.0.0", "2.0.0", "1.0.1", "1.2.0"}},
wantResult: "1.0.1",
name: "semver, no tag filter",
policy: imagev1.ImagePolicyChoice{SemVer: &imagev1.SemVerPolicy{Range: "1.0.x"}},
db: &mockDatabase{TagData: []database.Tag{
{Name: "1.0.0"},
{Name: "2.0.0"},
{Name: "1.0.1"},
{Name: "1.2.0"},
}},
wantResult: &database.Tag{Name: "1.0.1"},
},
{
name: "invalid tag filter",
policy: imagev1.ImagePolicyChoice{SemVer: &imagev1.SemVerPolicy{Range: "1.0.x"}},
filter: &imagev1.TagFilter{Pattern: "[="},
db: &mockDatabase{TagData: []string{"1.0.0", "1.0.1"}},
db: &mockDatabase{TagData: []database.Tag{{Name: "1.0.0"}, {Name: "1.0.1"}}},
wantErr: true,
},
{
Expand All @@ -270,10 +276,14 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) {
Pattern: "1.0.0-rc\\.(?P<num>[0-9]+)",
Extract: "$num",
},
db: &mockDatabase{TagData: []string{
"1.0.0", "1.0.0-rc.1", "1.0.0-rc.2", "1.0.0-rc.3", "1.0.1-rc.2",
db: &mockDatabase{TagData: []database.Tag{
{Name: "1.0.0"},
{Name: "1.0.0-rc.1"},
{Name: "1.0.0-rc.2"},
{Name: "1.0.0-rc.3"},
{Name: "1.0.1-rc.2"},
}},
wantResult: "1.0.0-rc.3",
wantResult: &database.Tag{Name: "1.0.0-rc.3"},
},
{
name: "valid tag filter with alphabetical policy",
Expand All @@ -282,10 +292,14 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) {
Pattern: "foo-(?P<word>[a-z]+)",
Extract: "$word",
},
db: &mockDatabase{TagData: []string{
"foo-aaa", "bar-bbb", "foo-zzz", "baz-nnn", "foo-ooo",
db: &mockDatabase{TagData: []database.Tag{
{Name: "foo-aaa"},
{Name: "bar-bbb"},
{Name: "foo-zzz"},
{Name: "baz-nnn"},
{Name: "foo-ooo"},
}},
wantResult: "foo-zzz",
wantResult: &database.Tag{Name: "foo-zzz"},
},
}

Expand Down
45 changes: 42 additions & 3 deletions internal/controllers/imagerepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"

"github.com/google/go-containerregistry/pkg/authn"
Expand Down Expand Up @@ -54,6 +55,7 @@ import (
"github.com/fluxcd/pkg/runtime/reconcile"

imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2"
"github.com/fluxcd/image-reflector-controller/internal/database"
"github.com/fluxcd/image-reflector-controller/internal/secret"
)

Expand Down Expand Up @@ -109,10 +111,11 @@ type ImageRepositoryReconciler struct {

ControllerName string
Database interface {
DatabaseWriter
DatabaseReader
database.DatabaseWriter
database.DatabaseReader
}
DeprecatedLoginOpts login.ProviderOptions
FetchDigests bool

patchOptions []patch.Option
}
Expand Down Expand Up @@ -516,8 +519,44 @@ func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.Image
return 0, err
}

storedTags := make([]database.Tag, 0, len(filteredTags))

resCh := make(chan database.Tag, len(filteredTags))
var wg sync.WaitGroup
for _, tag := range filteredTags {
wg.Add(1)
go func(tag string, ch chan database.Tag) {
defer wg.Done()
res := database.Tag{
Name: tag,
}

if r.FetchDigests {
tagRef, err := name.ParseReference(strings.Join([]string{ref.Context().Name(), tag}, ":"))
if err != nil {
return
}
desc, err := remote.Head(tagRef, remote.WithContext(ctx))
if err != nil {
return
}
res.Digest = desc.Digest.String()
}

resCh <- res

}(tag, resCh)
}

wg.Wait()
close(resCh)

for t := range resCh {
storedTags = append(storedTags, t)
}

canonicalName := ref.Context().String()
if err := r.Database.SetTags(canonicalName, filteredTags); err != nil {
if err := r.Database.SetTags(canonicalName, storedTags); err != nil {
return 0, fmt.Errorf("failed to set tags for %q: %w", canonicalName, err)
}

Expand Down
Loading

0 comments on commit 3e9a5bf

Please sign in to comment.