diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index ba3ea53..0b1ea79 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -29,13 +29,13 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: version: latest - name: Delete pre-installed shellcheck - run: sudo rm -f $(which shellcheck) + run: sudo rm -f "$(which shellcheck)" - name: Run shellcheck run: make run-shellcheck - name: Dependency Licenses Review @@ -45,7 +45,7 @@ jobs: env: CLICOLOR: "1" - name: Delete typos binary - run: rm typos + run: rm -f typos - name: Check if source code files have license header run: make check-addlicense - name: REUSE Compliance Check diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index edc522c..a2cb824 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,12 +32,12 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Build all binaries run: make build-all code_coverage: name: Code coverage report - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: - test runs-on: ubuntu-latest @@ -65,7 +65,7 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Run tests and generate coverage report run: make build/cover.out - name: Archive code coverage results diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index d9de56b..2c6225f 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -32,7 +32,7 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.golangci.yaml b/.golangci.yaml index df0e816..0290811 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -91,12 +91,14 @@ linters: # Applications wishing to use http.ServeMux should obtain local instances through http.NewServeMux() instead of using the global default instance. - pattern: ^http\.DefaultServeMux$ - pattern: ^http\.Handle(?:Func)?$ - - pkg: ^gopkg\.in/square/go-jose\.v2$ + - pkg: ^gopkg\.in/square/go-jose\.v2 msg: gopk.in/square/go-jose is archived and has CVEs. Replace it with gopkg.in/go-jose/go-jose.v2 - - pkg: ^github.com/coreos/go-oidc$ + - pkg: ^github\.com/coreos/go-oidc msg: github.com/coreos/go-oidc depends on gopkg.in/square/go-jose which has CVEs. Replace it with github.com/coreos/go-oidc/v3 - - pkg: ^github.com/howeyc/gopass$ + - pkg: ^github\.com/howeyc/gopass msg: github.com/howeyc/gopass is archived, use golang.org/x/term instead + - pkg: ^github\.com/containers/image/v5 + msg: github.com/containers/image/v5 is deprecated and was replaced with go.podman.io/image/v5 goconst: min-occurrences: 5 gocritic: @@ -130,6 +132,8 @@ linters: - github.com/mdlayher/arp # for github.com/sapcc/vpa_butler - k8s.io/client-go + # for github.com/sapcc/keppel et al + - github.com/go-gorp/gorp/v3 toolchain-forbidden: true go-version-pattern: 1\.\d+(\.0)?$ gosec: diff --git a/cloudprofilesync/source.go b/cloudprofilesync/source.go index 5d8f9d3..128e3ab 100644 --- a/cloudprofilesync/source.go +++ b/cloudprofilesync/source.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "strings" - "time" "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/registry/remote" @@ -24,7 +23,6 @@ type Result[T any] struct { type SourceImage struct { Version string Architectures []string - CreatedAt time.Time } type Source interface { @@ -106,21 +104,10 @@ func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { out <- Result[SourceImage]{err: errors.New("architecture annotation not found in descriptor")} return } - created := time.Time{} - if s, ok := manifest.Annotations["org.opencontainers.image.created"]; ok { - if t, err := time.Parse(time.RFC3339, s); err == nil { - created = t - } - } else if s, ok := manifest.Annotations["created"]; ok { - if t, err := time.Parse(time.RFC3339, s); err == nil { - created = t - } - } out <- Result[SourceImage]{ value: SourceImage{ Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention Architectures: []string{arch}, - CreatedAt: created, }, } }() diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 6ec3b8c..b5630c9 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -5,9 +5,12 @@ package controllers import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" + "net/http" + "net/url" "slices" "strings" "time" @@ -36,6 +39,16 @@ type OCISourceFactory interface { Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) } +type RegistryClient interface { + GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) +} + +type KeppelClient struct{} + +func (k *KeppelClient) GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + return fetchKeppelTags(ctx, registry, repository) +} + // DefaultOCISourceFactory is the default implementation of OCISourceFactory. type DefaultOCISourceFactory struct{} @@ -45,7 +58,23 @@ func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, inse type Reconciler struct { client.Client - OCISourceFactory OCISourceFactory + OCISourceFactory OCISourceFactory + RegistryProviderFunc func(registry string) (RegistryClient, error) +} + +type KeppelTag struct { + Name string `json:"name"` + PushedAt int64 `json:"pushed_at"` +} + +type KeppelManifest struct { + Digest string `json:"digest"` + PushedAt int64 `json:"pushed_at"` + Tags []KeppelTag `json:"tags"` +} + +type KeppelManifestsResponse struct { + Manifests []KeppelManifest `json:"manifests"` } func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -150,29 +179,25 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo continue } - log.V(1).Info("fetching OCI credentials", "image", updates.ImageName) - password, err := r.getCredential(ctx, updates.Source.OCI.Password) + log.V(1).Info("retrieving source registry", "registry", updates.Source.OCI.Registry) + registryClient, err := r.RegistryProviderFunc(updates.Source.OCI.Registry) if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to get credential for garbage collection: %w", err)) - } - - src, err := r.OCISourceFactory.Create(cloudprofilesync.OCIParams{ - Registry: updates.Source.OCI.Registry, - Repository: updates.Source.OCI.Repository, - Username: updates.Source.OCI.Username, - Password: string(password), - Parallel: 1, - }, updates.Source.OCI.Insecure) - if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to initialize OCI source for garbage collection: %w", err)) + return r.failWithStatusUpdate(ctx, mcp, + fmt.Errorf("no registry provider found for registry %q: %w", updates.Source.OCI.Registry, err)) } log.V(1).Info("retrieving source versions", "image", updates.ImageName) - versions, err := src.GetVersions(ctx) + ctx = logr.NewContext(ctx, log) + tags, err := registryClient.GetTags( + ctx, + updates.Source.OCI.Registry, + updates.Source.OCI.Repository, + ) if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to list source versions for garbage collection: %w", err)) + return r.failWithStatusUpdate(ctx, mcp, + fmt.Errorf("failed to fetch tags: %w", err)) } - log.V(1).Info("retrieved source versions", "count", len(versions), "image", updates.ImageName) + log.V(1).Info("retrieved source versions", "count", len(tags)) referencedVersions, err := r.getReferencedVersions(ctx, mcp.Name, updates.ImageName, log) if err != nil { @@ -181,18 +206,14 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo log.V(1).Info("referenced versions retrieved", "count", len(referencedVersions), "image", updates.ImageName) versionsToDelete := make(map[string]struct{}) - for _, v := range versions { - if v.CreatedAt.IsZero() { - log.V(1).Info("skipping version with zero CreatedAt", "version", v.Version) - continue - } - if _, isReferenced := referencedVersions[v.Version]; isReferenced { - log.V(2).Info("skipping referenced version", "version", v.Version) + for tag, pushedAt := range tags { + if _, isReferenced := referencedVersions[tag]; isReferenced { + log.V(2).Info("skipping referenced version", "version", tag) continue } - if v.CreatedAt.Before(cutoff) { - versionsToDelete[v.Version] = struct{}{} - log.V(1).Info("marking version for deletion", "version", v.Version) + if pushedAt.Before(cutoff) { + versionsToDelete[tag] = struct{}{} + log.V(1).Info("marking version for deletion", "version", tag, "pushedAt", pushedAt) } } @@ -462,11 +483,187 @@ func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.Man return returnErr } +func fetchKeppelTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + log, err := logr.FromContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to extract logger from context: %w", err) + } + baseURL := registryBaseURL(log, registry, false) + + keppelURL, err := keppelURL(log, baseURL, repository) + if err != nil { + log.Error(err, "failed to build keppel URL", + "registry", registry, + "repository", repository, + ) + return nil, err + } + + log.V(1).Info("fetching keppel tags", + "url", keppelURL, + "registry", registry, + "repository", repository, + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, keppelURL, http.NoBody) + if err != nil { + log.Error(err, "failed to create keppel request") + return nil, err + } + + tr := &http.Transport{ + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: tr, + } + + resp, err := httpClient.Do(req) + if err != nil { + log.Error(err, "keppel http request failed", "url", keppelURL) + return nil, err + } + defer resp.Body.Close() + + log.V(1).Info("keppel response received", "status", resp.StatusCode) + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("keppel API returned status %d", resp.StatusCode) + log.Error(err, "unexpected keppel status code") + return nil, err + } + + var result KeppelManifestsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + log.Error(err, "failed to decode keppel response") + return nil, err + } + + log.V(1).Info("decoded keppel response", "manifests", len(result.Manifests)) + + tagMap := make(map[string]time.Time) + + for i, m := range result.Manifests { + log.V(2).Info("processing manifest", + "index", i, + "digest", m.Digest, + "tags", len(m.Tags), + ) + + for _, t := range m.Tags { + if t.PushedAt == 0 { + log.V(1).Info("tag without pushed at", "name", t.Name) + continue + } + + log.V(2).Info("processing tag", + "tag", t.Name, + "pushedAt", t.PushedAt, + ) + tagMap[t.Name] = time.Unix(t.PushedAt, 0) + } + } + + log.V(1).Info("finished fetching keppel tags", "count", len(tagMap)) + + return tagMap, nil +} + +func keppelURL(log logr.Logger, baseURL, repository string) (string, error) { + account, repo, err := splitKeppelRepository(log, repository) + if err != nil { + log.Error(err, "failed to split keppel repository", "repository", repository) + return "", err + } + + keppelURL := fmt.Sprintf( + "%s/keppel/v1/accounts/%s/repositories/%s/_manifests", + baseURL, + account, + repo, + ) + + log.V(1).Info("constructed keppel url", + "baseURL", baseURL, + "account", account, + "repo", repo, + "url", keppelURL, + ) + + return keppelURL, nil +} + +func registryBaseURL(log logr.Logger, registryHost string, insecure bool) string { + scheme := "https" + if insecure { + scheme = "http" + } + + u := &url.URL{ + Scheme: scheme, + Host: registryHost, + } + + base := u.String() + + log.V(2).Info("computed registry base url", + "registryHost", registryHost, + "insecure", insecure, + "baseURL", base, + ) + + return base +} + +func splitKeppelRepository(log logr.Logger, repository string) (account, repo string, err error) { + parts := strings.SplitN(repository, "/", 2) + + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + err := fmt.Errorf("invalid repository format %q, must be /", repository) + + log.Error(err, "invalid keppel repository format", + "repository", repository, + ) + + return "", "", err + } + + account = parts[0] + repo = parts[1] + + log.V(2).Info("split keppel repository", + "repository", repository, + "account", account, + "repo", repo, + ) + + return account, repo, nil +} + +func (r *Reconciler) getRegistryProvider(registry string) (registryClient RegistryClient, err error) { + if registry == "" { + return nil, errors.New("registry cannot be empty") + } + if strings.Contains(strings.ToLower(registry), "keppel") { + return &KeppelClient{}, nil + } + + return nil, errors.New("no registry provider found for registry") +} + // SetupWithManager attaches the controller to the given manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { if r.OCISourceFactory == nil { r.OCISourceFactory = &DefaultOCISourceFactory{} } + if r.RegistryProviderFunc == nil { + r.RegistryProviderFunc = r.getRegistryProvider + } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ManagedCloudProfile{}). Owns(&gardenerv1beta1.CloudProfile{}). diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index c5508c7..b793fce 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -7,11 +7,13 @@ import ( "context" "encoding/json" "errors" + "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" @@ -36,62 +38,106 @@ type mockOCIFactory struct { createFunc func(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) } +type fakeOCISource struct{} + +func (f *fakeOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { + return []cloudprofilesync.SourceImage{ + {Version: "1.0.0", Architectures: []string{"amd64"}}, + {Version: "1.0.1+abc", Architectures: []string{"amd64"}}, + }, nil +} + +type fakeFactory struct{} + +func (f *fakeFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { + return &fakeOCISource{}, nil +} + func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { return m.createFunc(params, insecure) } +type fakeRegistryClient struct{} + +func (f *fakeRegistryClient) GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + now := time.Now() + return map[string]time.Time{ + "0.1.0": now.Add(-48 * time.Hour), + "1.0.0": now.Add(-1 * time.Hour), + "1.0.1+abc": now.Add(-48 * time.Hour), + }, nil +} + var _ = Describe("The ManagedCloudProfile reconciler", func() { amd64 := "amd64" - version := "1.0.0" + AfterEach(func(ctx SpecContext) { + Eventually(func(g Gomega) { + var mcpList v1alpha1.ManagedCloudProfileList + err := k8sClient.List(ctx, &mcpList) + g.Expect(err).To(Succeed()) + + for _, mcp := range mcpList.Items { + g.Expect(mcp.Status.Status).ToNot(Equal(v1alpha1.ReconcileStatus("InProgress"))) + } + }).Should(Succeed()) + var mcpList v1alpha1.ManagedCloudProfileList - Expect(k8sClient.List(ctx, &mcpList)).To(Succeed()) + err := k8sClient.List(ctx, &mcpList) + Expect(err).To(Succeed()) for _, mcp := range mcpList.Items { Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) } - var cpList gardenerv1beta1.CloudProfileList - Expect(k8sClient.List(ctx, &cpList)).To(Succeed()) - for _, cp := range cpList.Items { - Expect(k8sClient.Delete(ctx, &cp)).To(Succeed()) - } + Eventually(func() int { + var updated v1alpha1.ManagedCloudProfileList + err := k8sClient.List(ctx, &updated) + Expect(err).To(Succeed()) + return len(updated.Items) + }).Should(Equal(0)) var shootList gardenerv1beta1.ShootList - Expect(k8sClient.List(ctx, &shootList)).To(Succeed()) + err = k8sClient.List(ctx, &shootList) + Expect(err).To(Succeed()) for _, shoot := range shootList.Items { Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) } + var cpList gardenerv1beta1.CloudProfileList + err = k8sClient.List(ctx, &cpList) + Expect(err).To(Succeed()) + for _, cp := range cpList.Items { + Expect(k8sClient.Delete(ctx, &cp)).To(Succeed()) + } + var secList corev1.SecretList - Expect(k8sClient.List(ctx, &secList)).To(Succeed()) + err = k8sClient.List(ctx, &secList) + Expect(err).To(Succeed()) for _, sec := range secList.Items { if sec.Namespace == metav1.NamespaceDefault && sec.Name == "oci" { Expect(k8sClient.Delete(ctx, &sec)).To(Succeed()) } } - Eventually(func(g Gomega) int { - var updated v1alpha1.ManagedCloudProfileList - g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) - return len(updated.Items) - }).Should(Equal(0)) + Eventually(func() int { + total := 0 - Eventually(func(g Gomega) int { - var updated gardenerv1beta1.ShootList - g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) - return len(updated.Items) - }).Should(Equal(0)) + var mcpList v1alpha1.ManagedCloudProfileList + err := k8sClient.List(ctx, &mcpList) + Expect(err).To(Succeed()) + total += len(mcpList.Items) - Eventually(func(g Gomega) int { - var updated corev1.SecretList - g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) - count := 0 - for _, sec := range updated.Items { - if sec.Namespace == metav1.NamespaceDefault && sec.Name == "oci" { - count++ - } - } - return count + var shootList gardenerv1beta1.ShootList + err = k8sClient.List(ctx, &shootList) + Expect(err).To(Succeed()) + total += len(shootList.Items) + + var cpList gardenerv1beta1.CloudProfileList + err = k8sClient.List(ctx, &cpList) + Expect(err).To(Succeed()) + total += len(cpList.Items) + + return total }).Should(Equal(0)) }) @@ -183,7 +229,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: orasRepoName("repo"), Insecure: true, }, }, @@ -240,7 +286,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: orasRepoName("repo"), Insecure: true, Username: "user", Password: v1alpha1.SecretReference{ @@ -277,57 +323,111 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("deletes old machine image versions not referenced by any Shoot", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile - mcp.Name = "test-gc" + mcp.Name = "gc-mcp" + usable := true + + oldVersion := "0.1.0" + newVersion := "1.0.0" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "gc-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldVersion}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newVersion}, Architectures: []string{"amd64"}}, + }, + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{ + { + Name: "baz", + Architecture: &amd64, + Usable: &usable, + }, + }, } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { ImageName: "gc-image", Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: registryAddr, - Repository: "repo", + Registry: "keppel-fake", + Repository: "account/repo", Insecure: true, }, }, }, } + mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + MaxAge: metav1.Duration{Duration: 24 * time.Hour}, } Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) - Eventually(func(g Gomega) v1alpha1.ReconcileStatus { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot", + Namespace: metav1.NamespaceDefault, + }, + Spec: gardenerv1beta1.ShootSpec{ + CloudProfile: &gardenerv1beta1.CloudProfileReference{ + Name: mcp.Name, + }, + Provider: gardenerv1beta1.Provider{ + Workers: []gardenerv1beta1.Worker{ + { + Name: "worker1", + Machine: gardenerv1beta1.Machine{ + Image: &gardenerv1beta1.ShootMachineImage{ + Name: "gc-image", + Version: &newVersion, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, shoot)).To(Succeed()) - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = mcp.Name - Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClient{}, nil + }, + } - Eventually(func(g Gomega) int { - freshProfile := gardenerv1beta1.CloudProfile{} - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &freshProfile)).To(Succeed()) - if len(freshProfile.Spec.MachineImages) == 0 { - return 0 - } - for _, img := range freshProfile.Spec.MachineImages { - if img.Name == "gc-image" { - return len(img.Versions) + _, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: client.ObjectKey{Name: mcp.Name}, + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func(g Gomega) []string { + var cp gardenerv1beta1.CloudProfile + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, &cp)).To(Succeed()) + + var versions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "gc-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } } } - return 0 - }, "10s").Should(Equal(0)) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + return versions + }, 10*time.Second, 200*time.Millisecond). + Should(ConsistOf(newVersion)) }) It("preserves old machine image versions referenced by Shoot worker pools", func(ctx SpecContext) { + version := "1.0.0" + var cloudProfile gardenerv1beta1.CloudProfile cloudProfile.Name = "test-gc-preserve" cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} @@ -336,31 +436,20 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { { Name: "preserve-image", Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{"amd64"}}, - }, - }, - } - - var cfg providercfg.CloudProfileConfig - cfg.MachineImages = []providercfg.MachineImages{ - { - Name: "preserve-image", - Versions: []providercfg.MachineImageVersion{ - {Image: "repo/preserve-image:1.0.0"}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{amd64}}, }, }, } - raw, err := json.Marshal(cfg) - Expect(err).To(Succeed()) - cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: raw} Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) var shoot gardenerv1beta1.Shoot shoot.Name = "test-shoot-preserve" shoot.Namespace = metav1.NamespaceDefault - shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{Name: cloudProfile.Name} + shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{ + Name: cloudProfile.Name, + } shoot.Spec.Provider.Workers = []gardenerv1beta1.Worker{ { Name: "worker1", @@ -382,9 +471,9 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { { Name: "preserve-image", Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{amd64}}, }, }, }, @@ -396,15 +485,16 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: orasRepoName("repo"), Insecure: true, }, }, }, } + mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + MaxAge: metav1.Duration{Duration: time.Second}, } Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) @@ -412,158 +502,137 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Eventually(func(g Gomega) v1alpha1.ReconcileStatus { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + }).Should(Equal(v1alpha1.FailedReconcileStatus)) Eventually(func(g Gomega) []string { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) - if len(cloudProfile.Spec.MachineImages) == 0 { - return []string{} - } - versions := []string{} + + var versions []string for _, v := range cloudProfile.Spec.MachineImages[0].Versions { versions = append(versions, v.Version) } return versions - }).Should(ContainElement("1.0.0")) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) + }).Should(ConsistOf("1.0.0", "2.0.0", "3.0.0", "1.0.1+abc")) }) It("preserves machine image versions referenced by Shoot workers", func(ctx SpecContext) { - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = "test-gc-shoot-preserve" - cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} - cloudProfile.Spec.MachineTypes = []gardenerv1beta1.MachineType{{Name: "baz"}} - cloudProfile.Spec.MachineImages = []gardenerv1beta1.MachineImage{ - { - Name: "shoot-preserve-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + version := "1.0.0" + + cp := &gardenerv1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot-preserve", + }, + Spec: gardenerv1beta1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "shoot-preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{amd64}}, + }, + }, }, }, } - Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Create(ctx, cp)).To(Succeed()) - var shoot gardenerv1beta1.Shoot - shoot.Name = "test-shoot" - shoot.Namespace = metav1.NamespaceDefault - shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{Name: cloudProfile.Name} - shoot.Spec.Provider.Workers = []gardenerv1beta1.Worker{ - { - Name: "worker1", - Machine: gardenerv1beta1.Machine{ - Image: &gardenerv1beta1.ShootMachineImage{ - Name: "shoot-preserve-image", - Version: &version, + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot", + Namespace: metav1.NamespaceDefault, + }, + Spec: gardenerv1beta1.ShootSpec{ + CloudProfile: &gardenerv1beta1.CloudProfileReference{Name: cp.Name}, + Provider: gardenerv1beta1.Provider{ + Workers: []gardenerv1beta1.Worker{ + { + Name: "worker1", + Machine: gardenerv1beta1.Machine{ + Image: &gardenerv1beta1.ShootMachineImage{ + Name: "shoot-preserve-image", + Version: &version, + }, + }, + }, }, }, }, } - Expect(k8sClient.Create(ctx, &shoot)).To(Succeed()) + Expect(k8sClient.Create(ctx, shoot)).To(Succeed()) - var mcp v1alpha1.ManagedCloudProfile - mcp.Name = "test-gc-shoot-preserve" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "shoot-preserve-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot-preserve", + }, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "shoot-preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{amd64}}, + }, + }, }, }, - }, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } - mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ - { - ImageName: "shoot-preserve-image", - Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: registryAddr, - Repository: "repo", - Insecure: true, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{ + { + ImageName: "shoot-preserve-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "keppel-fake", + Repository: "account/repo", + Insecure: true, + }, + }, }, }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, }, } - mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ - Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClient{}, nil + }, } - Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) - Eventually(func(g Gomega) v1alpha1.ReconcileStatus { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + req := ctrl.Request{ + NamespacedName: client.ObjectKey{Name: mcp.Name}, + } + + res, err := reconciler.Reconcile(context.Background(), req) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(5 * time.Minute)) Eventually(func(g Gomega) []string { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) - if len(cloudProfile.Spec.MachineImages) == 0 { - return []string{} - } - versions := []string{} - for _, v := range cloudProfile.Spec.MachineImages[0].Versions { - versions = append(versions, v.Version) + updated := &gardenerv1beta1.CloudProfile{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) + + var versions []string + for _, mi := range updated.Spec.MachineImages { + if mi.Name == "shoot-preserve-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } } return versions }).Should(And( ContainElement("1.0.0"), Not(ContainElement("1.0.1+abc")), )) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) - }) - - It("handles missing credential for GC OCI source", func(ctx SpecContext) { - var mcp v1alpha1.ManagedCloudProfile - mcp.Name = "test-gc-cred-error" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } - mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ - { - ImageName: "test-image", - Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: registryAddr, - Repository: "repo", - Insecure: true, - Password: v1alpha1.SecretReference{ - Name: "nonexistent-secret", - Namespace: metav1.NamespaceDefault, - Key: "password", - }, - }, - }, - }, - } - mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ - Enabled: true, - MaxAge: metav1.Duration{Duration: 3600000000000}, - } - Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) - - Eventually(func(g Gomega) v1alpha1.ReconcileStatus { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - return mcp.Status.Status - }).Should(Equal(v1alpha1.FailedReconcileStatus)) - - Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( - HaveField("Type", controllers.CloudProfileAppliedConditionType), - HaveField("Status", metav1.ConditionFalse), - HaveField("Message", ContainSubstring("failed to get secret")), - ))) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) }) It("handles invalid OCI registry for GC", func(ctx SpecContext) { @@ -579,7 +648,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: "invalid://registry", - Repository: "repo", + Repository: orasRepoName("repository"), Insecure: true, }, }, @@ -600,7 +669,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { HaveField("Type", controllers.CloudProfileAppliedConditionType), HaveField("Status", metav1.ConditionFalse), HaveField("Reason", "ApplyFailed"), - HaveField("Message", ContainSubstring("Failed to apply CloudProfile: failed to initialize OCI source: invalid reference: invalid repository \"/registry/repo\"")), + HaveField("Message", ContainSubstring("Failed to apply CloudProfile: failed to initialize OCI source: invalid reference: invalid repository \"/registry/account/repository\"")), ))) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) @@ -783,7 +852,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: "repo/provider-config-image", Insecure: true, }, }, @@ -798,7 +867,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Eventually(func(g Gomega) v1alpha1.ReconcileStatus { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + }).Should(Equal(v1alpha1.FailedReconcileStatus)) Eventually(func(g Gomega) []string { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) @@ -819,7 +888,10 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } } return []string{} - }).Should(BeEmpty()) + }).Should(ConsistOf( + "repo/provider-config-image:1.0.0", + "repo/provider-config-image:1.0.1+abc", + )) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index c9af20f..1d8e599 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -50,10 +50,13 @@ var ( func TestControllers(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Controllers Suite") } +func orasRepoName(repo string) string { + return "account/" + strings.ReplaceAll(repo, "/", "_") +} + var _ = BeforeSuite(func(ctx SpecContext) { SetDefaultEventuallyTimeout(3 * time.Second) logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) @@ -81,20 +84,18 @@ var _ = BeforeSuite(func(ctx SpecContext) { reconciler = &controllers.Reconciler{ Client: k8sManager.GetClient(), } - err = reconciler.SetupWithManager(k8sManager) - Expect(err).ToNot(HaveOccurred()) + Expect(reconciler.SetupWithManager(k8sManager)).To(Succeed()) stopCtx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) stop = cancel + go func() { defer GinkgoRecover() - err = k8sManager.Start(stopCtx) - Expect(err).ToNot(HaveOccurred()) + Expect(k8sManager.Start(stopCtx)).To(Succeed()) }() k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).To(Succeed()) - Expect(k8sClient).ToNot(BeNil()) reg, err = registry.NewRegistry(stopCtx, &configuration.Configuration{ Storage: configuration.Storage{"inmemory": map[string]any{}}, @@ -103,10 +104,12 @@ var _ = BeforeSuite(func(ctx SpecContext) { Log: configuration.Log{Level: "error", AccessLog: configuration.AccessLog{Disabled: true}}, }) Expect(err).To(Succeed()) + go func() { defer GinkgoRecover() Expect(reg.ListenAndServe()).To(MatchError(http.ErrServerClosed)) }() + Eventually(func(g Gomega) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+registryAddr, http.NoBody) g.Expect(err).To(Succeed()) @@ -116,10 +119,15 @@ var _ = BeforeSuite(func(ctx SpecContext) { return nil }).Should(Succeed()) - repo, err := remote.NewRepository(registryAddr + "/repo") + repoName := orasRepoName("repo") + + repo, err := remote.NewRepository(registryAddr + "/" + repoName) Expect(err).To(Succeed()) repo.PlainHTTP = true + err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + Expect(err).To(Succeed()) + index := ocispec.Index{ Versioned: specs.Versioned{SchemaVersion: 2}, Manifests: []ocispec.Descriptor{ @@ -134,19 +142,21 @@ var _ = BeforeSuite(func(ctx SpecContext) { "created": time.Now().Add(-24 * time.Hour).Format(time.RFC3339), }, } + indexBlob, err := json.Marshal(index) Expect(err).To(Succeed()) - Expect(err).To(Succeed()) indexDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBlob) err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) Expect(err).To(Succeed()) + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "1.0.0") Expect(err).To(Succeed()) err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) Expect(err).To(Succeed()) + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "1.0.1_abc") Expect(err).To(Succeed()) }) @@ -154,7 +164,7 @@ var _ = BeforeSuite(func(ctx SpecContext) { var _ = AfterSuite(func(ctx SpecContext) { stop() Expect(reg.Shutdown(ctx)).To(Succeed()) + By("tearing down the test environment") - err := testEnv.Stop() - Expect(err).ToNot(HaveOccurred()) + Expect(testEnv.Stop()).To(Succeed()) })