diff --git a/cmd/authinfo.go b/cmd/authinfo.go new file mode 100644 index 0000000..5b8e2cd --- /dev/null +++ b/cmd/authinfo.go @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "maps" + "slices" + "sort" + "strings" + + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// authInfoEqual compares the credential-bearing fields of two AuthInfo objects +// for deduplication purposes. It compares ClientCertificateData, ClientKeyData, +// Exec (all fields except tokens), and AuthProvider (name + config, excluding +// "id-token" and "refresh-token"). Fields that carry local-only or session state +// (Token, TokenFile, Username, Password, Impersonate, etc.) are intentionally +// not compared so that local customisations do not prevent deduplication. +func authInfoEqual(a, b *clientcmdapi.AuthInfo) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + // Compare ClientCertificateData + if !bytes.Equal(a.ClientCertificateData, b.ClientCertificateData) { + return false + } + + // Compare ClientKeyData + if !bytes.Equal(a.ClientKeyData, b.ClientKeyData) { + return false + } + + // Compare Exec first (new style) + if (a.Exec == nil) != (b.Exec == nil) { + return false + } + if a.Exec != nil && b.Exec != nil { + if a.Exec.Command != b.Exec.Command || a.Exec.APIVersion != b.Exec.APIVersion { + return false + } + if !slices.Equal(a.Exec.Args, b.Exec.Args) { + return false + } + if a.Exec.InteractiveMode != b.Exec.InteractiveMode { + return false + } + if !equalExecEnv(a.Exec.Env, b.Exec.Env) { + return false + } + return true + } + + // Compare AuthProvider, excluding "id-token" and "refresh-token" + if (a.AuthProvider == nil) != (b.AuthProvider == nil) { + return false + } + if a.AuthProvider != nil && b.AuthProvider != nil { + // Compare AuthProvider Name + if a.AuthProvider.Name != b.AuthProvider.Name { + return false + } + + // Compare AuthProvider Config excluding "id-token" and "refresh-token" + aConfigFiltered := filterAuthProviderConfig(a.AuthProvider.Config) + bConfigFiltered := filterAuthProviderConfig(b.AuthProvider.Config) + if !maps.Equal(aConfigFiltered, bConfigFiltered) { + return false + } + } + + return true +} + +// equalExecEnv compares two ExecEnvVar slices for equality, independent of ordering. +func equalExecEnv(a, b []clientcmdapi.ExecEnvVar) bool { + if len(a) != len(b) { + return false + } + // Build a frequency map so order differences are not treated as changes. + counts := make(map[string]int, len(a)) + for _, e := range a { + counts[e.Name+"="+e.Value]++ + } + for _, e := range b { + counts[e.Name+"="+e.Value]-- + if counts[e.Name+"="+e.Value] < 0 { + return false + } + } + return true +} + +// filterAuthProviderConfig returns a copy of the config map excluding "id-token" and "refresh-token". +func filterAuthProviderConfig(config map[string]string) map[string]string { + filtered := make(map[string]string) + for k, v := range config { + if k != "id-token" && k != "refresh-token" { + filtered[k] = v + } + } + return filtered +} + +// generateAuthInfoKey creates a stable deduplication key for an AuthInfo. +// The key intentionally uses a subset of fields so that tokens and irrelevant +// args do not prevent deduplication of otherwise-identical credentials: +// - Exec-based: Command, APIVersion, InteractiveMode, Env, and the OIDC- +// related flag values (issuer, client-id, client-secret, extra-params, +// scopes). Non-OIDC extra args are intentionally excluded. +// - AuthProvider-based: provider Name plus the full filtered config +// (all keys except "id-token" and "refresh-token"), sorted for stability. +// - Certificate-based: SHA-256 of ClientCertificateData + ClientKeyData. +// +// Note: authInfoEqual compares the full Exec.Args slice, so two authinfos that +// differ only in non-OIDC extra args will have the same key but fail equality. +// The reuse path in mergeKubeconfig guards against this with authInfoEqual. +func generateAuthInfoKey(authInfo *clientcmdapi.AuthInfo) string { + // Exec-based key: derive from stable subset of args to avoid including tokens + if authInfo.Exec != nil { + // Extract known kubelogin flags + var issuer, clientID, clientSecret, extraParams string + var scopes []string + var envParts []string + for _, arg := range authInfo.Exec.Args { + switch { + case strings.HasPrefix(arg, "--oidc-issuer-url="): + issuer = strings.TrimPrefix(arg, "--oidc-issuer-url=") + case strings.HasPrefix(arg, "--oidc-client-id="): + clientID = strings.TrimPrefix(arg, "--oidc-client-id=") + case strings.HasPrefix(arg, "--oidc-client-secret="): + clientSecret = strings.TrimPrefix(arg, "--oidc-client-secret=") + case strings.HasPrefix(arg, "--oidc-extra-scope="): + scopes = append(scopes, strings.TrimPrefix(arg, "--oidc-extra-scope=")) + case strings.HasPrefix(arg, "--oidc-auth-request-extra-params="): + extraParams = strings.TrimPrefix(arg, "--oidc-auth-request-extra-params=") + } + } + sort.Strings(scopes) + // Include sorted Env in the key so changes to env vars result in a different key + for _, e := range authInfo.Exec.Env { + envParts = append(envParts, e.Name+"="+e.Value) + } + sort.Strings(envParts) + data := fmt.Sprintf("exec:cmd:%s;api:%s;mode:%s;issuer:%s;client-id:%s;client-secret:%s;extra-params:%s;scopes:%s;env:%s", + authInfo.Exec.Command, authInfo.Exec.APIVersion, authInfo.Exec.InteractiveMode, + issuer, clientID, clientSecret, extraParams, strings.Join(scopes, ","), strings.Join(envParts, ",")) + return data + } + + if authInfo.AuthProvider == nil { + // For AuthInfos without AuthProvider, use a different unique identifier + // Here, we'll use the hash of ClientCertificateData and ClientKeyData + h := sha256.New() + h.Write(authInfo.ClientCertificateData) + h.Write(authInfo.ClientKeyData) + return fmt.Sprintf("cert:%s", hex.EncodeToString(h.Sum(nil))) + } + + // Hash the full filtered config (same set authInfoEqual compares) so the key + // is exactly as discriminating as the equality check. Sorting the keys ensures + // a stable hash regardless of map iteration order. + filtered := filterAuthProviderConfig(authInfo.AuthProvider.Config) + keys := slices.Sorted(maps.Keys(filtered)) + var parts []string + for _, k := range keys { + parts = append(parts, k+"="+filtered[k]) + } + data := fmt.Sprintf("name:%s;config:%s", + authInfo.AuthProvider.Name, strings.Join(parts, ";")) + + return data +} + +// mergeAuthInfo merges two AuthInfo objects, preserving id-token and refresh-token from localAuth. +func mergeAuthInfo(serverAuth, localAuth *clientcmdapi.AuthInfo) *clientcmdapi.AuthInfo { + if localAuth == nil { + // If there's no local AuthInfo, return the server AuthInfo as is + return serverAuth + } + + // Create a copy of the serverAuth to avoid mutating the original + mergedAuth := serverAuth.DeepCopy() + + // Preserve id-token and refresh-token from localAuth + if localAuth.AuthProvider != nil && mergedAuth.AuthProvider != nil { + // Ensure the merged config map is initialized to avoid panics on assignment + if mergedAuth.AuthProvider.Config == nil { + mergedAuth.AuthProvider.Config = make(map[string]string) + } + if idToken, exists := localAuth.AuthProvider.Config["id-token"]; exists { + mergedAuth.AuthProvider.Config["id-token"] = idToken + } + if refreshToken, exists := localAuth.AuthProvider.Config["refresh-token"]; exists { + mergedAuth.AuthProvider.Config["refresh-token"] = refreshToken + } + } + + return mergedAuth +} diff --git a/cmd/kubeconfigdiff.go b/cmd/kubeconfigdiff.go new file mode 100644 index 0000000..3acb942 --- /dev/null +++ b/cmd/kubeconfigdiff.go @@ -0,0 +1,752 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/cloudoperators/cloudctl/cmd/output" +) + +// sensitiveAuthProviderKeys are auth-provider config keys whose values must +// not appear in plain or structured diff output. +var sensitiveAuthProviderKeys = map[string]bool{ + "client-secret": true, +} + +// sensitiveArgPrefixes are kubelogin (and similar) flag prefixes whose values +// must not appear verbatim in diff output. +var sensitiveArgPrefixes = []string{ + "--oidc-client-secret=", +} + +// DiffChangeType describes the kind of change detected for a kubeconfig entry. +type DiffChangeType string + +const ( + DiffChangeAdded DiffChangeType = "added" + DiffChangeRemoved DiffChangeType = "removed" + DiffChangeModified DiffChangeType = "modified" +) + +// KubeconfigDiff holds the set of entry-level differences between two kubeconfigs. +// Only managed entries (those carrying the configured prefix) are included. +type KubeconfigDiff struct { + Clusters []EntryDiff + Contexts []EntryDiff + AuthInfos []EntryDiff +} + +// EntryDiff describes the diff for a single named kubeconfig entry. +type EntryDiff struct { + Name string + ChangeType DiffChangeType + Fields []FieldDiff // non-empty only for DiffChangeModified +} + +// FieldDiff is an internal field-level change, mapped to output.FieldChange for printing. +type FieldDiff struct { + Field string + Old string + New string +} + +// diffKubeconfig computes the diff between the old and new kubeconfig, +// restricting to entries whose names are managed (have the prefix). +func diffKubeconfig(oldCfg, newCfg *clientcmdapi.Config) KubeconfigDiff { + var d KubeconfigDiff + d.Clusters = diffClusters(oldCfg, newCfg) + d.Contexts = diffContexts(oldCfg, newCfg) + d.AuthInfos = diffAuthInfos(oldCfg, newCfg) + return d +} + +// diffClusters returns added/removed/modified cluster entries for managed names. +func diffClusters(oldCfg, newCfg *clientcmdapi.Config) []EntryDiff { + var diffs []EntryDiff + + // Added or modified in new + for name, newCluster := range newCfg.Clusters { + if !isManaged(name) { + continue + } + if newCluster == nil { + continue + } + oldCluster, exists := oldCfg.Clusters[name] + if !exists || oldCluster == nil { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeAdded}) + continue + } + var fields []FieldDiff + if oldCluster.Server != newCluster.Server { + fields = append(fields, FieldDiff{Field: "Server", Old: oldCluster.Server, New: newCluster.Server}) + } + if !bytes.Equal(oldCluster.CertificateAuthorityData, newCluster.CertificateAuthorityData) { + oldFP := caFingerprint(oldCluster.CertificateAuthorityData) + newFP := caFingerprint(newCluster.CertificateAuthorityData) + fields = append(fields, FieldDiff{Field: "CA", Old: oldFP, New: newFP}) + } + if !labelsExtensionEqual(oldCluster.Extensions, newCluster.Extensions) { + oldLbl := string(extensionRaw(oldCluster.Extensions, "labels")) + newLbl := string(extensionRaw(newCluster.Extensions, "labels")) + fields = append(fields, FieldDiff{Field: "Labels", Old: oldLbl, New: newLbl}) + } + if len(fields) > 0 { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeModified, Fields: fields}) + } + } + + // Removed from old + for name, oldCluster := range oldCfg.Clusters { + if !isManaged(name) { + continue + } + if oldCluster == nil { + continue + } + newCluster, exists := newCfg.Clusters[name] + if !exists || newCluster == nil { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeRemoved}) + } + } + + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Name < diffs[j].Name }) + return diffs +} + +// isManagedContext returns true if the context at name references a managed cluster +// in either the old or new config (i.e. a cluster whose name has the managed prefix). +// Checking both configs ensures that a context reassigned from a managed to an unmanaged +// cluster is still included in the diff. +func isManagedContext(name string, oldCfg, newCfg *clientcmdapi.Config) bool { + if ctx, ok := newCfg.Contexts[name]; ok && ctx != nil && isManaged(ctx.Cluster) { + return true + } + if ctx, ok := oldCfg.Contexts[name]; ok && ctx != nil && isManaged(ctx.Cluster) { + return true + } + return false +} + +// diffContexts returns added/removed/modified context entries for managed contexts. +// A context is considered managed if its cluster reference carries the managed prefix. +func diffContexts(oldCfg, newCfg *clientcmdapi.Config) []EntryDiff { + var diffs []EntryDiff + + for name, newCtx := range newCfg.Contexts { + if !isManagedContext(name, oldCfg, newCfg) { + continue + } + if newCtx == nil { + continue + } + oldCtx, exists := oldCfg.Contexts[name] + if !exists || oldCtx == nil { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeAdded}) + continue + } + var fields []FieldDiff + if oldCtx.Cluster != newCtx.Cluster { + fields = append(fields, FieldDiff{Field: "Cluster", Old: oldCtx.Cluster, New: newCtx.Cluster}) + } + if oldCtx.AuthInfo != newCtx.AuthInfo { + fields = append(fields, FieldDiff{Field: "AuthInfo", Old: oldCtx.AuthInfo, New: newCtx.AuthInfo}) + } + if oldCtx.Namespace != newCtx.Namespace { + fields = append(fields, FieldDiff{Field: "Namespace", Old: oldCtx.Namespace, New: newCtx.Namespace}) + } + if len(fields) > 0 { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeModified, Fields: fields}) + } + } + + for name, oldCtx := range oldCfg.Contexts { + if !isManagedContext(name, oldCfg, newCfg) { + continue + } + if oldCtx == nil { + continue + } + newCtx, exists := newCfg.Contexts[name] + if !exists || newCtx == nil { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeRemoved}) + } + } + + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Name < diffs[j].Name }) + return diffs +} + +// diffAuthInfos returns added/removed/modified authinfo entries for managed names. +func diffAuthInfos(oldCfg, newCfg *clientcmdapi.Config) []EntryDiff { + var diffs []EntryDiff + + for name, newAuth := range newCfg.AuthInfos { + if !isManaged(name) { + continue + } + if newAuth == nil { + continue + } + oldAuth, exists := oldCfg.AuthInfos[name] + if !exists { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeAdded}) + continue + } + if oldAuth == nil { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeAdded}) + continue + } + if authInfoEqual(oldAuth, newAuth) { + continue + } + var fields []FieldDiff + // Exec-based diff + if newAuth.Exec != nil && oldAuth.Exec != nil { + if oldAuth.Exec.Command != newAuth.Exec.Command { + fields = append(fields, FieldDiff{Field: "Exec Command", Old: oldAuth.Exec.Command, New: newAuth.Exec.Command}) + } + if oldAuth.Exec.APIVersion != newAuth.Exec.APIVersion { + fields = append(fields, FieldDiff{Field: "Exec API version", Old: oldAuth.Exec.APIVersion, New: newAuth.Exec.APIVersion}) + } + if oldAuth.Exec.InteractiveMode != newAuth.Exec.InteractiveMode { + fields = append(fields, FieldDiff{Field: "Exec interactive mode", Old: string(oldAuth.Exec.InteractiveMode), New: string(newAuth.Exec.InteractiveMode)}) + } + if !equalExecEnv(oldAuth.Exec.Env, newAuth.Exec.Env) { + fields = append(fields, FieldDiff{Field: "Exec Env", Old: fmt.Sprintf("%d var(s)", len(oldAuth.Exec.Env)), New: fmt.Sprintf("%d var(s)", len(newAuth.Exec.Env))}) + } + fields = append(fields, argsDiff(oldAuth.Exec.Args, newAuth.Exec.Args)...) + } else if newAuth.Exec != nil && oldAuth.Exec == nil { + fields = append(fields, FieldDiff{Field: "Auth type", Old: "auth-provider", New: "exec-plugin"}) + } else if newAuth.Exec == nil && oldAuth.Exec != nil { + fields = append(fields, FieldDiff{Field: "Auth type", Old: "exec-plugin", New: "auth-provider"}) + } + // AuthProvider diff + if newAuth.AuthProvider != nil && oldAuth.AuthProvider != nil { + if oldAuth.AuthProvider.Name != newAuth.AuthProvider.Name { + fields = append(fields, FieldDiff{Field: "Auth provider", Old: oldAuth.AuthProvider.Name, New: newAuth.AuthProvider.Name}) + } + oldFiltered := filterAuthProviderConfig(oldAuth.AuthProvider.Config) + newFiltered := filterAuthProviderConfig(newAuth.AuthProvider.Config) + allKeys := make(map[string]struct{}) + for k := range oldFiltered { + allKeys[k] = struct{}{} + } + for k := range newFiltered { + allKeys[k] = struct{}{} + } + sortedKeys := make([]string, 0, len(allKeys)) + for k := range allKeys { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + for _, k := range sortedKeys { + ov := oldFiltered[k] + nv := newFiltered[k] + if ov != nv { + if sensitiveAuthProviderKeys[k] { + ov, nv = "", "" + } + fields = append(fields, FieldDiff{Field: fmt.Sprintf("auth-provider.%s", k), Old: ov, New: nv}) + } + } + } + if len(fields) > 0 { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeModified, Fields: fields}) + } else { + // authInfoEqual returned false but no specific fields identified — generic modified + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeModified}) + } + } + + for name, oldAuth := range oldCfg.AuthInfos { + if !isManaged(name) { + continue + } + if oldAuth == nil { + continue + } + newAuth, exists := newCfg.AuthInfos[name] + if !exists || newAuth == nil { + diffs = append(diffs, EntryDiff{Name: name, ChangeType: DiffChangeRemoved}) + } + } + + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Name < diffs[j].Name }) + return diffs +} + +// redactArg replaces the value portion of a sensitive flag with , +// leaving the flag name intact for readability (e.g. "--oidc-client-secret="). +func redactArg(arg string) string { + for _, pfx := range sensitiveArgPrefixes { + if strings.HasPrefix(arg, pfx) { + return pfx + "" + } + } + return arg +} + +// argsDiff computes per-argument differences between two exec arg lists, returning +// FieldDiff entries for args that appear only in old (removed) or only in new (added). +// Values of sensitive flags (e.g. --oidc-client-secret=) are redacted, with the flag +// prefix preserved (e.g. "--oidc-client-secret="). +// When a sensitive flag appears in both removed and added (value changed), a single +// entry with Old and New both set to "" is emitted rather than +// separate lines. +func argsDiff(oldArgs, newArgs []string) []FieldDiff { + // Use frequency maps so duplicate arguments (e.g. repeated --oidc-extra-scope) + // are handled correctly: an arg is "removed" if it appears more times in + // oldArgs than newArgs, and "added" if the opposite. + oldCount := make(map[string]int, len(oldArgs)) + for _, a := range oldArgs { + oldCount[a]++ + } + newCount := make(map[string]int, len(newArgs)) + for _, a := range newArgs { + newCount[a]++ + } + + var removed, added []string + for _, a := range oldArgs { + if oldCount[a] > newCount[a] { + removed = append(removed, a) + oldCount[a]-- // consume one occurrence so duplicates are counted once each + } + } + for _, a := range newArgs { + if newCount[a] > oldCount[a] { + added = append(added, a) + newCount[a]-- + } + } + + // Pair up sensitive flag changes: if the same prefix appears in both removed + // and added, the value changed — emit a single modified entry instead of + // separate remove+add lines that both redact to the same visible string. + pairedPrefixes := make(map[string]bool) + for _, pfx := range sensitiveArgPrefixes { + var inRemoved, inAdded bool + for _, r := range removed { + if strings.HasPrefix(r, pfx) { + inRemoved = true + break + } + } + for _, a := range added { + if strings.HasPrefix(a, pfx) { + inAdded = true + break + } + } + if inRemoved && inAdded { + pairedPrefixes[pfx] = true + } + } + + var diffs []FieldDiff + // Emit paired sensitive changes as a single modified entry. + // Iterate over sensitiveArgPrefixes (ordered slice) rather than the map to + // keep output order deterministic regardless of how many prefixes match. + for _, pfx := range sensitiveArgPrefixes { + if pairedPrefixes[pfx] { + diffs = append(diffs, FieldDiff{Field: "Exec Args", Old: pfx + "", New: pfx + ""}) + } + } + for _, r := range removed { + isPaired := false + for pfx := range pairedPrefixes { + if strings.HasPrefix(r, pfx) { + isPaired = true + break + } + } + if !isPaired { + diffs = append(diffs, FieldDiff{Field: "Exec Args", Old: redactArg(r), New: ""}) + } + } + for _, a := range added { + isPaired := false + for pfx := range pairedPrefixes { + if strings.HasPrefix(a, pfx) { + isPaired = true + break + } + } + if !isPaired { + diffs = append(diffs, FieldDiff{Field: "Exec Args", Old: "", New: redactArg(a)}) + } + } + return diffs +} + +// caFingerprint returns the first 16 hex characters of the SHA-256 hash of ca data. +func caFingerprint(data []byte) string { + if len(data) == 0 { + return "" + } + h := sha256.Sum256(data) + return hex.EncodeToString(h[:])[:16] +} + +// toOutputDiffEntries converts internal EntryDiff slice to output.DiffEntry slice. +func toOutputDiffEntries(diffs []EntryDiff) []output.DiffEntry { + result := make([]output.DiffEntry, 0, len(diffs)) + for _, d := range diffs { + entry := output.DiffEntry{ + Name: d.Name, + ChangeType: string(d.ChangeType), + } + for _, f := range d.Fields { + entry.Fields = append(entry.Fields, output.FieldChange{ + Field: f.Field, + Old: f.Old, + New: f.New, + }) + } + result = append(result, entry) + } + return result +} + +// buildAccessDiffs derives a []output.AccessDiff from the context-level diff, +// enriching each entry with the server URL (looked up from the cluster the +// context references) and credential-change information. +// It also synthesises modified access entries for contexts whose underlying +// cluster changed server URL or CA (tracked in diff.Clusters) even when the +// context itself was not structurally modified. +func buildAccessDiffs(diff KubeconfigDiff, oldCfg, newCfg *clientcmdapi.Config) []output.AccessDiff { + // accesses keyed by context name to allow merging + type accessEntry struct { + access output.AccessDiff + } + byName := make(map[string]*accessEntry) + + // 1. Process explicit context-level diffs (added / removed / modified context refs) + for _, ctxDiff := range diff.Contexts { + name := ctxDiff.Name + entry := &accessEntry{ + access: output.AccessDiff{ + Name: name, + ChangeType: string(ctxDiff.ChangeType), + }, + } + + switch ctxDiff.ChangeType { + case DiffChangeAdded: + if ctx, ok := newCfg.Contexts[name]; ok && ctx != nil { + if cluster, ok := newCfg.Clusters[ctx.Cluster]; ok && cluster != nil { + entry.access.Server = cluster.Server + } + } + case DiffChangeRemoved: + if ctx, ok := oldCfg.Contexts[name]; ok && ctx != nil { + if cluster, ok := oldCfg.Clusters[ctx.Cluster]; ok && cluster != nil { + entry.access.Server = cluster.Server + } + } + case DiffChangeModified: + oldCtx := oldCfg.Contexts[name] + newCtx := newCfg.Contexts[name] + if oldCtx == nil || newCtx == nil { + break + } + // Namespace change (carried directly from the context-level diff fields) + for _, fd := range ctxDiff.Fields { + if fd.Field == "Namespace" { + entry.access.Fields = append(entry.access.Fields, output.FieldChange{ + Field: "Namespace", + Old: fd.Old, + New: fd.New, + }) + } + } + // Server URL change (resolve through cluster refs) + oldCluster := oldCfg.Clusters[oldCtx.Cluster] + newCluster := newCfg.Clusters[newCtx.Cluster] + oldServer, newServer := "", "" + if oldCluster != nil { + oldServer = oldCluster.Server + } + if newCluster != nil { + newServer = newCluster.Server + } + if oldServer != newServer { + entry.access.Fields = append(entry.access.Fields, output.FieldChange{ + Field: "Server", + Old: oldServer, + New: newServer, + }) + } + // Carry through any credential field diffs from the authinfo diff + // (exec args, auth-provider config). If the auth objects are simply + // absent or unequal with no specific fields, fall back to the + // generic sentinel. + oldAuth := oldCfg.AuthInfos[oldCtx.AuthInfo] + newAuth := newCfg.AuthInfos[newCtx.AuthInfo] + credChanged := (oldAuth != nil && newAuth != nil && !authInfoEqual(oldAuth, newAuth)) || + (oldAuth == nil) != (newAuth == nil) + if credChanged { + // Try to produce specific field-level diffs by comparing the + // auth objects directly, the same way diffAuthInfos does. + var authFields []output.FieldChange + if oldAuth != nil && newAuth != nil { + if newAuth.Exec != nil && oldAuth.Exec != nil { + if oldAuth.Exec.Command != newAuth.Exec.Command { + authFields = append(authFields, output.FieldChange{Field: "Exec Command", Old: oldAuth.Exec.Command, New: newAuth.Exec.Command}) + } + if oldAuth.Exec.APIVersion != newAuth.Exec.APIVersion { + authFields = append(authFields, output.FieldChange{Field: "Exec API version", Old: oldAuth.Exec.APIVersion, New: newAuth.Exec.APIVersion}) + } + if oldAuth.Exec.InteractiveMode != newAuth.Exec.InteractiveMode { + authFields = append(authFields, output.FieldChange{Field: "Exec interactive mode", Old: string(oldAuth.Exec.InteractiveMode), New: string(newAuth.Exec.InteractiveMode)}) + } + if !equalExecEnv(oldAuth.Exec.Env, newAuth.Exec.Env) { + authFields = append(authFields, output.FieldChange{Field: "Exec Env", Old: fmt.Sprintf("%d var(s)", len(oldAuth.Exec.Env)), New: fmt.Sprintf("%d var(s)", len(newAuth.Exec.Env))}) + } + for _, fd := range argsDiff(oldAuth.Exec.Args, newAuth.Exec.Args) { + authFields = append(authFields, output.FieldChange{ + Field: fd.Field, + Old: fd.Old, + New: fd.New, + }) + } + } else if newAuth.Exec != nil && oldAuth.Exec == nil { + authFields = append(authFields, output.FieldChange{Field: "Auth type", Old: "auth-provider", New: "exec-plugin"}) + } else if newAuth.Exec == nil && oldAuth.Exec != nil { + authFields = append(authFields, output.FieldChange{Field: "Auth type", Old: "exec-plugin", New: "auth-provider"}) + } + if newAuth.AuthProvider != nil && oldAuth.AuthProvider != nil { + if oldAuth.AuthProvider.Name != newAuth.AuthProvider.Name { + authFields = append(authFields, output.FieldChange{Field: "Auth provider", Old: oldAuth.AuthProvider.Name, New: newAuth.AuthProvider.Name}) + } + oldFiltered := filterAuthProviderConfig(oldAuth.AuthProvider.Config) + newFiltered := filterAuthProviderConfig(newAuth.AuthProvider.Config) + for _, k := range sortedKeys(oldFiltered, newFiltered) { + ov, nv := oldFiltered[k], newFiltered[k] + if ov != nv { + if sensitiveAuthProviderKeys[k] { + ov, nv = "", "" + } + authFields = append(authFields, output.FieldChange{ + Field: fmt.Sprintf("auth-provider.%s", k), + Old: ov, + New: nv, + }) + } + } + } + } + if len(authFields) > 0 { + entry.access.Fields = append(entry.access.Fields, authFields...) + } else { + entry.access.Fields = append(entry.access.Fields, output.FieldChange{ + Field: "Credentials", + Old: "changed", + New: "changed", + }) + } + } + } + // Skip modified entries that have no user-visible field changes — + // these arise from internal authinfo hash reassignment during + // deduplication where the effective credentials are unchanged. + if ctxDiff.ChangeType == DiffChangeModified && len(entry.access.Fields) == 0 { + continue + } + byName[name] = entry + } + + // 2. For each modified cluster, find contexts in newCfg that reference it. + // Merge cluster field changes (Server, CA, Labels) into the access entry, + // creating a new entry if the context was not already captured in step 1. + modifiedClusters := make(map[string]EntryDiff, len(diff.Clusters)) + for _, cd := range diff.Clusters { + if cd.ChangeType == DiffChangeModified { + modifiedClusters[cd.Name] = cd + } + } + if len(modifiedClusters) > 0 { + for ctxName, ctx := range newCfg.Contexts { + if ctx == nil { + continue + } + clusterDiff, affected := modifiedClusters[ctx.Cluster] + if !affected { + continue + } + var fields []output.FieldChange + for _, f := range clusterDiff.Fields { + fields = append(fields, output.FieldChange{ + Field: f.Field, + Old: f.Old, + New: f.New, + }) + } + if len(fields) == 0 { + continue + } + if existing, ok := byName[ctxName]; ok { + // Merge cluster fields into the existing entry. + existing.access.Fields = append(fields, existing.access.Fields...) + } else { + byName[ctxName] = &accessEntry{ + access: output.AccessDiff{ + Name: ctxName, + ChangeType: string(DiffChangeModified), + Fields: fields, + }, + } + } + } + } + + // 3. For each modified authinfo, find contexts in newCfg that reference it. + // Carry the actual field diffs (exec args, auth-provider config) through so + // diff format can show real old/new values. Merge into an existing entry when + // one was already created by step 1 or 2, so credential changes are never + // silently dropped. Fall back to a generic "Credentials: changed" entry only + // when no specific fields were identified. + modifiedAuthInfos := make(map[string]EntryDiff, len(diff.AuthInfos)) + for _, ad := range diff.AuthInfos { + if ad.ChangeType == DiffChangeModified { + modifiedAuthInfos[ad.Name] = ad + } + } + if len(modifiedAuthInfos) > 0 { + for ctxName, ctx := range newCfg.Contexts { + if ctx == nil { + continue + } + ad, affected := modifiedAuthInfos[ctx.AuthInfo] + if !affected { + continue + } + var fields []output.FieldChange + for _, f := range ad.Fields { + fields = append(fields, output.FieldChange{ + Field: f.Field, + Old: f.Old, + New: f.New, + }) + } + if len(fields) == 0 { + // authInfoEqual returned false but no specific fields were identified. + fields = []output.FieldChange{{Field: "Credentials", Old: "changed", New: "changed"}} + } + if existing, ok := byName[ctxName]; ok { + // Merge credential fields into the existing entry. + existing.access.Fields = append(existing.access.Fields, fields...) + } else { + byName[ctxName] = &accessEntry{ + access: output.AccessDiff{ + Name: ctxName, + ChangeType: string(DiffChangeModified), + Fields: fields, + }, + } + } + } + } + + // Flatten, deduplicate fields, and sort + accesses := make([]output.AccessDiff, 0, len(byName)) + for _, e := range byName { + e.access.Fields = deduplicateFieldChanges(e.access.Fields) + accesses = append(accesses, e.access) + } + sort.Slice(accesses, func(i, j int) bool { return accesses[i].Name < accesses[j].Name }) + return accesses +} + +// buildDryRunResult converts a KubeconfigDiff to an output.SyncDryRunResult. +// oldCfg and newCfg are needed to build the context-centric AccessDiff entries. +func buildDryRunResult(diff KubeconfigDiff, oldCfg, newCfg *clientcmdapi.Config) output.SyncDryRunResult { + accesses := buildAccessDiffs(diff, oldCfg, newCfg) + + var added, removed, modified int + for _, a := range accesses { + switch DiffChangeType(a.ChangeType) { + case DiffChangeAdded: + added++ + case DiffChangeRemoved: + removed++ + case DiffChangeModified: + modified++ + } + } + + clusters := toOutputDiffEntries(diff.Clusters) + contexts := toOutputDiffEntries(diff.Contexts) + authInfos := toOutputDiffEntries(diff.AuthInfos) + + // Use empty slices instead of nil for consistent JSON/YAML output + if accesses == nil { + accesses = []output.AccessDiff{} + } + if clusters == nil { + clusters = []output.DiffEntry{} + } + if contexts == nil { + contexts = []output.DiffEntry{} + } + if authInfos == nil { + authInfos = []output.DiffEntry{} + } + + return output.SyncDryRunResult{ + Accesses: accesses, + Clusters: clusters, + Contexts: contexts, + AuthInfos: authInfos, + Added: added, + Removed: removed, + Modified: modified, + } +} + +// sortedKeys returns the union of keys from two string maps, sorted. +func sortedKeys(a, b map[string]string) []string { + seen := make(map[string]struct{}, len(a)+len(b)) + for k := range a { + seen[k] = struct{}{} + } + for k := range b { + seen[k] = struct{}{} + } + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// deduplicateFieldChanges removes duplicate FieldChange entries, preserving order. +// Duplicates can arise when multiple diff sources (context, cluster, authinfo) all +// contribute the same field change to the same access entry. +func deduplicateFieldChanges(fields []output.FieldChange) []output.FieldChange { + if len(fields) <= 1 { + return fields + } + type key struct{ field, old, new string } + seen := make(map[key]struct{}, len(fields)) + out := fields[:0:0] + for _, f := range fields { + k := key{f.Field, f.Old, f.New} + if _, dup := seen[k]; !dup { + seen[k] = struct{}{} + out = append(out, f) + } + } + return out +} diff --git a/cmd/output/interactive_printer.go b/cmd/output/interactive_printer.go index 6978e97..70c79ab 100644 --- a/cmd/output/interactive_printer.go +++ b/cmd/output/interactive_printer.go @@ -6,6 +6,7 @@ package output import ( "fmt" "io" + "strings" "sync" "time" @@ -94,6 +95,8 @@ func (p *interactivePrinter) Print(v any) error { switch t := v.(type) { case SyncResult: writeErr = p.printSyncResult(t) + case SyncDryRunResult: + writeErr = p.printSyncDryRunResult(t) case ClusterVersionResult: w("%s %s\n", styleFaint.Render("Kubernetes version:"), styleBold.Render(t.Version)) case VersionInfo: @@ -199,3 +202,93 @@ func (p *interactivePrinter) printSyncResult(r SyncResult) error { w("%s\n", summary) return writeErr } + +func (p *interactivePrinter) printSyncDryRunResult(r SyncDryRunResult) error { + var writeErr error + w := func(format string, a ...any) { + if writeErr != nil { + return + } + _, writeErr = fmt.Fprintf(p.w, format, a...) + } + + total := r.Added + r.Removed + r.Modified + if total == 0 { + w("%s\n", styleFaint.Render("No changes detected.")) + return writeErr + } + + w("%s\n\n", styleFaint.Render("Dry-run: no changes will be written.")) + w("%s (%d change(s))\n", styleHeader.Render("CLUSTER ACCESSES"), total) + + p.printDryRunDiff(w, r) + + return writeErr +} + +func (p *interactivePrinter) printDryRunDiff(w func(string, ...any), r SyncDryRunResult) { + for _, a := range r.Accesses { + switch a.ChangeType { + case "added": + w("%s %s\n", styleGreen.Render("+"), a.Name) + if a.Server != "" { + w(" %s %-12s %s\n", styleGreen.Render("+"), "server:", a.Server) + } + case "removed": + w("%s %s\n", styleRed.Render("-"), a.Name) + if a.Server != "" { + w(" %s %-12s %s\n", styleRed.Render("-"), "server:", a.Server) + } + case "modified": + w("%s %s\n", styleYellow.Render("~"), a.Name) + for _, f := range a.Fields { + if f.Field == "Credentials" { + w(" %s %-12s %s\n", styleYellow.Render("~"), "credentials:", styleYellow.Render("changed")) + } else if f.Old == f.New { + w(" %s %-12s %s\n", styleYellow.Render("~"), strings.ToLower(f.Field)+":", styleYellow.Render("changed")) + } else { + label := strings.ToLower(f.Field) + ":" + // For per-argument Exec Args diffs, Old=="" means the arg was added + // and New=="" means it was removed — print only the present side. + if f.Field == "Exec Args" { + if f.Old != "" { + w(" %s %-12s %s\n", styleRed.Render("-"), label, styleRed.Render(f.Old)) + } else { + w(" %s %-12s %s\n", styleGreen.Render("+"), label, styleGreen.Render(f.New)) + } + } else { + oldVal := f.Old + if oldVal == "" { + oldVal = "" + } + newVal := f.New + if newVal == "" { + newVal = "" + } + w(" %s %-12s %s\n", styleRed.Render("-"), label, styleRed.Render(oldVal)) + w(" %s %-12s %s\n", styleGreen.Render("+"), label, styleGreen.Render(newVal)) + } + } + } + } + } + + p.printDryRunSummary(w, r) +} + +func (p *interactivePrinter) printDryRunSummary(w func(string, ...any), r SyncDryRunResult) { + modBreakdown := modifiedBreakdown(r.Accesses) + modSuffix := "" + if r.Modified > 0 && len(modBreakdown) > 0 { + modSuffix = " (" + strings.Join(modBreakdown, ", ") + ")" + } + w("\n") + summaryParts := []string{ + styleGreen.Render(fmt.Sprintf("%d added", r.Added)), + styleRed.Render(fmt.Sprintf("%d removed", r.Removed)), + styleYellow.Render(fmt.Sprintf("%d modified%s", r.Modified, modSuffix)), + } + w("Summary: %s, %s, %s. %s\n", + summaryParts[0], summaryParts[1], summaryParts[2], + styleFaint.Render("No changes will be written.")) +} diff --git a/cmd/output/output_test.go b/cmd/output/output_test.go index 3a21751..1393bf4 100644 --- a/cmd/output/output_test.go +++ b/cmd/output/output_test.go @@ -209,3 +209,109 @@ func TestStartSpinner_NoOp_JSON(t *testing.T) { stop() g.Expect(buf.String()).To(BeEmpty()) } + +// --------------------------------------------------------------------------- +// SyncDryRunResult printers +// --------------------------------------------------------------------------- + +func TestPlainPrinter_SyncDryRunResult_Changes(t *testing.T) { + g := NewWithT(t) + var buf bytes.Buffer + p := output.New(output.FormatText, false, &buf) + + result := output.SyncDryRunResult{ + Accesses: []output.AccessDiff{ + {Name: "prod-eu-1", ChangeType: "added", Server: "https://prod-eu-1.example.com"}, + {Name: "staging-de", ChangeType: "removed", Server: "https://staging.example.com"}, + {Name: "prod-eu-2", ChangeType: "modified", Fields: []output.FieldChange{ + {Field: "Server", Old: "https://old.example.com", New: "https://new.example.com"}, + }}, + }, + Clusters: []output.DiffEntry{}, + Contexts: []output.DiffEntry{}, + AuthInfos: []output.DiffEntry{}, + Added: 1, + Removed: 1, + Modified: 1, + } + g.Expect(p.Print(result)).To(Succeed()) + + out := buf.String() + g.Expect(out).To(ContainSubstring("Dry-run: no changes will be written.")) + g.Expect(out).To(ContainSubstring("CLUSTER ACCESSES")) + g.Expect(out).To(ContainSubstring("+ prod-eu-1")) + g.Expect(out).To(ContainSubstring("https://prod-eu-1.example.com")) + g.Expect(out).To(ContainSubstring("- staging-de")) + g.Expect(out).To(ContainSubstring("~ prod-eu-2")) + g.Expect(out).To(ContainSubstring("server")) // change summary column + g.Expect(out).To(ContainSubstring("Summary:")) + g.Expect(out).To(ContainSubstring("1 added")) + g.Expect(out).To(ContainSubstring("1 removed")) + g.Expect(out).To(ContainSubstring("1 modified")) + g.Expect(out).To(ContainSubstring("No changes will be written.")) +} + +func TestPlainPrinter_SyncDryRunResult_NoChanges(t *testing.T) { + g := NewWithT(t) + var buf bytes.Buffer + p := output.New(output.FormatText, false, &buf) + + result := output.SyncDryRunResult{ + Clusters: []output.DiffEntry{}, + Contexts: []output.DiffEntry{}, + AuthInfos: []output.DiffEntry{}, + } + g.Expect(p.Print(result)).To(Succeed()) + + out := buf.String() + g.Expect(out).To(ContainSubstring("No changes detected.")) +} + +func TestJSONPrinter_SyncDryRunResult(t *testing.T) { + g := NewWithT(t) + var buf bytes.Buffer + p := output.New(output.FormatJSON, false, &buf) + + result := output.SyncDryRunResult{ + Clusters: []output.DiffEntry{ + {Name: "cloudctl:prod", ChangeType: "added"}, + }, + Contexts: []output.DiffEntry{}, + AuthInfos: []output.DiffEntry{}, + Added: 1, + Removed: 0, + Modified: 0, + } + g.Expect(p.Print(result)).To(Succeed()) + + var got output.SyncDryRunResult + g.Expect(json.Unmarshal(buf.Bytes(), &got)).To(Succeed()) + g.Expect(got.Added).To(Equal(1)) + g.Expect(got.Removed).To(Equal(0)) + g.Expect(got.Clusters).To(HaveLen(1)) + g.Expect(got.Clusters[0].Name).To(Equal("cloudctl:prod")) + g.Expect(got.Clusters[0].ChangeType).To(Equal("added")) +} + +func TestYAMLPrinter_SyncDryRunResult(t *testing.T) { + g := NewWithT(t) + var buf bytes.Buffer + p := output.New(output.FormatYAML, false, &buf) + + result := output.SyncDryRunResult{ + Clusters: []output.DiffEntry{ + {Name: "cloudctl:staging", ChangeType: "removed"}, + }, + Contexts: []output.DiffEntry{}, + AuthInfos: []output.DiffEntry{}, + Added: 0, + Removed: 1, + Modified: 0, + } + g.Expect(p.Print(result)).To(Succeed()) + + var got output.SyncDryRunResult + g.Expect(yaml.Unmarshal(buf.Bytes(), &got)).To(Succeed()) + g.Expect(got.Removed).To(Equal(1)) + g.Expect(got.Clusters[0].ChangeType).To(Equal("removed")) +} diff --git a/cmd/output/plain_printer.go b/cmd/output/plain_printer.go index ad79c08..7f5fc53 100644 --- a/cmd/output/plain_printer.go +++ b/cmd/output/plain_printer.go @@ -6,6 +6,8 @@ package output import ( "fmt" "io" + "sort" + "strings" ) type plainPrinter struct { @@ -66,6 +68,16 @@ func (p *plainPrinter) Print(v any) error { } } + case SyncDryRunResult: + total := t.Added + t.Removed + t.Modified + if total == 0 { + w("No changes detected.\n") + break + } + w("Dry-run: no changes will be written.\n\n") + w("CLUSTER ACCESSES (%d change(s))\n", total) + p.printDryRunDiff(w, t) + case ClusterVersionResult: w("Kubernetes version: %s\n", t.Version) @@ -88,3 +100,118 @@ func (p *plainPrinter) PrintError(err error) { func (p *plainPrinter) StartSpinner(_ string) func() { return func() {} } + +// printDryRunDiff renders dry-run output in git-style unified diff format: +// each changed field is shown as a - (old) and + (new) line. +func (p *plainPrinter) printDryRunDiff(w func(string, ...any), t SyncDryRunResult) { + for _, a := range t.Accesses { + switch a.ChangeType { + case "added": + w("+ %s\n", a.Name) + if a.Server != "" { + w(" + server: %s\n", a.Server) + } + case "removed": + w("- %s\n", a.Name) + if a.Server != "" { + w(" - server: %s\n", a.Server) + } + case "modified": + w("~ %s\n", a.Name) + for _, f := range a.Fields { + if f.Field == "Credentials" { + w(" ~ credentials: changed\n") + } else if f.Old == f.New { + // Both sides redacted to the same string — show as a generic change. + w(" ~ %-12s changed\n", strings.ToLower(f.Field)+":") + } else { + label := strings.ToLower(f.Field) + ":" + // For per-argument Exec Args diffs, Old=="" means the arg was added + // and New=="" means it was removed — print only the present side. + if f.Field == "Exec Args" { + if f.Old != "" { + w(" - %-12s %s\n", label, f.Old) + } else { + w(" + %-12s %s\n", label, f.New) + } + } else { + oldVal := f.Old + if oldVal == "" { + oldVal = "" + } + newVal := f.New + if newVal == "" { + newVal = "" + } + w(" - %-12s %s\n", label, oldVal) + w(" + %-12s %s\n", label, newVal) + } + } + } + } + } + + modBreakdown := modifiedBreakdown(t.Accesses) + modSuffix := "" + if t.Modified > 0 && len(modBreakdown) > 0 { + modSuffix = " (" + strings.Join(modBreakdown, ", ") + ")" + } + w("\nSummary: %d added, %d removed, %d modified%s. No changes will be written.\n", + t.Added, t.Removed, t.Modified, modSuffix) +} + +// modifiedBreakdown counts individual change categories across all modified +// accesses and returns them as sorted "N reason" strings +// (e.g. ["45 credentials", "1 server"]). +// A single access entry can contribute to multiple categories. +func modifiedBreakdown(accesses []AccessDiff) []string { + counts := make(map[string]int) + for _, a := range accesses { + if a.ChangeType != "modified" { + continue + } + // Count each category at most once per access entry. + seen := make(map[string]struct{}) + for _, f := range a.Fields { + var cat string + switch { + case f.Field == "Credentials": + cat = "credentials" + case f.Field == "Server": + cat = "server" + case f.Field == "CA": + cat = "ca" + case f.Field == "Labels": + cat = "labels" + case f.Field == "Exec Args" || f.Field == "Auth type" || f.Field == "Auth provider": + cat = "credentials" + case strings.HasPrefix(f.Field, "Exec "): + cat = "credentials" + case strings.HasPrefix(f.Field, "auth-provider."): + cat = "credentials" + default: + cat = "config" + } + if _, already := seen[cat]; !already { + seen[cat] = struct{}{} + counts[cat]++ + } + } + if len(a.Fields) == 0 { + counts["config"]++ + } + } + if len(counts) == 0 { + return nil + } + keys := make([]string, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%d %s", counts[k], k)) + } + return parts +} diff --git a/cmd/output/types.go b/cmd/output/types.go index cded2bf..65accc4 100644 --- a/cmd/output/types.go +++ b/cmd/output/types.go @@ -50,3 +50,36 @@ type VersionInfo struct { type ErrorResult struct { Error string `json:"error" yaml:"error"` } + +// AccessDiff describes one cluster access (context) that is changing. +type AccessDiff struct { + Name string `json:"name" yaml:"name"` + ChangeType string `json:"changeType" yaml:"changeType"` + Server string `json:"server,omitempty" yaml:"server,omitempty"` + Fields []FieldChange `json:"fields,omitzero" yaml:"fields,omitempty"` +} + +// SyncDryRunResult is the output of `sync --dry-run`. +type SyncDryRunResult struct { + Accesses []AccessDiff `json:"accesses" yaml:"accesses"` + Clusters []DiffEntry `json:"clusters" yaml:"clusters"` + Contexts []DiffEntry `json:"contexts" yaml:"contexts"` + AuthInfos []DiffEntry `json:"authInfos" yaml:"authInfos"` + Added int `json:"added" yaml:"added"` + Removed int `json:"removed" yaml:"removed"` + Modified int `json:"modified" yaml:"modified"` +} + +// DiffEntry describes a single added, removed, or modified kubeconfig entry. +type DiffEntry struct { + Name string `json:"name" yaml:"name"` + ChangeType string `json:"changeType" yaml:"changeType"` + Fields []FieldChange `json:"fields,omitzero" yaml:"fields,omitempty"` +} + +// FieldChange describes a field-level change within a modified entry. +type FieldChange struct { + Field string `json:"field" yaml:"field"` + Old string `json:"old" yaml:"old"` + New string `json:"new" yaml:"new"` +} diff --git a/cmd/sync.go b/cmd/sync.go index 142417b..96b527a 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -10,11 +10,10 @@ import ( "encoding/json" "fmt" "log/slog" - "maps" "os" "os/exec" "path/filepath" - "sort" + "slices" "strings" greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1" @@ -42,6 +41,7 @@ var ( kubeloginPath string kubeloginExtraArgs []string kubeloginTokenCacheDir string + dryRun bool ) func init() { @@ -73,8 +73,10 @@ func init() { } syncCmd.Flags().StringVar(&kubeloginTokenCacheDir, "kubelogin-token-cache-dir", defaultTokenCacheDir, "Directory for OIDC token cache files") - // BindPFlags can theroretically return an error if called with `nil` as an argument - // which should never happened after at least one flag was defined. That's why the output + syncCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing to the kubeconfig file") + + // 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. viper.BindPFlags(syncCmd.Flags()) } @@ -102,6 +104,9 @@ Examples: # Use a dedicated Greenhouse kubeconfig and emit JSON output cloudctl sync -n my-org -k ~/.kube/greenhouse.yaml -o json + # Preview what would change without writing + cloudctl sync -n my-org --dry-run + # Debug mode — shows every cluster/authinfo/context decision on stderr cloudctl sync -n my-org --log-level debug`, RunE: runSync, @@ -120,6 +125,7 @@ func runSync(cmd *cobra.Command, args []string) error { kubeloginPath = viper.GetString("kubelogin-path") kubeloginExtraArgs = viper.GetStringSlice("kubelogin-extra-args") kubeloginTokenCacheDir = viper.GetString("kubelogin-token-cache-dir") + dryRun = viper.GetBool("dry-run") format, err := output.ParseFormat(viper.GetString("output")) if err != nil { @@ -211,7 +217,17 @@ func runSync(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create server config: %w", err) } - stopMerge := printer.StartSpinner("Merging kubeconfigs...") + // Take a snapshot before merge for dry-run diff — only needed when --dry-run is set. + var localConfigBefore *clientcmdapi.Config + if dryRun { + localConfigBefore = localConfig.DeepCopy() + } + + spinnerLabel := "Merging kubeconfigs..." + if dryRun { + spinnerLabel = "Simulating merge (dry-run)..." + } + stopMerge := printer.StartSpinner(spinnerLabel) err = mergeKubeconfig(localConfig, serverConfig) stopMerge() if err != nil { @@ -219,6 +235,11 @@ func runSync(cmd *cobra.Command, args []string) error { return fmt.Errorf(`failed to merge ClusterKubeconfig: %w`, err) } + if dryRun { + diff := diffKubeconfig(localConfigBefore, localConfig) + return printer.Print(buildDryRunResult(diff, localConfigBefore, localConfig)) + } + if writeErr := writeConfig(localConfig, remoteClusterKubeconfig); writeErr != nil { _ = printer.Print(buildFailedSyncResult(ready, notReady, writeErr)) return fmt.Errorf("failed to write merged kubeconfig: %w", writeErr) @@ -408,119 +429,6 @@ func isManaged(name string) bool { return strings.HasPrefix(name, prefix+":") } -// authInfoEqual compares two AuthInfo objects, excluding "id-token" and "refresh-token". -func authInfoEqual(a, b *clientcmdapi.AuthInfo) bool { - // Compare ClientCertificateData - if !bytes.Equal(a.ClientCertificateData, b.ClientCertificateData) { - return false - } - - // Compare ClientKeyData - if !bytes.Equal(a.ClientKeyData, b.ClientKeyData) { - return false - } - - // Compare Exec first (new style) - if (a.Exec == nil) != (b.Exec == nil) { - return false - } - if a.Exec != nil && b.Exec != nil { - if a.Exec.Command != b.Exec.Command || a.Exec.APIVersion != b.Exec.APIVersion { - return false - } - if len(a.Exec.Args) != len(b.Exec.Args) { - return false - } - for i := range a.Exec.Args { - if a.Exec.Args[i] != b.Exec.Args[i] { - return false - } - } - return true - } - - // Compare AuthProvider, excluding "id-token" and "refresh-token" - if (a.AuthProvider == nil) != (b.AuthProvider == nil) { - return false - } - if a.AuthProvider != nil && b.AuthProvider != nil { - // Compare AuthProvider Name - if a.AuthProvider.Name != b.AuthProvider.Name { - return false - } - - // Compare AuthProvider Config excluding "id-token" and "refresh-token" - aConfigFiltered := filterAuthProviderConfig(a.AuthProvider.Config) - bConfigFiltered := filterAuthProviderConfig(b.AuthProvider.Config) - if !maps.Equal(aConfigFiltered, bConfigFiltered) { - return false - } - } - - return true -} - -// filterAuthProviderConfig returns a copy of the config map excluding "id-token" and "refresh-token". -func filterAuthProviderConfig(config map[string]string) map[string]string { - filtered := make(map[string]string) - for k, v := range config { - if k != "id-token" && k != "refresh-token" { - filtered[k] = v - } - } - return filtered -} - -// generateAuthInfoKey creates a unique key for an AuthInfo based on specific AuthProvider fields, -// excluding "id-token" and "refresh-token". It uses "client-id", "client-secret", -// "auth-request-extra-params", and "extra-scopes" to generate the key. -func generateAuthInfoKey(authInfo *clientcmdapi.AuthInfo) string { - // Exec-based key: derive from stable subset of args to avoid including tokens - if authInfo.Exec != nil { - // Extract known kubelogin flags - var issuer, clientID, clientSecret, extraParams string - var scopes []string - for _, arg := range authInfo.Exec.Args { - switch { - case strings.HasPrefix(arg, "--oidc-issuer-url="): - issuer = strings.TrimPrefix(arg, "--oidc-issuer-url=") - case strings.HasPrefix(arg, "--oidc-client-id="): - clientID = strings.TrimPrefix(arg, "--oidc-client-id=") - case strings.HasPrefix(arg, "--oidc-client-secret="): - clientSecret = strings.TrimPrefix(arg, "--oidc-client-secret=") - case strings.HasPrefix(arg, "--oidc-extra-scope="): - scopes = append(scopes, strings.TrimPrefix(arg, "--oidc-extra-scope=")) - case strings.HasPrefix(arg, "--oidc-auth-request-extra-params="): - extraParams = strings.TrimPrefix(arg, "--oidc-auth-request-extra-params=") - } - } - sort.Strings(scopes) - data := fmt.Sprintf("exec:issuer:%s;client-id:%s;client-secret:%s;extra-params:%s;scopes:%s", issuer, clientID, clientSecret, extraParams, strings.Join(scopes, ",")) - return data - } - - if authInfo.AuthProvider == nil { - // For AuthInfos without AuthProvider, use a different unique identifier - // Here, we'll use the hash of ClientCertificateData and ClientKeyData - h := sha256.New() - h.Write(authInfo.ClientCertificateData) - h.Write(authInfo.ClientKeyData) - return fmt.Sprintf("cert:%s", hex.EncodeToString(h.Sum(nil))) - } - - // Extract the required fields from AuthProvider Config - clientID := authInfo.AuthProvider.Config["client-id"] - clientSecret := authInfo.AuthProvider.Config["client-secret"] - authRequestExtraParams := authInfo.AuthProvider.Config["auth-request-extra-params"] - extraScopes := authInfo.AuthProvider.Config["extra-scopes"] - - // Concatenate the fields in a consistent order - data := fmt.Sprintf("client-id:%s;client-secret:%s;auth-request-extra-params:%s;extra-scopes:%s", - clientID, clientSecret, authRequestExtraParams, extraScopes) - - return data -} - // buildKubeloginArgs constructs kubelogin arguments from an oidc auth-provider config and extra args func buildKubeloginArgs(cfg map[string]string, extra []string, tokenCacheDir string) []string { args := []string{"get-token"} @@ -586,44 +494,103 @@ func mergeKubeconfig(localConfig *clientcmdapi.Config, serverConfig *clientcmdap } } - // Prepare a map to track unique AuthInfos if merging is enabled - var authInfoMap map[string]string // key: unique identifier, value: managed AuthInfo name + // Prepare a map to track unique AuthInfos if merging is enabled. + // Keyed by server-side authinfo name so that two server AuthInfos that share + // the same generateAuthInfoKey (e.g. same OIDC flags but different non-OIDC + // exec args) each get their own managed-name entry and are never conflated. + var authInfoMap map[string]string // key: server authinfo name, value: authinfo name to use (may be unmanaged local or managed hash-based) if mergeIdenticalUsers { authInfoMap = make(map[string]string) - } - // Merge AuthInfos - for serverName, serverAuth := range serverConfig.AuthInfos { - var managedAuthName string + // Build a reverse lookup of unmanaged local auth entries so we can reuse their names + // instead of creating new cloudctl:auth- entries. + // Store all candidate names per key; at reuse time we pick the lexicographically + // smallest one that actually passes authInfoEqual so that key collisions between + // non-equivalent exec-plugin entries (different non-OIDC args) don't cause a miss. + keyToNames := make(map[string][]string) + for localName, localAuth := range localConfig.AuthInfos { + if !isManaged(localName) { + if localAuth == nil { + continue + } + key := generateAuthInfoKey(localAuth) + keyToNames[key] = append(keyToNames[key], localName) + } + } + // Pre-sort each candidate list so iteration order is deterministic. + for key := range keyToNames { + slices.Sort(keyToNames[key]) + } - if mergeIdenticalUsers { - // Generate a unique key based on AuthInfo excluding id-token and refresh-token + // Merge AuthInfos + for serverName, serverAuth := range serverConfig.AuthInfos { + if serverAuth == nil { + slog.Debug("skipping nil server authinfo", "name", serverName) + continue + } uniqueKey := generateAuthInfoKey(serverAuth) - hash := sha256.Sum256([]byte(uniqueKey)) - hashString := hex.EncodeToString(hash[:])[:16] // Using the first 16 chars for brevity - managedAuthName = fmt.Sprintf("%s:auth-%s", prefix, hashString) - - // **Merge AuthInfo to preserve id-token and refresh-token** - if existingAuth, exists := localConfig.AuthInfos[managedAuthName]; exists { - slog.Debug("merging authinfo tokens", "name", managedAuthName, "server", serverName) - mergedAuth := mergeAuthInfo(serverAuth, existingAuth) - localConfig.AuthInfos[managedAuthName] = mergedAuth - } else { - slog.Debug("adding authinfo", "name", managedAuthName, "server", serverName) - localConfig.AuthInfos[managedAuthName] = serverAuth + + // If an unmanaged local entry has the same credentials, reuse its name. + // Iterate candidates in sorted order and pick the first that passes + // authInfoEqual — handles key collisions where only some entries are + // actually equivalent (e.g. differing in non-OIDC exec args). + for _, localName := range keyToNames[uniqueKey] { + localAuth := localConfig.AuthInfos[localName] + if localAuth == nil { + continue + } + if authInfoEqual(localAuth, serverAuth) { + slog.Debug("reusing existing local authinfo", "name", localName, "server", serverName) + // Credentials are identical — leave the unmanaged local entry untouched + // to preserve any local-only fields (Token, TokenFile, Impersonate, etc.) + // that are outside authInfoEqual's comparison scope. + authInfoMap[serverName] = localName + goto nextServerAuth + } } - authInfoMap[uniqueKey] = managedAuthName - } else { - // Without merging, manage AuthInfos normally - managedAuthName = managedNameFunc(serverName) + { + hash := sha256.Sum256([]byte(uniqueKey)) + hashString := hex.EncodeToString(hash[:])[:16] // Using the first 16 chars for brevity + managedAuthName := fmt.Sprintf("%s:auth-%s", prefix, hashString) + + // If the hash-derived name is already taken by a non-equal authinfo + // (two server authinfos share the same OIDC key but differ in non-OIDC + // exec args), fall back to a per-server managed name to avoid conflation. + if existingAuth, exists := localConfig.AuthInfos[managedAuthName]; exists && !authInfoEqual(existingAuth, serverAuth) { + slog.Debug("hash collision with non-equal authinfo, using per-server name", "name", managedAuthName, "server", serverName) + managedAuthName = managedNameFunc(serverName) + } + + // Merge AuthInfo to preserve id-token and refresh-token + if existingAuth, exists := localConfig.AuthInfos[managedAuthName]; exists { + slog.Debug("merging authinfo tokens", "name", managedAuthName, "server", serverName) + mergedAuth := mergeAuthInfo(serverAuth, existingAuth) + localConfig.AuthInfos[managedAuthName] = mergedAuth + } else { + slog.Debug("adding authinfo", "name", managedAuthName, "server", serverName) + localConfig.AuthInfos[managedAuthName] = serverAuth + } + + authInfoMap[serverName] = managedAuthName + } + nextServerAuth: + } + } else { + // Without merging, manage AuthInfos normally + for serverName, serverAuth := range serverConfig.AuthInfos { + if serverAuth == nil { + slog.Debug("skipping nil server authinfo", "name", serverName) + continue + } + managedAuthName := managedNameFunc(serverName) localAuth, exists := localConfig.AuthInfos[managedAuthName] if !exists { slog.Debug("adding authinfo", "name", managedAuthName) localConfig.AuthInfos[managedAuthName] = serverAuth } else { if !authInfoEqual(localAuth, serverAuth) { - // **Merge AuthInfo to preserve id-token and refresh-token** + // Merge AuthInfo to preserve id-token and refresh-token slog.Debug("updating authinfo", "name", managedAuthName) mergedAuth := mergeAuthInfo(serverAuth, localAuth) localConfig.AuthInfos[managedAuthName] = mergedAuth @@ -638,22 +605,23 @@ func mergeKubeconfig(localConfig *clientcmdapi.Config, serverConfig *clientcmdap var managedAuthInfoName string if mergeIdenticalUsers { - // Generate the unique key for the AuthInfo referenced by this context + // Look up by server authinfo name — authInfoMap is keyed by server name, + // so each distinct server AuthInfo resolves to its own managed entry. serverAuthName := serverCtx.AuthInfo - serverAuth, exists := serverConfig.AuthInfos[serverAuthName] - if !exists { - return fmt.Errorf("AuthInfo %s referenced in context %s does not exist", serverAuthName, serverName) - } - uniqueKey := generateAuthInfoKey(serverAuth) var existsInMap bool - managedAuthInfoName, existsInMap = authInfoMap[uniqueKey] + managedAuthInfoName, existsInMap = authInfoMap[serverAuthName] if !existsInMap { // This should not happen as all AuthInfos should have been processed. - // However, to be safe, generate a new managedAuthName + // However, to be safe, generate a new managedAuthName. + serverAuth, exists := serverConfig.AuthInfos[serverAuthName] + if !exists || serverAuth == nil { + return fmt.Errorf("AuthInfo %s referenced in context %s does not exist or is nil", serverAuthName, serverName) + } + uniqueKey := generateAuthInfoKey(serverAuth) hash := sha256.Sum256([]byte(uniqueKey)) hashString := hex.EncodeToString(hash[:])[:16] managedAuthInfoName = fmt.Sprintf("%s:auth-%s", prefix, hashString) - authInfoMap[uniqueKey] = managedAuthInfoName + authInfoMap[serverAuthName] = managedAuthInfoName localConfig.AuthInfos[managedAuthInfoName] = serverAuth } } else { @@ -722,78 +690,44 @@ func mergeKubeconfig(localConfig *clientcmdapi.Config, serverConfig *clientcmdap } } - // Delete managed Contexts not present in serverConfig + // Delete managed Contexts not present in serverConfig. + // A context is considered managed when its cluster reference is managed + // (context names are not prefixed — only the referenced cluster is). for localName, localCtx := range localConfig.Contexts { - if isManaged(localName) { - // Derive the server-side name by stripping the prefix - serverName := unmanagedNameFunc(localName) - if _, exists := serverConfig.Contexts[serverName]; !exists { - slog.Debug("removing stale context", "name", localName) - delete(localConfig.Contexts, localName) - } else { - // Additionally, verify that the context's Cluster and AuthInfo are still managed - serverCtx := serverConfig.Contexts[serverName] - expectedCluster := managedNameFunc(serverCtx.Cluster) - var expectedAuthInfo string - if mergeIdenticalUsers { - serverAuthName := serverCtx.AuthInfo - serverAuth, exists := serverConfig.AuthInfos[serverAuthName] - if !exists { - slog.Debug("removing stale context (missing authinfo)", "name", localName) - delete(localConfig.Contexts, localName) - continue - } - uniqueKey := generateAuthInfoKey(serverAuth) - mappedName, exists := authInfoMap[uniqueKey] - if !exists { - slog.Debug("removing stale context (unmapped authinfo)", "name", localName) - delete(localConfig.Contexts, localName) - continue - } - expectedAuthInfo = mappedName - } else { - expectedAuthInfo = managedNameFunc(serverCtx.AuthInfo) - } - - if localCtx.Cluster != expectedCluster || localCtx.AuthInfo != expectedAuthInfo { - slog.Debug("removing stale context (mismatched refs)", "name", localName) + if localCtx == nil || !isManaged(localCtx.Cluster) { + continue + } + // Context name equals the server-side name (no prefix applied). + serverName := localName + if _, exists := serverConfig.Contexts[serverName]; !exists { + slog.Debug("removing stale context", "name", localName) + delete(localConfig.Contexts, localName) + } else { + // Additionally, verify that the context's Cluster and AuthInfo are still managed + serverCtx := serverConfig.Contexts[serverName] + expectedCluster := managedNameFunc(serverCtx.Cluster) + var expectedAuthInfo string + if mergeIdenticalUsers { + serverAuthName := serverCtx.AuthInfo + mappedName, exists := authInfoMap[serverAuthName] + if !exists { + slog.Debug("removing stale context (unmapped authinfo)", "name", localName) delete(localConfig.Contexts, localName) + continue } + expectedAuthInfo = mappedName + } else { + expectedAuthInfo = managedNameFunc(serverCtx.AuthInfo) } - } - } - return nil -} - -// Helper function to merge AuthInfo objects while preserving id-token and refresh-token -func mergeAuthInfo(serverAuth, localAuth *clientcmdapi.AuthInfo) *clientcmdapi.AuthInfo { - if localAuth == nil { - // If there's no local AuthInfo, return the server AuthInfo as is - return serverAuth - } - - // Create a copy of the serverAuth to avoid mutating the original - mergedAuth := serverAuth.DeepCopy() - - // Preserve id-token and refresh-token from localAuth - if localAuth.AuthProvider != nil && mergedAuth.AuthProvider != nil { - // Ensure the merged config map is initialized to avoid panics on assignment - if mergedAuth.AuthProvider.Config == nil { - mergedAuth.AuthProvider.Config = make(map[string]string) - } - if idToken, exists := localAuth.AuthProvider.Config["id-token"]; exists { - mergedAuth.AuthProvider.Config["id-token"] = idToken - } - if refreshToken, exists := localAuth.AuthProvider.Config["refresh-token"]; exists { - mergedAuth.AuthProvider.Config["refresh-token"] = refreshToken + if localCtx.Cluster != expectedCluster || localCtx.AuthInfo != expectedAuthInfo { + slog.Debug("removing stale context (mismatched refs)", "name", localName) + delete(localConfig.Contexts, localName) + } } } - // Additionally, preserve other fields if necessary. - // For example, ClientCertificateData and ClientKeyData are already handled - - return mergedAuth + return nil } // labelsExtensionEqual returns true if the "labels" named extension is equal in both maps. diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 2d61a31..cc44136 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -467,3 +467,521 @@ func TestGenerateAuthInfoKey_ExecStableIgnoresOrder(t *testing.T) { kb := generateAuthInfoKey(b) g.Expect(ka).To(Equal(kb)) } + +// --------------------------------------------------------------------------- +// AuthInfo refactor (#54) — new tests +// --------------------------------------------------------------------------- + +func TestAuthInfoEqual_ExecEnvConsidered(t *testing.T) { + g := NewWithT(t) + + base := &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "kubelogin", + Args: []string{"get-token"}, + Env: []clientcmdapi.ExecEnvVar{{Name: "HTTP_PROXY", Value: "http://proxy.example.com"}}, + }} + diff := &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "kubelogin", + Args: []string{"get-token"}, + Env: []clientcmdapi.ExecEnvVar{{Name: "HTTP_PROXY", Value: "http://other.example.com"}}, + }} + + g.Expect(authInfoEqual(base, diff)).To(BeFalse(), "different Env values must not be equal") + + same := base.DeepCopy() + g.Expect(authInfoEqual(base, same)).To(BeTrue(), "identical Env must be equal") +} + +func TestAuthInfoEqual_ExecInteractiveModeConsidered(t *testing.T) { + g := NewWithT(t) + + a := &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "kubelogin", + Args: []string{"get-token"}, + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, + }} + b := a.DeepCopy() + b.Exec.InteractiveMode = clientcmdapi.NeverExecInteractiveMode + + g.Expect(authInfoEqual(a, b)).To(BeFalse(), "different InteractiveMode must not be equal") + g.Expect(authInfoEqual(a, a.DeepCopy())).To(BeTrue()) +} + +func TestGenerateAuthInfoKey_AuthProviderIncludesIssuer(t *testing.T) { + g := NewWithT(t) + + // Same client-id but different issuers — must produce different keys + a := &clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: map[string]string{ + "idp-issuer-url": "https://issuer-a.example.com", + "client-id": "same-client-id", + "client-secret": "same-secret", + }, + }, + } + b := &clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: map[string]string{ + "idp-issuer-url": "https://issuer-b.example.com", + "client-id": "same-client-id", + "client-secret": "same-secret", + }, + }, + } + + ka := generateAuthInfoKey(a) + kb := generateAuthInfoKey(b) + g.Expect(ka).ToNot(Equal(kb), "different idp-issuer-url must produce different keys") +} + +func TestGenerateAuthInfoKey_ExecEnvAffectsKey(t *testing.T) { + g := NewWithT(t) + + a := &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "kubelogin", + Args: []string{"get-token", "--oidc-issuer-url=https://issuer", "--oidc-client-id=cid"}, + Env: []clientcmdapi.ExecEnvVar{{Name: "HTTP_PROXY", Value: "http://proxy.example.com"}}, + }} + b := &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "kubelogin", + Args: []string{"get-token", "--oidc-issuer-url=https://issuer", "--oidc-client-id=cid"}, + // no Env + }} + + ka := generateAuthInfoKey(a) + kb := generateAuthInfoKey(b) + g.Expect(ka).ToNot(Equal(kb), "exec env change must produce different key") +} + +func TestMergeKubeconfig_PrefersExistingLocalAuthName(t *testing.T) { + g := NewWithT(t) + + orig := prefix + origMerge := mergeIdenticalUsers + prefix = "cloudctl" + mergeIdenticalUsers = true + t.Cleanup(func() { + prefix = orig + mergeIdenticalUsers = origMerge + }) + + // The local kubeconfig has an unmanaged user entry with the same OIDC creds + // as what the server would produce. + localAuth := &clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: map[string]string{ + "idp-issuer-url": "https://issuer.example.com", + "client-id": "cid", + "client-secret": "csec", + }, + }, + } + localConfig := clientcmdapi.NewConfig() + localConfig.AuthInfos["my-existing-user"] = localAuth + + // Server config has the same OIDC creds + serverAuth := &clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: map[string]string{ + "idp-issuer-url": "https://issuer.example.com", + "client-id": "cid", + "client-secret": "csec", + }, + }, + } + serverConfig := clientcmdapi.NewConfig() + serverConfig.AuthInfos["server-user"] = serverAuth + serverConfig.Clusters["prod"] = &clientcmdapi.Cluster{Server: "https://prod.example.com"} + serverConfig.Contexts["prod"] = &clientcmdapi.Context{ + Cluster: "prod", + AuthInfo: "server-user", + } + + err := mergeKubeconfig(localConfig, serverConfig) + g.Expect(err).ToNot(HaveOccurred()) + + // The unmanaged "my-existing-user" should be reused — no cloudctl:auth-* should be created + for name := range localConfig.AuthInfos { + g.Expect(name).ToNot(HavePrefix("cloudctl:auth-"), "should reuse existing local user, not create managed auth entry") + } + // The context should reference the existing local user + ctx, ok := localConfig.Contexts["prod"] + g.Expect(ok).To(BeTrue()) + g.Expect(ctx.AuthInfo).To(Equal("my-existing-user")) +} + +func TestMergeKubeconfig_DeduplicatesSameOIDCUsers(t *testing.T) { + g := NewWithT(t) + + orig := prefix + origMerge := mergeIdenticalUsers + prefix = "cloudctl" + mergeIdenticalUsers = true + t.Cleanup(func() { + prefix = orig + mergeIdenticalUsers = origMerge + }) + + // Two clusters with the same OIDC config should share one auth entry + sharedAuth := clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "oidc", + Config: map[string]string{ + "idp-issuer-url": "https://issuer.example.com", + "client-id": "shared-client-id", + "client-secret": "shared-secret", + }, + }, + } + serverConfig := clientcmdapi.NewConfig() + serverConfig.AuthInfos["cluster-a-user"] = sharedAuth.DeepCopy() + serverConfig.AuthInfos["cluster-b-user"] = sharedAuth.DeepCopy() + serverConfig.Clusters["cluster-a"] = &clientcmdapi.Cluster{Server: "https://a.example.com"} + serverConfig.Clusters["cluster-b"] = &clientcmdapi.Cluster{Server: "https://b.example.com"} + serverConfig.Contexts["cluster-a"] = &clientcmdapi.Context{Cluster: "cluster-a", AuthInfo: "cluster-a-user"} + serverConfig.Contexts["cluster-b"] = &clientcmdapi.Context{Cluster: "cluster-b", AuthInfo: "cluster-b-user"} + + localConfig := clientcmdapi.NewConfig() + err := mergeKubeconfig(localConfig, serverConfig) + g.Expect(err).ToNot(HaveOccurred()) + + // Count managed auth entries — should be exactly 1 + managedAuthCount := 0 + var sharedAuthName string + for name := range localConfig.AuthInfos { + if isManaged(name) { + managedAuthCount++ + sharedAuthName = name + } + } + g.Expect(managedAuthCount).To(Equal(1), "two clusters with same OIDC config should share one auth entry") + + // Both contexts must reference the same auth entry + ctxA := localConfig.Contexts["cluster-a"] + ctxB := localConfig.Contexts["cluster-b"] + g.Expect(ctxA).ToNot(BeNil()) + g.Expect(ctxB).ToNot(BeNil()) + g.Expect(ctxA.AuthInfo).To(Equal(sharedAuthName)) + g.Expect(ctxB.AuthInfo).To(Equal(sharedAuthName)) +} + +// --------------------------------------------------------------------------- +// Dry-run / diffKubeconfig tests (#51) +// --------------------------------------------------------------------------- + +func newCfg() *clientcmdapi.Config { + return clientcmdapi.NewConfig() +} + +func TestDiffKubeconfig_AddedCluster(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + newCfg2 := newCfg() + newCfg2.Clusters["cloudctl:prod-eu-1"] = &clientcmdapi.Cluster{Server: "https://prod-eu-1.example.com"} + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Clusters).To(HaveLen(1)) + g.Expect(diff.Clusters[0].Name).To(Equal("cloudctl:prod-eu-1")) + g.Expect(diff.Clusters[0].ChangeType).To(Equal(DiffChangeAdded)) +} + +func TestDiffKubeconfig_RemovedCluster(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.Clusters["cloudctl:staging-de"] = &clientcmdapi.Cluster{Server: "https://staging.example.com"} + newCfg2 := newCfg() + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Clusters).To(HaveLen(1)) + g.Expect(diff.Clusters[0].Name).To(Equal("cloudctl:staging-de")) + g.Expect(diff.Clusters[0].ChangeType).To(Equal(DiffChangeRemoved)) +} + +func TestDiffKubeconfig_ModifiedClusterServerURL(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.Clusters["cloudctl:prod-eu-2"] = &clientcmdapi.Cluster{Server: "https://old.example.com"} + newCfg2 := newCfg() + newCfg2.Clusters["cloudctl:prod-eu-2"] = &clientcmdapi.Cluster{Server: "https://new.example.com"} + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Clusters).To(HaveLen(1)) + g.Expect(diff.Clusters[0].ChangeType).To(Equal(DiffChangeModified)) + g.Expect(diff.Clusters[0].Fields).To(HaveLen(1)) + g.Expect(diff.Clusters[0].Fields[0].Field).To(Equal("Server")) + g.Expect(diff.Clusters[0].Fields[0].Old).To(Equal("https://old.example.com")) + g.Expect(diff.Clusters[0].Fields[0].New).To(Equal("https://new.example.com")) +} + +func TestDiffKubeconfig_ModifiedClusterCA(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.Clusters["cloudctl:prod"] = &clientcmdapi.Cluster{CertificateAuthorityData: []byte("old-ca-data")} + newCfg2 := newCfg() + newCfg2.Clusters["cloudctl:prod"] = &clientcmdapi.Cluster{CertificateAuthorityData: []byte("new-ca-data")} + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Clusters).To(HaveLen(1)) + g.Expect(diff.Clusters[0].ChangeType).To(Equal(DiffChangeModified)) + caField := diff.Clusters[0].Fields[0] + g.Expect(caField.Field).To(Equal("CA")) + // Value should be a hex fingerprint, not raw bytes + g.Expect(caField.Old).To(HaveLen(16)) + g.Expect(caField.New).To(HaveLen(16)) + g.Expect(caField.Old).ToNot(Equal(caField.New)) +} + +func TestDiffKubeconfig_AddedContext(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + newCfg2 := newCfg() + // Context name has no prefix; cluster reference is prefixed (managed) + newCfg2.Contexts["prod"] = &clientcmdapi.Context{Cluster: "cloudctl:prod", AuthInfo: "cloudctl:auth-abc"} + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Contexts).To(HaveLen(1)) + g.Expect(diff.Contexts[0].ChangeType).To(Equal(DiffChangeAdded)) +} + +func TestDiffKubeconfig_RemovedContext(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + // Context name has no prefix; cluster reference is prefixed (managed) + oldCfg.Contexts["staging"] = &clientcmdapi.Context{Cluster: "cloudctl:staging"} + newCfg2 := newCfg() + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Contexts).To(HaveLen(1)) + g.Expect(diff.Contexts[0].ChangeType).To(Equal(DiffChangeRemoved)) +} + +func TestDiffKubeconfig_ModifiedAuthInfoExecArgs(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.AuthInfos["cloudctl:auth-abc123"] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "kubelogin", + Args: []string{"get-token", "--oidc-client-secret=old-secret"}, + }, + } + newCfg2 := newCfg() + newCfg2.AuthInfos["cloudctl:auth-abc123"] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "kubelogin", + Args: []string{"get-token", "--oidc-client-secret=new-secret"}, + }, + } + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.AuthInfos).To(HaveLen(1)) + g.Expect(diff.AuthInfos[0].ChangeType).To(Equal(DiffChangeModified)) + g.Expect(diff.AuthInfos[0].Fields).ToNot(BeEmpty()) +} + +func TestDiffKubeconfig_UnchangedEntriesExcluded(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + cfg := newCfg() + cfg.Clusters["cloudctl:unchanged"] = &clientcmdapi.Cluster{Server: "https://same.example.com"} + + diff := diffKubeconfig(cfg, cfg) + g.Expect(diff.Clusters).To(BeEmpty(), "unchanged entries must not appear in diff") +} + +func TestDiffKubeconfig_UnmanagedEntriesIgnored(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.Clusters["my-own-cluster"] = &clientcmdapi.Cluster{Server: "https://personal.example.com"} + newCfg2 := newCfg() + // unmanaged entry removed from new — must not appear in diff + + diff := diffKubeconfig(oldCfg, newCfg2) + g.Expect(diff.Clusters).To(BeEmpty()) + g.Expect(diff.Contexts).To(BeEmpty()) + g.Expect(diff.AuthInfos).To(BeEmpty()) +} + +func TestDryRun_MergeAndDiff_NoWrite(t *testing.T) { + g := NewWithT(t) + + orig := prefix + origMerge := mergeIdenticalUsers + prefix = "cloudctl" + mergeIdenticalUsers = true + t.Cleanup(func() { + prefix = orig + mergeIdenticalUsers = origMerge + }) + + // Build a "before" and "after" config to simulate what dry-run does + localConfigBefore := clientcmdapi.NewConfig() + localConfigBefore.Clusters["cloudctl:existing"] = &clientcmdapi.Cluster{Server: "https://existing.example.com"} + localConfigBefore.Contexts["existing"] = &clientcmdapi.Context{Cluster: "cloudctl:existing", AuthInfo: "cloudctl:auth-abc"} + + // Simulate an incoming server config that adds a new cluster + serverConfig := clientcmdapi.NewConfig() + serverConfig.Clusters["existing"] = &clientcmdapi.Cluster{Server: "https://existing.example.com"} + serverConfig.Clusters["new-cluster"] = &clientcmdapi.Cluster{Server: "https://new.example.com"} + sharedAuth := &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "kubelogin", + Args: []string{"get-token", "--oidc-issuer-url=https://issuer.example.com"}, + }, + } + serverConfig.AuthInfos["shared-user"] = sharedAuth + serverConfig.Contexts["existing"] = &clientcmdapi.Context{Cluster: "existing", AuthInfo: "shared-user"} + serverConfig.Contexts["new-cluster"] = &clientcmdapi.Context{Cluster: "new-cluster", AuthInfo: "shared-user"} + + localConfig := localConfigBefore.DeepCopy() + err := mergeKubeconfig(localConfig, serverConfig) + g.Expect(err).ToNot(HaveOccurred()) + + diff := diffKubeconfig(localConfigBefore, localConfig) + result := buildDryRunResult(diff, localConfigBefore, localConfig) + + // The new cluster access should appear as added + g.Expect(result.Added).To(BeNumerically(">=", 1)) + g.Expect(result.Accesses).To(ContainElement( + HaveField("ChangeType", "added"), + )) + + // Original localConfigBefore must not have been modified + g.Expect(localConfigBefore.Clusters).ToNot(HaveKey("cloudctl:new-cluster")) +} + +// --------------------------------------------------------------------------- +// buildAccessDiffs tests +// --------------------------------------------------------------------------- + +func TestBuildAccessDiffs_Added(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + newCfg2 := newCfg() + newCfg2.Clusters["cloudctl:prod-eu-1"] = &clientcmdapi.Cluster{Server: "https://prod-eu-1.example.com"} + newCfg2.Contexts["prod-eu-1"] = &clientcmdapi.Context{Cluster: "cloudctl:prod-eu-1", AuthInfo: "cloudctl:auth-abc"} + + diff := diffKubeconfig(oldCfg, newCfg2) + accesses := buildAccessDiffs(diff, oldCfg, newCfg2) + + g.Expect(accesses).To(HaveLen(1)) + g.Expect(accesses[0].Name).To(Equal("prod-eu-1")) + g.Expect(accesses[0].ChangeType).To(Equal("added")) + g.Expect(accesses[0].Server).To(Equal("https://prod-eu-1.example.com")) +} + +func TestBuildAccessDiffs_Removed(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.Clusters["cloudctl:staging"] = &clientcmdapi.Cluster{Server: "https://staging.example.com"} + oldCfg.Contexts["staging"] = &clientcmdapi.Context{Cluster: "cloudctl:staging", AuthInfo: "cloudctl:auth-abc"} + newCfg2 := newCfg() + + diff := diffKubeconfig(oldCfg, newCfg2) + accesses := buildAccessDiffs(diff, oldCfg, newCfg2) + + g.Expect(accesses).To(HaveLen(1)) + g.Expect(accesses[0].Name).To(Equal("staging")) + g.Expect(accesses[0].ChangeType).To(Equal("removed")) + g.Expect(accesses[0].Server).To(Equal("https://staging.example.com")) +} + +func TestBuildAccessDiffs_ModifiedServer(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + oldCfg := newCfg() + oldCfg.Clusters["cloudctl:prod-eu-2"] = &clientcmdapi.Cluster{Server: "https://old.example.com"} + oldCfg.Contexts["prod-eu-2"] = &clientcmdapi.Context{Cluster: "cloudctl:prod-eu-2", AuthInfo: "cloudctl:auth-abc"} + oldCfg.AuthInfos["cloudctl:auth-abc"] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{Command: "kubelogin", Args: []string{"get-token"}}, + } + + newCfg2 := newCfg() + newCfg2.Clusters["cloudctl:prod-eu-2"] = &clientcmdapi.Cluster{Server: "https://new.example.com"} + newCfg2.Contexts["prod-eu-2"] = &clientcmdapi.Context{Cluster: "cloudctl:prod-eu-2", AuthInfo: "cloudctl:auth-abc"} + newCfg2.AuthInfos["cloudctl:auth-abc"] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{Command: "kubelogin", Args: []string{"get-token"}}, + } + + diff := diffKubeconfig(oldCfg, newCfg2) + accesses := buildAccessDiffs(diff, oldCfg, newCfg2) + + g.Expect(accesses).To(HaveLen(1)) + g.Expect(accesses[0].Name).To(Equal("prod-eu-2")) + g.Expect(accesses[0].ChangeType).To(Equal("modified")) + g.Expect(accesses[0].Fields).To(ContainElement( + And(HaveField("Field", "Server"), HaveField("Old", "https://old.example.com"), HaveField("New", "https://new.example.com")), + )) +} + +func TestBuildAccessDiffs_NoChanges(t *testing.T) { + g := NewWithT(t) + orig := prefix + prefix = "cloudctl" + t.Cleanup(func() { prefix = orig }) + + cfg := newCfg() + cfg.Clusters["cloudctl:unchanged"] = &clientcmdapi.Cluster{Server: "https://same.example.com"} + cfg.Contexts["unchanged"] = &clientcmdapi.Context{Cluster: "cloudctl:unchanged", AuthInfo: "cloudctl:auth-abc"} + + diff := diffKubeconfig(cfg, cfg) + accesses := buildAccessDiffs(diff, cfg, cfg) + + g.Expect(accesses).To(BeEmpty()) +}