Skip to content
Open
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
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:
--dry-run 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
23 changes: 20 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,31 @@ 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)
return fmt.Errorf("failed to build kubeconfig (source: %s, context: %q): %w", displayKubeconfig(kubeconfig), kubecontext, err)
}

// 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
36 changes: 36 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,38 @@ 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))
}
69 changes: 49 additions & 20 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,19 @@ Examples:

func runSync(cmd *cobra.Command, args []string) error {
// Use viper as a source of configuration
greenhouseClusterKubeconfig = viper.GetString("greenhouse-cluster-kubeconfig")
greenhouseClusterKubeconfig = resolveKubeconfig("greenhouse-cluster-kubeconfig", viper.GetString("greenhouse-cluster-kubeconfig"))
greenhouseClusterContext = viper.GetString("greenhouse-cluster-context")
greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace")
remoteClusterKubeconfig = viper.GetString("remote-cluster-kubeconfig")
remoteClusterKubeconfig = resolveKubeconfig("remote-cluster-kubeconfig", viper.GetString("remote-cluster-kubeconfig"))
Comment thread
onuryilmaz marked this conversation as resolved.

// Reject an explicit empty-string value — it would silently fall through to
// client-go default loading rules instead of failing fast.
if viper.IsSet("greenhouse-cluster-kubeconfig") && greenhouseClusterKubeconfig == "" {
return fmt.Errorf("--greenhouse-cluster-kubeconfig must not be empty")
}
if viper.IsSet("remote-cluster-kubeconfig") && remoteClusterKubeconfig == "" {
return fmt.Errorf("--remote-cluster-kubeconfig must not be empty")
}
remoteClusterName = viper.GetString("remote-cluster-name")
prefix = viper.GetString("prefix")
mergeIdenticalUsers = viper.GetBool("merge-identical-users")
Expand All @@ -134,31 +143,35 @@ func runSync(cmd *cobra.Command, args []string) error {
w := cmd.OutOrStdout()
printer := output.New(format, output.IsTTYWriter(w), w)

if greenhouseClusterKubeconfig == "" {
return fmt.Errorf("greenhouse cluster kubeconfig path is empty")
}

if _, err := os.Stat(greenhouseClusterKubeconfig); err != nil {
return fmt.Errorf("greenhouse cluster kubeconfig file not found at %q: %w", greenhouseClusterKubeconfig, err)
// When path is not empty (explicit file), verify it exists before proceeding.
if greenhouseClusterKubeconfig != "" {
if _, err := os.Stat(greenhouseClusterKubeconfig); err != nil {
return fmt.Errorf("greenhouse cluster kubeconfig file not found at %q: %w", greenhouseClusterKubeconfig, err)
}
}

if err := validateAuthType(authType, kubeloginPath); err != nil {
return err
}

// Log informational summary so the user knows which files/context/namespace are active.
ctxLabel := greenhouseClusterContext
if ctxLabel == "" {
ctxLabel = "(current context)"
}
slog.Info("syncing kubeconfigs",
Comment thread
onuryilmaz marked this conversation as resolved.
"greenhouse", displayKubeconfig(greenhouseClusterKubeconfig),
"context", ctxLabel,
"namespace", greenhouseClusterNamespace,
"local", displayKubeconfig(remoteClusterKubeconfig),
)

var (
centralConfig *rest.Config
)
if greenhouseClusterContext != "" {
centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig)
if err != nil {
return fmt.Errorf("failed to build greenhouse kubeconfig with context %q from %q: %w", greenhouseClusterContext, greenhouseClusterKubeconfig, err)
}
} else {
centralConfig, err = clientcmd.BuildConfigFromFlags("", greenhouseClusterKubeconfig)
if err != nil {
return fmt.Errorf("failed to build greenhouse kubeconfig from %q: %w", greenhouseClusterKubeconfig, err)
}
centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig)
if err != nil {
return fmt.Errorf("failed to build greenhouse kubeconfig (source: %s, context: %s): %w", displayKubeconfig(greenhouseClusterKubeconfig), ctxLabel, err)
}
Comment thread
onuryilmaz marked this conversation as resolved.

// Create a scheme and register Greenhouse types.
Expand Down Expand Up @@ -203,7 +216,12 @@ func runSync(cmd *cobra.Command, args []string) error {
return printer.Print(buildSyncResult(nil, notReady))
}

localConfig, err := clientcmd.LoadFromFile(remoteClusterKubeconfig)
var localConfig *clientcmdapi.Config
if remoteClusterKubeconfig != "" {
localConfig, err = clientcmd.LoadFromFile(remoteClusterKubeconfig)
} else {
localConfig, err = clientcmd.NewDefaultClientConfigLoadingRules().Load()
}
if err != nil {
return fmt.Errorf("failed to load local kubeconfig: %w", err)
}
Expand Down Expand Up @@ -240,7 +258,18 @@ func runSync(cmd *cobra.Command, args []string) error {
return printer.Print(buildDryRunResult(diff, localConfigBefore, localConfig))
}

if writeErr := writeConfig(localConfig, remoteClusterKubeconfig); writeErr != nil {
writeTarget := remoteClusterKubeconfig
if writeTarget == "" {
// Multi-file KUBECONFIG: write to the first path (kubectl convention).
if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" {
writeTarget = parts[0]
}
if writeTarget == "" {
return fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path")
}
Comment thread
onuryilmaz marked this conversation as resolved.
}

if writeErr := writeConfig(localConfig, writeTarget); writeErr != nil {
_ = printer.Print(buildFailedSyncResult(ready, notReady, writeErr))
return fmt.Errorf("failed to write merged kubeconfig: %w", writeErr)
}
Expand Down
Loading
Loading