diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index b52ef12..5faa11d 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -9,16 +9,20 @@ import ( "crypto/x509" "encoding/json" "fmt" + "log/slog" "net/http" "os" "strings" "time" + "github.com/cloudoperators/greenhouse/api/v1alpha1" "github.com/spf13/cobra" "github.com/spf13/viper" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/rest" clientcmd "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/cloudoperators/cloudctl/cmd/output" ) @@ -26,19 +30,26 @@ import ( var clusterVersionCmd = &cobra.Command{ Use: "cluster-version", Short: "Print the Kubernetes server version for a kubeconfig context", - Long: `Queries the Kubernetes API server version for the given kubeconfig context. + Long: `Queries the Kubernetes server version for the given kubeconfig context. -An unauthenticated GET to /version is attempted first (faster, no token -refresh required). If the server requires authentication, cloudctl falls -back to an authenticated GET to /version using the kubeconfig credentials. +When Greenhouse connection flags are provided (--greenhouse-cluster-namespace and +--greenhouse-cluster-name), the version is read from the greenhouse.sap/kubernetes-version +label on the ClusterKubeconfig resource — faster and resilient to remote API downtime. + +If the label is absent or Greenhouse flags are not provided, cloudctl falls back to +querying the remote cluster directly: an unauthenticated GET to /version is attempted +first; if the server requires authentication, an authenticated GET is used instead. If the API server is unreachable the command exits after --timeout (default 10s). Examples: - # Version of the current context + # Version of the current context (live query) cloudctl cluster-version - # Version of a specific context + # Version from Greenhouse label (preferred when syncing via cloudctl) + cloudctl cluster-version -n my-org --greenhouse-cluster-name prod-eu + + # Version of a specific context with live query cloudctl cluster-version --context prod-eu # Machine-readable output @@ -52,6 +63,11 @@ Examples: var ( kubeconfig string kubecontext string + + cvGreenhouseKubeconfig string + cvGreenhouseContext string + cvGreenhouseNamespace string + cvGreenhouseClusterName string ) func runClusterVersion(cmd *cobra.Command, args []string) error { @@ -63,6 +79,19 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { return fmt.Errorf("--kubeconfig must not be empty") } + // Read Greenhouse flags from the cv-* viper keys, which are bound only to + // cluster-version's flags (not sync.go's), so env vars and config files + // work without colliding with sync's identically-named viper bindings. + // Using a hyphen separator (cv-*) rather than a dot ensures the global + // SetEnvKeyReplacer("-","_") maps CLOUDCTL_CV_GREENHOUSE_* correctly. + cvGreenhouseKubeconfig = resolveKubeconfig("cv-greenhouse-cluster-kubeconfig", viper.GetString("cv-greenhouse-cluster-kubeconfig")) + if viper.IsSet("cv-greenhouse-cluster-kubeconfig") && cvGreenhouseKubeconfig == "" { + return fmt.Errorf("--greenhouse-cluster-kubeconfig must not be empty") + } + cvGreenhouseContext = viper.GetString("cv-greenhouse-cluster-context") + cvGreenhouseNamespace = viper.GetString("cv-greenhouse-cluster-namespace") + cvGreenhouseClusterName = viper.GetString("cv-greenhouse-cluster-name") + timeoutStr := viper.GetString("timeout") timeout, err := time.ParseDuration(timeoutStr) if err != nil { @@ -109,31 +138,94 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(cmd.Context(), timeout) defer cancel() - // 1) Try unauthenticated GET /version - ver, err := getUnauthenticatedVersion(ctx, cfg) - if err != nil { - // 2) Fallback to authenticated - if !hasAuth(cfg) { - stopQuery() - return fmt.Errorf("no authentication methods found in your kubeconfig. Please authenticate (`kubelogin`, etc.) and try again") + var clusterVersion string + + // 1) Try reading version from the ClusterKubeconfig label on Greenhouse. + // Use half the total timeout so the live-query fallback always has a + // meaningful deadline even if the Greenhouse cluster is slow to respond. + if cvGreenhouseNamespace != "" && cvGreenhouseClusterName != "" { + labelCtx, labelCancel := context.WithTimeout(cmd.Context(), timeout/2) + labelVer, labelErr := clusterVersionLabelLookup(labelCtx, cvGreenhouseKubeconfig, cvGreenhouseContext, cvGreenhouseNamespace, cvGreenhouseClusterName) + labelCancel() + if labelErr != nil { + slog.Debug("label-based version lookup failed, falling back to live query", "error", labelErr) + } else if labelVer != "" { + clusterVersion = normalizeVersion(labelVer) } + } - ver, err = getAuthenticatedVersion(ctx, cfg) + if clusterVersion == "" { + // 2) Try unauthenticated GET /version + var ver *version.Info + ver, err = getUnauthenticatedVersion(ctx, cfg) if err != nil { - stopQuery() - return fmt.Errorf("authenticated version fetch failed: %w", err) + // 3) Fallback to authenticated + if !hasAuth(cfg) { + stopQuery() + return fmt.Errorf("no authentication methods found in your kubeconfig. Please authenticate (`kubelogin`, etc.) and try again") + } + + ver, err = getAuthenticatedVersion(ctx, cfg) + if err != nil { + stopQuery() + return fmt.Errorf("authenticated version fetch failed: %w", err) + } } + + // Strip build metadata so we get a clean semver string (e.g. "1.29.3"). + clusterVersion = normalizeVersion(ver.GitVersion) } + stopQuery() + return printer.Print(output.ClusterVersionResult{Context: effectiveContext, Version: clusterVersion}) +} - // Strip build metadata so we get a clean semver string (e.g. "1.29.3"). - parts := strings.Split(ver.GitVersion, "-") - clean := parts[0] - parts = strings.Split(clean, "+") - clean = parts[0] - clusterVersion := strings.TrimPrefix(clean, "v") +// clusterVersionLabelLookup is the function used to fetch the version label from +// Greenhouse. It is a variable so tests can substitute a fake implementation. +var clusterVersionLabelLookup = getVersionFromLabel + +// normalizeVersion strips a leading "v", prerelease suffix, and build metadata +// from a Kubernetes version string, returning a clean semver (e.g. "1.29.3"). +func normalizeVersion(v string) string { + v = strings.TrimPrefix(v, "v") + v = strings.Split(v, "-")[0] + v = strings.Split(v, "+")[0] + return v +} - return printer.Print(output.ClusterVersionResult{Context: effectiveContext, Version: clusterVersion}) +// getVersionFromLabel reads the greenhouse.sap/kubernetes-version label from the +// named ClusterKubeconfig resource. Returns ("", nil) when the resource has no +// such label or when the resource is not found, so callers can fall through to +// a live query. +func getVersionFromLabel(ctx context.Context, greenhouseKubeconfig, greenhouseContext, namespace, clusterName string) (string, error) { + cfg, err := configWithContext(greenhouseContext, greenhouseKubeconfig) + if err != nil { + return "", fmt.Errorf("failed to build greenhouse kubeconfig: %w", err) + } + + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + return "", fmt.Errorf("failed to add greenhouse scheme: %w", err) + } + + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + return "", fmt.Errorf("failed to create greenhouse client: %w", err) + } + + return versionLabelFromClient(ctx, c, namespace, clusterName) +} + +// versionLabelFromClient fetches the greenhouse.sap/kubernetes-version label +// using an already-constructed client. Separated for testability. +// Returns ("", nil) only on not-found; other errors (RBAC, network, timeout) +// are propagated so the caller can log them and fall back to a live query. +func versionLabelFromClient(ctx context.Context, c client.Client, namespace, clusterName string) (string, error) { + var ckc v1alpha1.ClusterKubeconfig + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: clusterName}, &ckc); err != nil { + return "", client.IgnoreNotFound(err) + } + return ckc.Labels["greenhouse.sap/kubernetes-version"], nil } // hasAuth returns true if the rest.Config contains any credential source. @@ -251,8 +343,21 @@ func init() { clusterVersionCmd.Flags().StringVarP(&kubecontext, "context", "c", "", "Kubeconfig context to query (defaults to current context)") clusterVersionCmd.Flags().String("timeout", "10s", "Maximum time to wait for the API server to respond") - // BindPFlags can theoretically return an error if called with `nil` as an argument - // which should never happen after at least one flag was defined. That's why the output - // there is ignored. + clusterVersionCmd.Flags().StringVarP(&cvGreenhouseKubeconfig, "greenhouse-cluster-kubeconfig", "g", clientcmd.RecommendedHomeFile, "Path to the Greenhouse cluster kubeconfig (for label-based version lookup)") + clusterVersionCmd.Flags().StringVar(&cvGreenhouseContext, "greenhouse-cluster-context", "", "Context to use from the Greenhouse kubeconfig") + clusterVersionCmd.Flags().StringVarP(&cvGreenhouseNamespace, "greenhouse-cluster-namespace", "n", "", "Greenhouse organization namespace") + clusterVersionCmd.Flags().StringVar(&cvGreenhouseClusterName, "greenhouse-cluster-name", "", "ClusterKubeconfig resource name in Greenhouse to read the version label from") + + // Bind the shared flags (kubeconfig, context, timeout, output) to their standard viper keys. _ = viper.BindPFlags(clusterVersionCmd.Flags()) + + // Bind Greenhouse flags under a cv-* prefix so they do not collide with the + // identically-named flags registered by sync.go in the global viper instance. + // Using a hyphen separator (not dot) ensures SetEnvKeyReplacer("-","_") maps + // CLOUDCTL_CV_GREENHOUSE_CLUSTER_* env vars to these keys correctly. + f := clusterVersionCmd.Flags() + _ = viper.BindPFlag("cv-greenhouse-cluster-kubeconfig", f.Lookup("greenhouse-cluster-kubeconfig")) + _ = viper.BindPFlag("cv-greenhouse-cluster-context", f.Lookup("greenhouse-cluster-context")) + _ = viper.BindPFlag("cv-greenhouse-cluster-namespace", f.Lookup("greenhouse-cluster-namespace")) + _ = viper.BindPFlag("cv-greenhouse-cluster-name", f.Lookup("greenhouse-cluster-name")) } diff --git a/cmd/cluster-version_test.go b/cmd/cluster-version_test.go index 8dc76e6..a57fee3 100644 --- a/cmd/cluster-version_test.go +++ b/cmd/cluster-version_test.go @@ -7,15 +7,24 @@ import ( "context" "crypto/tls" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" + "strings" "testing" + "github.com/cloudoperators/greenhouse/api/v1alpha1" . "github.com/onsi/gomega" + "github.com/spf13/cobra" + "github.com/spf13/viper" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestHasAuth(t *testing.T) { @@ -140,3 +149,216 @@ func TestClusterVersionKubeconfigFlag_DefaultEqualsRecommendedHomeFile(t *testin g.Expect(f).ToNot(BeNil()) g.Expect(f.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) } + +func newGreenhouseFakeClient(objs ...v1alpha1.ClusterKubeconfig) *fake.ClientBuilder { + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + builder := fake.NewClientBuilder().WithScheme(scheme) + for i := range objs { + builder = builder.WithObjects(&objs[i]) + } + return builder +} + +func TestNormalizeVersion(t *testing.T) { + g := NewWithT(t) + + g.Expect(normalizeVersion("v1.29.3")).To(Equal("1.29.3")) + g.Expect(normalizeVersion("1.29.3")).To(Equal("1.29.3")) + g.Expect(normalizeVersion("v1.31.4+k3s1")).To(Equal("1.31.4")) + g.Expect(normalizeVersion("v1.29.3-eks-1234567")).To(Equal("1.29.3")) + g.Expect(normalizeVersion("v1.31.4-k3s1")).To(Equal("1.31.4")) +} + +func TestVersionLabelFromClient_LabelPresent(t *testing.T) { + g := NewWithT(t) + + ckc := v1alpha1.ClusterKubeconfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prod-eu", + Namespace: "my-org", + // Greenhouse controller stores values like "v1.29.3" or "v1.31.4-k3s1". + Labels: map[string]string{"greenhouse.sap/kubernetes-version": "v1.29.3"}, + }, + } + c := newGreenhouseFakeClient(ckc).Build() + + ver, err := versionLabelFromClient(context.Background(), c, "my-org", "prod-eu") + g.Expect(err).ToNot(HaveOccurred()) + // versionLabelFromClient returns the raw label; normalization is the caller's job. + g.Expect(ver).To(Equal("v1.29.3")) + g.Expect(normalizeVersion(ver)).To(Equal("1.29.3")) +} + +func TestVersionLabelFromClient_LabelAbsent(t *testing.T) { + g := NewWithT(t) + + ckc := v1alpha1.ClusterKubeconfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prod-eu", + Namespace: "my-org", + }, + } + c := newGreenhouseFakeClient(ckc).Build() + + ver, err := versionLabelFromClient(context.Background(), c, "my-org", "prod-eu") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ver).To(BeEmpty()) +} + +func TestVersionLabelFromClient_NotFound(t *testing.T) { + g := NewWithT(t) + + c := newGreenhouseFakeClient().Build() + + ver, err := versionLabelFromClient(context.Background(), c, "my-org", "missing-cluster") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ver).To(BeEmpty()) +} + +func TestVersionLabelFromClient_WrongNamespace(t *testing.T) { + g := NewWithT(t) + + ckc := v1alpha1.ClusterKubeconfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prod-eu", + Namespace: "other-org", + Labels: map[string]string{"greenhouse.sap/kubernetes-version": "1.30.0"}, + }, + } + c := newGreenhouseFakeClient(ckc).Build() + + // Looking up in the wrong namespace returns not-found, falls back gracefully. + ver, err := versionLabelFromClient(context.Background(), c, "my-org", "prod-eu") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ver).To(BeEmpty()) +} + +func TestClusterVersionGreenhouseFlags(t *testing.T) { + g := NewWithT(t) + + g.Expect(clusterVersionCmd.Flags().Lookup("greenhouse-cluster-kubeconfig")).ToNot(BeNil()) + g.Expect(clusterVersionCmd.Flags().Lookup("greenhouse-cluster-context")).ToNot(BeNil()) + g.Expect(clusterVersionCmd.Flags().Lookup("greenhouse-cluster-namespace")).ToNot(BeNil()) + g.Expect(clusterVersionCmd.Flags().Lookup("greenhouse-cluster-name")).ToNot(BeNil()) +} + +func TestGetVersionFromLabel_BadKubeconfig(t *testing.T) { + g := NewWithT(t) + + // A kubeconfig with invalid YAML should cause getVersionFromLabel to return an error. + f, err := os.CreateTemp("", "bad-kube-*.yaml") + g.Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(f.Name()) }() + _, _ = f.WriteString("invalid yaml: [") + g.Expect(f.Close()).To(Succeed()) + + ver, err := getVersionFromLabel(context.Background(), f.Name(), "", "my-org", "prod-eu") + g.Expect(err).To(HaveOccurred()) + g.Expect(ver).To(BeEmpty()) +} + +// writeTLSKubeconfig writes a minimal kubeconfig that points at srv and returns its path. +// It uses insecure-skip-tls-verify so the test server's self-signed cert is accepted. +func writeTLSKubeconfig(t *testing.T, srv *httptest.Server) string { + t.Helper() + g := NewWithT(t) + + f, err := os.CreateTemp("", "kubeconfig-*.yaml") + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(func() { _ = os.Remove(f.Name()) }) + + cfg := "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: " + srv.URL + "\n insecure-skip-tls-verify: true\n name: test\ncontexts:\n- context:\n cluster: test\n user: test\n name: test\ncurrent-context: test\nusers:\n- name: test\n user: {}\n" + _, err = f.WriteString(cfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(f.Close()).To(Succeed()) + return f.Name() +} + +// buildTestClusterVersionCmd returns a fresh cobra.Command wired to runClusterVersion +// with all flags registered, suitable for use in integration tests. +func buildTestClusterVersionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster-version", + SilenceUsage: true, + SilenceErrors: true, + RunE: runClusterVersion, + } + cmd.Flags().StringVarP(&kubeconfig, "kubeconfig", "k", clientcmd.RecommendedHomeFile, "") + cmd.Flags().StringVarP(&kubecontext, "context", "c", "", "") + cmd.Flags().String("timeout", "10s", "") + cmd.Flags().StringVarP(&cvGreenhouseKubeconfig, "greenhouse-cluster-kubeconfig", "g", clientcmd.RecommendedHomeFile, "") + cmd.Flags().StringVar(&cvGreenhouseContext, "greenhouse-cluster-context", "", "") + cmd.Flags().StringVarP(&cvGreenhouseNamespace, "greenhouse-cluster-namespace", "n", "", "") + cmd.Flags().StringVar(&cvGreenhouseClusterName, "greenhouse-cluster-name", "", "") + cmd.Flags().StringP("output", "o", "text", "") + _ = viper.BindPFlags(cmd.Flags()) + _ = viper.BindPFlag("cv-greenhouse-cluster-kubeconfig", cmd.Flags().Lookup("greenhouse-cluster-kubeconfig")) + _ = viper.BindPFlag("cv-greenhouse-cluster-context", cmd.Flags().Lookup("greenhouse-cluster-context")) + _ = viper.BindPFlag("cv-greenhouse-cluster-namespace", cmd.Flags().Lookup("greenhouse-cluster-namespace")) + _ = viper.BindPFlag("cv-greenhouse-cluster-name", cmd.Flags().Lookup("greenhouse-cluster-name")) + return cmd +} + +func TestRunClusterVersion_LabelPathShortCircuitsLiveQuery(t *testing.T) { + g := NewWithT(t) + + // Remote cluster server — must NOT be called when the label path succeeds. + liveCallCount := 0 + remoteSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + liveCallCount++ + t.Errorf("unexpected live /version call to remote cluster") + _ = json.NewEncoder(w).Encode(&version.Info{GitVersion: "v9.9.9"}) + })) + defer remoteSrv.Close() + + remoteKubeconfig := writeTLSKubeconfig(t, remoteSrv) + + // Inject a fake label lookup that returns a version without hitting any server. + original := clusterVersionLabelLookup + t.Cleanup(func() { clusterVersionLabelLookup = original }) + clusterVersionLabelLookup = func(_ context.Context, _, _, _, _ string) (string, error) { + return "v1.29.3", nil + } + + cmd := buildTestClusterVersionCmd() + cmd.SetArgs([]string{"--kubeconfig", remoteKubeconfig, "-n", "my-org", "--greenhouse-cluster-name", "prod-eu"}) + var out strings.Builder + cmd.SetOut(&out) + g.Expect(cmd.ExecuteContext(context.Background())).To(Succeed()) + g.Expect(out.String()).To(ContainSubstring("1.29.3")) + g.Expect(liveCallCount).To(Equal(0), "live /version should not have been called") +} + +func TestRunClusterVersion_LabelErrorFallsBackToLiveQuery(t *testing.T) { + g := NewWithT(t) + + // Remote cluster server — must be called as fallback when label lookup fails. + liveCallCount := 0 + remoteSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/version" { + liveCallCount++ + _ = json.NewEncoder(w).Encode(&version.Info{GitVersion: "v1.30.0"}) + } else { + http.NotFound(w, r) + } + })) + defer remoteSrv.Close() + + remoteKubeconfig := writeTLSKubeconfig(t, remoteSrv) + + // Inject a fake label lookup that always returns an error. + original := clusterVersionLabelLookup + t.Cleanup(func() { clusterVersionLabelLookup = original }) + clusterVersionLabelLookup = func(_ context.Context, _, _, _, _ string) (string, error) { + return "", fmt.Errorf("greenhouse unavailable") + } + + cmd := buildTestClusterVersionCmd() + cmd.SetArgs([]string{"--kubeconfig", remoteKubeconfig, "-n", "my-org", "--greenhouse-cluster-name", "prod-eu"}) + var out strings.Builder + cmd.SetOut(&out) + g.Expect(cmd.ExecuteContext(context.Background())).To(Succeed()) + g.Expect(out.String()).To(ContainSubstring("1.30.0")) + g.Expect(liveCallCount).To(BeNumerically(">=", 1), "live /version should have been called as fallback") +} diff --git a/go.mod b/go.mod index d24ac71..f7516a3 100644 --- a/go.mod +++ b/go.mod @@ -104,6 +104,7 @@ require ( golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/api v0.35.0 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect