diff options
| -rw-r--r-- | .github/actions/spelling/allow.txt | 6 | ||||
| -rw-r--r-- | .github/workflows/ci-tests.yaml | 6 | ||||
| -rw-r--r-- | pkg/image/options.go | 16 | ||||
| -rw-r--r-- | pkg/image/version.go | 9 | ||||
| -rw-r--r-- | pkg/registry/client.go | 93 | ||||
| -rw-r--r-- | pkg/registry/mocks/RegistryClient.go | 51 | ||||
| -rw-r--r-- | pkg/registry/registry.go | 55 | ||||
| -rw-r--r-- | pkg/registry/registry_test.go | 48 | ||||
| -rw-r--r-- | pkg/tag/tag.go | 5 |
9 files changed, 231 insertions, 58 deletions
diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index ee57cac..94a692d 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -40,6 +40,7 @@ Ctx deadcode Debugf defaultns +deserialized dest dexidp dirname @@ -50,6 +51,7 @@ dst egrep endif ENTRYPOINT +eps Errorf extldflags Fatalf @@ -108,6 +110,7 @@ logctx Logf loglevel logrus +Matchfunc memcache metadata misconfigured @@ -133,6 +136,7 @@ otherimg otherparam othervalue parametrizable +params parseable patrickmn pb @@ -190,6 +194,7 @@ toolchain Torvalds Tracef unmarshal +unmarshals unparam updateable url @@ -197,6 +202,7 @@ Useragent username usr varcheck +versioned versioning Warnf webkit diff --git a/.github/workflows/ci-tests.yaml b/.github/workflows/ci-tests.yaml index ae0d057..8dab78e 100644 --- a/.github/workflows/ci-tests.yaml +++ b/.github/workflows/ci-tests.yaml @@ -55,9 +55,9 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - name: Run golangci-lint - uses: golangci/golangci-lint-action@v1 + uses: golangci/golangci-lint-action@v2 with: - version: v1.26 + version: v1.32 args: --timeout 5m test: name: Ensure unit tests are passing @@ -75,4 +75,4 @@ jobs: - name: Upload code coverage information to codecov.io uses: codecov/codecov-action@v1 with: - file: coverage.out
\ No newline at end of file + file: coverage.out diff --git a/pkg/image/options.go b/pkg/image/options.go index 75f9cbd..d0b585b 100644 --- a/pkg/image/options.go +++ b/pkg/image/options.go @@ -63,20 +63,23 @@ func (img *ContainerImage) GetParameterUpdateStrategy(annotations map[string]str log.Tracef("No sort option %s found", key) return VersionSortSemVer } + log.Tracef("found update strategy %s in %s", val, key) + return ParseUpdateStrategy(val) +} + +func ParseUpdateStrategy(val string) VersionSortMode { switch strings.ToLower(val) { case "semver": - log.Tracef("Sort option semver in %s", key) return VersionSortSemVer case "latest": - log.Tracef("Sort option date in %s", key) return VersionSortLatest case "name": - log.Tracef("Sort option name in %s", key) return VersionSortName default: - log.Warnf("Unknown sort option in %s: %s -- using semver", key, val) + log.Warnf("Unknown sort option %s -- using semver", val) return VersionSortSemVer } + } // GetParameterMatch returns the match function and pattern to use for matching @@ -99,6 +102,11 @@ func (img *ContainerImage) GetParameterMatch(annotations map[string]string) (Mat } } + return ParseMatchfunc(val) +} + +// ParseMatchfunc returns a matcher function and its argument from given value +func ParseMatchfunc(val string) (MatchFuncFn, interface{}) { // The special value "any" doesn't take any parameter if strings.ToLower(val) == "any" { return MatchFuncAny, nil diff --git a/pkg/image/version.go b/pkg/image/version.go index 3abdc7d..26c3a19 100644 --- a/pkg/image/version.go +++ b/pkg/image/version.go @@ -75,10 +75,13 @@ func (img *ContainerImage) GetNewestVersionFromTags(vc *VersionConstraint, tagLi // The given constraint MUST match a semver constraint var semverConstraint *semver.Constraints + var err error if vc.SortMode == VersionSortSemVer { - _, err := semver.NewVersion(img.ImageTag.TagName) - if err != nil { - return nil, err + if img.ImageTag != nil { + _, err := semver.NewVersion(img.ImageTag.TagName) + if err != nil { + return nil, err + } } if vc.Constraint != "" { diff --git a/pkg/registry/client.go b/pkg/registry/client.go index a5682ba..0e478bc 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -1,16 +1,28 @@ package registry import ( - "github.com/argoproj-labs/argocd-image-updater/pkg/registry/mocks" + "bytes" + "encoding/json" + "fmt" + "time" + "github.com/argoproj-labs/argocd-image-updater/pkg/log" + "github.com/argoproj-labs/argocd-image-updater/pkg/tag" + + "github.com/docker/distribution" "github.com/docker/distribution/manifest/schema1" + "github.com/docker/distribution/manifest/schema2" "github.com/nokia/docker-registry-client/registry" ) +// TODO: Check image's architecture and OS + // RegistryClient defines the methods we need for querying container registries type RegistryClient interface { Tags(nameInRepository string) ([]string, error) ManifestV1(repository string, reference string) (*schema1.SignedManifest, error) + ManifestV2(repository string, reference string) (*schema2.DeserializedManifest, error) + TagMetadata(repository string, manifest distribution.Manifest) (*tag.TagInfo, error) } type NewRegistryClient func(*RegistryEndpoint, string, string) (RegistryClient, error) @@ -20,11 +32,6 @@ type registryClient struct { regClient *registry.Registry } -// NewMockClient returns a new mocked RegistryClient -func NewMockClient(endpoint *RegistryEndpoint, username, password string) (RegistryClient, error) { - return &mocks.RegistryClient{}, nil -} - // NewClient returns a new RegistryClient for the given endpoint information func NewClient(endpoint *RegistryEndpoint, username, password string) (RegistryClient, error) { @@ -55,7 +62,79 @@ func (client *registryClient) Tags(nameInRepository string) ([]string, error) { return client.regClient.Tags(nameInRepository) } -// ManifestV1 returns a signed manifest for a given tag in given repository +// ManifestV1 returns a signed V1 manifest for a given tag in given repository func (client *registryClient) ManifestV1(repository string, reference string) (*schema1.SignedManifest, error) { return client.regClient.ManifestV1(repository, reference) } + +// ManifestV2 returns a deserialized V2 manifest for a given tag in given repository +func (client *registryClient) ManifestV2(repository string, reference string) (*schema2.DeserializedManifest, error) { + return client.regClient.ManifestV2(repository, reference) +} + +// GetTagInfo retrieves metadata for a given manifest of given repository +func (client *registryClient) TagMetadata(repository string, manifest distribution.Manifest) (*tag.TagInfo, error) { + ti := &tag.TagInfo{} + + var info struct { + Arch string `json:"architecture"` + Created string `json:"created"` + OS string `json:"os"` + } + + // We support both V1 and V2 manifest schemas. Everything else will trigger + // an error. + switch deserialized := manifest.(type) { + + case *schema1.SignedManifest: + var man schema1.Manifest = deserialized.Manifest + if len(man.History) == 0 { + return nil, fmt.Errorf("no history information found in schema V1") + } + if err := json.Unmarshal([]byte(man.History[0].V1Compatibility), &info); err != nil { + return nil, err + } + if createdAt, err := time.Parse(time.RFC3339Nano, info.Created); err != nil { + return nil, err + } else { + ti.CreatedAt = createdAt + } + return ti, nil + + case *schema2.DeserializedManifest: + var man schema2.Manifest = deserialized.Manifest + + // The data we require from a V2 manifest is in a blob that we need to + // fetch from the registry. + _, err := client.regClient.BlobMetadata(repository, man.Config.Digest) + if err != nil { + return nil, fmt.Errorf("could not get metadata: %v", err) + } + + blobReader, err := client.regClient.DownloadBlob(repository, man.Config.Digest) + if err != nil { + return nil, err + } + defer blobReader.Close() + + blobBytes := bytes.Buffer{} + n, err := blobBytes.ReadFrom(blobReader) + if err != nil { + return nil, err + } + + log.Tracef("read %d bytes of blob data for %s", n, repository) + + if err := json.Unmarshal(blobBytes.Bytes(), &info); err != nil { + return nil, err + } + + if ti.CreatedAt, err = time.Parse(time.RFC3339Nano, info.Created); err != nil { + return nil, err + } + return ti, nil + + default: + return nil, fmt.Errorf("invalid manifest type") + } +} diff --git a/pkg/registry/mocks/RegistryClient.go b/pkg/registry/mocks/RegistryClient.go index ae86cac..6352903 100644 --- a/pkg/registry/mocks/RegistryClient.go +++ b/pkg/registry/mocks/RegistryClient.go @@ -3,9 +3,14 @@ package mocks import ( + distribution "github.com/docker/distribution" mock "github.com/stretchr/testify/mock" schema1 "github.com/docker/distribution/manifest/schema1" + + schema2 "github.com/docker/distribution/manifest/schema2" + + tag "github.com/argoproj-labs/argocd-image-updater/pkg/tag" ) // RegistryClient is an autogenerated mock type for the RegistryClient type @@ -36,6 +41,52 @@ func (_m *RegistryClient) ManifestV1(repository string, reference string) (*sche return r0, r1 } +// ManifestV2 provides a mock function with given fields: repository, reference +func (_m *RegistryClient) ManifestV2(repository string, reference string) (*schema2.DeserializedManifest, error) { + ret := _m.Called(repository, reference) + + var r0 *schema2.DeserializedManifest + if rf, ok := ret.Get(0).(func(string, string) *schema2.DeserializedManifest); ok { + r0 = rf(repository, reference) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*schema2.DeserializedManifest) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(repository, reference) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TagMetadata provides a mock function with given fields: repository, manifest +func (_m *RegistryClient) TagMetadata(repository string, manifest distribution.Manifest) (*tag.TagInfo, error) { + ret := _m.Called(repository, manifest) + + var r0 *tag.TagInfo + if rf, ok := ret.Get(0).(func(string, distribution.Manifest) *tag.TagInfo); ok { + r0 = rf(repository, manifest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*tag.TagInfo) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, distribution.Manifest) error); ok { + r1 = rf(repository, manifest) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Tags provides a mock function with given fields: nameInRepository func (_m *RegistryClient) Tags(nameInRepository string) ([]string, error) { ret := _m.Called(nameInRepository) diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 496e301..d0aaa80 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -6,11 +6,12 @@ package registry // TODO: Refactor this package and provide mocks for better testing. import ( - "encoding/json" "fmt" "strings" "time" + "github.com/docker/distribution" + "github.com/argoproj-labs/argocd-image-updater/pkg/client" "github.com/argoproj-labs/argocd-image-updater/pkg/image" "github.com/argoproj-labs/argocd-image-updater/pkg/log" @@ -76,8 +77,9 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R // Fetch the manifest for the tag -- we need v1, because it contains history // information that we require. + i := 0 for _, tagStr := range tags { - + i += 1 // Look into the cache first and re-use any found item. If GetTag() returns // an error, we treat it as a cache miss and just go ahead to invalidate // the entry. @@ -90,44 +92,35 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R continue } - ml, err := regClient.ManifestV1(nameInRegistry, tagStr) - if err != nil { - return nil, err - } + log.Tracef("Getting manifest for image %s:%s (operation %d/%d)", nameInRegistry, tagStr, i, len(tags)) - if len(ml.History) < 1 { - log.Warnf("Could not get creation date for %s: History information missing", img.GetFullNameWithTag()) - continue + var ml distribution.Manifest + var err error + + // We first try to fetch a V2 manifest, and if that's not available we fall + // back to fetching V1 manifest. If that fails also, we just skip this tag. + if ml, err = regClient.ManifestV2(nameInRegistry, tagStr); err != nil { + log.Debugf("No V2 manifest for %s:%s, fetching V1 (%v)", nameInRegistry, tagStr, err) + if ml, err = regClient.ManifestV1(nameInRegistry, tagStr); err != nil { + log.Errorf("Error fetching metadata for %s:%s - neither V1 or V2 manifest returned by registry: %v", nameInRegistry, tagStr, err) + continue + } } - var histInfo map[string]interface{} - err = json.Unmarshal([]byte(ml.History[0].V1Compatibility), &histInfo) + // Parse required meta data from the manifest. The metadata contains all + // information needed to decide whether to consider this tag or not. + ti, err := regClient.TagMetadata(nameInRegistry, ml) if err != nil { - log.Warnf("Could not unmarshal history info for %s: %v", img.GetFullNameWithTag(), err) - continue + return nil, err } - - crIf, ok := histInfo["created"] - if !ok { - log.Warnf("Incomplete history information for %s: no creation timestamp found", img.GetFullNameWithTag()) + if ti == nil { + log.Debugf("No metadata found for %s:%s", nameInRegistry, tagStr) continue } - crStr, ok := crIf.(string) - if !ok { - log.Warnf("Creation timestamp for %s has wrong type - need string, is %T", img.GetFullNameWithTag(), crIf) - continue - } + log.Tracef("Found date %s", ti.CreatedAt.String()) - // Creation date is stored as RFC3339 timestamp with nanoseconds, i.e. like - // this: 2017-12-01T23:06:12.607835588Z - log.Tracef("Found origin creation date for %s: %s", tagStr, crStr) - crDate, err := time.Parse(time.RFC3339Nano, crStr) - if err != nil { - log.Warnf("Could not parse creation timestamp for %s (%s): %v", img.GetFullNameWithTag(), crStr, err) - continue - } - imgTag = tag.NewImageTag(tagStr, crDate) + imgTag = tag.NewImageTag(tagStr, ti.CreatedAt) tagList.Add(imgTag) endpoint.Cache.SetTag(nameInRegistry, imgTag) } diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index f49fa96..2b890a5 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -1,12 +1,15 @@ package registry import ( + "fmt" "testing" "github.com/argoproj-labs/argocd-image-updater/pkg/image" "github.com/argoproj-labs/argocd-image-updater/pkg/registry/mocks" + "github.com/argoproj-labs/argocd-image-updater/pkg/tag" "github.com/docker/distribution/manifest/schema1" + "github.com/docker/distribution/manifest/schema2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -71,7 +74,7 @@ func Test_GetTags(t *testing.T) { t.Run("Check for correctly returned tags with latest sort", func(t *testing.T) { ts := "2006-01-02T15:04:05.999999999Z" - meta := &schema1.SignedManifest{ + meta1 := &schema1.SignedManifest{ Manifest: schema1.Manifest{ History: []schema1.History{ { @@ -80,10 +83,15 @@ func Test_GetTags(t *testing.T) { }, }, } + meta2 := &schema2.DeserializedManifest{ + Manifest: schema2.Manifest{}, + } regClient := mocks.RegistryClient{} regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta, nil) + regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("ManifestV2", mock.Anything, mock.Anything).Return(meta2, nil) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(&tag.TagInfo{}, nil) ep, err := GetRegistryEndpoint("") require.NoError(t, err) @@ -101,15 +109,20 @@ func Test_GetTags(t *testing.T) { }) t.Run("Check for correct error handling when manifest contains no history", func(t *testing.T) { - meta := &schema1.SignedManifest{ + meta1 := &schema1.SignedManifest{ Manifest: schema1.Manifest{ History: []schema1.History{}, }, } + meta2 := &schema2.DeserializedManifest{ + Manifest: schema2.Manifest{}, + } regClient := mocks.RegistryClient{} regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta, nil) + regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("ManifestV2", mock.Anything, mock.Anything).Return(meta2, fmt.Errorf("not implemented")) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(nil, nil) ep, err := GetRegistryEndpoint("") require.NoError(t, err) @@ -126,7 +139,7 @@ func Test_GetTags(t *testing.T) { }) t.Run("Check for correct error handling when manifest contains invalid history", func(t *testing.T) { - meta := &schema1.SignedManifest{ + meta1 := &schema1.SignedManifest{ Manifest: schema1.Manifest{ History: []schema1.History{ { @@ -135,10 +148,15 @@ func Test_GetTags(t *testing.T) { }, }, } + meta2 := &schema2.DeserializedManifest{ + Manifest: schema2.Manifest{}, + } regClient := mocks.RegistryClient{} regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta, nil) + regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("ManifestV2", mock.Anything, mock.Anything).Return(meta2, fmt.Errorf("not implemented")) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(nil, nil) ep, err := GetRegistryEndpoint("") require.NoError(t, err) @@ -155,7 +173,7 @@ func Test_GetTags(t *testing.T) { }) t.Run("Check for correct error handling when manifest contains invalid history", func(t *testing.T) { - meta := &schema1.SignedManifest{ + meta1 := &schema1.SignedManifest{ Manifest: schema1.Manifest{ History: []schema1.History{ { @@ -164,10 +182,15 @@ func Test_GetTags(t *testing.T) { }, }, } + meta2 := &schema2.DeserializedManifest{ + Manifest: schema2.Manifest{}, + } regClient := mocks.RegistryClient{} regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta, nil) + regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("ManifestV2", mock.Anything, mock.Anything).Return(meta2, fmt.Errorf("not implemented")) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(nil, nil) ep, err := GetRegistryEndpoint("") require.NoError(t, err) @@ -185,7 +208,7 @@ func Test_GetTags(t *testing.T) { t.Run("Check for correct error handling when time stamp cannot be parsed", func(t *testing.T) { ts := "invalid" - meta := &schema1.SignedManifest{ + meta1 := &schema1.SignedManifest{ Manifest: schema1.Manifest{ History: []schema1.History{ { @@ -194,10 +217,15 @@ func Test_GetTags(t *testing.T) { }, }, } + meta2 := &schema2.DeserializedManifest{ + Manifest: schema2.Manifest{}, + } regClient := mocks.RegistryClient{} regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta, nil) + regClient.On("ManifestV1", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("ManifestV2", mock.Anything, mock.Anything).Return(meta2, fmt.Errorf("not implemented")) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(nil, nil) ep, err := GetRegistryEndpoint("") require.NoError(t, err) diff --git a/pkg/tag/tag.go b/pkg/tag/tag.go index 69edc6f..e6798d8 100644 --- a/pkg/tag/tag.go +++ b/pkg/tag/tag.go @@ -24,6 +24,11 @@ type ImageTagList struct { lock *sync.RWMutex } +// TagInfo contains information for a tag +type TagInfo struct { + CreatedAt time.Time +} + // SortableImageTagList is just that - a sortable list of ImageTag entries type SortableImageTagList []*ImageTag |
