Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 131 additions & 26 deletions cmd/cluster-version.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,47 @@ 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"
)

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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Comment thread
onuryilmaz marked this conversation as resolved.

// 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"))
}
Loading
Loading