diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 0ab9c05..f546001 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -20,9 +20,9 @@ type ManagedCloudProfileSpec struct { // +optional GarbageCollection *GarbageCollectionConfig `json:"garbageCollection,omitempty"` - // KubernetesVersionUpdateConfig contains the source and provider information to automate Kubernetes version updates. + // KubernetesUpdate contains the source and provider information to automate Kubernetes version updates. // +optional - KubernetesVersionUpdateConfig *KubernetesVersionUpdateConfig `json:"kubernetesVersionUpdateConfig,omitempty"` + KubernetesUpdate *KubernetesVersionUpdateConfig `json:"kubernetesUpdate,omitempty"` } // Copy the cloud profile spec to override some validation @@ -119,52 +119,17 @@ type KubernetesVersionUpdateConfig struct { // removed from the CloudProfile. ExpirationThreshold metav1.Duration `json:"expirationThreshold,omitempty"` - // LandscapeSetup contains the required OCI and GitHub sources for Kubernetes versions. + // LandscapeSetup contains the OCI source configuration for Kubernetes versions. // +optional LandscapeSetup *LandscapeSetup `json:"landscapeSetup,omitempty"` } -// LandscapeSetup configures the combined OCI and GitHub sources for Kubernetes versions. +// LandscapeSetup configures the OCI component-descriptor source 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 is the provider name to select from the kubernetes-version-data blob, 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 { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aaa62fc..9a0b437 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -102,47 +102,6 @@ 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 @@ -150,7 +109,7 @@ func (in *KubernetesVersionUpdateConfig) DeepCopyInto(out *KubernetesVersionUpda if in.LandscapeSetup != nil { in, out := &in.LandscapeSetup, &out.LandscapeSetup *out = new(LandscapeSetup) - (*in).DeepCopyInto(*out) + **out = **in } } @@ -168,7 +127,6 @@ func (in *KubernetesVersionUpdateConfig) DeepCopy() *KubernetesVersionUpdateConf 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. @@ -328,8 +286,8 @@ func (in *ManagedCloudProfileSpec) DeepCopyInto(out *ManagedCloudProfileSpec) { *out = new(GarbageCollectionConfig) **out = **in } - if in.KubernetesVersionUpdateConfig != nil { - in, out := &in.KubernetesVersionUpdateConfig, &out.KubernetesVersionUpdateConfig + if in.KubernetesUpdate != nil { + in, out := &in.KubernetesUpdate, &out.KubernetesUpdate *out = new(KubernetesVersionUpdateConfig) (*in).DeepCopyInto(*out) } diff --git a/cloudprofilesync/k8ssync/source/landscape/landscape_source.go b/cloudprofilesync/k8ssync/source/landscape/landscape_source.go index 6cb6656..e0f0305 100644 --- a/cloudprofilesync/k8ssync/source/landscape/landscape_source.go +++ b/cloudprofilesync/k8ssync/source/landscape/landscape_source.go @@ -7,26 +7,17 @@ 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" + digest "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "go.yaml.in/yaml/v3" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -45,20 +36,31 @@ const componentDescriptorFile = "component-descriptor.yaml" // as Kubernetes versions. const kubeAPIServerResourceName = "kube-apiserver" -const githubClientTimeout = 30 * time.Second +// kubernetesVersionDataResourceName is the OCM resource that embeds +// KUBERNETES_VERSIONS.yaml as a localBlob. +const kubernetesVersionDataResourceName = "kubernetes-version-data" + +// resourceAccess is the minimal shape of a resource's access stanza. +type resourceAccess struct { + Type string `yaml:"type"` + LocalReference string `yaml:"localReference"` + MediaType string `yaml:"mediaType"` + Size int64 `yaml:"size"` +} // componentDescriptor is the minimal shape of component-descriptor.yaml. type componentDescriptor struct { Component struct { Resources []struct { - Name string `yaml:"name"` - Version string `yaml:"version"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Access resourceAccess `yaml:"access"` } `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 +// 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"` @@ -66,7 +68,7 @@ type yamlExpirableVersion struct { ExpirationDate *time.Time `yaml:"expirationDate"` } -// kubernetesVersions is the shape of the GitHub versions file. +// kubernetesVersions is the shape of the versions file (KUBERNETES_VERSIONS.yaml). type kubernetesVersions struct { Providers []struct { Name string `yaml:"name"` @@ -74,73 +76,34 @@ type kubernetesVersions struct { } `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. +// LandscapeKubernetesSource fetches Kubernetes versions exclusively from the +// OCI component descriptor and its embedded kubernetes-version-data localBlob. 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} + ociRepo *remote.Repository + provider string } -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") +// NewLandscapeKubernetesSource creates a source that fetches Kubernetes +// versions and their classifications from the OCI component descriptor. +// provider must match a provider name in the kubernetes-version-data blob. +func NewLandscapeKubernetesSource(ociParams ocirepo.Params, provider string) (*LandscapeKubernetesSource, error) { + if provider == "" { + return nil, errors.New("provider 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, + ociRepo: repo, + provider: 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. +// to get supported versions, fetches the kubernetes-version-data localBlob for +// classifications 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 { @@ -226,6 +189,44 @@ func (s *LandscapeKubernetesSource) fetchSupportedVersions(ctx context.Context, return versions, nil } +// fetchKubernetesVersionsBlob reads the component descriptor at the given OCI +// tag, locates the kubernetes-version-data resource, and fetches its localBlob +// content from the same OCI repository. The raw YAML bytes are returned. +func (s *LandscapeKubernetesSource) fetchKubernetesVersionsBlob(ctx context.Context, tag string) ([]byte, error) { + cd, err := s.fetchComponentDescriptor(ctx, tag) + if err != nil { + return nil, fmt.Errorf("fetching component descriptor: %w", err) + } + + for _, res := range cd.Component.Resources { + if res.Name != kubernetesVersionDataResourceName { + continue + } + if res.Access.Type != "localBlob/v1" { + return nil, fmt.Errorf("resource %q has unexpected access type %q, want localBlob/v1", kubernetesVersionDataResourceName, res.Access.Type) + } + localRef := res.Access.LocalReference + if localRef == "" { + return nil, fmt.Errorf("resource %q has empty localReference", kubernetesVersionDataResourceName) + } + rc, err := s.ociRepo.Blobs().Fetch(ctx, ocispec.Descriptor{ + Digest: digest.Digest(localRef), + Size: res.Access.Size, + MediaType: res.Access.MediaType, + }) + if err != nil { + return nil, fmt.Errorf("fetching blob %s: %w", localRef, err) + } + defer rc.Close() + raw, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("reading blob %s: %w", localRef, err) + } + return raw, nil + } + return nil, fmt.Errorf("resource %q not found in component descriptor at tag %s", kubernetesVersionDataResourceName, tag) +} + func (s *LandscapeKubernetesSource) fetchComponentDescriptor(ctx context.Context, tag string) (*componentDescriptor, error) { _, manifestBytes, err := oras.FetchBytes(ctx, s.ociRepo, tag, oras.DefaultFetchBytesOptions) if err != nil { @@ -283,47 +284,19 @@ func matchesFile(name, target string) bool { 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) { +// fetchClassification fetches the kubernetes-version-data localBlob at the +// given tag and returns the versions for the configured provider. +func (s *LandscapeKubernetesSource) fetchClassification(ctx context.Context, tag string) ([]gardenerv1beta1.ExpirableVersion, error) { if s.provider == "" { return nil, errors.New("provider must be set") } - raw, err := s.fetchGithubFile(ctx, ref) + raw, err := s.fetchKubernetesVersionsBlob(ctx, tag) if err != nil { - return nil, fmt.Errorf("fetch github file: %w", err) + return nil, fmt.Errorf("fetching kubernetes versions blob: %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 { @@ -349,161 +322,9 @@ func parseProviderVersions(raw []byte, provider string) ([]gardenerv1beta1.Expir 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 index 94f225b..597d9a8 100644 --- a/cloudprofilesync/k8ssync/source/landscape/landscape_source_test.go +++ b/cloudprofilesync/k8ssync/source/landscape/landscape_source_test.go @@ -7,15 +7,11 @@ import ( "archive/tar" "bytes" "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" + "crypto/sha256" "encoding/json" - "encoding/pem" "fmt" "net" "net/http" - "net/http/httptest" "strings" "testing" "time" @@ -23,6 +19,7 @@ import ( "github.com/distribution/distribution/v3/configuration" "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + . "github.com/onsi/gomega" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" @@ -47,32 +44,6 @@ func tarWith(t *testing.T, name, body string) []byte { 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 @@ -86,204 +57,35 @@ providers: 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) - } +// descriptorYAMLWithBlob builds a component descriptor YAML that includes both +// kube-apiserver resources and a kubernetes-version-data localBlob resource +// pointing to the given digest and size. +func descriptorYAMLWithBlob(t *testing.T, versionsDigest string, versionsSize int) string { + t.Helper() + return fmt.Sprintf(` +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 + - name: kubernetes-version-data + version: v1.2.3 + type: gardener.cloud/kubernetes-versions+yaml + access: + type: localBlob/v1 + localReference: %s + mediaType: application/vnd.gardener.cloud/kubernetes-versions+yaml + size: %d +`, versionsDigest, versionsSize) } -// ---- FetchVersions end-to-end (real OCI registry + httptest GitHub) ---- +// ---- OCI helpers ---- -// 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. +// freePort returns a TCP port number that is free at call time. func freePort(t *testing.T) string { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -295,9 +97,7 @@ func freePort(t *testing.T) string { 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. +// startRegistry spins up an in-process distribution registry on addr. func startRegistry(t *testing.T, addr string) func() { t.Helper() ctx, cancel := context.WithCancel(context.Background()) @@ -337,10 +137,11 @@ func startRegistry(t *testing.T, addr string) func() { } } -// 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) { +// pushArtifactWithVersionsBlob pushes an OCI artifact that contains: +// - a tar layer with the component descriptor (including a kubernetes-version-data +// resource pointing to the versions blob by digest) +// - the raw versions YAML as a standalone OCI blob +func pushArtifactWithVersionsBlob(t *testing.T, addr, repoName, tag, versionsYAML string) { t.Helper() ctx := context.Background() @@ -350,7 +151,20 @@ func pushComponentDescriptorArtifact(t *testing.T, addr, repoName, tag, descript } repo.PlainHTTP = true - // Build the tar layer. + // Push the versions YAML blob first; compute its digest. + versionsBlobBytes := []byte(versionsYAML) + h := sha256.Sum256(versionsBlobBytes) + versionsDigest := fmt.Sprintf("sha256:%x", h) + versionsSize := len(versionsBlobBytes) + versionsDesc := content.NewDescriptorFromBytes("application/vnd.gardener.cloud/kubernetes-versions+yaml", versionsBlobBytes) + if err := repo.Push(ctx, versionsDesc, bytes.NewReader(versionsBlobBytes)); err != nil { + t.Fatalf("push versions blob: %v", err) + } + + // Build the component descriptor YAML referencing the versions blob. + descriptorYAML := descriptorYAMLWithBlob(t, versionsDigest, versionsSize) + + // Build the tar layer containing the component descriptor. layerBytes := tarWith(t, componentDescriptorFile, descriptorYAML) layerDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, layerBytes) if err := repo.Push(ctx, layerDesc, bytes.NewReader(layerBytes)); err != nil { @@ -363,6 +177,45 @@ func pushComponentDescriptorArtifact(t *testing.T, addr, repoName, tag, descript } // Build and push the OCI manifest. + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.DescriptorEmptyJSON, + Layers: []ocispec.Descriptor{layerDesc, versionsDesc}, + } + 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) + } +} + +// pushComponentDescriptorArtifact pushes a minimal OCI artifact whose first +// layer is a tar containing componentDescriptorFile with the given YAML body. +// Use this for tests that do not need the versions blob. +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 + + 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) + } + + if err := repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")); err != nil { + t.Fatalf("push config: %v", err) + } + manifest := ocispec.Manifest{ Versioned: specs.Versioned{SchemaVersion: 2}, MediaType: ocispec.MediaTypeImageManifest, @@ -379,17 +232,10 @@ func pushComponentDescriptorArtifact(t *testing.T, addr, repoName, tag, descript } } -// 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() +// ---- unit tests ---- - // OCI: descriptor contains 1.31.4 and 1.32.1 only (1.33.0 absent). - descriptor := ` +func TestExtractComponentDescriptor(t *testing.T) { + const testDescriptor = ` component: name: landscape-setup resources: @@ -400,21 +246,186 @@ component: - name: kubelet version: 1.31.4 ` - pushComponentDescriptorArtifact(t, addr, "k8s-versions", "v1.2.3", descriptor) + tests := []struct { + name string + tarName string + tarBody string + wantResCount int + wantErr string + }{ + { + name: "parses resources from the tar", + tarName: componentDescriptorFile, + tarBody: testDescriptor, + wantResCount: 3, + }, + { + name: "tolerates a leading path prefix", + tarName: "landscape-setup/" + componentDescriptorFile, + tarBody: testDescriptor, + wantResCount: 3, + }, + { + name: "errors when the file is absent", + tarName: "other-file.yaml", + tarBody: "hello", + wantErr: "not found in layer", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + blob := tarWith(t, tc.tarName, tc.tarBody) + cd, err := extractComponentDescriptor(bytes.NewReader(blob)) + if tc.wantErr != "" { + g.Expect(err).To(MatchError(ContainSubstring(tc.wantErr))) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cd.Component.Resources).To(HaveLen(tc.wantResCount)) + }) + } + t.Run("errors on a non-tar blob", func(t *testing.T) { + // Not a table entry: uses a non-tar io.Reader, not a tarWith blob. + g := NewWithT(t) + _, err := extractComponentDescriptor(strings.NewReader("not a tar")) + g.Expect(err).To(HaveOccurred()) + }) +} - // 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() +func TestParseProviderVersions(t *testing.T) { + tests := []struct { + name string + raw string + provider string + wantCount int + wantExpiry bool // true if versions[1] must have a non-nil ExpirationDate + wantErr string + }{ + { + name: "selects the configured provider", + raw: testProvidersYAML, + provider: "converged-cloud", + wantCount: 3, + wantExpiry: true, + }, + { + name: "errors for an unknown provider", + raw: testProvidersYAML, + provider: "gcp", + wantErr: "not found", + }, + { + name: "errors when the provider has no versions", + raw: "providers:\n- name: empty\n versions: []\n", + provider: "empty", + wantErr: "no versions", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + versions, err := parseProviderVersions([]byte(tc.raw), tc.provider) + if tc.wantErr != "" { + g.Expect(err).To(MatchError(ContainSubstring(tc.wantErr))) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(versions).To(HaveLen(tc.wantCount)) + if tc.wantExpiry { + g.Expect(versions[1].ExpirationDate).ToNot(BeNil()) //nolint:staticcheck + } + }) + } +} - fileURL, err := contentsURL(githubSrv.URL, "org/repo", "kubernetes/versions.yaml") - if err != nil { - t.Fatalf("contentsURL: %v", err) +// TestFetchKubernetesVersionsBlob tests fetchKubernetesVersionsBlob directly. +func TestFetchKubernetesVersionsBlob(t *testing.T) { + addr := freePort(t) + stop := startRegistry(t, addr) + defer stop() + + tests := []struct { + name string + setup func(t *testing.T) // pushes the artifact to addr + repo string + wantStr string // non-empty: raw bytes must contain this string + wantErr string // non-empty: error must contain this string + }{ + { + name: "returns versions YAML from localBlob", + setup: func(t *testing.T) { + pushArtifactWithVersionsBlob(t, addr, "blob-ok", "v1.0.0", testProvidersYAML) + }, + repo: "blob-ok", + wantStr: "converged-cloud", + }, + { + name: "errors when kubernetes-version-data resource is absent", + setup: func(t *testing.T) { + pushComponentDescriptorArtifact(t, addr, "blob-missing", "v1.0.0", ` +component: + name: landscape-setup + resources: + - name: kube-apiserver + version: 1.31.4 +`) + }, + repo: "blob-missing", + wantErr: kubernetesVersionDataResourceName, + }, + { + name: "errors when access type is not localBlob/v1", + setup: func(t *testing.T) { + pushComponentDescriptorArtifact(t, addr, "blob-wrongtype", "v1.0.0", ` +component: + name: landscape-setup + resources: + - name: kubernetes-version-data + version: v1.0.0 + access: + type: ociBlob/v1 + localReference: sha256:abc123 +`) + }, + repo: "blob-wrongtype", + wantErr: "unexpected access type", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + tc.setup(t) + ociRepo, err := remote.NewRepository(addr + "/" + tc.repo) + g.Expect(err).ToNot(HaveOccurred()) + ociRepo.PlainHTTP = true + + src := &LandscapeKubernetesSource{ociRepo: ociRepo, provider: "converged-cloud"} + raw, err := src.fetchKubernetesVersionsBlob(context.Background(), "v1.0.0") + if tc.wantErr != "" { + g.Expect(err).To(MatchError(ContainSubstring(tc.wantErr))) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(raw)).To(ContainSubstring(tc.wantStr)) + }) } +} + +// TestFetchVersions_EndToEnd exercises the full FetchVersions code path using +// a real in-process OCI registry. The component descriptor and the +// kubernetes-version-data localBlob are both served from the same registry. +// It verifies that only versions present in both the kube-apiserver resources +// and the classifications blob 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; versions blob has 1.33.0 too. + // 1.33.0 must be filtered out because it is absent from kube-apiserver resources. + pushArtifactWithVersionsBlob(t, addr, "k8s-versions", "v1.2.3", testProvidersYAML) ociRepo, err := remote.NewRepository(addr + "/k8s-versions") if err != nil { @@ -422,12 +433,7 @@ component: } ociRepo.PlainHTTP = true - src := &LandscapeKubernetesSource{ - ociRepo: ociRepo, - githubClient: &http.Client{}, - fileURL: fileURL, - provider: "converged-cloud", - } + src := &LandscapeKubernetesSource{ociRepo: ociRepo, provider: "converged-cloud"} versions, err := src.FetchVersions(context.Background()) if err != nil { @@ -447,7 +453,7 @@ component: } } if got["1.33.0"] { - t.Error("1.33.0 should not be in result (absent from OCI component descriptor)") + t.Error("1.33.0 should not be in result (absent from kube-apiserver resources)") } } @@ -458,8 +464,7 @@ func TestFetchVersions_NoSemverTags(t *testing.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) + pushComponentDescriptorArtifact(t, addr, "k8s-nosemver", "not-a-version", "component:\n name: x\n resources: []\n") ociRepo, err := remote.NewRepository(addr + "/k8s-nosemver") if err != nil { @@ -467,12 +472,7 @@ func TestFetchVersions_NoSemverTags(t *testing.T) { } ociRepo.PlainHTTP = true - src := &LandscapeKubernetesSource{ - ociRepo: ociRepo, - githubClient: &http.Client{}, - fileURL: "http://unused", - provider: "converged-cloud", - } + src := &LandscapeKubernetesSource{ociRepo: ociRepo, 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) @@ -487,25 +487,58 @@ func TestFetchVersions_NoKubeAPIServerResources(t *testing.T) { stop := startRegistry(t, addr) defer stop() - noAPIServerDescriptor := ` + // Build an artifact where the versions blob is present but there are no + // kube-apiserver resources — the intersection must be empty. + versionsBlobBytes := []byte(testProvidersYAML) + h := sha256.Sum256(versionsBlobBytes) + versionsDigest := fmt.Sprintf("sha256:%x", h) + versionsSize := len(versionsBlobBytes) + descriptorYAML := fmt.Sprintf(` 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() + - name: kubernetes-version-data + version: v1.0.0 + access: + type: localBlob/v1 + localReference: %s + mediaType: application/vnd.gardener.cloud/kubernetes-versions+yaml + size: %d +`, versionsDigest, versionsSize) - fileURL, err := contentsURL(githubSrv.URL, "org/repo", "kubernetes/versions.yaml") + ctx := context.Background() + repo2, err := remote.NewRepository(addr + "/k8s-noapiserver") + if err != nil { + t.Fatalf("new repo: %v", err) + } + repo2.PlainHTTP = true + vDesc := content.NewDescriptorFromBytes("application/vnd.gardener.cloud/kubernetes-versions+yaml", versionsBlobBytes) + if err := repo2.Push(ctx, vDesc, bytes.NewReader(versionsBlobBytes)); err != nil { + t.Fatalf("push versions blob: %v", err) + } + layerBytes := tarWith(t, componentDescriptorFile, descriptorYAML) + layerDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, layerBytes) + if err := repo2.Push(ctx, layerDesc, bytes.NewReader(layerBytes)); err != nil { + t.Fatalf("push layer: %v", err) + } + if err := repo2.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")); err != nil { + t.Fatalf("push config: %v", err) + } + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.DescriptorEmptyJSON, + Layers: []ocispec.Descriptor{layerDesc, vDesc}, + } + manifestBytes, err := json.Marshal(manifest) if err != nil { - t.Fatalf("contentsURL: %v", err) + t.Fatalf("marshal manifest: %v", err) + } + manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifestBytes) + if err := repo2.PushReference(ctx, manifestDesc, bytes.NewReader(manifestBytes), "v1.0.0"); err != nil { + t.Fatalf("push manifest: %v", err) } ociRepo, err := remote.NewRepository(addr + "/k8s-noapiserver") @@ -514,12 +547,7 @@ component: } ociRepo.PlainHTTP = true - src := &LandscapeKubernetesSource{ - ociRepo: ociRepo, - githubClient: &http.Client{}, - fileURL: fileURL, - provider: "converged-cloud", - } + src := &LandscapeKubernetesSource{ociRepo: ociRepo, provider: "converged-cloud"} versions, err := src.FetchVersions(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index c957f0c..2ba2194 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "net/http" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" @@ -48,9 +47,9 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, errs = append(errs, updateErr) } } - if mcp.Spec.KubernetesVersionUpdateConfig != nil { + if mcp.Spec.KubernetesUpdate != nil { log.Info("updating kubernetes versions", "cloudProfile", cloudProfile.Name) - if updateErr := r.updateKubernetesVersions(ctx, *mcp.Spec.KubernetesVersionUpdateConfig, &cloudProfile.Spec); updateErr != nil { + if updateErr := r.updateKubernetesVersions(ctx, *mcp.Spec.KubernetesUpdate, &cloudProfile.Spec); updateErr != nil { errs = append(errs, updateErr) } } @@ -188,41 +187,9 @@ func (r *Reconciler) landscapeSetupSource(ctx context.Context, ls v1alpha1.Lands 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) + landscapeSource, err := landscape.NewLandscapeKubernetesSource(ociParams, ls.Provider) if err != nil { return nil, fmt.Errorf("initializing landscape source: %w", err) } - return landscapeSource, nil } diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index a067c32..6a3d904 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -601,9 +601,9 @@ 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. + kubernetesUpdate: + description: KubernetesUpdate contains the source and provider information + to automate Kubernetes version updates. properties: expirationThreshold: description: |- @@ -612,95 +612,9 @@ spec: removed from the CloudProfile. type: string landscapeSetup: - description: LandscapeSetup contains the required OCI and GitHub - sources for Kubernetes versions. + description: LandscapeSetup contains the OCI source configuration + 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. @@ -740,9 +654,13 @@ spec: - registry - repository type: object + provider: + description: Provider is the provider name to select from + the kubernetes-version-data blob, + type: string required: - - github - oci + - provider type: object type: object machineImageUpdates: diff --git a/go.mod b/go.mod index d101a65..3396d5d 100644 --- a/go.mod +++ b/go.mod @@ -10,10 +10,10 @@ require ( github.com/ironcore-dev/gardener-extension-provider-ironcore-metal v0.1.1-0.20260624151759-9166baa81e86 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 + github.com/opencontainers/go-digest v1.0.0 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 @@ -74,7 +74,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -116,6 +115,7 @@ require ( 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