diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go new file mode 100644 index 0000000..8c4972a --- /dev/null +++ b/cmd/bootstrap.go @@ -0,0 +1,539 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/cloudoperators/cloudctl/cmd/output" +) + +var ( + bootstrapData string + bootstrapServer string + bootstrapOrg string + bootstrapCAData string + bootstrapIDPIssuerURL string + bootstrapClientID string + bootstrapClientSecret string + bootstrapExtraScopes string + bootstrapNamespace string + bootstrapKubeconfig string + bootstrapContextName string + bootstrapSetCurrentCtx bool + bootstrapDryRun bool +) + +func init() { + bootstrapCmd.Flags().StringVar(&bootstrapData, "data", "", "Base64-encoded kubeconfig (as downloaded from the Greenhouse Web UI)") + bootstrapCmd.Flags().StringVar(&bootstrapServer, "greenhouse-server", "", "Greenhouse API server URL (e.g. https://greenhouse.example.com)") + bootstrapCmd.Flags().StringVar(&bootstrapOrg, "greenhouse-org", "", "Greenhouse organization name") + bootstrapCmd.Flags().StringVar(&bootstrapCAData, "greenhouse-ca-data", "", "Base64-encoded CA certificate data for the Greenhouse server") + bootstrapCmd.Flags().StringVar(&bootstrapIDPIssuerURL, "greenhouse-idp-issuer-url", "", "OIDC issuer URL (e.g. https://idp.example.com)") + bootstrapCmd.Flags().StringVar(&bootstrapClientID, "greenhouse-client-id", "", "OIDC client ID") + bootstrapCmd.Flags().StringVar(&bootstrapClientSecret, "greenhouse-client-secret", "", "OIDC client secret (optional)") + bootstrapCmd.Flags().StringVar(&bootstrapExtraScopes, "greenhouse-extra-scopes", "", "Comma-separated extra OIDC scopes (optional, e.g. groups)") + bootstrapCmd.Flags().StringVar(&bootstrapNamespace, "greenhouse-namespace", "", "Kubernetes namespace to set in the context (defaults to --greenhouse-org)") + bootstrapCmd.Flags().StringVar(&bootstrapKubeconfig, "kubeconfig", clientcmd.RecommendedHomeFile, "Path to the local kubeconfig file to merge into") + bootstrapCmd.Flags().StringVar(&bootstrapContextName, "context-name", "", "Context name to use in the local kubeconfig (default: from the downloaded kubeconfig or greenhouse-)") + bootstrapCmd.Flags().BoolVar(&bootstrapSetCurrentCtx, "set-current-context", false, "Set the bootstrapped context as the current context") + bootstrapCmd.Flags().BoolVar(&bootstrapDryRun, "dry-run", false, "Preview what would be written without touching the kubeconfig file") + + _ = viper.BindPFlags(bootstrapCmd.Flags()) + + rootCmd.AddCommand(bootstrapCmd) +} + +var bootstrapCmd = &cobra.Command{ + Use: "bootstrap", + Short: "Bootstrap first-time access to a Greenhouse cluster", + Long: `Merges a Greenhouse kubeconfig into your local kubeconfig so you can +reach the Greenhouse API server with kubectl or cloudctl. + +Two input modes are supported: + + Mode 1 — kubeconfig blob from the Greenhouse Web UI (recommended): + cloudctl bootstrap --data= + + The --data value is a standard kubeconfig file base64-encoded. + You can generate it yourself with: + base64 < ~/Downloads/greenhouse-my-org.kubeconfig + + Mode 2 — individual flags (OIDC, scriptable): + cloudctl bootstrap \ + --greenhouse-server=https://greenhouse.example.com \ + --greenhouse-org=my-org \ + --greenhouse-idp-issuer-url=https://idp.example.com \ + --greenhouse-client-id=greenhouse \ + --greenhouse-extra-scopes=groups \ + --greenhouse-ca-data= + +Both modes ask interactively for the context name and whether to set it as +the current context, unless --context-name and --set-current-context are given. +Running twice with the same input is safe (idempotent). + +Examples: + # Bootstrap from a kubeconfig downloaded from the Web UI + cloudctl bootstrap --data=$(base64 < greenhouse-my-org.kubeconfig) + + # Bootstrap from individual flags (non-interactive) + cloudctl bootstrap \ + --greenhouse-server=https://greenhouse.example.com \ + --greenhouse-org=my-org \ + --greenhouse-idp-issuer-url=https://idp.example.com \ + --greenhouse-client-id=greenhouse \ + --greenhouse-extra-scopes=groups \ + --context-name=greenhouse-my-org \ + --set-current-context + + # Preview what would change without writing + cloudctl bootstrap --data= --dry-run`, + RunE: runBootstrap, +} + +func runBootstrap(cmd *cobra.Command, args []string) error { + bootstrapData = viper.GetString("data") + bootstrapServer = viper.GetString("greenhouse-server") + bootstrapOrg = viper.GetString("greenhouse-org") + bootstrapCAData = viper.GetString("greenhouse-ca-data") + bootstrapIDPIssuerURL = viper.GetString("greenhouse-idp-issuer-url") + bootstrapClientID = viper.GetString("greenhouse-client-id") + bootstrapClientSecret = viper.GetString("greenhouse-client-secret") + bootstrapExtraScopes = viper.GetString("greenhouse-extra-scopes") + bootstrapNamespace = viper.GetString("greenhouse-namespace") + // Resolve the write target: prefer an explicitly-provided value from any + // configuration source (flag, CLOUDCTL_KUBECONFIG env var, or config file) + // before falling back to KUBECONFIG / home default. + // viper.IsSet covers env-var and config-file sources; cmd.Flags().Changed + // covers the --kubeconfig flag (Viper's pflag binding only works when + // BindPFlags runs after flag parsing, which is not guaranteed here). + if cmd.Flags().Changed("kubeconfig") { + bootstrapKubeconfig, _ = cmd.Flags().GetString("kubeconfig") + } else if viper.IsSet("kubeconfig") { + bootstrapKubeconfig = viper.GetString("kubeconfig") + } else if kc := os.Getenv("KUBECONFIG"); kc != "" { + if parts := strings.SplitN(kc, string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { + bootstrapKubeconfig = parts[0] + } else { + return fmt.Errorf("cannot determine write target: KUBECONFIG=%q contains no usable first path", kc) + } + } else { + bootstrapKubeconfig = clientcmd.RecommendedHomeFile + } + bootstrapContextName = viper.GetString("context-name") + bootstrapSetCurrentCtx = viper.GetBool("set-current-context") + // Read dry-run from the flag first to avoid viper cross-command pollution + // (sync also binds "dry-run" to the global viper instance). Fall back to + // viper.IsSet to honour CLOUDCTL_DRY_RUN and config-file values. + if cmd.Flags().Changed("dry-run") { + bootstrapDryRun, _ = cmd.Flags().GetBool("dry-run") + } else if viper.IsSet("dry-run") { + bootstrapDryRun = viper.GetBool("dry-run") + } else { + bootstrapDryRun = false + } + + format, err := output.ParseFormat(viper.GetString("output")) + if err != nil { + return err + } + w := cmd.OutOrStdout() + printer := output.New(format, output.IsTTYWriter(w), w) + + incoming, org, err := resolveIncomingKubeconfig() + if err != nil { + return err + } + + // Determine default context name from the incoming config or the org. + defaultCtxName := incoming.CurrentContext + if defaultCtxName == "" && org != "" { + defaultCtxName = fmt.Sprintf("greenhouse-%s", org) + } + if defaultCtxName == "" && len(incoming.Contexts) == 1 { + for name := range incoming.Contexts { + defaultCtxName = name + } + } + if defaultCtxName == "" { + defaultCtxName = "greenhouse" + } + + // Capture org from the selected context's namespace before any rename, so + // the sync hint is correct even when --context-name differs from greenhouse-. + if org == "" { + if ctxEntry, ok := incoming.Contexts[defaultCtxName]; ok && ctxEntry.Namespace != "" { + org = ctxEntry.Namespace + } + } + + contextName := bootstrapContextName + if contextName == "" { + contextName = defaultCtxName + } + + // Prompt interactively for context name when running on a TTY and the flag wasn't set. + if !cmd.Flags().Changed("context-name") && !viper.IsSet("context-name") && output.IsTTYWriter(w) { + contextName, err = promptContextName(contextName) + if err != nil { + return err + } + } + + setCurrentCtx := bootstrapSetCurrentCtx + if !cmd.Flags().Changed("set-current-context") && !viper.IsSet("set-current-context") && output.IsTTYWriter(w) { + setCurrentCtx, err = promptYesNo(fmt.Sprintf("Set %q as the current context?", contextName), false) + if err != nil { + return err + } + } + + // Rename entries in the incoming config to use the chosen context name. + if err := renameKubeconfigContext(incoming, contextName); err != nil { + return err + } + + // Load the config that will be mutated and written back (first file only). + // When KUBECONFIG contains multiple files we also build a merged view used + // solely for collision detection — we never write the merged object back so + // unmanaged entries in other files are not copied into the first file. + var localConfig *clientcmdapi.Config + if _, statErr := os.Stat(bootstrapKubeconfig); statErr == nil { + localConfig, err = clientcmd.LoadFromFile(bootstrapKubeconfig) + if err != nil { + return fmt.Errorf("failed to load local kubeconfig %q: %w", bootstrapKubeconfig, err) + } + } + if localConfig == nil { + localConfig = clientcmdapi.NewConfig() + } + + // When kubeconfig was not explicitly set (via flag, env var, or config file), also load + // the merged view of all KUBECONFIG files so we can detect collisions with entries in other files. + mergedView := localConfig + if !cmd.Flags().Changed("kubeconfig") && !viper.IsSet("kubeconfig") { + mv, mvErr := clientcmd.NewDefaultClientConfigLoadingRules().Load() + if mvErr != nil { + return fmt.Errorf("failed to load kubeconfig: %w", mvErr) + } + mergedView = mv + } + + result, err := mergeBootstrapKubeconfig(localConfig, mergedView, incoming, contextName, setCurrentCtx, org) + if err != nil { + return err + } + + if bootstrapDryRun { + result.DryRun = true + return printer.Print(result) + } + + if err := writeConfig(localConfig, bootstrapKubeconfig); err != nil { + return fmt.Errorf("failed to write kubeconfig: %w", err) + } + + result.KubeconfigPath = bootstrapKubeconfig + return printer.Print(result) +} + +// resolveIncomingKubeconfig returns a clientcmdapi.Config from either the --data blob +// or the individual --greenhouse-* flags. It also returns the org name (may be empty +// when derived from a blob with no org context). +func resolveIncomingKubeconfig() (*clientcmdapi.Config, string, error) { + if bootstrapData != "" { + raw, err := base64.StdEncoding.DecodeString(bootstrapData) + if err != nil { + raw, err = base64.RawStdEncoding.DecodeString(bootstrapData) + if err != nil { + return nil, "", fmt.Errorf("--data is not valid base64: %w", err) + } + } + cfg, err := clientcmd.Load(raw) + if err != nil { + return nil, "", fmt.Errorf("--data does not contain a valid kubeconfig: %w", err) + } + if len(cfg.Clusters) == 0 { + return nil, "", fmt.Errorf("--data kubeconfig contains no clusters") + } + // Verify we can identify exactly which context to use. + if cfg.CurrentContext == "" && len(cfg.Contexts) != 1 { + return nil, "", fmt.Errorf("--data kubeconfig has no current-context and contains %d contexts; set one explicitly with kubectl config use-context", len(cfg.Contexts)) + } + activeCtx := cfg.CurrentContext + if activeCtx == "" { + for name := range cfg.Contexts { + activeCtx = name + } + } + if ctx, ok := cfg.Contexts[activeCtx]; !ok || ctx == nil { + return nil, "", fmt.Errorf("--data kubeconfig current-context %q not found in contexts", activeCtx) + } else if _, clOK := cfg.Clusters[ctx.Cluster]; !clOK { + return nil, "", fmt.Errorf("--data kubeconfig context %q references unknown cluster %q", activeCtx, ctx.Cluster) + } else if _, aiOK := cfg.AuthInfos[ctx.AuthInfo]; !aiOK { + return nil, "", fmt.Errorf("--data kubeconfig context %q references unknown user %q", activeCtx, ctx.AuthInfo) + } + return cfg, bootstrapOrg, nil + } + + // Individual flags path. + if bootstrapServer == "" { + return nil, "", fmt.Errorf("one of --data or --greenhouse-server is required") + } + if bootstrapOrg == "" { + return nil, "", fmt.Errorf("--greenhouse-org is required when not using --data") + } + if bootstrapIDPIssuerURL == "" { + return nil, "", fmt.Errorf("--greenhouse-idp-issuer-url is required when not using --data") + } + if bootstrapClientID == "" { + return nil, "", fmt.Errorf("--greenhouse-client-id is required when not using --data") + } + + cfg, err := buildOIDCKubeconfig(bootstrapServer, bootstrapOrg, bootstrapCAData, bootstrapIDPIssuerURL, bootstrapClientID, bootstrapClientSecret, bootstrapExtraScopes, bootstrapNamespace) + if err != nil { + return nil, "", err + } + return cfg, bootstrapOrg, nil +} + +// buildOIDCKubeconfig constructs a kubeconfig matching the Greenhouse auth-provider shape: +// +// users: +// - name: greenhouse- +// user: +// auth-provider: +// name: oidc +// config: +// idp-issuer-url: ... +// client-id: ... +// client-secret: ... (always present; may be empty string) +// extra-scopes: ... (omitted when empty) +func buildOIDCKubeconfig(server, org, caDataB64, idpIssuerURL, clientID, clientSecret, extraScopes, namespace string) (*clientcmdapi.Config, error) { + name := fmt.Sprintf("greenhouse-%s", org) + + cluster := &clientcmdapi.Cluster{Server: server} + if caDataB64 != "" { + caBytes, err := base64.StdEncoding.DecodeString(caDataB64) + if err != nil { + caBytes, err = base64.RawStdEncoding.DecodeString(caDataB64) + if err != nil { + return nil, fmt.Errorf("--greenhouse-ca-data is not valid base64: %w", err) + } + } + cluster.CertificateAuthorityData = caBytes + } + + oidcConfig := map[string]string{ + "idp-issuer-url": idpIssuerURL, + "client-id": clientID, + "client-secret": clientSecret, + } + if extraScopes != "" { + oidcConfig["extra-scopes"] = extraScopes + } + + ns := namespace + if ns == "" { + ns = org + } + + cfg := clientcmdapi.NewConfig() + cfg.Clusters[name] = cluster + cfg.AuthInfos[name] = &clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: oidcConfig, + }, + } + cfg.Contexts[name] = &clientcmdapi.Context{ + Cluster: name, + AuthInfo: name, + Namespace: ns, + } + cfg.CurrentContext = name + return cfg, nil +} + +// renameKubeconfigContext renames the active context (and its referenced cluster/authinfo) +// in cfg to targetName. When there is exactly one context it is always the one renamed. +// All other entries (multiple contexts in a blob) are left untouched. +// Old cluster/authinfo keys are only removed when no other context still references them. +// Returns an error when targetName already names a different, unrelated entry in any of +// the three maps, which would silently overwrite it. +func renameKubeconfigContext(cfg *clientcmdapi.Config, targetName string) error { + // Identify which context to rename: prefer CurrentContext, fall back to the only one. + source := cfg.CurrentContext + if _, ok := cfg.Contexts[source]; !ok { + if len(cfg.Contexts) == 1 { + for name := range cfg.Contexts { + source = name + } + } + } + if source == "" { + cfg.CurrentContext = targetName + return nil + } + + ctx := cfg.Contexts[source] + if ctx == nil { + cfg.CurrentContext = targetName + return nil + } + + oldCluster := ctx.Cluster + oldAuth := ctx.AuthInfo + + // Reject the rename when targetName is already used by an unrelated entry. + // "Unrelated" means: the key exists AND it is not the same entry we are about + // to rename (i.e. it is not oldCluster / oldAuth / source). + if _, exists := cfg.Clusters[targetName]; exists && targetName != oldCluster { + return fmt.Errorf("cannot rename to %q: a different cluster entry with that name already exists in the kubeconfig blob", targetName) + } + if _, exists := cfg.AuthInfos[targetName]; exists && targetName != oldAuth { + return fmt.Errorf("cannot rename to %q: a different user entry with that name already exists in the kubeconfig blob", targetName) + } + if _, exists := cfg.Contexts[targetName]; exists && targetName != source { + return fmt.Errorf("cannot rename to %q: a different context entry with that name already exists in the kubeconfig blob", targetName) + } + + // Only delete the old cluster key if no other context (other than source) references it. + clusterRefCount := 0 + for ctxName, c := range cfg.Contexts { + if ctxName != source && c != nil && c.Cluster == oldCluster { + clusterRefCount++ + } + } + + // Rename cluster (when it differs from the target name). + if cl, ok := cfg.Clusters[oldCluster]; ok && oldCluster != targetName { + cfg.Clusters[targetName] = cl + ctx.Cluster = targetName + if clusterRefCount == 0 { + delete(cfg.Clusters, oldCluster) + } + } + + // Only delete the old authinfo key if no other context (other than source) references it. + authRefCount := 0 + for ctxName, c := range cfg.Contexts { + if ctxName != source && c != nil && c.AuthInfo == oldAuth { + authRefCount++ + } + } + + // Rename authinfo (when it differs from the target name). + if ai, ok := cfg.AuthInfos[oldAuth]; ok && oldAuth != targetName { + cfg.AuthInfos[targetName] = ai + ctx.AuthInfo = targetName + if authRefCount == 0 { + delete(cfg.AuthInfos, oldAuth) + } + } + + // Rename context key when it differs; otherwise just update CurrentContext. + if source != targetName { + cfg.Contexts[targetName] = ctx + delete(cfg.Contexts, source) + } + cfg.CurrentContext = targetName + return nil +} + +// mergeBootstrapKubeconfig merges incoming into localConfig (the file that will +// be written). collisionView is used for existence checks — when KUBECONFIG +// spans multiple files it is the merged view of all of them, so we detect +// collisions with entries in other files without copying those entries into the +// first file. +func mergeBootstrapKubeconfig(localConfig, collisionView, incoming *clientcmdapi.Config, ctxName string, setCurrentCtx bool, org string) (output.BootstrapResult, error) { + result := output.BootstrapResult{ + ContextName: ctxName, + SetAsCurrent: setCurrentCtx, + Org: org, + } + + for name, cluster := range incoming.Clusters { + if _, exists := collisionView.Clusters[name]; !exists { + localConfig.Clusters[name] = cluster + result.Added = append(result.Added, fmt.Sprintf("cluster %q", name)) + } else { + result.Skipped = append(result.Skipped, fmt.Sprintf("cluster %q (already exists)", name)) + } + } + + for name, auth := range incoming.AuthInfos { + if _, exists := collisionView.AuthInfos[name]; !exists { + localConfig.AuthInfos[name] = auth + result.Added = append(result.Added, fmt.Sprintf("user %q", name)) + } else { + result.Skipped = append(result.Skipped, fmt.Sprintf("user %q (already exists)", name)) + } + } + + for name, ctx := range incoming.Contexts { + if _, exists := collisionView.Contexts[name]; !exists { + localConfig.Contexts[name] = ctx + result.Added = append(result.Added, fmt.Sprintf("context %q", name)) + } else { + result.Skipped = append(result.Skipped, fmt.Sprintf("context %q (already exists)", name)) + } + } + + if setCurrentCtx && localConfig.CurrentContext != ctxName { + localConfig.CurrentContext = ctxName + result.CurrentContextUpdated = true + } + + return result, nil +} + +// promptContextName reads a context name from stdin, returning defaultName on empty input. +func promptContextName(defaultName string) (string, error) { + fmt.Fprintf(os.Stderr, "Context name [%s]: ", defaultName) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to read context name: %w", err) + } + return defaultName, nil + } + if val := strings.TrimSpace(scanner.Text()); val != "" { + return val, nil + } + return defaultName, nil +} + +// promptYesNo asks a yes/no question; defaultVal is used on empty input or unrecognised input. +func promptYesNo(question string, defaultVal bool) (bool, error) { + hint := "y/N" + if defaultVal { + hint = "Y/n" + } + fmt.Fprintf(os.Stderr, "%s [%s]: ", question, hint) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("failed to read answer: %w", err) + } + return defaultVal, nil + } + switch strings.TrimSpace(strings.ToLower(scanner.Text())) { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + return defaultVal, nil + } +} diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go new file mode 100644 index 0000000..07ff299 --- /dev/null +++ b/cmd/bootstrap_test.go @@ -0,0 +1,968 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + . "github.com/onsi/gomega" + "github.com/spf13/pflag" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// ── helpers ────────────────────────────────────────────────────────────────── + +// realGreenHouseKubeconfig returns a kubeconfig that matches exactly what +// Greenhouse serves for an organization: OIDC auth-provider, CA data, namespace. +func realGreenhouseKubeconfig(org string) *clientcmdapi.Config { + name := "greenhouse-" + org + cfg := clientcmdapi.NewConfig() + cfg.Clusters[name] = &clientcmdapi.Cluster{ + Server: "https://greenhouse.global.cloud.sap", + CertificateAuthorityData: []byte("fake-ca-data"), + } + cfg.AuthInfos[name] = &clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: map[string]string{ + "idp-issuer-url": "https://idp.global.cloud.sap", + "client-id": "greenhouse", + "client-secret": "", + "extra-scopes": "groups", + }, + }, + } + cfg.Contexts[name] = &clientcmdapi.Context{ + Cluster: name, + AuthInfo: name, + Namespace: org, + } + cfg.CurrentContext = name + return cfg +} + +// encodeKubeconfig serialises cfg to YAML and base64-encodes it. +func encodeKubeconfig(t *testing.T, cfg *clientcmdapi.Config) string { + t.Helper() + raw, err := clientcmd.Write(*cfg) + if err != nil { + t.Fatalf("encodeKubeconfig: %v", err) + } + return base64.StdEncoding.EncodeToString(raw) +} + +// writeTempKubeconfig writes cfg to a temp file and returns the path. +func writeTempKubeconfig(t *testing.T, cfg *clientcmdapi.Config) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "kubeconfig-*.yaml") + if err != nil { + t.Fatalf("writeTempKubeconfig: %v", err) + } + raw, err := clientcmd.Write(*cfg) + if err != nil { + t.Fatalf("writeTempKubeconfig write: %v", err) + } + if _, err := f.Write(raw); err != nil { + t.Fatalf("writeTempKubeconfig: %v", err) + } + _ = f.Close() + return f.Name() +} + +// loadKubeconfig loads a kubeconfig from disk. +func loadKubeconfig(t *testing.T, path string) *clientcmdapi.Config { + t.Helper() + cfg, err := clientcmd.LoadFromFile(path) + if err != nil { + t.Fatalf("loadKubeconfig: %v", err) + } + return cfg +} + +// runBootstrapCmd executes the bootstrap cobra command with the given args and +// returns stdout, stderr, and any error. It resets global flag vars before each run. +func runBootstrapCmd(t *testing.T, args []string) (stdout, stderr string, err error) { + t.Helper() + + // Reset package-level flag vars so tests are isolated from each other. + bootstrapData = "" + bootstrapServer = "" + bootstrapOrg = "" + bootstrapCAData = "" + bootstrapIDPIssuerURL = "" + bootstrapClientID = "" + bootstrapClientSecret = "" + bootstrapExtraScopes = "" + bootstrapNamespace = "" + bootstrapKubeconfig = "" + bootstrapContextName = "" + bootstrapSetCurrentCtx = false + bootstrapDryRun = false + + // Reset cobra's "Changed" state on bootstrapCmd flags so flag.Changed() is + // accurate for each test regardless of what previous tests passed. + bootstrapCmd.Flags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) + + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + rootCmd.SetOut(outBuf) + rootCmd.SetErr(errBuf) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + }) + + rootCmd.SetArgs(append([]string{"bootstrap"}, args...)) + err = rootCmd.Execute() + return outBuf.String(), errBuf.String(), err +} + +// ── buildOIDCKubeconfig ─────────────────────────────────────────────────────── + +func TestBuildOIDCKubeconfig_AllFields(t *testing.T) { + g := NewWithT(t) + + caB64 := base64.StdEncoding.EncodeToString([]byte("fake-ca")) + cfg, err := buildOIDCKubeconfig( + "https://greenhouse.example.com", + "my-org", + caB64, + "https://idp.example.com", + "greenhouse", + "secret123", + "groups", + "", + ) + g.Expect(err).To(BeNil()) + + name := "greenhouse-my-org" + g.Expect(cfg.CurrentContext).To(Equal(name)) + + cl := cfg.Clusters[name] + g.Expect(cl).NotTo(BeNil()) + g.Expect(cl.Server).To(Equal("https://greenhouse.example.com")) + g.Expect(cl.CertificateAuthorityData).To(Equal([]byte("fake-ca"))) + + ai := cfg.AuthInfos[name] + g.Expect(ai).NotTo(BeNil()) + g.Expect(ai.AuthProvider).NotTo(BeNil()) + g.Expect(ai.AuthProvider.Name).To(Equal("oidc")) + g.Expect(ai.AuthProvider.Config["idp-issuer-url"]).To(Equal("https://idp.example.com")) + g.Expect(ai.AuthProvider.Config["client-id"]).To(Equal("greenhouse")) + g.Expect(ai.AuthProvider.Config["client-secret"]).To(Equal("secret123")) + g.Expect(ai.AuthProvider.Config["extra-scopes"]).To(Equal("groups")) + + ctx := cfg.Contexts[name] + g.Expect(ctx).NotTo(BeNil()) + g.Expect(ctx.Cluster).To(Equal(name)) + g.Expect(ctx.AuthInfo).To(Equal(name)) + g.Expect(ctx.Namespace).To(Equal("my-org")) // defaults to org +} + +func TestBuildOIDCKubeconfig_ExplicitNamespace(t *testing.T) { + g := NewWithT(t) + cfg, err := buildOIDCKubeconfig( + "https://greenhouse.example.com", "my-org", "", + "https://idp.example.com", "greenhouse", "", "", "custom-ns", + ) + g.Expect(err).To(BeNil()) + g.Expect(cfg.Contexts["greenhouse-my-org"].Namespace).To(Equal("custom-ns")) +} + +func TestBuildOIDCKubeconfig_NoExtraScopes(t *testing.T) { + g := NewWithT(t) + cfg, err := buildOIDCKubeconfig( + "https://greenhouse.example.com", "my-org", "", + "https://idp.example.com", "greenhouse", "", "", "", + ) + g.Expect(err).To(BeNil()) + _, hasScopes := cfg.AuthInfos["greenhouse-my-org"].AuthProvider.Config["extra-scopes"] + g.Expect(hasScopes).To(BeFalse()) +} + +func TestBuildOIDCKubeconfig_InvalidCAData(t *testing.T) { + g := NewWithT(t) + _, err := buildOIDCKubeconfig( + "https://greenhouse.example.com", "my-org", "not-valid-base64!!!", + "https://idp.example.com", "greenhouse", "", "", "", + ) + g.Expect(err).To(MatchError(ContainSubstring("not valid base64"))) +} + +func TestBuildOIDCKubeconfig_MatchesRealGreenhouseShape(t *testing.T) { + g := NewWithT(t) + + caB64 := base64.StdEncoding.EncodeToString([]byte("fake-ca-data")) + cfg, err := buildOIDCKubeconfig( + "https://greenhouse.global.cloud.sap", + "sap-cna", + caB64, + "https://idp.global.cloud.sap", + "greenhouse", + "", + "groups", + "", + ) + g.Expect(err).To(BeNil()) + + want := realGreenhouseKubeconfig("sap-cna") + + name := "greenhouse-sap-cna" + g.Expect(cfg.Clusters[name].Server).To(Equal(want.Clusters[name].Server)) + g.Expect(cfg.Clusters[name].CertificateAuthorityData).To(Equal(want.Clusters[name].CertificateAuthorityData)) + g.Expect(cfg.AuthInfos[name].AuthProvider.Name).To(Equal(want.AuthInfos[name].AuthProvider.Name)) + g.Expect(cfg.AuthInfos[name].AuthProvider.Config).To(Equal(want.AuthInfos[name].AuthProvider.Config)) + g.Expect(cfg.Contexts[name].Namespace).To(Equal(want.Contexts[name].Namespace)) + g.Expect(cfg.CurrentContext).To(Equal(want.CurrentContext)) +} + +// ── renameKubeconfigContext ─────────────────────────────────────────────────── + +func TestRenameKubeconfigContext_RenamesAll(t *testing.T) { + g := NewWithT(t) + + cfg := realGreenhouseKubeconfig("sap-cna") + g.Expect(renameKubeconfigContext(cfg, "my-custom-name")).To(Succeed()) + + g.Expect(cfg.CurrentContext).To(Equal("my-custom-name")) + g.Expect(cfg.Contexts).To(HaveKey("my-custom-name")) + g.Expect(cfg.Contexts).NotTo(HaveKey("greenhouse-sap-cna")) + g.Expect(cfg.Clusters).To(HaveKey("my-custom-name")) + g.Expect(cfg.Clusters).NotTo(HaveKey("greenhouse-sap-cna")) + g.Expect(cfg.AuthInfos).To(HaveKey("my-custom-name")) + g.Expect(cfg.AuthInfos).NotTo(HaveKey("greenhouse-sap-cna")) + + ctx := cfg.Contexts["my-custom-name"] + g.Expect(ctx.Cluster).To(Equal("my-custom-name")) + g.Expect(ctx.AuthInfo).To(Equal("my-custom-name")) + g.Expect(ctx.Namespace).To(Equal("sap-cna")) // namespace unchanged +} + +func TestRenameKubeconfigContext_NoOpWhenNameUnchanged(t *testing.T) { + g := NewWithT(t) + + cfg := realGreenhouseKubeconfig("sap-cna") + g.Expect(renameKubeconfigContext(cfg, "greenhouse-sap-cna")).To(Succeed()) + + g.Expect(cfg.CurrentContext).To(Equal("greenhouse-sap-cna")) + g.Expect(cfg.Contexts).To(HaveKey("greenhouse-sap-cna")) + g.Expect(cfg.Clusters).To(HaveKey("greenhouse-sap-cna")) + g.Expect(cfg.AuthInfos).To(HaveKey("greenhouse-sap-cna")) +} + +func TestRenameKubeconfigContext_MultiContextBlobOnlyRenamesCurrent(t *testing.T) { + g := NewWithT(t) + + // A blob with two contexts — only the current one should be renamed. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["cluster-a"] = &clientcmdapi.Cluster{Server: "https://a.example.com"} + cfg.Clusters["cluster-b"] = &clientcmdapi.Cluster{Server: "https://b.example.com"} + cfg.AuthInfos["user-a"] = &clientcmdapi.AuthInfo{} + cfg.AuthInfos["user-b"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["ctx-a"] = &clientcmdapi.Context{Cluster: "cluster-a", AuthInfo: "user-a"} + cfg.Contexts["ctx-b"] = &clientcmdapi.Context{Cluster: "cluster-b", AuthInfo: "user-b"} + cfg.CurrentContext = "ctx-a" + + g.Expect(renameKubeconfigContext(cfg, "gh-prod")).To(Succeed()) + + // ctx-a → gh-prod; ctx-b untouched + g.Expect(cfg.Contexts).To(HaveKey("gh-prod")) + g.Expect(cfg.Contexts).NotTo(HaveKey("ctx-a")) + g.Expect(cfg.Contexts).To(HaveKey("ctx-b")) + g.Expect(cfg.Clusters).To(HaveKey("gh-prod")) + g.Expect(cfg.Clusters).To(HaveKey("cluster-b")) + g.Expect(cfg.AuthInfos).To(HaveKey("gh-prod")) + g.Expect(cfg.AuthInfos).To(HaveKey("user-b")) + g.Expect(cfg.CurrentContext).To(Equal("gh-prod")) +} + +func TestRenameKubeconfigContext_SharedClusterPreserved(t *testing.T) { + g := NewWithT(t) + + // Two contexts share the same cluster and authinfo — renaming one should + // not delete the shared keys. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["shared-cluster"] = &clientcmdapi.Cluster{Server: "https://shared.example.com"} + cfg.AuthInfos["shared-user"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["ctx-a"] = &clientcmdapi.Context{Cluster: "shared-cluster", AuthInfo: "shared-user"} + cfg.Contexts["ctx-b"] = &clientcmdapi.Context{Cluster: "shared-cluster", AuthInfo: "shared-user"} + cfg.CurrentContext = "ctx-a" + + g.Expect(renameKubeconfigContext(cfg, "gh-prod")).To(Succeed()) + + // The renamed context uses new keys. + g.Expect(cfg.Contexts).To(HaveKey("gh-prod")) + g.Expect(cfg.Contexts).NotTo(HaveKey("ctx-a")) + // ctx-b still references shared-cluster and shared-user — they must not be deleted. + g.Expect(cfg.Clusters).To(HaveKey("shared-cluster")) + g.Expect(cfg.AuthInfos).To(HaveKey("shared-user")) + // The renamed context also gets gh-prod cluster/user copies. + g.Expect(cfg.Clusters).To(HaveKey("gh-prod")) + g.Expect(cfg.AuthInfos).To(HaveKey("gh-prod")) +} + +func TestRenameKubeconfigContext_ContextKeyMatchesButEntriesDiffer(t *testing.T) { + g := NewWithT(t) + + // The context key already equals targetName, but cluster/authinfo keys differ. + // renameKubeconfigContext must still rename the cluster and authinfo entries. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["greenhouse-org-cluster"] = &clientcmdapi.Cluster{Server: "https://greenhouse.example.com"} + cfg.AuthInfos["greenhouse-org-user"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["greenhouse-org"] = &clientcmdapi.Context{Cluster: "greenhouse-org-cluster", AuthInfo: "greenhouse-org-user"} + cfg.CurrentContext = "greenhouse-org" + + g.Expect(renameKubeconfigContext(cfg, "greenhouse-org")).To(Succeed()) + + // Context key unchanged. + g.Expect(cfg.Contexts).To(HaveKey("greenhouse-org")) + g.Expect(cfg.CurrentContext).To(Equal("greenhouse-org")) + // Cluster and authinfo must be renamed to match the context name. + g.Expect(cfg.Clusters).To(HaveKey("greenhouse-org")) + g.Expect(cfg.Clusters).NotTo(HaveKey("greenhouse-org-cluster")) + g.Expect(cfg.AuthInfos).To(HaveKey("greenhouse-org")) + g.Expect(cfg.AuthInfos).NotTo(HaveKey("greenhouse-org-user")) + // The context's Cluster and AuthInfo fields must point to the new keys. + g.Expect(cfg.Contexts["greenhouse-org"].Cluster).To(Equal("greenhouse-org")) + g.Expect(cfg.Contexts["greenhouse-org"].AuthInfo).To(Equal("greenhouse-org")) +} + +func TestRenameKubeconfigContext_NilContextEntryDoesNotPanic(t *testing.T) { + // A multi-context blob where one context value is nil (e.g. decoded from a + // YAML null) must not panic when computing cluster/authinfo ref-counts. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["greenhouse-org"] = &clientcmdapi.Cluster{Server: "https://greenhouse.example.com"} + cfg.AuthInfos["greenhouse-org"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["greenhouse-org"] = &clientcmdapi.Context{Cluster: "greenhouse-org", AuthInfo: "greenhouse-org"} + cfg.Contexts["other-ctx"] = nil // nil entry simulating a null in the YAML + cfg.CurrentContext = "greenhouse-org" + + g := NewWithT(t) + g.Expect(func() { _ = renameKubeconfigContext(cfg, "gh-prod") }).NotTo(Panic()) + g.Expect(cfg.Contexts).To(HaveKey("gh-prod")) + g.Expect(cfg.CurrentContext).To(Equal("gh-prod")) +} + +func TestRenameKubeconfigContext_RejectsCollisionWithUnrelatedEntry(t *testing.T) { + g := NewWithT(t) + + // A two-context blob: renaming ctx-a to "ctx-b" would clobber ctx-b's entries. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["cluster-a"] = &clientcmdapi.Cluster{Server: "https://a.example.com"} + cfg.Clusters["ctx-b"] = &clientcmdapi.Cluster{Server: "https://b.example.com"} + cfg.AuthInfos["user-a"] = &clientcmdapi.AuthInfo{} + cfg.AuthInfos["ctx-b"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["ctx-a"] = &clientcmdapi.Context{Cluster: "cluster-a", AuthInfo: "user-a"} + cfg.Contexts["ctx-b"] = &clientcmdapi.Context{Cluster: "ctx-b", AuthInfo: "ctx-b"} + cfg.CurrentContext = "ctx-a" + + err := renameKubeconfigContext(cfg, "ctx-b") + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("ctx-b")) + // Neither map was mutated. + g.Expect(cfg.Clusters).To(HaveKey("cluster-a")) + g.Expect(cfg.Contexts).To(HaveKey("ctx-a")) + g.Expect(cfg.CurrentContext).To(Equal("ctx-a")) +} + +// ── mergeBootstrapKubeconfig ────────────────────────────────────────────────── + +func TestMergeBootstrapKubeconfig_AddsAllEntries(t *testing.T) { + g := NewWithT(t) + + local := clientcmdapi.NewConfig() + incoming := realGreenhouseKubeconfig("sap-cna") + + result, err := mergeBootstrapKubeconfig(local, local, incoming, "greenhouse-sap-cna", false, "sap-cna") + g.Expect(err).To(BeNil()) + + g.Expect(result.Added).To(HaveLen(3)) + g.Expect(result.Skipped).To(BeEmpty()) + g.Expect(local.Clusters).To(HaveKey("greenhouse-sap-cna")) + g.Expect(local.AuthInfos).To(HaveKey("greenhouse-sap-cna")) + g.Expect(local.Contexts).To(HaveKey("greenhouse-sap-cna")) + g.Expect(local.CurrentContext).To(BeEmpty()) // setCurrentCtx=false +} + +func TestMergeBootstrapKubeconfig_SetsCurrentContext(t *testing.T) { + g := NewWithT(t) + + local := clientcmdapi.NewConfig() + incoming := realGreenhouseKubeconfig("sap-cna") + + result, err := mergeBootstrapKubeconfig(local, local, incoming, "greenhouse-sap-cna", true, "sap-cna") + g.Expect(err).To(BeNil()) + g.Expect(local.CurrentContext).To(Equal("greenhouse-sap-cna")) + g.Expect(result.CurrentContextUpdated).To(BeTrue()) +} + +func TestMergeBootstrapKubeconfig_IdempotentSkipsExisting(t *testing.T) { + g := NewWithT(t) + + incoming := realGreenhouseKubeconfig("sap-cna") + local := realGreenhouseKubeconfig("sap-cna") // same entries already present + + result, err := mergeBootstrapKubeconfig(local, local, incoming, "greenhouse-sap-cna", false, "sap-cna") + g.Expect(err).To(BeNil()) + g.Expect(result.Added).To(BeEmpty()) + g.Expect(result.Skipped).To(HaveLen(3)) +} + +func TestMergeBootstrapKubeconfig_NeverOverwritesExistingEntries(t *testing.T) { + g := NewWithT(t) + + // Local has an existing cluster entry with a different server URL. + local := clientcmdapi.NewConfig() + local.Clusters["greenhouse-sap-cna"] = &clientcmdapi.Cluster{Server: "https://original.example.com"} + + incoming := realGreenhouseKubeconfig("sap-cna") + _, err := mergeBootstrapKubeconfig(local, local, incoming, "greenhouse-sap-cna", false, "sap-cna") + g.Expect(err).To(BeNil()) + + // Original server must not be overwritten. + g.Expect(local.Clusters["greenhouse-sap-cna"].Server).To(Equal("https://original.example.com")) +} + +func TestMergeBootstrapKubeconfig_PreservesExistingLocalEntries(t *testing.T) { + g := NewWithT(t) + + // Local already has unrelated entries. + local := clientcmdapi.NewConfig() + local.Clusters["other-cluster"] = &clientcmdapi.Cluster{Server: "https://other.example.com"} + local.AuthInfos["other-user"] = &clientcmdapi.AuthInfo{Token: "tok"} + local.Contexts["other-ctx"] = &clientcmdapi.Context{Cluster: "other-cluster", AuthInfo: "other-user"} + local.CurrentContext = "other-ctx" + + incoming := realGreenhouseKubeconfig("sap-cna") + _, err := mergeBootstrapKubeconfig(local, local, incoming, "greenhouse-sap-cna", false, "sap-cna") + g.Expect(err).To(BeNil()) + + // Unrelated entries must still be there. + g.Expect(local.Clusters).To(HaveKey("other-cluster")) + g.Expect(local.AuthInfos).To(HaveKey("other-user")) + g.Expect(local.Contexts).To(HaveKey("other-ctx")) + // Current context unchanged because setCurrentCtx=false. + g.Expect(local.CurrentContext).To(Equal("other-ctx")) +} + +// ── resolveIncomingKubeconfig (via package-level vars) ─────────────────────── + +func TestResolveIncomingKubeconfig_DataBlob(t *testing.T) { + g := NewWithT(t) + + want := realGreenhouseKubeconfig("sap-cna") + bootstrapData = encodeKubeconfig(t, want) + bootstrapOrg = "" + t.Cleanup(func() { bootstrapData = ""; bootstrapOrg = "" }) + + cfg, org, err := resolveIncomingKubeconfig() + g.Expect(err).To(BeNil()) + g.Expect(org).To(BeEmpty()) // no --greenhouse-org provided alongside --data + g.Expect(cfg.Clusters).To(HaveKey("greenhouse-sap-cna")) + g.Expect(cfg.AuthInfos["greenhouse-sap-cna"].AuthProvider.Name).To(Equal("oidc")) + g.Expect(cfg.AuthInfos["greenhouse-sap-cna"].AuthProvider.Config["idp-issuer-url"]). + To(Equal("https://idp.global.cloud.sap")) + g.Expect(cfg.Contexts["greenhouse-sap-cna"].Namespace).To(Equal("sap-cna")) +} + +func TestResolveIncomingKubeconfig_DataBlobWithOrgOverride(t *testing.T) { + g := NewWithT(t) + + want := realGreenhouseKubeconfig("sap-cna") + bootstrapData = encodeKubeconfig(t, want) + bootstrapOrg = "override-org" + t.Cleanup(func() { bootstrapData = ""; bootstrapOrg = "" }) + + _, org, err := resolveIncomingKubeconfig() + g.Expect(err).To(BeNil()) + g.Expect(org).To(Equal("override-org")) +} + +func TestResolveIncomingKubeconfig_DataBlobInvalidBase64(t *testing.T) { + g := NewWithT(t) + + bootstrapData = "not!!!base64" + t.Cleanup(func() { bootstrapData = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("not valid base64"))) +} + +func TestResolveIncomingKubeconfig_DataBlobNotKubeconfig(t *testing.T) { + g := NewWithT(t) + + bootstrapData = base64.StdEncoding.EncodeToString([]byte("just some text, not yaml")) + t.Cleanup(func() { bootstrapData = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("valid kubeconfig"))) +} + +func TestResolveIncomingKubeconfig_DataBlobEmptyClusters(t *testing.T) { + g := NewWithT(t) + + empty := clientcmdapi.NewConfig() + bootstrapData = encodeKubeconfig(t, empty) + t.Cleanup(func() { bootstrapData = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("no clusters"))) +} + +func TestResolveIncomingKubeconfig_DataBlobNoCurrentContext(t *testing.T) { + g := NewWithT(t) + + // Two contexts, no CurrentContext set — ambiguous, should error. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["cluster-a"] = &clientcmdapi.Cluster{Server: "https://a.example.com"} + cfg.Clusters["cluster-b"] = &clientcmdapi.Cluster{Server: "https://b.example.com"} + cfg.AuthInfos["user-a"] = &clientcmdapi.AuthInfo{} + cfg.AuthInfos["user-b"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["ctx-a"] = &clientcmdapi.Context{Cluster: "cluster-a", AuthInfo: "user-a"} + cfg.Contexts["ctx-b"] = &clientcmdapi.Context{Cluster: "cluster-b", AuthInfo: "user-b"} + bootstrapData = encodeKubeconfig(t, cfg) + t.Cleanup(func() { bootstrapData = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("no current-context"))) +} + +func TestResolveIncomingKubeconfig_DataBlobMissingClusterRef(t *testing.T) { + g := NewWithT(t) + + // Context references a cluster that doesn't exist in the blob. + cfg := clientcmdapi.NewConfig() + cfg.Clusters["cluster-a"] = &clientcmdapi.Cluster{Server: "https://a.example.com"} + cfg.AuthInfos["user-a"] = &clientcmdapi.AuthInfo{} + cfg.Contexts["ctx-a"] = &clientcmdapi.Context{Cluster: "missing-cluster", AuthInfo: "user-a"} + cfg.CurrentContext = "ctx-a" + bootstrapData = encodeKubeconfig(t, cfg) + t.Cleanup(func() { bootstrapData = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("missing-cluster"))) +} + +func TestResolveIncomingKubeconfig_IndividualFlags(t *testing.T) { + g := NewWithT(t) + + bootstrapServer = "https://greenhouse.example.com" + bootstrapOrg = "my-org" + bootstrapIDPIssuerURL = "https://idp.example.com" + bootstrapClientID = "greenhouse" + bootstrapClientSecret = "" + bootstrapExtraScopes = "groups" + bootstrapNamespace = "" + t.Cleanup(func() { + bootstrapServer = "" + bootstrapOrg = "" + bootstrapIDPIssuerURL = "" + bootstrapClientID = "" + bootstrapClientSecret = "" + bootstrapExtraScopes = "" + bootstrapNamespace = "" + }) + + cfg, org, err := resolveIncomingKubeconfig() + g.Expect(err).To(BeNil()) + g.Expect(org).To(Equal("my-org")) + + name := "greenhouse-my-org" + g.Expect(cfg.Clusters[name].Server).To(Equal("https://greenhouse.example.com")) + g.Expect(cfg.AuthInfos[name].AuthProvider.Name).To(Equal("oidc")) + g.Expect(cfg.AuthInfos[name].AuthProvider.Config["idp-issuer-url"]).To(Equal("https://idp.example.com")) + g.Expect(cfg.AuthInfos[name].AuthProvider.Config["client-id"]).To(Equal("greenhouse")) + g.Expect(cfg.AuthInfos[name].AuthProvider.Config["extra-scopes"]).To(Equal("groups")) + g.Expect(cfg.Contexts[name].Namespace).To(Equal("my-org")) +} + +func TestResolveIncomingKubeconfig_MissingServer(t *testing.T) { + g := NewWithT(t) + bootstrapOrg = "my-org" + bootstrapIDPIssuerURL = "https://idp.example.com" + bootstrapClientID = "greenhouse" + t.Cleanup(func() { bootstrapOrg = ""; bootstrapIDPIssuerURL = ""; bootstrapClientID = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("--greenhouse-server"))) +} + +func TestResolveIncomingKubeconfig_MissingOrg(t *testing.T) { + g := NewWithT(t) + bootstrapServer = "https://greenhouse.example.com" + bootstrapIDPIssuerURL = "https://idp.example.com" + bootstrapClientID = "greenhouse" + t.Cleanup(func() { bootstrapServer = ""; bootstrapIDPIssuerURL = ""; bootstrapClientID = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("--greenhouse-org"))) +} + +func TestResolveIncomingKubeconfig_MissingIDPIssuerURL(t *testing.T) { + g := NewWithT(t) + bootstrapServer = "https://greenhouse.example.com" + bootstrapOrg = "my-org" + bootstrapClientID = "greenhouse" + t.Cleanup(func() { bootstrapServer = ""; bootstrapOrg = ""; bootstrapClientID = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("--greenhouse-idp-issuer-url"))) +} + +func TestResolveIncomingKubeconfig_MissingClientID(t *testing.T) { + g := NewWithT(t) + bootstrapServer = "https://greenhouse.example.com" + bootstrapOrg = "my-org" + bootstrapIDPIssuerURL = "https://idp.example.com" + t.Cleanup(func() { bootstrapServer = ""; bootstrapOrg = ""; bootstrapIDPIssuerURL = "" }) + + _, _, err := resolveIncomingKubeconfig() + g.Expect(err).To(MatchError(ContainSubstring("--greenhouse-client-id"))) +} + +// ── end-to-end via cobra command ────────────────────────────────────────────── + +// newEmptyKubeconfigFile creates a temp file with an empty (but valid) kubeconfig. +func newEmptyKubeconfigFile(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + empty := clientcmdapi.NewConfig() + raw, err := clientcmd.Write(*empty) + if err != nil { + t.Fatalf("newEmptyKubeconfigFile: %v", err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatalf("newEmptyKubeconfigFile: %v", err) + } + return path +} + +func TestBootstrapCmd_DataBlob_WritesCorrectKubeconfig(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + dest := newEmptyKubeconfigFile(t) + + stdout, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--set-current-context", + "--kubeconfig=" + dest, + }) + g.Expect(err).To(BeNil()) + g.Expect(stdout).To(ContainSubstring(`[+] cluster "greenhouse-sap-cna"`)) + g.Expect(stdout).To(ContainSubstring(`[+] user "greenhouse-sap-cna"`)) + g.Expect(stdout).To(ContainSubstring(`[+] context "greenhouse-sap-cna"`)) + g.Expect(stdout).To(ContainSubstring("Bootstrap complete.")) + g.Expect(stdout).To(ContainSubstring("cloudctl sync -n sap-cna")) + + result := loadKubeconfig(t, dest) + g.Expect(result.CurrentContext).To(Equal("greenhouse-sap-cna")) + + cl := result.Clusters["greenhouse-sap-cna"] + g.Expect(cl).NotTo(BeNil()) + g.Expect(cl.Server).To(Equal("https://greenhouse.global.cloud.sap")) + g.Expect(cl.CertificateAuthorityData).To(Equal([]byte("fake-ca-data"))) + + ai := result.AuthInfos["greenhouse-sap-cna"] + g.Expect(ai).NotTo(BeNil()) + g.Expect(ai.AuthProvider.Name).To(Equal("oidc")) + g.Expect(ai.AuthProvider.Config["idp-issuer-url"]).To(Equal("https://idp.global.cloud.sap")) + g.Expect(ai.AuthProvider.Config["client-id"]).To(Equal("greenhouse")) + g.Expect(ai.AuthProvider.Config["client-secret"]).To(Equal("")) + g.Expect(ai.AuthProvider.Config["extra-scopes"]).To(Equal("groups")) + + ctx := result.Contexts["greenhouse-sap-cna"] + g.Expect(ctx).NotTo(BeNil()) + g.Expect(ctx.Cluster).To(Equal("greenhouse-sap-cna")) + g.Expect(ctx.AuthInfo).To(Equal("greenhouse-sap-cna")) + g.Expect(ctx.Namespace).To(Equal("sap-cna")) +} + +func TestBootstrapCmd_IndividualFlags_WritesOIDCKubeconfig(t *testing.T) { + g := NewWithT(t) + + caB64 := base64.StdEncoding.EncodeToString([]byte("fake-ca-data")) + dest := newEmptyKubeconfigFile(t) + + _, _, err := runBootstrapCmd(t, []string{ + "--greenhouse-server=https://greenhouse.global.cloud.sap", + "--greenhouse-org=sap-cna", + "--greenhouse-idp-issuer-url=https://idp.global.cloud.sap", + "--greenhouse-client-id=greenhouse", + "--greenhouse-client-secret=", + "--greenhouse-extra-scopes=groups", + "--greenhouse-ca-data=" + caB64, + "--context-name=greenhouse-sap-cna", + "--set-current-context", + "--kubeconfig=" + dest, + }) + g.Expect(err).To(BeNil()) + + result := loadKubeconfig(t, dest) + cl := result.Clusters["greenhouse-sap-cna"] + g.Expect(cl.Server).To(Equal("https://greenhouse.global.cloud.sap")) + g.Expect(cl.CertificateAuthorityData).To(Equal([]byte("fake-ca-data"))) + + ai := result.AuthInfos["greenhouse-sap-cna"] + g.Expect(ai.AuthProvider.Name).To(Equal("oidc")) + g.Expect(ai.AuthProvider.Config["idp-issuer-url"]).To(Equal("https://idp.global.cloud.sap")) + g.Expect(ai.AuthProvider.Config["client-id"]).To(Equal("greenhouse")) + g.Expect(ai.AuthProvider.Config["extra-scopes"]).To(Equal("groups")) + + ctx := result.Contexts["greenhouse-sap-cna"] + g.Expect(ctx.Namespace).To(Equal("sap-cna")) + g.Expect(result.CurrentContext).To(Equal("greenhouse-sap-cna")) +} + +func TestBootstrapCmd_IndividualFlags_DataAndIndividualFlagsProduceSameShape(t *testing.T) { + g := NewWithT(t) + + // Build via individual flags. + caB64 := base64.StdEncoding.EncodeToString([]byte("fake-ca-data")) + dest1 := newEmptyKubeconfigFile(t) + _, _, err := runBootstrapCmd(t, []string{ + "--greenhouse-server=https://greenhouse.global.cloud.sap", + "--greenhouse-org=sap-cna", + "--greenhouse-idp-issuer-url=https://idp.global.cloud.sap", + "--greenhouse-client-id=greenhouse", + "--greenhouse-client-secret=", + "--greenhouse-extra-scopes=groups", + "--greenhouse-ca-data=" + caB64, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest1, + }) + g.Expect(err).To(BeNil()) + + // Build via --data blob from the same source config. + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + dest2 := newEmptyKubeconfigFile(t) + _, _, err = runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest2, + }) + g.Expect(err).To(BeNil()) + + r1 := loadKubeconfig(t, dest1) + r2 := loadKubeconfig(t, dest2) + + cl1 := r1.Clusters["greenhouse-sap-cna"] + cl2 := r2.Clusters["greenhouse-sap-cna"] + g.Expect(cl1.Server).To(Equal(cl2.Server)) + g.Expect(cl1.CertificateAuthorityData).To(Equal(cl2.CertificateAuthorityData)) + + ai1 := r1.AuthInfos["greenhouse-sap-cna"] + ai2 := r2.AuthInfos["greenhouse-sap-cna"] + g.Expect(ai1.AuthProvider.Name).To(Equal(ai2.AuthProvider.Name)) + g.Expect(ai1.AuthProvider.Config).To(Equal(ai2.AuthProvider.Config)) + + ctx1 := r1.Contexts["greenhouse-sap-cna"] + ctx2 := r2.Contexts["greenhouse-sap-cna"] + g.Expect(ctx1.Namespace).To(Equal(ctx2.Namespace)) +} + +func TestBootstrapCmd_DryRun_DoesNotWriteFile(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + dest := newEmptyKubeconfigFile(t) + + before, err := os.ReadFile(dest) + g.Expect(err).To(BeNil()) + + stdout, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest, + "--dry-run", + }) + g.Expect(err).To(BeNil()) + g.Expect(stdout).To(ContainSubstring("Dry-run")) + g.Expect(stdout).To(ContainSubstring(`[+] cluster "greenhouse-sap-cna"`)) + + after, err := os.ReadFile(dest) + g.Expect(err).To(BeNil()) + g.Expect(after).To(Equal(before)) // file must be unchanged +} + +func TestBootstrapCmd_Idempotent(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + dest := newEmptyKubeconfigFile(t) + + args := []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest, + } + + _, _, err := runBootstrapCmd(t, args) + g.Expect(err).To(BeNil()) + + // Second run must succeed and report all entries as skipped. + stdout, _, err := runBootstrapCmd(t, args) + g.Expect(err).To(BeNil()) + g.Expect(stdout).To(ContainSubstring(`[=] cluster "greenhouse-sap-cna" (already exists)`)) + g.Expect(stdout).To(ContainSubstring(`[=] user "greenhouse-sap-cna" (already exists)`)) + g.Expect(stdout).To(ContainSubstring(`[=] context "greenhouse-sap-cna" (already exists)`)) + g.Expect(stdout).NotTo(ContainSubstring("[+]")) +} + +func TestBootstrapCmd_ContextRename(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") // default name: greenhouse-sap-cna + data := encodeKubeconfig(t, src) + dest := newEmptyKubeconfigFile(t) + + _, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=gh-prod", // user picks a custom name + "--set-current-context", + "--kubeconfig=" + dest, + }) + g.Expect(err).To(BeNil()) + + result := loadKubeconfig(t, dest) + g.Expect(result.CurrentContext).To(Equal("gh-prod")) + g.Expect(result.Clusters).To(HaveKey("gh-prod")) + g.Expect(result.AuthInfos).To(HaveKey("gh-prod")) + g.Expect(result.Contexts).To(HaveKey("gh-prod")) + g.Expect(result.Clusters).NotTo(HaveKey("greenhouse-sap-cna")) + // Namespace preserved after rename. + g.Expect(result.Contexts["gh-prod"].Namespace).To(Equal("sap-cna")) +} + +func TestBootstrapCmd_PreservesExistingUnmanagedEntries(t *testing.T) { + g := NewWithT(t) + + // Start with a kubeconfig that already has an unrelated entry. + existing := clientcmdapi.NewConfig() + existing.Clusters["my-cluster"] = &clientcmdapi.Cluster{Server: "https://my.cluster.example.com"} + existing.AuthInfos["my-user"] = &clientcmdapi.AuthInfo{Token: "tok"} + existing.Contexts["my-ctx"] = &clientcmdapi.Context{Cluster: "my-cluster", AuthInfo: "my-user"} + existing.CurrentContext = "my-ctx" + dest := writeTempKubeconfig(t, existing) + + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + + _, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest, + }) + g.Expect(err).To(BeNil()) + + result := loadKubeconfig(t, dest) + // Existing entry must still be present. + g.Expect(result.Clusters).To(HaveKey("my-cluster")) + g.Expect(result.AuthInfos).To(HaveKey("my-user")) + g.Expect(result.Contexts).To(HaveKey("my-ctx")) + // Current context unchanged because --set-current-context was not passed. + g.Expect(result.CurrentContext).To(Equal("my-ctx")) + // New Greenhouse entry also present. + g.Expect(result.Clusters).To(HaveKey("greenhouse-sap-cna")) +} + +func TestBootstrapCmd_CreatesKubeconfigFileWhenAbsent(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + dest := filepath.Join(t.TempDir(), "new-kubeconfig.yaml") // does not exist yet + + _, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest, + }) + g.Expect(err).To(BeNil()) + g.Expect(dest).To(BeAnExistingFile()) + + result := loadKubeconfig(t, dest) + g.Expect(result.Clusters).To(HaveKey("greenhouse-sap-cna")) +} + +func TestBootstrapCmd_JSONOutput(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") + data := encodeKubeconfig(t, src) + dest := newEmptyKubeconfigFile(t) + + stdout, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest, + "-o", "json", + }) + g.Expect(err).To(BeNil()) + g.Expect(stdout).To(ContainSubstring(`"contextName": "greenhouse-sap-cna"`)) + g.Expect(stdout).To(ContainSubstring(`"added"`)) +} + +func TestBootstrapCmd_MissingBothDataAndServer(t *testing.T) { + g := NewWithT(t) + dest := newEmptyKubeconfigFile(t) + + _, _, err := runBootstrapCmd(t, []string{ + "--kubeconfig=" + dest, + "--greenhouse-org=my-org", + }) + g.Expect(err).To(MatchError(ContainSubstring("--greenhouse-server"))) +} + +func TestBootstrapCmd_RawBase64Encoding(t *testing.T) { + g := NewWithT(t) + + src := realGreenhouseKubeconfig("sap-cna") + raw, err := clientcmd.Write(*src) + g.Expect(err).To(BeNil()) + + // Some tools emit raw base64 (no padding). + data := base64.RawStdEncoding.EncodeToString(raw) + // Ensure there's no padding so we're testing the raw path. + g.Expect(strings.Contains(data, "=")).To(BeFalse()) + + dest := newEmptyKubeconfigFile(t) + _, _, err = runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap-cna", + "--kubeconfig=" + dest, + }) + g.Expect(err).To(BeNil()) + result := loadKubeconfig(t, dest) + g.Expect(result.Clusters).To(HaveKey("greenhouse-sap-cna")) +} + +func TestBootstrapCmd_KUBECONFIGEmptyFirstSegment(t *testing.T) { + g := NewWithT(t) + cfg := realGreenhouseKubeconfig("sap") + data := encodeKubeconfig(t, cfg) + + second := writeTempKubeconfig(t, clientcmdapi.NewConfig()) + t.Setenv("KUBECONFIG", string(os.PathListSeparator)+second) + + _, _, err := runBootstrapCmd(t, []string{ + "--data=" + data, + "--context-name=greenhouse-sap", + }) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no usable first path")) +} diff --git a/cmd/output/interactive_printer.go b/cmd/output/interactive_printer.go index 1f52d39..0001b4d 100644 --- a/cmd/output/interactive_printer.go +++ b/cmd/output/interactive_printer.go @@ -125,6 +125,40 @@ func (p *interactivePrinter) Print(v any) error { default: w("cloudctl update status: %s (current: %s, latest: %s)\n", t.Status, t.CurrentVersion, t.LatestVersion) } + case BootstrapResult: + if t.DryRun { + w("%s\n\n", styleFaint.Render("Dry-run: no changes will be written.")) + } + for _, entry := range t.Added { + w(" %s %s\n", styleGreen.Render("[+]"), entry) + } + for _, entry := range t.Skipped { + w(" %s %s\n", styleFaint.Render("[=]"), entry) + } + nothingNew := len(t.Added) == 0 && !t.CurrentContextUpdated + if nothingNew && len(t.Skipped) > 0 { + w("%s\n", styleFaint.Render("Bootstrap: nothing new to write — all entries already exist.")) + } + if t.DryRun { + w("\n%s\n", styleFaint.Render("Bootstrap complete (dry-run). Run without --dry-run to apply.")) + break + } + if !nothingNew { + w("\n%s\n", styleGreen.Render("Bootstrap complete.")) + if t.KubeconfigPath != "" { + w(" %s %s\n", styleFaint.Render("kubeconfig:"), t.KubeconfigPath) + } + w(" %s %s\n", styleFaint.Render("context: "), styleBold.Render(t.ContextName)) + if t.SetAsCurrent { + w(" %s\n", styleFaint.Render("set as current context.")) + } + } + org := t.Org + if org == "" { + org = strings.TrimPrefix(t.ContextName, "greenhouse-") + } + w("\nRun %s to pull in your cluster access.\n", + styleBold.Render(fmt.Sprintf("cloudctl sync -n %s", org))) default: w("%v\n", v) } diff --git a/cmd/output/plain_printer.go b/cmd/output/plain_printer.go index 5688dc7..2b43daf 100644 --- a/cmd/output/plain_printer.go +++ b/cmd/output/plain_printer.go @@ -100,6 +100,41 @@ func (p *plainPrinter) Print(v any) error { w("cloudctl update status: %s (current: %s, latest: %s)\n", t.Status, t.CurrentVersion, t.LatestVersion) } + case BootstrapResult: + if t.DryRun { + w("Dry-run: no changes will be written.\n\n") + } + for _, entry := range t.Added { + w(" [+] %s\n", entry) + } + for _, entry := range t.Skipped { + w(" [=] %s\n", entry) + } + nothingNew := len(t.Added) == 0 && !t.CurrentContextUpdated + if nothingNew && len(t.Skipped) > 0 { + w("Bootstrap: nothing new to write — all entries already exist.\n") + } + if t.DryRun { + w("\nBootstrap complete (dry-run). Run without --dry-run to apply.\n") + break + } + if !nothingNew { + w("\nBootstrap complete.\n") + if t.KubeconfigPath != "" { + w(" kubeconfig: %s\n", t.KubeconfigPath) + } + w(" context: %s\n", t.ContextName) + if t.SetAsCurrent { + w(" set as current context.\n") + } + } + org := t.Org + if org == "" { + // Extract org from context name "greenhouse-". + org = strings.TrimPrefix(t.ContextName, "greenhouse-") + } + w("\nRun `cloudctl sync -n %s` to pull in your cluster access.\n", org) + default: w("%v\n", v) } diff --git a/cmd/output/types.go b/cmd/output/types.go index 7511648..5738d38 100644 --- a/cmd/output/types.go +++ b/cmd/output/types.go @@ -99,3 +99,15 @@ type FieldChange struct { Old string `json:"old" yaml:"old"` New string `json:"new" yaml:"new"` } + +// BootstrapResult is the output of the bootstrap command. +type BootstrapResult struct { + ContextName string `json:"contextName" yaml:"contextName"` + SetAsCurrent bool `json:"setAsCurrent" yaml:"setAsCurrent"` + CurrentContextUpdated bool `json:"currentContextUpdated,omitzero" yaml:"currentContextUpdated,omitempty"` + Added []string `json:"added,omitzero" yaml:"added,omitempty"` + Skipped []string `json:"skipped,omitzero" yaml:"skipped,omitempty"` + KubeconfigPath string `json:"kubeconfigPath,omitempty" yaml:"kubeconfigPath,omitempty"` + DryRun bool `json:"dryRun,omitzero" yaml:"dryRun,omitempty"` + Org string `json:"org,omitempty" yaml:"org,omitempty"` +}