Skip to content
Merged
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

- **Syncs kubeconfigs** — fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local `~/.kube/config`, handling OIDC token caching, deduplication, and prefix-based entry management
- **Reports cluster versions** — queries the Kubernetes API version of any context, trying unauthenticated first for speed
- **Self-updates** — checks for and installs the latest cloudctl release from GitHub
- **Structured output** — every command supports `--output text|json|yaml` for scripting and pipelines; interactive terminals get a spinner and a colour-coded table

## Installation
Expand Down Expand Up @@ -44,6 +45,9 @@ cloudctl cluster-version --context <context>
# Print version info
cloudctl version
cloudctl version --output json # machine-readable

# Update cloudctl to the latest release
cloudctl update
```

## Output formats
Expand Down Expand Up @@ -109,34 +113,39 @@ log-level: info

### `sync`

Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig.
Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. Before connecting, it logs a summary to stderr showing which kubeconfig files, context, and namespace are in use.

The `--greenhouse-cluster-kubeconfig` and `--remote-cluster-kubeconfig` flags support the standard `KUBECONFIG` environment variable: when no explicit path is given, cloudctl defers to `KUBECONFIG` (multi-file merge, same as `kubectl`).

```
cloudctl sync -n <org> [flags]

Flags:
-k, --greenhouse-cluster-kubeconfig Path to Greenhouse cluster kubeconfig (default: ~/.kube/config)
-k, --greenhouse-cluster-kubeconfig Path to Greenhouse cluster kubeconfig (default: $KUBECONFIG or ~/.kube/config)
-c, --greenhouse-cluster-context Context inside the Greenhouse kubeconfig
-n, --greenhouse-cluster-namespace Greenhouse organization namespace (required)
-r, --remote-cluster-kubeconfig Local kubeconfig to merge into (default: ~/.kube/config)
-r, --remote-cluster-kubeconfig Local kubeconfig to merge into (default: $KUBECONFIG or ~/.kube/config)
--remote-cluster-name Sync only this cluster (default: all ready clusters)
--prefix Prefix for managed kubeconfig entries (default: cloudctl)
--merge-identical-users Share a single auth entry for clusters with identical OIDC config (default: true)
--auth-type auth-provider or exec-plugin (default: exec-plugin)
--kubelogin-path Path to kubelogin binary (default: kubelogin)
--kubelogin-extra-args Extra flags passed to kubelogin
--kubelogin-token-cache-dir OIDC token cache directory
--dry-run Preview changes without writing to the kubeconfig file
```

### `cluster-version`

Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed.
Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. Logs a summary to stderr showing the kubeconfig source and context before querying.

Respects the `KUBECONFIG` environment variable when no explicit `--kubeconfig` path is given.

```
cloudctl cluster-version [flags]

Flags:
-k, --kubeconfig Path to kubeconfig file (default: ~/.kube/config)
-k, --kubeconfig Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config)
-c, --context Context to query
--timeout Maximum time to wait for the API server (default: 10s)
```
Expand All @@ -152,6 +161,17 @@ Flags:
--short Print only the version number
```

### `update`

Checks for the latest cloudctl release on GitHub and installs it, replacing the current binary.

```
cloudctl update [flags]

Flags:
--check Check for a newer version without installing it
```

## Support, Feedback, Contributing

This project is open to feature requests, bug reports, and contributions via [GitHub issues](https://github.com/cloudoperators/cloudctl/issues) and pull requests. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Expand Down
27 changes: 24 additions & 3 deletions cmd/cluster-version.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/x509"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
Expand Down Expand Up @@ -55,9 +56,14 @@ var (
)

func runClusterVersion(cmd *cobra.Command, args []string) error {
kubeconfig = viper.GetString("kubeconfig")
kubeconfig = resolveKubeconfig("kubeconfig", viper.GetString("kubeconfig"))
kubecontext = viper.GetString("context")

// Reject an explicit empty-string value.
if viper.IsSet("kubeconfig") && kubeconfig == "" {
return fmt.Errorf("--kubeconfig must not be empty")
}

timeoutStr := viper.GetString("timeout")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
Expand All @@ -66,20 +72,35 @@ func runClusterVersion(cmd *cobra.Command, args []string) error {

cfg, err := configWithContext(kubecontext, kubeconfig)
if err != nil {
return fmt.Errorf("failed to build kubeconfig with context %q: %w", kubecontext, err)
ctxDisplay := kubecontext
if ctxDisplay == "" {
ctxDisplay = "(current context)"
}
return fmt.Errorf("failed to build kubeconfig (source: %s, context: %s): %w", displayKubeconfig(kubeconfig), ctxDisplay, err)
}
Comment thread
onuryilmaz marked this conversation as resolved.

// Resolve the actual context name used so the output is never empty.
// When --context is not given, kubecontext is "" and we fall back to the
// current-context field from the kubeconfig.
effectiveContext := kubecontext
if effectiveContext == "" {
loadingRules := clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig}
var loadingRules *clientcmd.ClientConfigLoadingRules
if kubeconfig != "" {
loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig}
} else {
loadingRules = clientcmd.NewDefaultClientConfigLoadingRules()
}
raw, rawErr := loadingRules.Load()
if rawErr == nil && raw != nil {
effectiveContext = raw.CurrentContext
}
}
Comment thread
onuryilmaz marked this conversation as resolved.
if effectiveContext == "" {
effectiveContext = "(unknown)"
}

// Log informational line before querying the server.
slog.Info("querying cluster version", "kubeconfig", displayKubeconfig(kubeconfig), "context", effectiveContext)

ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
Expand Down
12 changes: 12 additions & 0 deletions cmd/cluster-version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
. "github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/version"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)

Expand Down Expand Up @@ -128,3 +129,14 @@ func TestGetAuthenticatedVersion_NonOKStatus(t *testing.T) {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("500"))
}

func TestClusterVersionKubeconfigFlag_DefaultEqualsRecommendedHomeFile(t *testing.T) {
g := NewWithT(t)

// Verify that the --kubeconfig flag default equals clientcmd.RecommendedHomeFile.
// resolveKubeconfig detects "user did not explicitly set a path" via viper.IsSet:
// when the flag is unset, viper returns the default and IsSet returns false.
f := clusterVersionCmd.Flags().Lookup("kubeconfig")
g.Expect(f).ToNot(BeNil())
g.Expect(f.DefValue).To(Equal(clientcmd.RecommendedHomeFile))
}
37 changes: 35 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,43 @@ func init() {
rootCmd.AddCommand(updateCmd)
}

// resolveKubeconfig returns the kubeconfig path to use.
// viperKey is the viper key for the flag (e.g. "kubeconfig", "greenhouse-cluster-kubeconfig").
// When the key was not explicitly set by the user (via flag, CLOUDCTL_* env var, or config file)
// and the standard KUBECONFIG env var is set, it returns "" so that client-go uses its standard
// multi-file loading rules. An explicitly provided value is always returned as-is.
func resolveKubeconfig(viperKey, flagValue string) string {
if viper.IsSet(viperKey) {
return flagValue
}
if os.Getenv("KUBECONFIG") != "" {
return ""
}
return flagValue
}

// displayKubeconfig returns a human-readable label for the effective kubeconfig source.
// When path is "" the KUBECONFIG env var is active; otherwise the explicit path is shown.
// If path is "" and KUBECONFIG is also unset, client-go's default (~/.kube/config) is used.
func displayKubeconfig(path string) string {
if path == "" {
if kc := os.Getenv("KUBECONFIG"); kc != "" {
return "$KUBECONFIG (" + kc + ")"
}
return clientcmd.RecommendedHomeFile
}
return path
}
Comment thread
onuryilmaz marked this conversation as resolved.

// configWithContext builds a rest.Config for the specified context name from the given kubeconfig path.
// When kubeconfigPath is empty, client-go's default loading rules are used (reads KUBECONFIG env var
// and falls back to ~/.kube/config).
func configWithContext(contextName, kubeconfigPath string) (*rest.Config, error) {
loadingRules := &clientcmd.ClientConfigLoadingRules{
ExplicitPath: kubeconfigPath,
var loadingRules *clientcmd.ClientConfigLoadingRules
if kubeconfigPath != "" {
loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}
} else {
loadingRules = clientcmd.NewDefaultClientConfigLoadingRules()
}
overrides := &clientcmd.ConfigOverrides{
CurrentContext: contextName,
Expand Down
64 changes: 64 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"github.com/spf13/viper"
"k8s.io/client-go/tools/clientcmd"

. "github.com/onsi/gomega"
)
Expand Down Expand Up @@ -88,3 +89,66 @@ func TestMissingConfigurationFile(t *testing.T) {
err := setupConfig()
g.Expect(err).NotTo(BeNil())
}

func TestResolveKubeconfig_ExplicitlySet(t *testing.T) {
g := NewWithT(t)

t.Setenv("KUBECONFIG", "/some/other/config")
t.Cleanup(func() { viper.Reset() })

// When the viper key is explicitly set (simulating flag/CLOUDCTL_* env var),
// the provided value must be returned as-is regardless of KUBECONFIG.
viper.Set("kubeconfig", "/my/explicit/path")
result := resolveKubeconfig("kubeconfig", "/my/explicit/path")
g.Expect(result).To(Equal("/my/explicit/path"))
}

func TestResolveKubeconfig_NotSetWithKUBECONFIG(t *testing.T) {
g := NewWithT(t)

t.Setenv("KUBECONFIG", "/env/kubeconfig")
t.Cleanup(func() { viper.Reset() })

// When the key was not explicitly set and KUBECONFIG is set, return "" to let client-go handle it.
result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile)
g.Expect(result).To(BeEmpty())
}

func TestResolveKubeconfig_NotSetWithoutKUBECONFIG(t *testing.T) {
g := NewWithT(t)

t.Setenv("KUBECONFIG", "")
t.Cleanup(func() { viper.Reset() })

// When KUBECONFIG is not set either, return the default path as-is.
result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile)
g.Expect(result).To(Equal(clientcmd.RecommendedHomeFile))
}
Comment thread
onuryilmaz marked this conversation as resolved.

func TestResolveKubeconfig_ViperIsSetFalseForUnparsedCobraFlag(t *testing.T) {
g := NewWithT(t)

t.Setenv("KUBECONFIG", "/env/kubeconfig")
t.Cleanup(func() { viper.Reset() })

// Bind clusterVersionCmd's flags to a fresh viper instance and parse
// with no arguments so the --kubeconfig flag is left at its default.
// viper.IsSet must remain false, meaning resolveKubeconfig defers to
// KUBECONFIG (returns "").
//
// This guards against a regression where Cobra/pflag/viper semantics
// change such that binding a flag with a default causes IsSet to return
// true even when the user never touched the flag.
localViper := viper.New()
g.Expect(localViper.BindPFlags(clusterVersionCmd.Flags())).To(Succeed())
g.Expect(clusterVersionCmd.ParseFlags([]string{})).To(Succeed())

g.Expect(localViper.IsSet("kubeconfig")).To(BeFalse(),
"viper.IsSet must be false for a flag that was not explicitly set by the user")

// Confirm resolveKubeconfig returns "" (defer to KUBECONFIG) in this state.
// We use the global viper (which is what resolveKubeconfig reads) — it
// has not been set, so IsSet is also false there.
result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile)
g.Expect(result).To(BeEmpty())
}
Loading
Loading