diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 3c341ab..0ab9c05 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -19,6 +19,10 @@ type ManagedCloudProfileSpec struct { // GarbageCollection contains configuration for automated garbage collection // +optional GarbageCollection *GarbageCollectionConfig `json:"garbageCollection,omitempty"` + + // KubernetesVersionUpdateConfig contains the source and provider information to automate Kubernetes version updates. + // +optional + KubernetesVersionUpdateConfig *KubernetesVersionUpdateConfig `json:"kubernetesVersionUpdateConfig,omitempty"` } // Copy the cloud profile spec to override some validation @@ -109,13 +113,67 @@ type GarbageCollectionConfig struct { MaxAge metav1.Duration `json:"maxAge,omitempty"` } +type KubernetesVersionUpdateConfig struct { + // ExpirationThreshold defines the grace period after a version's expiration date. + // Versions whose expiration date has passed by more than this duration will be + // removed from the CloudProfile. + ExpirationThreshold metav1.Duration `json:"expirationThreshold,omitempty"` + + // LandscapeSetup contains the required OCI and GitHub sources for Kubernetes versions. + // +optional + LandscapeSetup *LandscapeSetup `json:"landscapeSetup,omitempty"` +} + +// LandscapeSetup configures the combined OCI and GitHub sources for Kubernetes versions. +type LandscapeSetup struct { + // OCI contains configuration for the OCI component-descriptor source. + OCI OCI `json:"oci"` + // Github contains configuration for fetching Kubernetes version classifications from a GitHub repository. + Github KubernetesVersionSourceGithub `json:"github"` +} + +// KubernetesVersionSourceGithub configures fetching Kubernetes versions from a +// YAML file in a GitHub repository. The file has a providers[].versions[] shape. +type KubernetesVersionSourceGithub struct { + // RepositoryApiURL is the base URL of the GitHub REST API, e.g. + // "https://api.github.com" or "https://github.mycompany.com/api/v3". + RepositoryApiURL string `json:"repositoryApiUrl"` + // Repository is the owner/repo path, e.g. "my-org/landscape-setup". + Repository string `json:"repository"` + // FilePath is the path to the versions file within the repository, + // e.g. "kubernetes/versions.yaml". + FilePath string `json:"filePath"` + // Provider is the provider whose Kubernetes versions are read from the file. + Provider string `json:"provider"` + // PersonalAccessTokenSecret is a reference to a secret containing a GitHub + // personal access token. Mutually exclusive with GithubApp. + // +optional + PersonalAccessTokenSecret *SecretReference `json:"personalAccessTokenSecret,omitempty"` + // GithubApp configures authentication via a GitHub App installation. + // Mutually exclusive with PersonalAccessTokenSecret. + // +optional + GithubApp *GithubAppAuth `json:"githubApp,omitempty"` +} + +// GithubAppAuth holds the credentials needed to authenticate as a GitHub App +// installation. +type GithubAppAuth struct { + // AppID is the numeric GitHub App ID. + AppID int64 `json:"appID"` + // InstallationID is the numeric installation ID for the target repository. + InstallationID int64 `json:"installationID"` + // PrivateKeySecret is a reference to a secret containing the RSA private key + // (PEM-encoded) used to sign JWTs. + PrivateKeySecret SecretReference `json:"privateKeySecret"` +} + type MachineImageUpdateSource struct { // OCI contains configuration for an OCI source. // +optional - OCI *MachineImageUpdateSourceOCI `json:"oci,omitempty"` + OCI *OCI `json:"oci,omitempty"` } -type MachineImageUpdateSourceOCI struct { +type OCI struct { // Registry contains the hostname and port of the OCI registry Registry string `json:"registry"` // Repository contains the monitored repository diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1ee67b6..aaa62fc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -102,6 +102,85 @@ func (in *GarbageCollectionConfig) DeepCopy() *GarbageCollectionConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GithubAppAuth) DeepCopyInto(out *GithubAppAuth) { + *out = *in + out.PrivateKeySecret = in.PrivateKeySecret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GithubAppAuth. +func (in *GithubAppAuth) DeepCopy() *GithubAppAuth { + if in == nil { + return nil + } + out := new(GithubAppAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesVersionSourceGithub) DeepCopyInto(out *KubernetesVersionSourceGithub) { + *out = *in + if in.PersonalAccessTokenSecret != nil { + in, out := &in.PersonalAccessTokenSecret, &out.PersonalAccessTokenSecret + *out = new(SecretReference) + **out = **in + } + if in.GithubApp != nil { + in, out := &in.GithubApp, &out.GithubApp + *out = new(GithubAppAuth) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesVersionSourceGithub. +func (in *KubernetesVersionSourceGithub) DeepCopy() *KubernetesVersionSourceGithub { + if in == nil { + return nil + } + out := new(KubernetesVersionSourceGithub) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesVersionUpdateConfig) DeepCopyInto(out *KubernetesVersionUpdateConfig) { + *out = *in + out.ExpirationThreshold = in.ExpirationThreshold + if in.LandscapeSetup != nil { + in, out := &in.LandscapeSetup, &out.LandscapeSetup + *out = new(LandscapeSetup) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesVersionUpdateConfig. +func (in *KubernetesVersionUpdateConfig) DeepCopy() *KubernetesVersionUpdateConfig { + if in == nil { + return nil + } + out := new(KubernetesVersionUpdateConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LandscapeSetup) DeepCopyInto(out *LandscapeSetup) { + *out = *in + out.OCI = in.OCI + in.Github.DeepCopyInto(&out.Github) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LandscapeSetup. +func (in *LandscapeSetup) DeepCopy() *LandscapeSetup { + if in == nil { + return nil + } + out := new(LandscapeSetup) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MachineImageUpdate) DeepCopyInto(out *MachineImageUpdate) { *out = *in @@ -144,7 +223,7 @@ func (in *MachineImageUpdateSource) DeepCopyInto(out *MachineImageUpdateSource) *out = *in if in.OCI != nil { in, out := &in.OCI, &out.OCI - *out = new(MachineImageUpdateSourceOCI) + *out = new(OCI) **out = **in } } @@ -159,22 +238,6 @@ func (in *MachineImageUpdateSource) DeepCopy() *MachineImageUpdateSource { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MachineImageUpdateSourceOCI) DeepCopyInto(out *MachineImageUpdateSourceOCI) { - *out = *in - out.Password = in.Password -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateSourceOCI. -func (in *MachineImageUpdateSourceOCI) DeepCopy() *MachineImageUpdateSourceOCI { - if in == nil { - return nil - } - out := new(MachineImageUpdateSourceOCI) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MachineImagesUpdateProviderIroncoreMetal) DeepCopyInto(out *MachineImagesUpdateProviderIroncoreMetal) { *out = *in @@ -265,6 +328,11 @@ func (in *ManagedCloudProfileSpec) DeepCopyInto(out *ManagedCloudProfileSpec) { *out = new(GarbageCollectionConfig) **out = **in } + if in.KubernetesVersionUpdateConfig != nil { + in, out := &in.KubernetesVersionUpdateConfig, &out.KubernetesVersionUpdateConfig + *out = new(KubernetesVersionUpdateConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedCloudProfileSpec. @@ -299,6 +367,22 @@ func (in *ManagedCloudProfileStatus) DeepCopy() *ManagedCloudProfileStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OCI) DeepCopyInto(out *OCI) { + *out = *in + out.Password = in.Password +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCI. +func (in *OCI) DeepCopy() *OCI { + if in == nil { + return nil + } + out := new(OCI) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretReference) DeepCopyInto(out *SecretReference) { *out = *in diff --git a/cloudprofilesync/k8ssync/k8s_image_updater.go b/cloudprofilesync/k8ssync/k8s_image_updater.go new file mode 100644 index 0000000..bce237f --- /dev/null +++ b/cloudprofilesync/k8ssync/k8s_image_updater.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 +package k8ssync + +import ( + "context" + "errors" + "fmt" + "time" + + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" +) + +// KubernetesVersionSource is the single interface for sources that return +// Kubernetes versions ready to assign to a CloudProfile. +type KubernetesVersionSource interface { + FetchVersions(ctx context.Context) ([]gardenerv1beta1.ExpirableVersion, error) +} + +// KubernetesVersionUpdater writes Kubernetes versions to a CloudProfileSpec, +// dropping any version whose expiration date has already passed the configured +// threshold. +type KubernetesVersionUpdater struct { + Source KubernetesVersionSource + ExpirationThreshold time.Duration +} + +func NewKubernetesVersionUpdater(source KubernetesVersionSource, expirationThreshold time.Duration) *KubernetesVersionUpdater { + return &KubernetesVersionUpdater{ + Source: source, + ExpirationThreshold: expirationThreshold, + } +} + +func (ku *KubernetesVersionUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error { + versions, err := ku.Source.FetchVersions(ctx) + if err != nil { + return fmt.Errorf("fetching kubernetes versions: %w", err) + } + + cutoff := time.Now().Add(-ku.ExpirationThreshold) + filteredVersions := make([]gardenerv1beta1.ExpirableVersion, 0, len(versions)) + for _, v := range versions { + if v.ExpirationDate != nil && v.ExpirationDate.Time.Before(cutoff) { //nolint:staticcheck + continue + } + filteredVersions = append(filteredVersions, v) + } + + if len(filteredVersions) == 0 { + return errors.New("source returned no kubernetes versions after expiration filtering, refusing to wipe CloudProfile") + } + cpSpec.Kubernetes.Versions = filteredVersions + return nil +} diff --git a/cloudprofilesync/k8ssync/k8s_image_updater_test.go b/cloudprofilesync/k8ssync/k8s_image_updater_test.go new file mode 100644 index 0000000..57db9dd --- /dev/null +++ b/cloudprofilesync/k8ssync/k8s_image_updater_test.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 +package k8ssync + +import ( + "context" + "errors" + "testing" + "time" + + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// fakeSource is a KubernetesVersionSource that returns a fixed list or error. +type fakeSource struct { + versions []gardenerv1beta1.ExpirableVersion + err error +} + +func (f *fakeSource) FetchVersions(_ context.Context) ([]gardenerv1beta1.ExpirableVersion, error) { + return f.versions, f.err +} + +func expiry(t time.Time) *metav1.Time { return &metav1.Time{Time: t} } + +func TestKubernetesImageUpdater_Update(t *testing.T) { + now := time.Now() + + t.Run("writes versions to CloudProfileSpec.Kubernetes", func(t *testing.T) { + src := &fakeSource{versions: []gardenerv1beta1.ExpirableVersion{ + {Version: "1.31.0"}, + {Version: "1.32.0"}, + }} + ku := NewKubernetesVersionUpdater(src, 0) + var spec gardenerv1beta1.CloudProfileSpec + if err := ku.Update(context.Background(), &spec); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.Kubernetes.Versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(spec.Kubernetes.Versions)) + } + }) + + t.Run("keeps versions with no expiration date", func(t *testing.T) { + src := &fakeSource{versions: []gardenerv1beta1.ExpirableVersion{ + {Version: "1.31.0"}, // no ExpirationDate + }} + ku := NewKubernetesVersionUpdater(src, 30*24*time.Hour) + var spec gardenerv1beta1.CloudProfileSpec + if err := ku.Update(context.Background(), &spec); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.Kubernetes.Versions) != 1 { + t.Fatalf("expected 1 version, got %d", len(spec.Kubernetes.Versions)) + } + }) + + t.Run("drops version expired beyond threshold", func(t *testing.T) { + src := &fakeSource{versions: []gardenerv1beta1.ExpirableVersion{ + {Version: "1.29.0", ExpirationDate: expiry(now.Add(-60 * 24 * time.Hour))}, + }} + ku := NewKubernetesVersionUpdater(src, 30*24*time.Hour) + var spec gardenerv1beta1.CloudProfileSpec + spec.Kubernetes.Versions = []gardenerv1beta1.ExpirableVersion{{Version: "existing"}} + err := ku.Update(context.Background(), &spec) + if err == nil { + t.Fatal("expected error when all versions filtered, got nil") + } + // CloudProfile must not have been modified. + if len(spec.Kubernetes.Versions) != 1 || spec.Kubernetes.Versions[0].Version != "existing" { + t.Errorf("spec was modified despite error: %v", spec.Kubernetes.Versions) + } + }) + + t.Run("keeps version expired within threshold", func(t *testing.T) { + src := &fakeSource{versions: []gardenerv1beta1.ExpirableVersion{ + {Version: "1.30.0", ExpirationDate: expiry(now.Add(-10 * 24 * time.Hour))}, + }} + ku := NewKubernetesVersionUpdater(src, 30*24*time.Hour) + var spec gardenerv1beta1.CloudProfileSpec + if err := ku.Update(context.Background(), &spec); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.Kubernetes.Versions) != 1 { + t.Fatalf("expected 1 version, got %d", len(spec.Kubernetes.Versions)) + } + }) + + t.Run("mixed: keeps recent, drops stale", func(t *testing.T) { + src := &fakeSource{versions: []gardenerv1beta1.ExpirableVersion{ + {Version: "1.31.0"}, + {Version: "1.30.0", ExpirationDate: expiry(now.Add(-10 * 24 * time.Hour))}, + {Version: "1.29.0", ExpirationDate: expiry(now.Add(-60 * 24 * time.Hour))}, + }} + ku := NewKubernetesVersionUpdater(src, 30*24*time.Hour) + var spec gardenerv1beta1.CloudProfileSpec + if err := ku.Update(context.Background(), &spec); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.Kubernetes.Versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(spec.Kubernetes.Versions)) + } + got := map[string]bool{} + for _, v := range spec.Kubernetes.Versions { + got[v.Version] = true + } + if !got["1.31.0"] || !got["1.30.0"] { + t.Errorf("unexpected versions in result: %v", spec.Kubernetes.Versions) + } + if got["1.29.0"] { + t.Error("1.29.0 should have been filtered out") + } + }) + + t.Run("returns error when source fails", func(t *testing.T) { + src := &fakeSource{err: errors.New("upstream failure")} + ku := NewKubernetesVersionUpdater(src, 0) + var spec gardenerv1beta1.CloudProfileSpec + if err := ku.Update(context.Background(), &spec); err == nil { + t.Fatal("expected error from source, got nil") + } + }) + + t.Run("refuses to wipe CloudProfile when all versions filtered", func(t *testing.T) { + src := &fakeSource{versions: []gardenerv1beta1.ExpirableVersion{ + {Version: "1.29.0", ExpirationDate: expiry(now.Add(-60 * 24 * time.Hour))}, + {Version: "1.28.0", ExpirationDate: expiry(now.Add(-90 * 24 * time.Hour))}, + }} + ku := NewKubernetesVersionUpdater(src, 30*24*time.Hour) + var spec gardenerv1beta1.CloudProfileSpec + spec.Kubernetes.Versions = []gardenerv1beta1.ExpirableVersion{{Version: "existing"}} + err := ku.Update(context.Background(), &spec) + if err == nil { + t.Fatal("expected error when all versions filtered") + } + // CloudProfile must not have been modified. + if len(spec.Kubernetes.Versions) != 1 || spec.Kubernetes.Versions[0].Version != "existing" { + t.Errorf("spec was modified despite error: %v", spec.Kubernetes.Versions) + } + }) +} diff --git a/cloudprofilesync/k8ssync/source/landscape/landscape_source.go b/cloudprofilesync/k8ssync/source/landscape/landscape_source.go new file mode 100644 index 0000000..6cb6656 --- /dev/null +++ b/cloudprofilesync/k8ssync/source/landscape/landscape_source.go @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package landscape + +import ( + "archive/tar" + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" + + "github.com/blang/semver/v4" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "go.yaml.in/yaml/v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" +) + +// componentDescriptorFile is the file in the OCI artifact layer that holds +// the OCM component descriptor. +const componentDescriptorFile = "component-descriptor.yaml" + +// kubeAPIServerResourceName is the component resource whose versions are used +// as Kubernetes versions. +const kubeAPIServerResourceName = "kube-apiserver" + +const githubClientTimeout = 30 * time.Second + +// componentDescriptor is the minimal shape of component-descriptor.yaml. +type componentDescriptor struct { + Component struct { + Resources []struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + } `yaml:"resources"` + } `yaml:"component"` +} + +// yamlExpirableVersion is a YAML-unmarshalling intermediate for entries in the +// GitHub versions file. metav1.Time has no UnmarshalYAML, so we use *time.Time +// here and convert to gardenerv1beta1.ExpirableVersion after parsing. +type yamlExpirableVersion struct { + Version string `yaml:"version"` + Classification *gardenerv1beta1.VersionClassification `yaml:"classification"` + ExpirationDate *time.Time `yaml:"expirationDate"` +} + +// kubernetesVersions is the shape of the GitHub versions file. +type kubernetesVersions struct { + Providers []struct { + Name string `yaml:"name"` + Versions []yamlExpirableVersion `yaml:"versions"` + } `yaml:"providers"` +} + +// GithubParams configures the GitHub classification source. +type GithubParams struct { + // RepositoryApiURL is the GitHub REST API base URL, + // e.g. "https://api.github.com" or "https://github.mycompany.com/api/v3". + RepositoryApiURL string + // Repository is the owner/repo path, e.g. "my-org/landscape-setup". + Repository string + // FilePath is the path to the versions YAML file within the repository. + FilePath string + // Provider is the provider name to select from the versions file. + Provider string + + // Transport is an optional custom HTTP transport for the GitHub client. + Transport http.RoundTripper +} + +// LandscapeKubernetesSource fetches Kubernetes versions from a Keppel OCI +// registry and their classifications from a GitHub repository, returning the +// intersection as []gardenerv1beta1.ExpirableVersion. +type LandscapeKubernetesSource struct { + ociRepo *remote.Repository + githubClient *http.Client + fileURL string + provider string +} + +func GithubPATTransport(token string) http.RoundTripper { + return &patTransport{token: token, base: http.DefaultTransport} +} + +func GithubAppTransport(apiBase string, appID, installationID int64, privateKeyPEM []byte) (http.RoundTripper, error) { + key, err := parseRSAPrivateKey(privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("parsing private key: %w", err) + } + return &githubAppTransport{ + appID: appID, + installationID: installationID, + apiBase: apiBase, + key: key, + base: http.DefaultTransport, + }, nil +} + +func NewLandscapeKubernetesSource(ociParams ocirepo.Params, gh GithubParams) (*LandscapeKubernetesSource, error) { + if gh.RepositoryApiURL == "" { + return nil, errors.New("repositoryApiUrl must be set") + } + repo, err := ocirepo.New(ociParams) + if err != nil { + return nil, fmt.Errorf("initializing OCI repository: %w", err) + } + fileURL, err := contentsURL(gh.RepositoryApiURL, gh.Repository, gh.FilePath) + if err != nil { + return nil, fmt.Errorf("building github contents URL: %w", err) + } + return &LandscapeKubernetesSource{ + ociRepo: repo, + githubClient: &http.Client{Transport: gh.Transport, Timeout: githubClientTimeout}, + fileURL: fileURL, + provider: gh.Provider, + }, nil +} + +// FetchVersions resolves the latest OCI tag, fetches the component descriptor +// to get supported versions, fetches the GitHub classification file at the same +// tag, and returns the intersection as []gardenerv1beta1.ExpirableVersion. +func (s *LandscapeKubernetesSource) FetchVersions(ctx context.Context) ([]gardenerv1beta1.ExpirableVersion, error) { + tag, err := s.LatestTag(ctx) + if err != nil { + return nil, fmt.Errorf("resolving latest tag: %w", err) + } + + supportedVersions, err := s.fetchSupportedVersions(ctx, tag) + if err != nil { + return nil, fmt.Errorf("fetching supported versions: %w", err) + } + + classification, err := s.fetchClassification(ctx, tag) + if err != nil { + return nil, fmt.Errorf("fetching classification: %w", err) + } + + supported := make(map[string]bool, len(supportedVersions)) + for _, v := range supportedVersions { + supported[v] = true + } + + result := make([]gardenerv1beta1.ExpirableVersion, 0, len(classification)) + for _, v := range classification { + if !supported[v.Version] { + continue + } + result = append(result, v) + } + return result, nil +} + +// LatestTag returns the highest semver tag in the OCI repository. +// Tags that cannot be parsed as semver are ignored. +func (s *LandscapeKubernetesSource) LatestTag(ctx context.Context) (string, error) { + var tags []string + appendTagsFunc := func(newTags []string) error { + tags = append(tags, newTags...) + return nil + } + + if err := s.ociRepo.Tags(ctx, "", appendTagsFunc); err != nil { + return "", fmt.Errorf("listing tags: %w", err) + } + if len(tags) == 0 { + return "", fmt.Errorf("no tags found in %s", s.ociRepo.Reference) + } + + type semverTag struct { + raw string + ver semver.Version + } + var parseable []semverTag + for _, t := range tags { + if v, err := semver.ParseTolerant(t); err == nil { + parseable = append(parseable, semverTag{raw: t, ver: v}) + } + } + if len(parseable) == 0 { + return "", fmt.Errorf("no semver tags found in %s", s.ociRepo.Reference) + } + + latest := slices.MaxFunc(parseable, func(a, b semverTag) int { + return a.ver.Compare(b.ver) + }) + return latest.raw, nil +} + +// fetchSupportedVersions returns the kube-apiserver version strings from the +// component descriptor at the given OCI tag. +func (s *LandscapeKubernetesSource) fetchSupportedVersions(ctx context.Context, tag string) ([]string, error) { + cd, err := s.fetchComponentDescriptor(ctx, tag) + if err != nil { + return nil, fmt.Errorf("tag %s: %w", tag, err) + } + + versions := make([]string, 0, len(cd.Component.Resources)) + for _, res := range cd.Component.Resources { + if res.Name == kubeAPIServerResourceName { + versions = append(versions, res.Version) + } + } + + return versions, nil +} + +func (s *LandscapeKubernetesSource) fetchComponentDescriptor(ctx context.Context, tag string) (*componentDescriptor, error) { + _, manifestBytes, err := oras.FetchBytes(ctx, s.ociRepo, tag, oras.DefaultFetchBytesOptions) + if err != nil { + return nil, fmt.Errorf("fetching manifest: %w", err) + } + + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("decoding manifest: %w", err) + } + if len(manifest.Layers) == 0 { + return nil, errors.New("manifest has no layers") + } + + layerBytes, err := content.FetchAll(ctx, s.ociRepo, manifest.Layers[0]) + if err != nil { + return nil, fmt.Errorf("fetching layer blob: %w", err) + } + + cd, err := extractComponentDescriptor(bytes.NewReader(layerBytes)) + if err != nil { + return nil, fmt.Errorf("extracting component descriptor: %w", err) + } + + return cd, nil +} + +func extractComponentDescriptor(r io.Reader) (*componentDescriptor, error) { + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s not found in layer", componentDescriptorFile) + } + if err != nil { + return nil, fmt.Errorf("reading tar: %w", err) + } + if !matchesFile(hdr.Name, componentDescriptorFile) { + continue + } + raw, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", componentDescriptorFile, err) + } + var cd componentDescriptor + if err := yaml.Unmarshal(raw, &cd); err != nil { + return nil, fmt.Errorf("parsing %s: %w", componentDescriptorFile, err) + } + return &cd, nil + } +} + +func matchesFile(name, target string) bool { + name = strings.TrimPrefix(name, "./") + return name == target || strings.HasSuffix(name, "/"+target) +} + +// fetchClassification downloads the versions YAML from GitHub at the given ref +// and returns the versions for the configured provider. +func (s *LandscapeKubernetesSource) fetchClassification(ctx context.Context, ref string) ([]gardenerv1beta1.ExpirableVersion, error) { + if s.provider == "" { + return nil, errors.New("provider must be set") + } + raw, err := s.fetchGithubFile(ctx, ref) + if err != nil { + return nil, fmt.Errorf("fetch github file: %w", err) + } + return parseProviderVersions(raw, s.provider) +} + +func (s *LandscapeKubernetesSource) fetchGithubFile(ctx context.Context, ref string) ([]byte, error) { + fileURL := s.fileURL + if ref != "" { + fileURL += "?ref=" + ref + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.raw") + + resp, err := s.githubClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("can't read body, github API returned %d: %w", resp.StatusCode, err) + } + return nil, fmt.Errorf("github API returned %d: %s", resp.StatusCode, body) + } + + return io.ReadAll(resp.Body) +} + +func parseProviderVersions(raw []byte, provider string) ([]gardenerv1beta1.ExpirableVersion, error) { + var kv kubernetesVersions + if err := yaml.Unmarshal(raw, &kv); err != nil { + return nil, fmt.Errorf("parsing versions file: %w", err) + } + for _, p := range kv.Providers { + if p.Name == provider { + if len(p.Versions) == 0 { + return nil, fmt.Errorf("provider %q has no versions", provider) + } + result := make([]gardenerv1beta1.ExpirableVersion, 0, len(p.Versions)) + for _, v := range p.Versions { + result = append(result, gardenerv1beta1.ExpirableVersion{ + Version: v.Version, + Classification: v.Classification, + ExpirationDate: convertExpirationDate(v.ExpirationDate), + }) + } + + return result, nil + } + } + return nil, fmt.Errorf("provider %q not found in the fetched data", provider) +} + +func contentsURL(apiURL, repo, filePath string) (string, error) { + return url.JoinPath(apiURL, "repos", repo, "contents", filePath) +} + +func convertExpirationDate(t *time.Time) *metav1.Time { + if t == nil { + return nil + } + return &metav1.Time{Time: *t} +} + +// ---- GitHub PAT transport ---- + +type patTransport struct { + token string + base http.RoundTripper +} + +func (t *patTransport) RoundTrip(req *http.Request) (*http.Response, error) { + r := req.Clone(req.Context()) + r.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(r) +} + +// ---- GitHub App transport ---- + +const tokenExpiryMargin = 5 * time.Minute + +type githubAppTransport struct { + appID int64 + installationID int64 + apiBase string + key *rsa.PrivateKey + base http.RoundTripper + + mu sync.Mutex + cached string + expiresAt time.Time +} + +func (t *githubAppTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.installationToken(req.Context()) + if err != nil { + return nil, fmt.Errorf("getting installation token: %w", err) + } + r := req.Clone(req.Context()) + r.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(r) +} + +func (t *githubAppTransport) installationToken(ctx context.Context) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.cached != "" && time.Now().Add(tokenExpiryMargin).Before(t.expiresAt) { + return t.cached, nil + } + jwt, err := t.mintJWT() + if err != nil { + return "", fmt.Errorf("minting JWT: %w", err) + } + token, expiresAt, err := exchangeInstallationToken(ctx, t.base, t.apiBase, jwt, t.installationID) + if err != nil { + return "", err + } + t.cached = token + t.expiresAt = expiresAt + return token, nil +} + +func (t *githubAppTransport) mintJWT() (string, error) { + now := time.Now() + header := base64.RawURLEncoding.EncodeToString(mustJSON(map[string]string{ + "alg": "RS256", + "typ": "JWT", + })) + payload := base64.RawURLEncoding.EncodeToString(mustJSON(map[string]any{ + "iat": now.Add(-60 * time.Second).Unix(), + "exp": now.Add(10 * time.Minute).Unix(), + "iss": t.appID, + })) + + sigInput := header + "." + payload + h := sha256.New() + h.Write([]byte(sigInput)) + + sig, err := rsa.SignPKCS1v15(rand.Reader, t.key, crypto.SHA256, h.Sum(nil)) + if err != nil { + return "", fmt.Errorf("signing JWT: %w", err) + } + return sigInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +func exchangeInstallationToken(ctx context.Context, base http.RoundTripper, apiBase, jwt string, installationID int64) (string, time.Time, error) { + tokenURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", apiBase, installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, http.NoBody) + if err != nil { + return "", time.Time{}, fmt.Errorf("creating token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := base.RoundTrip(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("requesting installation token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", time.Time{}, fmt.Errorf("reading token response: %w", err) + } + if resp.StatusCode != http.StatusCreated { + return "", time.Time{}, fmt.Errorf("github returned %d: %s", resp.StatusCode, body) + } + + var result struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", time.Time{}, fmt.Errorf("decoding token response: %w", err) + } + if result.Token == "" { + return "", time.Time{}, errors.New("empty token in response") + } + return result.Token, result.ExpiresAt, nil +} + +func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("no PEM block found") + } + switch block.Type { + case "RSA PRIVATE KEY": + return x509.ParsePKCS1PrivateKey(block.Bytes) + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("PKCS8 key is not RSA") + } + return rsaKey, nil + default: + return nil, fmt.Errorf("unsupported PEM block type %q", block.Type) + } +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("mustJSON: %v", err)) + } + return b +} diff --git a/cloudprofilesync/k8ssync/source/landscape/landscape_source_test.go b/cloudprofilesync/k8ssync/source/landscape/landscape_source_test.go new file mode 100644 index 0000000..94f225b --- /dev/null +++ b/cloudprofilesync/k8ssync/source/landscape/landscape_source_test.go @@ -0,0 +1,530 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package landscape + +import ( + "archive/tar" + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" +) + +// ---- helpers ---- + +func tarWith(t *testing.T, name, body string) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("write body: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + return buf.Bytes() +} + +func generateTestKey(t *testing.T) (key *rsa.PrivateKey, pemBytes []byte) { + t.Helper() + var err error + key, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating key: %v", err) + } + pemBytes = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + return key, pemBytes +} + +const testDescriptor = ` +component: + name: landscape-setup + resources: + - name: kube-apiserver + version: 1.31.4 + - name: kube-apiserver + version: 1.32.1 + - name: kubelet + version: 1.31.4 +` + +const testProvidersYAML = ` +providers: +- name: converged-cloud + versions: + - version: 1.31.4 + classification: supported + - version: 1.32.1 + classification: deprecated + expirationDate: '2027-06-10T23:59:59Z' + - version: 1.33.0 + classification: supported +` + +// ---- OCI helpers ---- + +func TestExtractComponentDescriptor(t *testing.T) { + t.Run("parses resources from the tar", func(t *testing.T) { + blob := tarWith(t, componentDescriptorFile, testDescriptor) + cd, err := extractComponentDescriptor(bytes.NewReader(blob)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(cd.Component.Resources); got != 3 { + t.Fatalf("expected 3 resources, got %d", got) + } + }) + t.Run("tolerates a leading path prefix", func(t *testing.T) { + blob := tarWith(t, "landscape-setup/"+componentDescriptorFile, testDescriptor) + if _, err := extractComponentDescriptor(bytes.NewReader(blob)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("errors when the file is absent", func(t *testing.T) { + blob := tarWith(t, "other-file.yaml", "hello") + _, err := extractComponentDescriptor(bytes.NewReader(blob)) + if err == nil || !strings.Contains(err.Error(), "not found in layer") { + t.Fatalf("expected not-found error, got %v", err) + } + }) + t.Run("errors on a non-tar blob", func(t *testing.T) { + _, err := extractComponentDescriptor(strings.NewReader("not a tar")) + if err == nil { + t.Fatal("expected error for non-tar blob") + } + }) +} + +// ---- GitHub classification helpers ---- + +func TestParseProviderVersions(t *testing.T) { + t.Run("selects the configured provider", func(t *testing.T) { + versions, err := parseProviderVersions([]byte(testProvidersYAML), "converged-cloud") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(versions) != 3 { + t.Fatalf("expected 3 versions, got %d", len(versions)) + } + if versions[1].ExpirationDate == nil { //nolint:staticcheck + t.Error("expected expiration date to be parsed for deprecated version") + } + }) + t.Run("errors for an unknown provider", func(t *testing.T) { + _, err := parseProviderVersions([]byte(testProvidersYAML), "gcp") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not-found error, got %v", err) + } + }) + t.Run("errors when the provider has no versions", func(t *testing.T) { + _, err := parseProviderVersions([]byte("providers:\n- name: empty\n versions: []\n"), "empty") + if err == nil || !strings.Contains(err.Error(), "no versions") { + t.Fatalf("expected no-versions error, got %v", err) + } + }) +} + +// ---- GitHub App transport ---- + +func TestGithubAppTransport_MintJWT(t *testing.T) { + key, _ := generateTestKey(t) + tr := &githubAppTransport{appID: 42, installationID: 99, key: key, base: http.DefaultTransport} + + jwt, err := tr.mintJWT() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parts := strings.Split(jwt, "."); len(parts) != 3 { + t.Fatalf("expected 3 JWT parts, got %d", len(parts)) + } +} + +func TestGithubAppTransport_TokenCaching(t *testing.T) { + key, _ := generateTestKey(t) + + tokenCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "access_tokens") { + tokenCalls++ + w.WriteHeader(http.StatusCreated) + resp := map[string]any{ + "token": fmt.Sprintf("inst-token-%d", tokenCalls), + "expires_at": time.Now().Add(1 * time.Hour).Format(time.RFC3339), + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Error(err) + } + return + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(testProvidersYAML)); err != nil { + t.Error(err) + } + })) + defer srv.Close() + + tr := &githubAppTransport{ + appID: 42, + installationID: 99, + apiBase: srv.URL, + key: key, + base: http.DefaultTransport, + } + + // Two requests should produce only one token exchange call due to caching. + for range 2 { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody) + if err != nil { + t.Fatalf("creating request: %v", err) + } + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + } + + if tokenCalls != 1 { + t.Errorf("expected 1 token exchange, got %d", tokenCalls) + } +} + +func TestParseRSAPrivateKey(t *testing.T) { + t.Run("parses PKCS1 PEM", func(t *testing.T) { + _, pemBytes := generateTestKey(t) + if _, err := parseRSAPrivateKey(pemBytes); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("errors on non-PEM input", func(t *testing.T) { + if _, err := parseRSAPrivateKey([]byte("not a pem")); err == nil { + t.Fatal("expected error") + } + }) + t.Run("errors on unsupported PEM type", func(t *testing.T) { + b := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("x")}) + if _, err := parseRSAPrivateKey(b); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("expected unsupported error, got %v", err) + } + }) +} + +// ---- GitHub fetch helpers ---- + +// TestFetchGithubFile_RefQueryParam verifies that fetchGithubFile appends +// the ?ref= query parameter when a ref is provided. +func TestFetchGithubFile_RefQueryParam(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + if _, err := w.Write([]byte(testProvidersYAML)); err != nil { + t.Error(err) + } + })) + defer srv.Close() + + src := &LandscapeKubernetesSource{ + githubClient: &http.Client{Transport: &patTransport{token: "tok", base: http.DefaultTransport}}, + fileURL: srv.URL, + provider: "converged-cloud", + } + if _, err := src.fetchClassification(context.Background(), "v1.2.3"); err != nil { + t.Fatalf("fetchClassification: %v", err) + } + if gotQuery != "ref=v1.2.3" { + t.Errorf("expected ref=v1.2.3 query param, got %q", gotQuery) + } +} + +// TestFetchGithubFile_NonOKStatus verifies that a non-200 GitHub response is +// propagated as an error containing the status code. +func TestFetchGithubFile_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden", http.StatusForbidden) + })) + defer srv.Close() + + src := &LandscapeKubernetesSource{ + githubClient: &http.Client{}, + fileURL: srv.URL, + provider: "converged-cloud", + } + _, err := src.fetchClassification(context.Background(), "") + if err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("expected 403 error, got %v", err) + } +} + +// ---- FetchVersions end-to-end (real OCI registry + httptest GitHub) ---- + +// freePort returns a TCP port number that is free at call time. There is a +// small TOCTOU window but it is negligible for local test registries. +func freePort(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("finding free port: %v", err) + } + addr := ln.Addr().String() + ln.Close() + return addr +} + +// startRegistry spins up an in-process distribution registry on addr and +// returns a cleanup function. It fails the test immediately if the registry +// does not become ready within 500 ms. +func startRegistry(t *testing.T, addr string) func() { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + reg, err := registry.NewRegistry(ctx, &configuration.Configuration{ + Storage: configuration.Storage{"inmemory": map[string]any{}}, + HTTP: configuration.HTTP{Addr: addr}, + Validation: configuration.Validation{Disabled: true}, + Log: configuration.Log{Level: "error", AccessLog: configuration.AccessLog{Disabled: true}}, + }) + if err != nil { + cancel() + t.Fatalf("creating registry: %v", err) + } + go func() { _ = reg.ListenAndServe() }() //nolint:errcheck + + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr, http.NoBody) + if err != nil { + cancel() + t.Fatalf("building readiness request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + break + } + time.Sleep(10 * time.Millisecond) + if time.Now().After(deadline) { + cancel() + t.Fatalf("registry on %s did not become ready within 500ms", addr) + } + } + return func() { + cancel() + _ = reg.Shutdown(context.Background()) //nolint:errcheck + } +} + +// pushComponentDescriptorArtifact pushes a minimal OCI artifact whose first +// layer is a tar containing componentDescriptorFile with the given YAML body, +// tagged with tag. +func pushComponentDescriptorArtifact(t *testing.T, addr, repoName, tag, descriptorYAML string) { + t.Helper() + ctx := context.Background() + + repo, err := remote.NewRepository(addr + "/" + repoName) + if err != nil { + t.Fatalf("new repo: %v", err) + } + repo.PlainHTTP = true + + // Build the tar layer. + layerBytes := tarWith(t, componentDescriptorFile, descriptorYAML) + layerDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, layerBytes) + if err := repo.Push(ctx, layerDesc, bytes.NewReader(layerBytes)); err != nil { + t.Fatalf("push layer: %v", err) + } + + // Push empty config blob. + if err := repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")); err != nil { + t.Fatalf("push config: %v", err) + } + + // Build and push the OCI manifest. + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.DescriptorEmptyJSON, + Layers: []ocispec.Descriptor{layerDesc}, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifestBytes) + if err := repo.PushReference(ctx, manifestDesc, bytes.NewReader(manifestBytes), tag); err != nil { + t.Fatalf("push manifest: %v", err) + } +} + +// TestFetchVersions_EndToEnd exercises the full FetchVersions code path: the +// OCI component descriptor is fetched from a real in-process registry; the +// GitHub classification file is served by an httptest.Server. +// It verifies that only versions present in both sources are returned. +func TestFetchVersions_EndToEnd(t *testing.T) { + addr := freePort(t) + stop := startRegistry(t, addr) + defer stop() + + // OCI: descriptor contains 1.31.4 and 1.32.1 only (1.33.0 absent). + descriptor := ` +component: + name: landscape-setup + resources: + - name: kube-apiserver + version: 1.31.4 + - name: kube-apiserver + version: 1.32.1 + - name: kubelet + version: 1.31.4 +` + pushComponentDescriptorArtifact(t, addr, "k8s-versions", "v1.2.3", descriptor) + + // GitHub: has 1.31.4, 1.32.1, and 1.33.0 — 1.33.0 must be filtered out + // because it is absent from the OCI component descriptor. + githubSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(testProvidersYAML)); err != nil { + t.Error(err) + } + })) + defer githubSrv.Close() + + fileURL, err := contentsURL(githubSrv.URL, "org/repo", "kubernetes/versions.yaml") + if err != nil { + t.Fatalf("contentsURL: %v", err) + } + + ociRepo, err := remote.NewRepository(addr + "/k8s-versions") + if err != nil { + t.Fatalf("new oci repo: %v", err) + } + ociRepo.PlainHTTP = true + + src := &LandscapeKubernetesSource{ + ociRepo: ociRepo, + githubClient: &http.Client{}, + fileURL: fileURL, + provider: "converged-cloud", + } + + versions, err := src.FetchVersions(context.Background()) + if err != nil { + t.Fatalf("FetchVersions: %v", err) + } + + if len(versions) != 2 { + t.Fatalf("expected 2 versions, got %d: %v", len(versions), versions) + } + got := make(map[string]bool, len(versions)) + for _, v := range versions { + got[v.Version] = true + } + for _, want := range []string{"1.31.4", "1.32.1"} { + if !got[want] { + t.Errorf("expected version %q in result", want) + } + } + if got["1.33.0"] { + t.Error("1.33.0 should not be in result (absent from OCI component descriptor)") + } +} + +// TestFetchVersions_NoSemverTags verifies that LatestTag returns an error when +// the OCI repository has no semver-parseable tags. +func TestFetchVersions_NoSemverTags(t *testing.T) { + addr := freePort(t) + stop := startRegistry(t, addr) + defer stop() + + // Push a single manifest under a non-semver tag. + pushComponentDescriptorArtifact(t, addr, "k8s-nosemver", "not-a-version", testDescriptor) + + ociRepo, err := remote.NewRepository(addr + "/k8s-nosemver") + if err != nil { + t.Fatalf("new oci repo: %v", err) + } + ociRepo.PlainHTTP = true + + src := &LandscapeKubernetesSource{ + ociRepo: ociRepo, + githubClient: &http.Client{}, + fileURL: "http://unused", + provider: "converged-cloud", + } + _, err = src.FetchVersions(context.Background()) + if err == nil || !strings.Contains(err.Error(), "no semver tags") { + t.Fatalf("expected no-semver-tags error, got %v", err) + } +} + +// TestFetchVersions_NoKubeAPIServerResources verifies that when the component +// descriptor has no kube-apiserver resources, FetchVersions returns an empty +// intersection (no versions written to the CloudProfile). +func TestFetchVersions_NoKubeAPIServerResources(t *testing.T) { + addr := freePort(t) + stop := startRegistry(t, addr) + defer stop() + + noAPIServerDescriptor := ` +component: + name: landscape-setup + resources: + - name: kubelet + version: 1.31.4 +` + pushComponentDescriptorArtifact(t, addr, "k8s-noapiserver", "v1.0.0", noAPIServerDescriptor) + + githubSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(testProvidersYAML)); err != nil { + t.Error(err) + } + })) + defer githubSrv.Close() + + fileURL, err := contentsURL(githubSrv.URL, "org/repo", "kubernetes/versions.yaml") + if err != nil { + t.Fatalf("contentsURL: %v", err) + } + + ociRepo, err := remote.NewRepository(addr + "/k8s-noapiserver") + if err != nil { + t.Fatalf("new oci repo: %v", err) + } + ociRepo.PlainHTTP = true + + src := &LandscapeKubernetesSource{ + ociRepo: ociRepo, + githubClient: &http.Client{}, + fileURL: fileURL, + provider: "converged-cloud", + } + versions, err := src.FetchVersions(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(versions) != 0 { + t.Errorf("expected empty result when no kube-apiserver resources, got %v", versions) + } +} diff --git a/cloudprofilesync/ocirepo/ocirepo.go b/cloudprofilesync/ocirepo/ocirepo.go new file mode 100644 index 0000000..f2beb55 --- /dev/null +++ b/cloudprofilesync/ocirepo/ocirepo.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 +package ocirepo + +import ( + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" +) + +type Params struct { + Registry string + Repository string + Username string + Password string + Insecure bool +} + +// New builds an oras-go remote repository with static-credential auth, +// shared by the OCI machine image source and the Keppel Kubernetes source. +func New(params Params) (*remote.Repository, error) { + repo, err := remote.NewRepository(params.Registry + "/" + params.Repository) + if err != nil { + return nil, err + } + + if params.Username != "" && params.Password != "" { + repo.Client = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: auth.StaticCredential(params.Registry, auth.Credential{ + Username: params.Username, + Password: params.Password, + }), + } + } + repo.PlainHTTP = params.Insecure + + return repo, nil +} diff --git a/cloudprofilesync/imageupdater.go b/cloudprofilesync/ossync/os_image_updater.go similarity index 78% rename from cloudprofilesync/imageupdater.go rename to cloudprofilesync/ossync/os_image_updater.go index 4914455..7ea4b9f 100644 --- a/cloudprofilesync/imageupdater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 -package cloudprofilesync +package ossync import ( "cmp" @@ -14,6 +14,37 @@ import ( "github.com/go-logr/logr" ) +type SourceImage struct { + // Version is the full tag from the registry (used as version key for legacy images). + Version string + // CleanVersion is the version from the "version" OCI annotation (e.g. "2262.0.0"). + // When set, flavors are grouped under it in the CloudProfile instead of the full tag. + CleanVersion string + // TODO: deprecate once all images carry capability annotations; use Capabilities["architecture"] instead. + Architectures []string + // Capabilities holds parsed OCI manifest annotations. Nil means the image + // predates capability annotations and should use the legacy format. + Capabilities gardenerv1beta1.Capabilities + // SupportInPlaceUpdate hold value if image supports in place updates + SupportInPlaceUpdate bool +} + +// effectiveVersion returns CleanVersion when available, falling back to Version. +func (s SourceImage) effectiveVersion() string { + if s.CleanVersion != "" { + return s.CleanVersion + } + return s.Version +} + +type Source interface { + GetVersions(ctx context.Context) ([]SourceImage, error) +} + +type Provider interface { + Configure(cloudProfile *gardenerv1beta1.CloudProfileSpec, versions []SourceImage) error +} + func filterImages(log logr.Logger, versions []SourceImage) []SourceImage { filtered := make([]SourceImage, 0, len(versions)) for _, version := range versions { @@ -86,7 +117,6 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou } for _, sourceImage := range sourceImages { - supportInPlaceUpdate := slices.Contains(sourceImage.Capabilities[FeatureCapability], USIFeature) // Always write the full tag version (legacy path, safe for running Shoots). if idx, exists := existingVersions[sourceImage.Version]; exists { image.Versions[idx].Architectures = sourceImage.Architectures @@ -104,9 +134,9 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou }, Architectures: sourceImage.Architectures, }) - if supportInPlaceUpdate { + if sourceImage.SupportInPlaceUpdate { image.Versions[len(image.Versions)-1].InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{ - Supported: supportInPlaceUpdate, + Supported: sourceImage.SupportInPlaceUpdate, } } existingVersions[sourceImage.Version] = len(image.Versions) - 1 @@ -122,9 +152,9 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou existing.Architectures = append(existing.Architectures, arch) } } - if supportInPlaceUpdate { + if sourceImage.SupportInPlaceUpdate { existing.InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{ - Supported: supportInPlaceUpdate, + Supported: sourceImage.SupportInPlaceUpdate, } } } else { @@ -134,9 +164,9 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou }, Architectures: slices.Clone(sourceImage.Architectures), }) - if supportInPlaceUpdate { + if sourceImage.SupportInPlaceUpdate { image.Versions[len(image.Versions)-1].InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{ - Supported: supportInPlaceUpdate, + Supported: sourceImage.SupportInPlaceUpdate, } } existingVersions[sourceImage.CleanVersion] = len(image.Versions) - 1 diff --git a/cloudprofilesync/imageupdater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go similarity index 80% rename from cloudprofilesync/imageupdater_test.go rename to cloudprofilesync/ossync/os_image_updater_test.go index e3faa9a..53134e2 100644 --- a/cloudprofilesync/imageupdater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 -package cloudprofilesync_test +package ossync_test import ( "encoding/json" @@ -11,14 +11,14 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) var _ = Describe("filterImages", func() { // helper: run Update and return the versions written to spec.machineImages - versions := func(ctx SpecContext, images []cloudprofilesync.SourceImage) []gardencorev1beta1.MachineImageVersion { + versions := func(ctx SpecContext, images []ossync.SourceImage) []gardencorev1beta1.MachineImageVersion { mockSource.images = images - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -33,21 +33,21 @@ var _ = Describe("filterImages", func() { } It("invalid tag + no clean version: drops the image entirely", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ {Version: "not-a-version", Architectures: []string{"amd64"}}, }) Expect(result).To(BeEmpty()) }) It("invalid tag + invalid clean version: drops the image entirely", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ {Version: "not-a-version", CleanVersion: "also-not-a-version", Architectures: []string{"amd64"}}, }) Expect(result).To(BeEmpty()) }) It("invalid tag + valid clean version: NEW format only (no legacy entry)", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ { Version: "1877.9.2.0-metal-sci-pxe-amd64", CleanVersion: "1877.9.2", @@ -60,7 +60,7 @@ var _ = Describe("filterImages", func() { }) It("valid tag + valid clean version: BOTH formats", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -74,7 +74,7 @@ var _ = Describe("filterImages", func() { }) It("valid tag + no clean version: OLD format only", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ {Version: "1921.0.0", Architectures: []string{"amd64"}}, }) Expect(result).To(HaveLen(1)) @@ -82,7 +82,7 @@ var _ = Describe("filterImages", func() { }) It("valid tag + invalid clean version: BOTH formats with clean version normalized", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ { Version: "1921.0.0-metal-sci-usi-amd64", CleanVersion: "1921.0", @@ -96,7 +96,7 @@ var _ = Describe("filterImages", func() { }) It("valid tag + unparsable clean version: does not write clean version entry", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ { Version: "1921.0.0-metal-sci-usi-amd64", CleanVersion: "not-a-version", @@ -108,7 +108,7 @@ var _ = Describe("filterImages", func() { }) It("no architectures: drops the image entirely", func(ctx SpecContext) { - result := versions(ctx, []cloudprofilesync.SourceImage{ + result := versions(ctx, []ossync.SourceImage{ {Version: "1.0.0"}, }) Expect(result).To(BeEmpty()) @@ -118,8 +118,8 @@ var _ = Describe("filterImages", func() { var _ = Describe("ImageUpdater", func() { Describe("flag OFF (default behavior)", func() { It("adds an image from the source to the CloudProfile spec", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{{Version: "1.0.0", Architectures: []string{"amd64"}}} - updater := cloudprofilesync.ImageUpdater{ + mockSource.images = []ossync.SourceImage{{Version: "1.0.0", Architectures: []string{"amd64"}}} + updater := ossync.ImageUpdater{ Log: logr.Discard(), Source: &mockSource, ImageName: "test", @@ -131,11 +131,11 @@ var _ = Describe("ImageUpdater", func() { }) It("adds multiple images from the source to the CloudProfile spec", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{ + mockSource.images = []ossync.SourceImage{ {Version: "1.0.0", Architectures: []string{"amd64"}}, {Version: "2.0.0", Architectures: []string{"arm64", "amd64"}}, } - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -156,8 +156,8 @@ var _ = Describe("ImageUpdater", func() { }}, }, } - mockSource.images = []cloudprofilesync.SourceImage{{Version: "2.0.0", Architectures: []string{"arm64"}}} - updater := cloudprofilesync.ImageUpdater{Log: GinkgoLogr, Source: &mockSource, ImageName: "test"} + mockSource.images = []ossync.SourceImage{{Version: "2.0.0", Architectures: []string{"arm64"}}} + updater := ossync.ImageUpdater{Log: GinkgoLogr, Source: &mockSource, ImageName: "test"} Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(2)) Expect(cpSpec.MachineImages[0].Versions[0].Version).To(Equal("1.0.0")) @@ -175,8 +175,8 @@ var _ = Describe("ImageUpdater", func() { }}, }, } - mockSource.images = []cloudprofilesync.SourceImage{{Version: "1.1.0", Architectures: []string{"arm64"}}} - updater := cloudprofilesync.ImageUpdater{Log: GinkgoLogr, Source: &mockSource, ImageName: "test"} + mockSource.images = []ossync.SourceImage{{Version: "1.1.0", Architectures: []string{"arm64"}}} + updater := ossync.ImageUpdater{Log: GinkgoLogr, Source: &mockSource, ImageName: "test"} Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) Expect(cpSpec.MachineImages).To(ConsistOf([]gardencorev1beta1.MachineImage{ {Name: "test", Versions: []gardencorev1beta1.MachineImageVersion{ @@ -190,7 +190,7 @@ var _ = Describe("ImageUpdater", func() { }) It("ignores CleanVersion when flag is OFF", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{ + mockSource.images = []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -198,7 +198,7 @@ var _ = Describe("ImageUpdater", func() { Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, }, } - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -211,8 +211,8 @@ var _ = Describe("ImageUpdater", func() { }) It("invokes the given provider", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{{Version: "1.0.0", Architectures: []string{"amd64"}}} - updater := cloudprofilesync.ImageUpdater{ + mockSource.images = []ossync.SourceImage{{Version: "1.0.0", Architectures: []string{"amd64"}}} + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -220,18 +220,18 @@ var _ = Describe("ImageUpdater", func() { } var cpSpec gardencorev1beta1.CloudProfileSpec Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) - var fromProvider []cloudprofilesync.SourceImage + var fromProvider []ossync.SourceImage Expect(json.Unmarshal(cpSpec.ProviderConfig.Raw, &fromProvider)).To(Succeed()) Expect(fromProvider).To(Equal(mockSource.images)) }) It("in-place update support", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{{ - Version: "1.0.0", - Architectures: []string{"amd64"}, - Capabilities: map[string]gardencorev1beta1.CapabilityValues{"feature": {cloudprofilesync.USIFeature}}}, - } - updater := cloudprofilesync.ImageUpdater{ + mockSource.images = []ossync.SourceImage{{ + Version: "1.0.0", + Architectures: []string{"amd64"}, + SupportInPlaceUpdate: true, + }} + updater := ossync.ImageUpdater{ Log: logr.Discard(), Source: &mockSource, ImageName: "test", @@ -247,15 +247,16 @@ var _ = Describe("ImageUpdater", func() { Describe("flag ON (dual-write clean version)", func() { It("writes both full tag and clean version entries when CleanVersion differs", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{ + mockSource.images = []ossync.SourceImage{ { - Version: "2254.0.0-baremetal-sci-usi-amd64", - CleanVersion: "2254.0.0", - Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Version: "2254.0.0-baremetal-sci-usi-amd64", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + SupportInPlaceUpdate: true, }, } - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -272,7 +273,7 @@ var _ = Describe("ImageUpdater", func() { }) It("does not add a duplicate clean version entry on re-reconcile", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{ + mockSource.images = []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -280,7 +281,7 @@ var _ = Describe("ImageUpdater", func() { Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, }, } - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -293,7 +294,7 @@ var _ = Describe("ImageUpdater", func() { }) It("skips legacy spec entry for non-semver raw tag but still passes image to provider", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{ + mockSource.images = []ossync.SourceImage{ { Version: "1877.9.2.0-metal-sci-pxe-amd64-1877-9-2-6bb2b442", CleanVersion: "1877.9.2", @@ -301,7 +302,7 @@ var _ = Describe("ImageUpdater", func() { Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, }, } - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -317,17 +318,17 @@ var _ = Describe("ImageUpdater", func() { Expect(cpSpec.MachineImages[0].Versions[0].Version).To(Equal("1877.9.2")) // The raw tag must still reach the provider (capabilityFlavors). - var fromProvider []cloudprofilesync.SourceImage + var fromProvider []ossync.SourceImage Expect(json.Unmarshal(cpSpec.ProviderConfig.Raw, &fromProvider)).To(Succeed()) Expect(fromProvider).To(HaveLen(1)) Expect(fromProvider[0].Version).To(Equal("1877.9.2.0-metal-sci-pxe-amd64-1877-9-2-6bb2b442")) }) It("writes only full tag when CleanVersion is absent", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{ + mockSource.images = []ossync.SourceImage{ {Version: "1877.0.0", Architectures: []string{"amd64"}}, } - updater := cloudprofilesync.ImageUpdater{ + updater := ossync.ImageUpdater{ Log: GinkgoLogr, Source: &mockSource, ImageName: "test", @@ -340,13 +341,13 @@ var _ = Describe("ImageUpdater", func() { }) It("in-place update support", func(ctx SpecContext) { - mockSource.images = []cloudprofilesync.SourceImage{{ - Version: "1.0.0", - CleanVersion: "1.1", - Architectures: []string{"amd64"}, - Capabilities: map[string]gardencorev1beta1.CapabilityValues{"feature": {cloudprofilesync.USIFeature}}}, - } - updater := cloudprofilesync.ImageUpdater{ + mockSource.images = []ossync.SourceImage{{ + Version: "1.0.0", + CleanVersion: "1.1", + Architectures: []string{"amd64"}, + SupportInPlaceUpdate: true, + }} + updater := ossync.ImageUpdater{ Log: logr.Discard(), Source: &mockSource, ImageName: "test", diff --git a/cloudprofilesync/provider.go b/cloudprofilesync/ossync/provider/ironcore/provider.go similarity index 93% rename from cloudprofilesync/provider.go rename to cloudprofilesync/ossync/provider/ironcore/provider.go index 37b475e..038e942 100644 --- a/cloudprofilesync/provider.go +++ b/cloudprofilesync/ossync/provider/ironcore/provider.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 -package cloudprofilesync +package ironcore import ( "encoding/json" @@ -10,11 +10,9 @@ import ( gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" "k8s.io/apimachinery/pkg/runtime" -) -type Provider interface { - Configure(cloudProfile *gardencorev1beta1.CloudProfileSpec, versions []SourceImage) error -} + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" +) type IroncoreProvider struct { Registry string @@ -23,7 +21,7 @@ type IroncoreProvider struct { EnableCapabilities bool } -func (p *IroncoreProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec, versions []SourceImage) error { +func (p *IroncoreProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec, versions []ossync.SourceImage) error { var cfg v1alpha1.CloudProfileConfig if cpSpec.ProviderConfig != nil { if err := json.Unmarshal(cpSpec.ProviderConfig.Raw, &cfg); err != nil { diff --git a/cloudprofilesync/provider_test.go b/cloudprofilesync/ossync/provider/ironcore/provider_test.go similarity index 92% rename from cloudprofilesync/provider_test.go rename to cloudprofilesync/ossync/provider/ironcore/provider_test.go index c976d6c..8eedf17 100644 --- a/cloudprofilesync/provider_test.go +++ b/cloudprofilesync/ossync/provider/ironcore/provider_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 -package cloudprofilesync_test +package ironcore_test import ( "encoding/json" @@ -11,19 +11,20 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/ironcore" ) var _ = Describe("IroncoreProvider", func() { - legacyProvider := &cloudprofilesync.IroncoreProvider{ + legacyProvider := &ironcore.IroncoreProvider{ Registry: "registry.io", Repository: "repo", ImageName: "test", EnableCapabilities: false, } - capProvider := &cloudprofilesync.IroncoreProvider{ + capProvider := &ironcore.IroncoreProvider{ Registry: "registry.io", Repository: "repo", ImageName: "test", @@ -33,7 +34,7 @@ var _ = Describe("IroncoreProvider", func() { Describe("flag OFF (legacy format only)", func() { It("should add an image to the provider config", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{{Version: "v1.0.0", Architectures: []string{"amd64"}}} + versions := []ossync.SourceImage{{Version: "v1.0.0", Architectures: []string{"amd64"}}} Expect(legacyProvider.Configure(&cpSpec, versions)).To(Succeed()) var providerConfig v1alpha1.CloudProfileConfig @@ -47,7 +48,7 @@ var _ = Describe("IroncoreProvider", func() { It("should multiply out architectures", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ {Version: "v1.0.0", Architectures: []string{"amd64", "arm64"}}, } Expect(legacyProvider.Configure(&cpSpec, versions)).To(Succeed()) @@ -65,7 +66,7 @@ var _ = Describe("IroncoreProvider", func() { It("should not add duplicate images", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ {Version: "v1.0.0", Architectures: []string{"amd64"}}, {Version: "v1.0.0", Architectures: []string{"arm64"}}, } @@ -79,7 +80,7 @@ var _ = Describe("IroncoreProvider", func() { It("should ignore Capabilities and CleanVersion", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -109,7 +110,7 @@ var _ = Describe("IroncoreProvider", func() { It("should write both legacy flat entry and CapabilityFlavors entry", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -141,7 +142,7 @@ var _ = Describe("IroncoreProvider", func() { It("should group multiple flavors under one clean version entry", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -180,7 +181,7 @@ var _ = Describe("IroncoreProvider", func() { It("should not add duplicate capability flavors on re-reconcile", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", @@ -206,7 +207,7 @@ var _ = Describe("IroncoreProvider", func() { It("should write only legacy entry for images without capabilities", func() { var cpSpec gardencorev1beta1.CloudProfileSpec - versions := []cloudprofilesync.SourceImage{ + versions := []ossync.SourceImage{ {Version: "1877.0.0", Architectures: []string{"amd64"}}, } Expect(capProvider.Configure(&cpSpec, versions)).To(Succeed()) diff --git a/cloudprofilesync/ossync/source/oci/os_source.go b/cloudprofilesync/ossync/source/oci/os_source.go new file mode 100644 index 0000000..52ce4a6 --- /dev/null +++ b/cloudprofilesync/ossync/source/oci/os_source.go @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package oci + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" + "golang.org/x/sync/semaphore" + "oras.land/oras-go/v2/registry/remote" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" +) + +const ( + // chostFeature represent having containerd + chostFeature = "chost" + // pxeFeature represent pxe boot build + pxeFeature = "_pxe" + sciFeature = "sci" + sciBaseFeature = "scibase" + // capiFeature includes server, khost, and PXE; excludes SELinux and firewall + capiFeature = "capi" + // USIFeature shows UEFI build + usiFeature = "_usi" + usiDevFeature = "_usidev" + + architectureCapability = "architecture" + featureCapability = "feature" +) + +// validFeatureValues is the allowlist of feature values extracted from the feature_set annotation. +var validFeatureValues = map[string]struct{}{ + chostFeature: {}, + pxeFeature: {}, + sciFeature: {}, + sciBaseFeature: {}, + capiFeature: {}, + usiFeature: {}, + usiDevFeature: {}, +} + +func filterFeatureSet(featureSet string) []string { + raw := strings.Split(featureSet, ",") + seen := make(map[string]struct{}, len(raw)) + result := make([]string, 0, len(raw)) + for _, f := range raw { + f = strings.TrimSpace(f) + if _, valid := validFeatureValues[f]; !valid { + continue + } + if _, dup := seen[f]; dup { + continue + } + seen[f] = struct{}{} + result = append(result, f) + } + return result +} + +type Result[T any] struct { + value T + err error +} + +type OCI struct { + log logr.Logger + repo *remote.Repository + sema *semaphore.Weighted +} + +func NewOCI(params ocirepo.Params, parallel int64, log logr.Logger) (*OCI, error) { + repo, err := ocirepo.New(params) + if err != nil { + return nil, err + } + + return &OCI{ + log: log, + repo: repo, + sema: semaphore.NewWeighted(parallel), + }, nil +} + +func (o *OCI) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { + tags := []string{} + err := o.repo.Tags(ctx, "", func(t []string) error { + tags = append(tags, t...) + return nil + }) + if err != nil { + return nil, err + } + + out := make(chan Result[ossync.SourceImage]) + for _, tag := range tags { + go func() { + if err := o.sema.Acquire(ctx, 1); err != nil { + out <- Result[ossync.SourceImage]{err: err} + return + } + defer o.sema.Release(1) + _, reader, err := o.repo.FetchReference(ctx, tag) + if err != nil { + out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: failed to fetch manifest: %w", tag, err)} + return + } + defer reader.Close() + manifest := struct { + Annotations map[string]string `json:"annotations"` + }{} + err = json.NewDecoder(reader).Decode(&manifest) + if err != nil { + out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: failed to decode manifest: %w", tag, err)} + return + } + arch, ok := manifest.Annotations["architecture"] + if !ok { + out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: architecture annotation not found", tag)} + return + } + var capabilities gardencorev1beta1.Capabilities + var cleanVersion string + var supportInPlaceUpdate bool + if featureSet, ok := manifest.Annotations["feature_set"]; ok { + if version, ok := manifest.Annotations["version"]; ok { + features := filterFeatureSet(featureSet) + if len(features) > 0 { + capabilities = gardencorev1beta1.Capabilities{ + architectureCapability: {arch}, + featureCapability: features, + } + cleanVersion = version + supportInPlaceUpdate = slices.Contains(features, usiFeature) + } + } + } + out <- Result[ossync.SourceImage]{ + value: ossync.SourceImage{ + Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention + CleanVersion: cleanVersion, + Architectures: []string{arch}, + Capabilities: capabilities, + SupportInPlaceUpdate: supportInPlaceUpdate, + }, + } + }() + } + + images := []ossync.SourceImage{} + var skipped []error + var errs []error + for range tags { + result := <-out + if result.err != nil { + if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { + errs = append(errs, result.err) + } else { + skipped = append(skipped, result.err) + } + continue + } + images = append(images, result.value) + } + if len(skipped) > 0 { + o.log.V(1).Info("skipped tags with errors", "count", len(skipped), "errors", errors.Join(skipped...)) + } + if len(errs) == 0 && len(images) == 0 && len(tags) > 0 { + return nil, fmt.Errorf("all %d tags were skipped; possible registry issue", len(tags)) + } + return images, errors.Join(errs...) +} diff --git a/cloudprofilesync/source_test.go b/cloudprofilesync/ossync/source/oci/os_source_test.go similarity index 90% rename from cloudprofilesync/source_test.go rename to cloudprofilesync/ossync/source/oci/os_source_test.go index 42e8c02..8f74165 100644 --- a/cloudprofilesync/source_test.go +++ b/cloudprofilesync/ossync/source/oci/os_source_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 -package cloudprofilesync_test +package oci_test import ( "bytes" @@ -17,7 +17,9 @@ import ( "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/oci" ) var _ = Describe("OCISource", func() { @@ -56,19 +58,19 @@ var _ = Describe("OCISource", func() { err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "1.0.1_abc") Expect(err).To(Succeed()) - oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, Repository: "repo", - Parallel: 4, - }, true, logr.Discard()) + Insecure: true, + }, 4, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) Expect(versions).To(HaveLen(2)) Expect(versions).To(ContainElement( - cloudprofilesync.SourceImage{Version: "1.0.0", Architectures: []string{"amd64"}})) + ossync.SourceImage{Version: "1.0.0", Architectures: []string{"amd64"}})) Expect(versions).To(ContainElement( - cloudprofilesync.SourceImage{Version: "1.0.1+abc", Architectures: []string{"amd64"}})) + ossync.SourceImage{Version: "1.0.1+abc", Architectures: []string{"amd64"}})) }) It("populates capabilities when feature_set annotation is present", func(ctx SpecContext) { @@ -100,11 +102,11 @@ var _ = Describe("OCISource", func() { err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "2.0.0") Expect(err).To(Succeed()) - oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, Repository: "repo-caps", - Parallel: 4, - }, true, logr.Discard()) + Insecure: true, + }, 4, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -145,11 +147,11 @@ var _ = Describe("OCISource", func() { err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "1.0.0-legacy") Expect(err).To(Succeed()) - oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, Repository: "repo-legacy", - Parallel: 4, - }, true, logr.Discard()) + Insecure: true, + }, 4, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -194,11 +196,11 @@ var _ = Describe("OCISource", func() { err = repo.PushReference(ctx, noArchDesc, bytes.NewReader(noArchBlob), "1.0.1") Expect(err).To(Succeed()) - oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, Repository: "repo-missing-arch", - Parallel: 4, - }, true, logr.Discard()) + Insecure: true, + }, 4, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -231,11 +233,11 @@ var _ = Describe("OCISource", func() { err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "3.0.0-no-valid-features") Expect(err).To(Succeed()) - oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, Repository: "repo-no-valid-features", - Parallel: 4, - }, true, logr.Discard()) + Insecure: true, + }, 4, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) diff --git a/cloudprofilesync/suite_test.go b/cloudprofilesync/ossync/source/oci/suite_test.go similarity index 87% rename from cloudprofilesync/suite_test.go rename to cloudprofilesync/ossync/source/oci/suite_test.go index 1580d7d..a4ace73 100644 --- a/cloudprofilesync/suite_test.go +++ b/cloudprofilesync/ossync/source/oci/suite_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 -package cloudprofilesync_test +package oci_test import ( "context" @@ -17,7 +17,7 @@ import ( . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/runtime" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) func TestSource(t *testing.T) { @@ -26,16 +26,16 @@ func TestSource(t *testing.T) { } type MockSource struct { - images []cloudprofilesync.SourceImage + images []ossync.SourceImage } -func (m *MockSource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { +func (m *MockSource) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { return m.images, nil } type MockProvider struct{} -func (m *MockProvider) Configure(cpSpec *gardenerv1beta1.CloudProfileSpec, versions []cloudprofilesync.SourceImage) error { +func (m *MockProvider) Configure(cpSpec *gardenerv1beta1.CloudProfileSpec, versions []ossync.SourceImage) error { data, err := json.Marshal(versions) if err != nil { return err diff --git a/cloudprofilesync/ossync/suite_test.go b/cloudprofilesync/ossync/suite_test.go new file mode 100644 index 0000000..482c1d3 --- /dev/null +++ b/cloudprofilesync/ossync/suite_test.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package ossync_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" +) + +func TestSource(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cloudprofilesync Suite") +} + +type MockSource struct { + images []ossync.SourceImage +} + +func (m *MockSource) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { + return m.images, nil +} + +type MockProvider struct{} + +func (m *MockProvider) Configure(cpSpec *gardenerv1beta1.CloudProfileSpec, versions []ossync.SourceImage) error { + data, err := json.Marshal(versions) + if err != nil { + return err + } + cpSpec.ProviderConfig = &runtime.RawExtension{Raw: data} + return nil +} + +const registryAddr = "127.0.0.1:48080" + +var ( + mockSource MockSource + reg *registry.Registry + stop context.CancelFunc +) + +var _ = BeforeSuite(func() { + mockSource = MockSource{} + ctx, cancel := context.WithCancel(context.Background()) + stop = cancel + var err error + + reg, err = registry.NewRegistry(ctx, &configuration.Configuration{ + Storage: configuration.Storage{"inmemory": map[string]any{}}, + HTTP: configuration.HTTP{Addr: registryAddr}, + Validation: configuration.Validation{Disabled: true}, + 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()) + res, err := http.DefaultClient.Do(req) + g.Expect(err).To(Succeed()) + defer res.Body.Close() + return nil + }).Should(Succeed()) +}) + +var _ = AfterSuite(func(ctx SpecContext) { + stop() + Expect(reg.Shutdown(ctx)).To(Succeed()) +}) diff --git a/cloudprofilesync/source.go b/cloudprofilesync/source.go deleted file mode 100644 index 68fd93a..0000000 --- a/cloudprofilesync/source.go +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company -// SPDX-License-Identifier: Apache-2.0 - -package cloudprofilesync - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - - gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" - "github.com/go-logr/logr" - "golang.org/x/sync/semaphore" - "oras.land/oras-go/v2/registry/remote" - "oras.land/oras-go/v2/registry/remote/auth" - "oras.land/oras-go/v2/registry/remote/retry" -) - -const ( - // ChostFeature represent having containerd - ChostFeature = "chost" - // PXEFeature represent pxe boot build - PXEFeature = "_pxe" - SCIFeature = "sci" - SCIBaseFeature = "scibase" - // CAPIFeature includes server, khost, and PXE; excludes SELinux and firewall - CAPIFeature = "capi" - // USIFeature shows UEFI build - USIFeature = "_usi" - USIDevFeature = "_usidev" - - ArchitectureCapability = "architecture" - FeatureCapability = "feature" -) - -// validFeatureValues is the allowlist of feature values extracted from the feature_set annotation. -var validFeatureValues = map[string]struct{}{ - ChostFeature: {}, - PXEFeature: {}, - SCIFeature: {}, - SCIBaseFeature: {}, - CAPIFeature: {}, - USIFeature: {}, - USIDevFeature: {}, -} - -func filterFeatureSet(featureSet string) []string { - raw := strings.Split(featureSet, ",") - seen := make(map[string]struct{}, len(raw)) - result := make([]string, 0, len(raw)) - for _, f := range raw { - f = strings.TrimSpace(f) - if _, valid := validFeatureValues[f]; !valid { - continue - } - if _, dup := seen[f]; dup { - continue - } - seen[f] = struct{}{} - result = append(result, f) - } - return result -} - -type Result[T any] struct { - value T - err error -} - -type SourceImage struct { - // Version is the full tag from the registry (used as version key for legacy images). - Version string - // CleanVersion is the version from the "version" OCI annotation (e.g. "2262.0.0"). - // When set, flavors are grouped under it in the CloudProfile instead of the full tag. - CleanVersion string - // TODO: deprecate once all images carry capability annotations; use Capabilities["architecture"] instead. - Architectures []string - // Capabilities holds parsed OCI manifest annotations. Nil means the image - // predates capability annotations and should use the legacy format. - Capabilities gardencorev1beta1.Capabilities -} - -// effectiveVersion returns CleanVersion when available, falling back to Version. -func (s SourceImage) effectiveVersion() string { - if s.CleanVersion != "" { - return s.CleanVersion - } - return s.Version -} - -type Source interface { - GetVersions(ctx context.Context) ([]SourceImage, error) -} - -type OCI struct { - log logr.Logger - repo *remote.Repository - sema *semaphore.Weighted -} - -type OCIParams struct { - Registry string `json:"registry"` - Repository string `json:"repository"` - Username string `json:"username"` - Password string `json:"password"` //nolint:gosec,nolintlint - Parallel int64 `json:"parallel"` -} - -func NewOCI(params OCIParams, insecure bool, log logr.Logger) (*OCI, error) { - // Create a new OCI repository - repo, err := remote.NewRepository(params.Registry + "/" + params.Repository) - if err != nil { - return nil, err - } - - if params.Username != "" && params.Password != "" { - repo.Client = &auth.Client{ - Client: retry.DefaultClient, - Cache: auth.NewCache(), - Credential: auth.StaticCredential(params.Registry, auth.Credential{ - Username: params.Username, - Password: params.Password, - }), - } - } - repo.PlainHTTP = insecure - - return &OCI{ - log: log, - repo: repo, - sema: semaphore.NewWeighted(params.Parallel), - }, nil -} - -func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { - tags := []string{} - err := o.repo.Tags(ctx, "", func(t []string) error { - tags = append(tags, t...) - return nil - }) - if err != nil { - return nil, err - } - - out := make(chan Result[SourceImage]) - for _, tag := range tags { - go func() { - if err := o.sema.Acquire(ctx, 1); err != nil { - out <- Result[SourceImage]{err: err} - return - } - defer o.sema.Release(1) - _, reader, err := o.repo.FetchReference(ctx, tag) - if err != nil { - out <- Result[SourceImage]{err: fmt.Errorf("tag %s: failed to fetch manifest: %w", tag, err)} - return - } - defer reader.Close() - manifest := struct { - Annotations map[string]string `json:"annotations"` - }{} - err = json.NewDecoder(reader).Decode(&manifest) - if err != nil { - out <- Result[SourceImage]{err: fmt.Errorf("tag %s: failed to decode manifest: %w", tag, err)} - return - } - arch, ok := manifest.Annotations["architecture"] - if !ok { - out <- Result[SourceImage]{err: fmt.Errorf("tag %s: architecture annotation not found", tag)} - return - } - var capabilities gardencorev1beta1.Capabilities - var cleanVersion string - if featureSet, ok := manifest.Annotations["feature_set"]; ok { - if version, ok := manifest.Annotations["version"]; ok { - features := filterFeatureSet(featureSet) - if len(features) > 0 { - capabilities = gardencorev1beta1.Capabilities{ - ArchitectureCapability: {arch}, - FeatureCapability: features, - } - cleanVersion = version - } - } - } - out <- Result[SourceImage]{ - value: SourceImage{ - Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention - CleanVersion: cleanVersion, - Architectures: []string{arch}, - Capabilities: capabilities, - }, - } - }() - } - - images := []SourceImage{} - var skipped []error - var errs []error - for range tags { - result := <-out - if result.err != nil { - if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { - errs = append(errs, result.err) - } else { - skipped = append(skipped, result.err) - } - continue - } - images = append(images, result.value) - } - if len(skipped) > 0 { - o.log.V(1).Info("skipped tags with errors", "count", len(skipped), "errors", errors.Join(skipped...)) - } - if len(errs) == 0 && len(images) == 0 && len(tags) > 0 { - return nil, fmt.Errorf("all %d tags were skipped; possible registry issue", len(tags)) - } - return images, errors.Join(errs...) -} diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go new file mode 100644 index 0000000..c957f0c --- /dev/null +++ b/controllers/cloud_profile.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 +package controllers + +import ( + "context" + "errors" + "fmt" + "net/http" + + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" + 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/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/k8ssync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/k8ssync/source/landscape" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/ironcore" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/oci" +) + +// DefaultOCISourceFactory is the default implementation of OCISourceFactory. +type DefaultOCISourceFactory struct{} + +func (f *DefaultOCISourceFactory) Create(params ocirepo.Params, parallel int64, log logr.Logger) (ossync.Source, error) { + return oci.NewOCI(params, parallel, log) +} + +func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, mcp *v1alpha1.ManagedCloudProfile) error { + var cloudProfile gardenerv1beta1.CloudProfile + cloudProfile.Name = mcp.Name + + _, err := controllerutil.CreateOrPatch(ctx, r.Client, &cloudProfile, func() error { + if err := controllerutil.SetControllerReference(mcp, &cloudProfile, r.Scheme()); err != nil { + return err + } + cloudProfile.Spec = CloudProfileSpecToGardener(&mcp.Spec.CloudProfile) + errs := make([]error, 0) + for _, updates := range mcp.Spec.MachineImageUpdates { + log.Info("updating machine images", "cloudProfile", cloudProfile.Name) + if updateErr := r.updateMachineImages(ctx, log, updates, &cloudProfile.Spec); updateErr != nil { + errs = append(errs, updateErr) + } + } + if mcp.Spec.KubernetesVersionUpdateConfig != nil { + log.Info("updating kubernetes versions", "cloudProfile", cloudProfile.Name) + if updateErr := r.updateKubernetesVersions(ctx, *mcp.Spec.KubernetesVersionUpdateConfig, &cloudProfile.Spec); updateErr != nil { + errs = append(errs, updateErr) + } + } + gardenerv1beta1.SetObjectDefaults_CloudProfile(&cloudProfile) + return errors.Join(errs...) + }) + if err != nil { + statusErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ + Type: CloudProfileAppliedConditionType, + Status: metav1.ConditionFalse, + ObservedGeneration: mcp.Generation, + Reason: "ApplyFailed", + Message: fmt.Sprintf("Failed to apply CloudProfile: %s", err), + }) + if statusErr != nil { + return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", statusErr) + } + if apierrors.IsInvalid(err) { + return nil + } + return fmt.Errorf("failed to create or patch CloudProfile: %w", err) + } + statusErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.SucceededReconcileStatus, metav1.Condition{ + Type: CloudProfileAppliedConditionType, + Status: metav1.ConditionTrue, + ObservedGeneration: mcp.Generation, + Reason: "Applied", + Message: "Generated CloudProfile applied successfully", + }) + if statusErr != nil { + return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", statusErr) + } + return nil +} + +func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, update v1alpha1.MachineImageUpdate, cpSpec *gardenerv1beta1.CloudProfileSpec) error { + var source ossync.Source + switch { + case update.Source.OCI != nil: + password, err := r.getCredential(ctx, update.Source.OCI.Password) + if err != nil { + return err + } + src, err := r.OCISourceFactory.Create(ocirepo.Params{ + Registry: update.Source.OCI.Registry, + Repository: update.Source.OCI.Repository, + Username: update.Source.OCI.Username, + Password: string(password), + Insecure: update.Source.OCI.Insecure, + }, 1, log) + if err != nil { + return fmt.Errorf("failed to initialize OCI source: %w", err) + } + source = src + + default: + return errors.New("no machine images source configured") + } + + var provider ossync.Provider + switch { + case update.Provider.IroncoreMetal != nil: + provider = &ironcore.IroncoreProvider{ + Registry: update.Provider.IroncoreMetal.Registry, + Repository: update.Provider.IroncoreMetal.Repository, + ImageName: update.ImageName, + EnableCapabilities: r.EnableCapabilities, + } + default: + return errors.New("no known provider configured") + } + imageUpdater := ossync.ImageUpdater{ + Log: log, + Source: source, + Provider: provider, + ImageName: update.ImageName, + EnableCapabilities: r.EnableCapabilities, + } + if err := imageUpdater.Update(ctx, cpSpec); err != nil { + return fmt.Errorf("updating machine images failed: %w", err) + } + return nil +} + +func (r *Reconciler) getCredential(ctx context.Context, ref v1alpha1.SecretReference) ([]byte, error) { + if ref.Name == "" { + return nil, nil + } + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: ref.Namespace}, &secret); err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + data, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("secret %s/%s does not have key %s", ref.Namespace, ref.Name, ref.Key) + } + return data, nil +} + +type KubernetesImageUpdater interface { + Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error +} + +func (r *Reconciler) updateKubernetesVersions(ctx context.Context, cfg v1alpha1.KubernetesVersionUpdateConfig, cpSpec *gardenerv1beta1.CloudProfileSpec) error { + var source k8ssync.KubernetesVersionSource + var err error + switch { + case cfg.LandscapeSetup != nil: + source, err = r.landscapeSetupSource(ctx, *cfg.LandscapeSetup) + if err != nil { + return fmt.Errorf("getting landscape setup source: %w", err) + } + default: + return errors.New("no kubernetes version source configured") + } + + kubernetesUpdater := k8ssync.NewKubernetesVersionUpdater(source, cfg.ExpirationThreshold.Duration) + + if err := kubernetesUpdater.Update(ctx, cpSpec); err != nil { + return fmt.Errorf("updating kubernetes versions failed: %w", err) + } + + return nil +} + +func (r *Reconciler) landscapeSetupSource(ctx context.Context, ls v1alpha1.LandscapeSetup) (k8ssync.KubernetesVersionSource, error) { + ociPassword, err := r.getCredential(ctx, ls.OCI.Password) + if err != nil { + return nil, fmt.Errorf("getting oci password: %w", err) + } + ociParams := ocirepo.Params{ + Registry: ls.OCI.Registry, + Repository: ls.OCI.Repository, + Username: ls.OCI.Username, + Password: string(ociPassword), + Insecure: ls.OCI.Insecure, + } + + gh := ls.Github + var ghTransport http.RoundTripper + switch { + case gh.PersonalAccessTokenSecret != nil: + pat, err := r.getCredential(ctx, *gh.PersonalAccessTokenSecret) + if err != nil { + return nil, fmt.Errorf("getting github PAT: %w", err) + } + ghTransport = landscape.GithubPATTransport(string(pat)) + case gh.GithubApp != nil: + privateKey, err := r.getCredential(ctx, gh.GithubApp.PrivateKeySecret) + if err != nil { + return nil, fmt.Errorf("getting github app private key: %w", err) + } + ghTransport, err = landscape.GithubAppTransport(gh.RepositoryApiURL, gh.GithubApp.AppID, gh.GithubApp.InstallationID, privateKey) + if err != nil { + return nil, fmt.Errorf("initializing github app transport: %w", err) + } + default: + return nil, errors.New("github source requires personalAccessTokenSecret or githubApp") + } + + ghParams := landscape.GithubParams{ + RepositoryApiURL: gh.RepositoryApiURL, + Repository: gh.Repository, + FilePath: gh.FilePath, + Provider: gh.Provider, + Transport: ghTransport, + } + + landscapeSource, err := landscape.NewLandscapeKubernetesSource(ociParams, ghParams) + if err != nil { + return nil, fmt.Errorf("initializing landscape source: %w", err) + } + + return landscapeSource, nil +} diff --git a/controllers/garbage_collection.go b/controllers/garbage_collection.go new file mode 100644 index 0000000..4ccb0e5 --- /dev/null +++ b/controllers/garbage_collection.go @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 +package controllers + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + providercfg "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" +) + +type KeppelClient struct{} + +func (k *KeppelClient) GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + return fetchKeppelTags(ctx, registry, repository) +} + +func (r *Reconciler) getRegistryProvider(registry string) (RegistryClient, 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") +} + +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) reconcileGarbageCollection(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile) error { + if mcp.Spec.GarbageCollection == nil || !mcp.Spec.GarbageCollection.Enabled { + return nil + } + if mcp.Spec.GarbageCollection.MaxAge.Duration < 0 { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("invalid garbage collection maxAge: %s", mcp.Spec.GarbageCollection.MaxAge.String())) + } + + cutoff := time.Now().Add(-mcp.Spec.GarbageCollection.MaxAge.Duration) + + shootList := &gardenerv1beta1.ShootList{} + if err := r.List(ctx, shootList, client.InNamespace(metav1.NamespaceAll)); err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to list Shoots: %w", err)) + } + var cp gardenerv1beta1.CloudProfile + if err := r.Get(ctx, types.NamespacedName{Name: mcp.Name}, &cp); err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to get CloudProfile: %w", err)) + } + + for _, updates := range mcp.Spec.MachineImageUpdates { + if updates.Source.OCI == nil { + continue + } + + registryClient, err := r.RegistryProviderFunc(updates.Source.OCI.Registry) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, + fmt.Errorf("no registry provider found for registry %q: %w", updates.Source.OCI.Registry, err)) + } + 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 fetch tags: %w", err)) + } + + referencedVersions, err := r.getReferencedVersions(shootList, &cp, updates.ImageName) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to determine referenced versions for garbage collection: %w", err)) + } + + versionsToDelete := make(map[string]struct{}) + for tag, pushedAt := range tags { + if _, isReferenced := referencedVersions[tag]; isReferenced { + continue + } + if pushedAt.Before(cutoff) { + versionsToDelete[tag] = struct{}{} + } + } + + if err := r.deleteVersions(ctx, mcp.Name, updates.ImageName, versionsToDelete); err != nil { + if apierrors.IsInvalid(err) { + continue + } + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to delete image versions: %w", err)) + } + } + + return nil +} + +func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, imageName string, versionsToDelete map[string]struct{}) error { + var cp gardenerv1beta1.CloudProfile + if err := r.Get(ctx, types.NamespacedName{Name: cloudProfileName}, &cp); err != nil { + return err + } + + // Track which clean versions still have remaining capability flavors after deletion, + // so we can cascade-delete empty clean version entries from spec.machineImages. + // A version present in this map was a clean version entry; true means it still has flavors. + cleanVersionsWithFlavors := make(map[string]bool) + + if cp.Spec.ProviderConfig != nil { + var cfg providercfg.CloudProfileConfig + if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { + return fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) + } + for i := range cfg.MachineImages { + if cfg.MachineImages[i].Name != imageName { + continue + } + for j := range cfg.MachineImages[i].Versions { + v := &cfg.MachineImages[i].Versions[j] + if v.Image != "" { + // Legacy flat entry — not a clean version, skip. + continue + } + // Mark as a clean version entry; value indicates whether any flavors remain. + cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 + if len(v.CapabilityFlavors) == 0 { + continue + } + v.CapabilityFlavors = slices.DeleteFunc(v.CapabilityFlavors, func(f providercfg.MachineImageFlavor) bool { + idx := strings.LastIndex(f.Image, ":") + if idx == -1 { + return false + } + _, exists := versionsToDelete[f.Image[idx+1:]] + return exists + }) + cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 + } + // Remove version entries that have no legacy image ref and no remaining flavors. + cfg.MachineImages[i].Versions = slices.DeleteFunc(cfg.MachineImages[i].Versions, func(mv providercfg.MachineImageVersion) bool { + if mv.Image != "" { + // Legacy flat entry — delete if its tag is in versionsToDelete. + idx := strings.LastIndex(mv.Image, ":") + if idx == -1 { + return false + } + _, exists := versionsToDelete[mv.Image[idx+1:]] + return exists + } + // Clean version entry — delete if all flavors were removed. + return !cleanVersionsWithFlavors[mv.Version] + }) + } + raw, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal ProviderConfig: %w", err) + } + cp.Spec.ProviderConfig.Raw = raw + } + + for i := range cp.Spec.MachineImages { + if cp.Spec.MachineImages[i].Name != imageName { + continue + } + cp.Spec.MachineImages[i].Versions = slices.DeleteFunc(cp.Spec.MachineImages[i].Versions, func(mv gardenerv1beta1.MachineImageVersion) bool { + if _, exists := versionsToDelete[mv.Version]; exists { + return true + } + // Cascade-delete clean version entry if all its capability flavors were removed. + // Only entries tracked as clean versions (present in the map) are eligible. + hasRemainingFlavors, isCleanVersion := cleanVersionsWithFlavors[mv.Version] + return isCleanVersion && !hasRemainingFlavors + }) + } + + if err := r.Update(ctx, &cp); err != nil { + return err + } + return nil +} + +func (r *Reconciler) getReferencedVersions(shootList *gardenerv1beta1.ShootList, cp *gardenerv1beta1.CloudProfile, imageName string) (map[string]struct{}, error) { + referenced := make(map[string]struct{}) + + for _, shoot := range shootList.Items { + if shoot.Spec.CloudProfile == nil || shoot.Spec.CloudProfile.Name != cp.Name { + continue + } + + for _, worker := range shoot.Spec.Provider.Workers { + if worker.Machine.Image == nil || worker.Machine.Image.Name != imageName { + continue + } + if worker.Machine.Image.Version != nil { + referenced[*worker.Machine.Image.Version] = struct{}{} + } + } + } + + // For any clean version referenced by a Shoot, also protect the raw OCI tags + // that back it via capabilityFlavors — otherwise GC would delete the images + // that the clean version depends on. + if len(referenced) > 0 { + if cp.Spec.ProviderConfig != nil { + var cfg providercfg.CloudProfileConfig + if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) + } + for _, img := range cfg.MachineImages { + if img.Name != imageName { + continue + } + for _, v := range img.Versions { + if _, isReferenced := referenced[v.Version]; !isReferenced { + continue + } + for _, flavor := range v.CapabilityFlavors { + idx := strings.LastIndex(flavor.Image, ":") + if idx == -1 { + continue + } + referenced[flavor.Image[idx+1:]] = struct{}{} + } + } + } + } + } + + return referenced, nil +} + +func fetchKeppelTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + baseURL := registryBaseURL(registry, false) + + keppelURL, err := keppelURL(baseURL, repository) + if err != nil { + return nil, fmt.Errorf("failed to build keppel URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, keppelURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create keppel request: %w", 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 { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("keppel API returned status %d", resp.StatusCode) + return nil, err + } + + var result KeppelManifestsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + tagMap := make(map[string]time.Time) + + for _, m := range result.Manifests { + for _, t := range m.Tags { + if t.PushedAt == 0 { + continue + } + tagMap[t.Name] = time.Unix(t.PushedAt, 0) + } + } + + return tagMap, nil +} + +func keppelURL(baseURL, repository string) (string, error) { + account, repo, err := splitKeppelRepository(repository) + if err != nil { + return "", err + } + + keppelURL := fmt.Sprintf( + "%s/keppel/v1/accounts/%s/repositories/%s/_manifests", + baseURL, + account, + repo, + ) + + return keppelURL, nil +} + +func registryBaseURL(registryHost string, insecure bool) string { + scheme := "https" + if insecure { + scheme = "http" + } + + u := &url.URL{ + Scheme: scheme, + Host: registryHost, + } + + base := u.String() + + return base +} + +func splitKeppelRepository(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) + + return "", "", err + } + + account = parts[0] + repo = parts[1] + + return account, repo, nil +} + +func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile, returnErr error) error { + if patchErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ + Type: CloudProfileAppliedConditionType, + Status: metav1.ConditionFalse, + ObservedGeneration: mcp.Generation, + Reason: "GarbageCollectionFailed", + Message: returnErr.Error(), + }); patchErr != nil { + return fmt.Errorf("failed to patch ManagedCloudProfile status: %w (original error: %w)", patchErr, returnErr) + } + return returnErr +} diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 84d0adb..44ae50a 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -5,29 +5,18 @@ package controllers import ( "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" "slices" - "strings" "time" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" - providercfg "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" - 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/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) const ( @@ -36,26 +25,13 @@ const ( // OCISourceFactory defines an interface for creating OCI sources. type OCISourceFactory interface { - Create(params cloudprofilesync.OCIParams, insecure bool, log logr.Logger) (cloudprofilesync.Source, error) + Create(params ocirepo.Params, parallel int64, log logr.Logger) (ossync.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{} - -func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, insecure bool, log logr.Logger) (cloudprofilesync.Source, error) { - return cloudprofilesync.NewOCI(params, insecure, log) -} - type Reconciler struct { client.Client OCISourceFactory OCISourceFactory @@ -63,21 +39,6 @@ type Reconciler struct { EnableCapabilities bool } -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) { log := ctrl.LoggerFrom(ctx) var mcp v1alpha1.ManagedCloudProfile @@ -96,314 +57,6 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil } -func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, mcp *v1alpha1.ManagedCloudProfile) error { - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = mcp.Name - - op, err := controllerutil.CreateOrPatch(ctx, r.Client, &cloudProfile, func() error { - if err := controllerutil.SetControllerReference(mcp, &cloudProfile, r.Scheme()); err != nil { - return err - } - cloudProfile.Spec = CloudProfileSpecToGardener(&mcp.Spec.CloudProfile) - errs := make([]error, 0) - for _, updates := range mcp.Spec.MachineImageUpdates { - if updateErr := r.updateMachineImages(ctx, log, updates, &cloudProfile.Spec); updateErr != nil { - errs = append(errs, updateErr) - } - } - gardenerv1beta1.SetObjectDefaults_CloudProfile(&cloudProfile) - return errors.Join(errs...) - }) - if err != nil { - statusErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ - Type: CloudProfileAppliedConditionType, - Status: metav1.ConditionFalse, - ObservedGeneration: mcp.Generation, - Reason: "ApplyFailed", - Message: fmt.Sprintf("Failed to apply CloudProfile: %s", err), - }) - if statusErr != nil { - return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", statusErr) - } - if apierrors.IsInvalid(err) { - return nil - } - return fmt.Errorf("failed to create or patch CloudProfile: %w", err) - } - if op != controllerutil.OperationResultNone { - statusErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.SucceededReconcileStatus, metav1.Condition{ - Type: CloudProfileAppliedConditionType, - Status: metav1.ConditionTrue, - ObservedGeneration: mcp.Generation, - Reason: "Applied", - Message: "Generated CloudProfile applied successfully", - }) - if statusErr != nil { - return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", statusErr) - } - } - return nil -} - -func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile) error { - if mcp.Spec.GarbageCollection == nil || !mcp.Spec.GarbageCollection.Enabled { - return nil - } - if mcp.Spec.GarbageCollection.MaxAge.Duration < 0 { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("invalid garbage collection maxAge: %s", mcp.Spec.GarbageCollection.MaxAge.String())) - } - - cutoff := time.Now().Add(-mcp.Spec.GarbageCollection.MaxAge.Duration) - - for _, updates := range mcp.Spec.MachineImageUpdates { - if updates.Source.OCI == nil { - continue - } - - registryClient, err := r.RegistryProviderFunc(updates.Source.OCI.Registry) - if err != nil { - return r.failWithStatusUpdate(ctx, mcp, - fmt.Errorf("no registry provider found for registry %q: %w", updates.Source.OCI.Registry, err)) - } - 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 fetch tags: %w", err)) - } - - referencedVersions, err := r.getReferencedVersions(ctx, mcp.Name, updates.ImageName) - if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to determine referenced versions for garbage collection: %w", err)) - } - - versionsToDelete := make(map[string]struct{}) - for tag, pushedAt := range tags { - if _, isReferenced := referencedVersions[tag]; isReferenced { - continue - } - if pushedAt.Before(cutoff) { - versionsToDelete[tag] = struct{}{} - } - } - - if err := r.deleteVersions(ctx, mcp.Name, updates.ImageName, versionsToDelete); err != nil { - if apierrors.IsInvalid(err) { - continue - } - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to delete image versions: %w", err)) - } - } - - return nil -} - -func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, imageName string, versionsToDelete map[string]struct{}) error { - var cp gardenerv1beta1.CloudProfile - if err := r.Get(ctx, types.NamespacedName{Name: cloudProfileName}, &cp); err != nil { - return err - } - - // Track which clean versions still have remaining capability flavors after deletion, - // so we can cascade-delete empty clean version entries from spec.machineImages. - // A version present in this map was a clean version entry; true means it still has flavors. - cleanVersionsWithFlavors := make(map[string]bool) - - if cp.Spec.ProviderConfig != nil { - var cfg providercfg.CloudProfileConfig - if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { - return fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) - } - for i := range cfg.MachineImages { - if cfg.MachineImages[i].Name != imageName { - continue - } - for j := range cfg.MachineImages[i].Versions { - v := &cfg.MachineImages[i].Versions[j] - if v.Image != "" { - // Legacy flat entry — not a clean version, skip. - continue - } - // Mark as a clean version entry; value indicates whether any flavors remain. - cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 - if len(v.CapabilityFlavors) == 0 { - continue - } - v.CapabilityFlavors = slices.DeleteFunc(v.CapabilityFlavors, func(f providercfg.MachineImageFlavor) bool { - idx := strings.LastIndex(f.Image, ":") - if idx == -1 { - return false - } - _, exists := versionsToDelete[f.Image[idx+1:]] - return exists - }) - cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 - } - // Remove version entries that have no legacy image ref and no remaining flavors. - cfg.MachineImages[i].Versions = slices.DeleteFunc(cfg.MachineImages[i].Versions, func(mv providercfg.MachineImageVersion) bool { - if mv.Image != "" { - // Legacy flat entry — delete if its tag is in versionsToDelete. - idx := strings.LastIndex(mv.Image, ":") - if idx == -1 { - return false - } - _, exists := versionsToDelete[mv.Image[idx+1:]] - return exists - } - // Clean version entry — delete if all flavors were removed. - return !cleanVersionsWithFlavors[mv.Version] - }) - } - raw, err := json.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal ProviderConfig: %w", err) - } - cp.Spec.ProviderConfig.Raw = raw - } - - for i := range cp.Spec.MachineImages { - if cp.Spec.MachineImages[i].Name != imageName { - continue - } - cp.Spec.MachineImages[i].Versions = slices.DeleteFunc(cp.Spec.MachineImages[i].Versions, func(mv gardenerv1beta1.MachineImageVersion) bool { - if _, exists := versionsToDelete[mv.Version]; exists { - return true - } - // Cascade-delete clean version entry if all its capability flavors were removed. - // Only entries tracked as clean versions (present in the map) are eligible. - hasRemainingFlavors, isCleanVersion := cleanVersionsWithFlavors[mv.Version] - return isCleanVersion && !hasRemainingFlavors - }) - } - - if err := r.Update(ctx, &cp); err != nil { - return err - } - return nil -} - -func (r *Reconciler) getReferencedVersions(ctx context.Context, cloudProfileName, imageName string) (map[string]struct{}, error) { - referenced := make(map[string]struct{}) - - shootList := &gardenerv1beta1.ShootList{} - if err := r.List(ctx, shootList, client.InNamespace(metav1.NamespaceAll)); err != nil { - return nil, fmt.Errorf("failed to list Shoots: %w", err) - } - for _, shoot := range shootList.Items { - if shoot.Spec.CloudProfile == nil || shoot.Spec.CloudProfile.Name != cloudProfileName { - continue - } - - for _, worker := range shoot.Spec.Provider.Workers { - if worker.Machine.Image == nil || worker.Machine.Image.Name != imageName { - continue - } - if worker.Machine.Image.Version != nil { - referenced[*worker.Machine.Image.Version] = struct{}{} - } - } - } - - // For any clean version referenced by a Shoot, also protect the raw OCI tags - // that back it via capabilityFlavors — otherwise GC would delete the images - // that the clean version depends on. - if len(referenced) > 0 { - var cp gardenerv1beta1.CloudProfile - if err := r.Get(ctx, types.NamespacedName{Name: cloudProfileName}, &cp); err != nil { - return nil, fmt.Errorf("failed to get CloudProfile: %w", err) - } - if cp.Spec.ProviderConfig != nil { - var cfg providercfg.CloudProfileConfig - if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) - } - for _, img := range cfg.MachineImages { - if img.Name != imageName { - continue - } - for _, v := range img.Versions { - if _, isReferenced := referenced[v.Version]; !isReferenced { - continue - } - for _, flavor := range v.CapabilityFlavors { - idx := strings.LastIndex(flavor.Image, ":") - if idx == -1 { - continue - } - referenced[flavor.Image[idx+1:]] = struct{}{} - } - } - } - } - } - - return referenced, nil -} - -func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, update v1alpha1.MachineImageUpdate, cpSpec *gardenerv1beta1.CloudProfileSpec) error { - var source cloudprofilesync.Source - switch { - case update.Source.OCI != nil: - password, err := r.getCredential(ctx, update.Source.OCI.Password) - if err != nil { - return err - } - src, err := r.OCISourceFactory.Create(cloudprofilesync.OCIParams{ - Registry: update.Source.OCI.Registry, - Repository: update.Source.OCI.Repository, - Username: update.Source.OCI.Username, - Password: string(password), - Parallel: 1, - }, update.Source.OCI.Insecure, log) - if err != nil { - return fmt.Errorf("failed to initialize OCI source: %w", err) - } - source = src - - default: - return errors.New("no machine images source configured") - } - - var provider cloudprofilesync.Provider - if update.Provider.IroncoreMetal != nil { - provider = &cloudprofilesync.IroncoreProvider{ - Registry: update.Provider.IroncoreMetal.Registry, - Repository: update.Provider.IroncoreMetal.Repository, - ImageName: update.ImageName, - EnableCapabilities: r.EnableCapabilities, - } - } - imageUpdater := cloudprofilesync.ImageUpdater{ - Log: log, - Source: source, - Provider: provider, - ImageName: update.ImageName, - EnableCapabilities: r.EnableCapabilities, - } - if err := imageUpdater.Update(ctx, cpSpec); err != nil { - return fmt.Errorf("updating machine images failed: %w", err) - } - return nil -} - -func (r *Reconciler) getCredential(ctx context.Context, ref v1alpha1.SecretReference) ([]byte, error) { - if ref.Name == "" { - return nil, nil - } - var secret corev1.Secret - if err := r.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: ref.Namespace}, &secret); err != nil { - return nil, fmt.Errorf("failed to get secret: %w", err) - } - data, ok := secret.Data[ref.Key] - if !ok { - return nil, fmt.Errorf("secret %s/%s does not have key %s", ref.Namespace, ref.Name, ref.Key) - } - return data, nil -} - func (r *Reconciler) patchStatusAndCondition(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile, status v1alpha1.ReconcileStatus, cond metav1.Condition) error { original := mcp.DeepCopy() mcp.Status.Status = status @@ -421,11 +74,15 @@ func applyCondition(conditions []metav1.Condition, cond metav1.Condition) []meta idx = len(conditions) conditions = append(conditions, metav1.Condition{}) } + lastTransition := conditions[idx].LastTransitionTime + if conditions[idx].Status != cond.Status { + lastTransition = metav1.Now() + } conditions[idx] = metav1.Condition{ Type: cond.Type, Status: cond.Status, ObservedGeneration: cond.ObservedGeneration, - LastTransitionTime: metav1.Now(), + LastTransitionTime: lastTransition, Reason: cond.Reason, Message: cond.Message, } @@ -450,132 +107,6 @@ func CloudProfileSpecToGardener(spec *v1alpha1.CloudProfileSpec) gardenerv1beta1 } } -func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile, returnErr error) error { - if patchErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ - Type: CloudProfileAppliedConditionType, - Status: metav1.ConditionFalse, - ObservedGeneration: mcp.Generation, - Reason: "GarbageCollectionFailed", - Message: returnErr.Error(), - }); patchErr != nil { - return fmt.Errorf("failed to patch ManagedCloudProfile status: %w (original error: %w)", patchErr, returnErr) - } - return returnErr -} - -func fetchKeppelTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { - baseURL := registryBaseURL(registry, false) - - keppelURL, err := keppelURL(baseURL, repository) - if err != nil { - return nil, fmt.Errorf("failed to build keppel URL: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, keppelURL, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create keppel request: %w", 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 { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - err := fmt.Errorf("keppel API returned status %d", resp.StatusCode) - return nil, err - } - - var result KeppelManifestsResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - tagMap := make(map[string]time.Time) - - for _, m := range result.Manifests { - for _, t := range m.Tags { - if t.PushedAt == 0 { - continue - } - tagMap[t.Name] = time.Unix(t.PushedAt, 0) - } - } - - return tagMap, nil -} - -func keppelURL(baseURL, repository string) (string, error) { - account, repo, err := splitKeppelRepository(repository) - if err != nil { - return "", err - } - - keppelURL := fmt.Sprintf( - "%s/keppel/v1/accounts/%s/repositories/%s/_manifests", - baseURL, - account, - repo, - ) - - return keppelURL, nil -} - -func registryBaseURL(registryHost string, insecure bool) string { - scheme := "https" - if insecure { - scheme = "http" - } - - u := &url.URL{ - Scheme: scheme, - Host: registryHost, - } - - base := u.String() - - return base -} - -func splitKeppelRepository(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) - - return "", "", err - } - - account = parts[0] - repo = parts[1] - - 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 { diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 0f2fd1d..ea88b00 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" corev1 "k8s.io/api/core/v1" @@ -21,28 +22,30 @@ import ( providercfg "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" "github.com/cobaltcore-dev/cloud-profile-sync/controllers" ) // fakeSource used to simulate GC list failures in tests type fakeSource struct{} -func (f *fakeSource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { +func (f *fakeSource) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { return nil, errors.New("simulated list error") } // mockOCIFactory implements controllers.OCISourceFactory for testing type mockOCIFactory struct { - createFunc func(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) + createFunc func(params ocirepo.Params, parallel int64) (ossync.Source, error) } type fakeOCISource struct{} -func (f *fakeOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { - return []cloudprofilesync.SourceImage{ +func (f *fakeOCISource) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { + return []ossync.SourceImage{ {Version: "1.0.0", Architectures: []string{"amd64"}}, {Version: "1.0.1+abc", Architectures: []string{"amd64"}}, }, nil @@ -50,24 +53,24 @@ func (f *fakeOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.Sou type emptyOCISource struct{} -func (f *emptyOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { +func (f *emptyOCISource) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { return nil, nil } type fakeFactory struct{} -func (f *fakeFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { +func (f *fakeFactory) Create(params ocirepo.Params, _ int64, _ logr.Logger) (ossync.Source, error) { return &fakeOCISource{}, nil } type emptyFactory struct{} -func (f *emptyFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { +func (f *emptyFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger) (ossync.Source, error) { return &emptyOCISource{}, nil } -func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { - return m.createFunc(params, insecure) +func (m *mockOCIFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger) (ossync.Source, error) { + return m.createFunc(params, parallel) } type fakeRegistryClient struct{} @@ -89,6 +92,101 @@ func (f *fakeRegistryClientWithTags) GetTags(ctx context.Context, registry, repo return f.tags, nil } +// --- shared test helpers --- + +// validMachineImage returns a machine image that satisfies Gardener CloudProfile +// validation (CRI, architecture and update strategy set). +func validMachineImage(name string, versions ...string) gardenerv1beta1.MachineImage { + mivs := make([]gardenerv1beta1.MachineImageVersion, 0, len(versions)) + for _, v := range versions { + mivs = append(mivs, gardenerv1beta1.MachineImageVersion{ + ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: v}, + CRI: []gardenerv1beta1.CRI{{Name: "containerd"}}, + Architectures: []string{"amd64"}, + }) + } + return gardenerv1beta1.MachineImage{ + Name: name, + Versions: mivs, + UpdateStrategy: ptr.To(gardenerv1beta1.UpdateStrategyMajor), + } +} + +// expectReconcileStatus waits until the ManagedCloudProfile reaches the given +// reconcile status, surfacing its conditions on failure. +func expectReconcileStatus(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile, status v1alpha1.ReconcileStatus) { + GinkgoHelper() + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(mcp), mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(status), func() string { + return fmt.Sprintf("conditions: %+v", mcp.Status.Conditions) + }) +} + +// expectAppliedCondition asserts the CloudProfileApplied condition has the given +// status. Extra matchers (e.g. on Reason/Message) may be appended. +func expectAppliedCondition(mcp *v1alpha1.ManagedCloudProfile, status metav1.ConditionStatus, extra ...types.GomegaMatcher) { + GinkgoHelper() + matchers := append([]types.GomegaMatcher{ + HaveField("Type", controllers.CloudProfileAppliedConditionType), + HaveField("Status", status), + }, extra...) + Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll(matchers...))) +} + +// getCloudProfile fetches the CloudProfile named after the MCP, waiting for it +// to exist, and returns it. +func getCloudProfile(ctx context.Context, name string) *gardenerv1beta1.CloudProfile { + GinkgoHelper() + cp := &gardenerv1beta1.CloudProfile{} + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: name}, cp) + }).Should(Succeed()) + return cp +} + +// versionsByMachineImage returns the version strings of the named machine image in +// the CloudProfile spec. +func versionsByMachineImage(cp *gardenerv1beta1.CloudProfile, imageName string) []string { + var versions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == imageName { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } + } + return versions +} + +// baseCloudProfileSpec returns a minimal valid CloudProfileSpec with regions, +// machine types, Kubernetes versions, and optional machine images. Callers should +// override other fields as needed. +func baseCloudProfileSpec(machineImages ...gardenerv1beta1.MachineImage) v1alpha1.CloudProfileSpec { + amd64 := "amd64" + usable := true + spec := v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{ + { + Name: "foo", + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{ + { + Name: "baz", + Architecture: &amd64, + Usable: &usable, + }, + }, + MachineImages: machineImages, + Kubernetes: gardenerv1beta1.KubernetesSettings{ + Versions: []gardenerv1beta1.ExpirableVersion{{Version: "1.30.0"}}, + }, + } + return spec +} + var _ = Describe("The ManagedCloudProfile reconciler", func() { amd64 := "amd64" @@ -165,53 +263,17 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("should copy the spec of a ManagedCloudProfile to the respective CloudProfile", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-mcp" - usable := true - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "bar", - Versions: []gardenerv1beta1.MachineImageVersion{ - { - ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "0.3.0"}, - CRI: []gardenerv1beta1.CRI{{Name: "containerd"}}, - Architectures: []string{"amd64"}, - }, - }, - UpdateStrategy: ptr.To(gardenerv1beta1.UpdateStrategyMajor), - }, - }, - MachineTypes: []gardenerv1beta1.MachineType{ - { - Name: "baz", - Architecture: &amd64, - Usable: &usable, - }, - }, - } + mcp.Spec.CloudProfile = baseCloudProfileSpec(validMachineImage("bar", "0.3.0")) 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)) - Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( - HaveField("Type", controllers.CloudProfileAppliedConditionType), - HaveField("Status", metav1.ConditionTrue), - ))) - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = mcp.Name - Eventually(func() error { - return k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile) - }).Should(Succeed()) + expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionTrue) + cloudProfile := getCloudProfile(ctx, mcp.Name) Expect(cloudProfile.Spec).To(Equal(controllers.CloudProfileSpecToGardener(&mcp.Spec.CloudProfile))) - Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - Expect(mcp.Status.Status).To(Equal(v1alpha1.SucceededReconcileStatus)) - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed()) }) It("reports failure given an invalid cloudprofile", func(ctx SpecContext) { @@ -219,14 +281,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { mcp.Name = "test-invalid" 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), - ))) + expectReconcileStatus(ctx, &mcp, v1alpha1.FailedReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionFalse) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) }) @@ -234,45 +290,31 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("invokes the image updater based on an image source", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-oci" - usable := true - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{ - { - Name: "baz", - Architecture: &amd64, - Usable: &usable, - }, - }, - } + mcp.Spec.CloudProfile = baseCloudProfileSpec() mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: registryAddr, Repository: orasRepoName("repo"), Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: registryAddr, + Repository: orasRepoName("repo"), + }, + }, ImageName: "the-image", }, } 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)) - Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( - HaveField("Type", controllers.CloudProfileAppliedConditionType), - HaveField("Status", metav1.ConditionTrue), - ))) - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = mcp.Name - Eventually(func() error { - return k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile) - }).Should(Succeed()) + expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionTrue) + cloudProfile := getCloudProfile(ctx, mcp.Name) Expect(cloudProfile.Spec.Regions).To(Equal(mcp.Spec.CloudProfile.Regions)) Expect(cloudProfile.Spec.MachineTypes).To(Equal(mcp.Spec.CloudProfile.MachineTypes)) mi := cloudProfile.Spec.MachineImages @@ -282,11 +324,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(vers).To(ContainElement(gardenerv1beta1.MachineImageVersion{ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}, CRI: []gardenerv1beta1.CRI{{Name: "containerd"}}})) Expect(vers).To(ContainElement(gardenerv1beta1.MachineImageVersion{ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}, CRI: []gardenerv1beta1.CRI{{Name: "containerd"}}})) - Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - Expect(mcp.Status.Status).To(Equal(v1alpha1.SucceededReconcileStatus)) - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed()) }) It("fetches a secret for the OCI source", func(ctx SpecContext) { @@ -298,14 +337,11 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-secret" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } + mcp.Spec.CloudProfile = baseCloudProfileSpec() mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: registryAddr, Repository: orasRepoName("repo"), Insecure: true, @@ -317,69 +353,61 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: registryAddr, + Repository: orasRepoName("repo"), + }, + }, ImageName: "the-image", }, } 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)) - Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( - HaveField("Type", controllers.CloudProfileAppliedConditionType), - HaveField("Status", metav1.ConditionTrue), - ))) - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = mcp.Name - Eventually(func() error { - return k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile) - }).Should(Succeed()) + expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionTrue) + + cloudProfile := getCloudProfile(ctx, mcp.Name) Expect(cloudProfile.Spec.MachineImages).To(HaveLen(1)) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed()) Expect(k8sClient.Delete(ctx, &secret)).To(Succeed()) }) It("deletes old machine image versions not referenced by any Shoot", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "gc-mcp" - usable := true oldVersion := "0.1.0" newVersion := "1.0.0" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - 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.CloudProfile = baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ + Name: "gc-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldVersion}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newVersion}, Architectures: []string{"amd64"}}, }, }, - } + ) mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { ImageName: "gc-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "keppel-fake", Repository: "account/repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", + Repository: "account/repo", + }, + }, }, } @@ -486,30 +514,32 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-gc-preserve" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - 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}}, - }, + mcp.Spec.CloudProfile = baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ + 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}}, }, }, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } + ) mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { ImageName: "preserve-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: registryAddr, Repository: orasRepoName("repo"), Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: registryAddr, + Repository: orasRepoName("repo"), + }, + }, }, } @@ -520,10 +550,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { 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)) + expectReconcileStatus(ctx, &mcp, v1alpha1.FailedReconcileStatus) Eventually(func(g Gomega) []string { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) @@ -588,29 +615,31 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { 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}}, - }, + CloudProfile: baseCloudProfileSpec( + 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}}, }, }, - }, + ), MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { ImageName: "shoot-preserve-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "keppel-fake", Repository: "account/repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", + Repository: "account/repo", + }, + }, }, }, GarbageCollection: &v1alpha1.GarbageCollectionConfig{ @@ -659,15 +688,12 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("handles invalid OCI registry for GC", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-gc-invalid-registry" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } + mcp.Spec.CloudProfile = baseCloudProfileSpec() mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { ImageName: "test-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "invalid://registry", Repository: orasRepoName("repository"), Insecure: true, @@ -681,17 +707,11 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } 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), + expectReconcileStatus(ctx, &mcp, v1alpha1.FailedReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionFalse, HaveField("Reason", "ApplyFailed"), 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()) }) @@ -700,27 +720,30 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { old := reconciler.OCISourceFactory defer func() { reconciler.OCISourceFactory = old }() reconciler.OCISourceFactory = &mockOCIFactory{ - createFunc: func(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { + createFunc: func(params ocirepo.Params, p int64) (ossync.Source, error) { return &fakeSource{}, nil }, } var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-gc-list-error" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } + mcp.Spec.CloudProfile = baseCloudProfileSpec() mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ { ImageName: "test-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: registryAddr, Repository: "repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: registryAddr, + Repository: "repo", + }, + }, }, } mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ @@ -729,17 +752,11 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } 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), + expectReconcileStatus(ctx, &mcp, v1alpha1.FailedReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionFalse, HaveField("Reason", "ApplyFailed"), HaveField("Message", ContainSubstring("Failed to apply CloudProfile: updating machine images failed: failed to retrieve image versions from OCI registry: simulated list error")), - ))) + ) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) }) @@ -747,18 +764,14 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("skips GC when no source is configured", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-gc-no-source" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "test-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - }, + mcp.Spec.CloudProfile = baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ + Name: "test-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, }, }, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } + ) Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) var cp gardenerv1beta1.CloudProfile @@ -803,16 +816,10 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-owned" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } + mcp.Spec.CloudProfile = baseCloudProfileSpec() 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)) + expectReconcileStatus(ctx, &mcp, v1alpha1.FailedReconcileStatus) Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( HaveField("Type", controllers.CloudProfileAppliedConditionType), HaveField("Status", metav1.ConditionFalse), @@ -854,24 +861,20 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-gc-provider-config" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "provider-config-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.Spec.CloudProfile = baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ + Name: "provider-config-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: "provider-config-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: registryAddr, Repository: "repo/provider-config-image", Insecure: true, @@ -885,10 +888,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } 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)) + expectReconcileStatus(ctx, &mcp, v1alpha1.FailedReconcileStatus) Eventually(func(g Gomega) []string { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) @@ -966,30 +966,35 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-protect-flavors"}, Spec: v1alpha1.ManagedCloudProfileSpec{ - CloudProfile: v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { + CloudProfile: func() v1alpha1.CloudProfileSpec { + cp := baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ Name: "cap-image", Versions: []gardenerv1beta1.MachineImageVersion{ {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: rawTag}, Architectures: []string{"amd64"}}, {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, }, }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, + ) + cp.ProviderConfig = &runtime.RawExtension{Raw: raw} + return cp + }(), MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { ImageName: "cap-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "keppel-fake", Repository: "account/cap-repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", + Repository: "account/cap-repo", + }, + }, }, }, GarbageCollection: &v1alpha1.GarbageCollectionConfig{ @@ -1017,14 +1022,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) // Raw tag must still be present in spec.machineImages because the Shoot protects it. - var versions []string - for _, mi := range cp.Spec.MachineImages { - if mi.Name == "cap-image" { - for _, v := range mi.Versions { - versions = append(versions, v.Version) - } - } - } + versions := versionsByMachineImage(cp, "cap-image") Expect(versions).To(ContainElement(rawTag)) // Flavor must still be present in providerConfig. @@ -1075,11 +1073,9 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-partial-flavor"}, Spec: v1alpha1.ManagedCloudProfileSpec{ - CloudProfile: v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { + CloudProfile: func() v1alpha1.CloudProfileSpec { + cp := baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ Name: "multi-flavor-image", Versions: []gardenerv1beta1.MachineImageVersion{ {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, @@ -1087,19 +1083,26 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64", "arm64"}}, }, }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, + ) + cp.ProviderConfig = &runtime.RawExtension{Raw: raw} + return cp + }(), MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { ImageName: "multi-flavor-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "keppel-fake", Repository: "account/multi-flavor-repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", + Repository: "account/multi-flavor-repo", + }, + }, }, }, GarbageCollection: &v1alpha1.GarbageCollectionConfig{ @@ -1147,15 +1150,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(flavors).To(ContainElement("repo/multi-flavor-image:" + newTag)) // Clean version entry must still be present in spec.machineImages (has remaining flavor). - var machineVersions []string - for _, mi := range cp.Spec.MachineImages { - if mi.Name == "multi-flavor-image" { - for _, v := range mi.Versions { - machineVersions = append(machineVersions, v.Version) - } - } - } - Expect(machineVersions).To(ContainElement(cleanVersion)) + Expect(versionsByMachineImage(cp, "multi-flavor-image")).To(ContainElement(cleanVersion)) Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) }) @@ -1187,30 +1182,35 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-cascade"}, Spec: v1alpha1.ManagedCloudProfileSpec{ - CloudProfile: v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { + CloudProfile: func() v1alpha1.CloudProfileSpec { + cp := baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ Name: "cascade-image", Versions: []gardenerv1beta1.MachineImageVersion{ {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, }, }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, + ) + cp.ProviderConfig = &runtime.RawExtension{Raw: raw} + return cp + }(), MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { ImageName: "cascade-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "keppel-fake", Repository: "account/cascade-repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", + Repository: "account/cascade-repo", + }, + }, }, }, GarbageCollection: &v1alpha1.GarbageCollectionConfig{ @@ -1238,15 +1238,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) // Both raw tag and clean version must be removed from spec.machineImages. - var machineVersions []string - for _, mi := range cp.Spec.MachineImages { - if mi.Name == "cascade-image" { - for _, v := range mi.Versions { - machineVersions = append(machineVersions, v.Version) - } - } - } - Expect(machineVersions).To(BeEmpty()) + Expect(versionsByMachineImage(cp, "cascade-image")).To(BeEmpty()) // Clean version entry must be gone from providerConfig as well. Expect(cp.Spec.ProviderConfig).ToNot(BeNil()) @@ -1286,29 +1278,34 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-stale-clean"}, Spec: v1alpha1.ManagedCloudProfileSpec{ - CloudProfile: v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { + CloudProfile: func() v1alpha1.CloudProfileSpec { + cp := baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ Name: "stale-clean-image", Versions: []gardenerv1beta1.MachineImageVersion{ {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, }, }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, + ) + cp.ProviderConfig = &runtime.RawExtension{Raw: raw} + return cp + }(), MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { ImageName: "stale-clean-image", Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + OCI: &v1alpha1.OCI{ Registry: "keppel-fake", Repository: "account/stale-clean-repo", Insecure: true, }, }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", + Repository: "account/stale-clean-repo", + }, + }, }, }, GarbageCollection: &v1alpha1.GarbageCollectionConfig{ @@ -1335,17 +1332,52 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) // Stale clean version entry must be gone from spec.machineImages. - var machineVersions []string - for _, mi := range cp.Spec.MachineImages { - if mi.Name == "stale-clean-image" { - for _, v := range mi.Versions { - machineVersions = append(machineVersions, v.Version) - } - } - } - Expect(machineVersions).To(BeEmpty()) + Expect(versionsByMachineImage(cp, "stale-clean-image")).To(BeEmpty()) Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) }) + It("does not update Kubernetes versions when KubernetesVersionUpdateConfig is not defined", func(ctx SpecContext) { + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-no-k8s-update" + mcp.Spec.CloudProfile = baseCloudProfileSpec(validMachineImage("bar", "0.3.0")) + // No KubernetesVersionUpdateConfig set. + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus) + + cp := getCloudProfile(ctx, mcp.Name) + + // The versions must be exactly what the MCP spec declared: the updater + // never ran, so no source-provided versions were added. + var versions []string + for _, v := range cp.Spec.Kubernetes.Versions { + versions = append(versions, v.Version) + } + Expect(versions).To(ConsistOf("1.30.0")) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) + }) + + It("does not update machine images when MachineImageUpdates is empty", func(ctx SpecContext) { + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-no-image-update" + mcp.Spec.CloudProfile = baseCloudProfileSpec(validMachineImage("static-image", "1.0.0")) + // No MachineImageUpdates set. + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus) + + cp := getCloudProfile(ctx, mcp.Name) + + // Machine images must be exactly what the MCP spec declared: no updater ran. + Expect(cp.Spec.MachineImages).To(HaveLen(1)) + Expect(cp.Spec.MachineImages[0].Name).To(Equal("static-image")) + Expect(versionsByMachineImage(cp, "static-image")).To(ConsistOf("1.0.0")) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) + }) + }) diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index 545997b..a067c32 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -601,6 +601,150 @@ spec: - message: maxAge must not be negative rule: duration(self) >= duration('0s') type: object + kubernetesVersionUpdateConfig: + description: KubernetesVersionUpdateConfig contains the source and + provider information to automate Kubernetes version updates. + properties: + expirationThreshold: + description: |- + ExpirationThreshold defines the grace period after a version's expiration date. + Versions whose expiration date has passed by more than this duration will be + removed from the CloudProfile. + type: string + landscapeSetup: + description: LandscapeSetup contains the required OCI and GitHub + sources for Kubernetes versions. + properties: + github: + description: Github contains configuration for fetching Kubernetes + version classifications from a GitHub repository. + properties: + filePath: + description: |- + FilePath is the path to the versions file within the repository, + e.g. "kubernetes/versions.yaml". + type: string + githubApp: + description: |- + GithubApp configures authentication via a GitHub App installation. + Mutually exclusive with PersonalAccessTokenSecret. + properties: + appID: + description: AppID is the numeric GitHub App ID. + format: int64 + type: integer + installationID: + description: InstallationID is the numeric installation + ID for the target repository. + format: int64 + type: integer + privateKeySecret: + description: |- + PrivateKeySecret is a reference to a secret containing the RSA private key + (PEM-encoded) used to sign JWTs. + properties: + key: + description: Key within the Secret to use for + required data. + type: string + name: + description: Name of a Secret. + type: string + namespace: + description: Namespace of a Secret. + type: string + required: + - key + - name + - namespace + type: object + required: + - appID + - installationID + - privateKeySecret + type: object + personalAccessTokenSecret: + description: |- + PersonalAccessTokenSecret is a reference to a secret containing a GitHub + personal access token. Mutually exclusive with GithubApp. + properties: + key: + description: Key within the Secret to use for required + data. + type: string + name: + description: Name of a Secret. + type: string + namespace: + description: Namespace of a Secret. + type: string + required: + - key + - name + - namespace + type: object + provider: + description: Provider is the provider whose Kubernetes + versions are read from the file. + type: string + repository: + description: Repository is the owner/repo path, e.g. "my-org/landscape-setup". + type: string + repositoryApiUrl: + description: |- + RepositoryApiURL is the base URL of the GitHub REST API, e.g. + "https://api.github.com" or "https://github.mycompany.com/api/v3". + type: string + required: + - filePath + - provider + - repository + - repositoryApiUrl + type: object + oci: + description: OCI contains configuration for the OCI component-descriptor + source. + properties: + insecure: + description: Insecure disables TLS + type: boolean + password: + description: Password for authentication + properties: + key: + description: Key within the Secret to use for required + data. + type: string + name: + description: Name of a Secret. + type: string + namespace: + description: Namespace of a Secret. + type: string + required: + - key + - name + - namespace + type: object + registry: + description: Registry contains the hostname and port of + the OCI registry + type: string + repository: + description: Repository contains the monitored repository + type: string + username: + description: Username for authentication + type: string + required: + - registry + - repository + type: object + required: + - github + - oci + type: object + type: object machineImageUpdates: description: MachineImageUpdates contains the source and provider information to automate machine images. diff --git a/go.mod b/go.mod index 9cbe7bb..d101a65 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( github.com/onsi/gomega v1.42.1 github.com/opencontainers/image-spec v1.1.1 go.uber.org/zap v1.28.0 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 k8s.io/api v0.36.0 k8s.io/apiextensions-apiserver v0.36.0 @@ -111,11 +113,9 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect