-
Notifications
You must be signed in to change notification settings - Fork 1
feat(sync): dry-run mode with diff output and authinfo deduplication #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
ebd03b4
feat(sync): add --dry-run flag and fix authinfo deduplication (#51, #54)
onuryilmaz d44d806
fix(sync): context-centric dry-run output with server URLs
onuryilmaz 524582a
fix(sync): address Copilot review — security, correctness, test isola…
onuryilmaz eba6ce6
fix(output): table layout for dry-run modified entries with change su…
onuryilmaz 4f2d2f9
fix(output): show old → new field values for structural changes in dr…
onuryilmaz 2d3cb0c
feat(sync): add --dry-run-format=table|diff flag
onuryilmaz 4488e57
fix(sync): suppress no-op context diffs from authinfo hash reassignment
onuryilmaz 8d84cea
feat(sync): show actual field diffs for credential changes in dry-run
onuryilmaz e5c5a24
refactor(sync): drop table view, use diff-style output exclusively fo…
onuryilmaz 0029230
fix(sync): fix dry-run summary breakdown and collapse redacted-equal …
onuryilmaz 7416b4d
fix(sync): address Copilot review — key correctness and diff complete…
onuryilmaz 758809b
fix(sync): gate authinfo reuse on authInfoEqual and deduplicate field…
onuryilmaz dd0014b
fix(output): remove unused accessChangeSummary function
onuryilmaz 6ce1e4f
fix(sync): pick lexicographically smallest name for deterministic aut…
onuryilmaz 5f019cd
fix(sync): surface namespace changes in dry-run access diff output
onuryilmaz 2a114ec
fix(sync): don't mutate unmanaged authinfo on reuse; fix non-determin…
onuryilmaz 60f8602
fix(output): use omitzero for Fields slices in AccessDiff and DiffEntry
onuryilmaz ea14589
fix(sync): align auth-provider key with full filtered config to match…
onuryilmaz eba5435
fix(sync): surface exec Command/APIVersion/InteractiveMode diffs; fix…
onuryilmaz f8fc25f
fix(sync): use frequency maps in argsDiff for correct duplicate-arg h…
onuryilmaz 0c5843b
fix(sync): emit Exec Env diff; add exec fields to buildAccessDiffs; c…
onuryilmaz 120ea0c
fix(sync): gate DeepCopy on dryRun; try all key candidates in authinf…
onuryilmaz c436d03
fix(sync): nil-safe authInfoEqual, keyToNames build, and diffAuthInfos
onuryilmaz 609179e
fix(sync): fix spelling in BindPFlags comment
onuryilmaz 95dfa03
fix(sync): nil-guard serverAuth in both mergeKubeconfig AuthInfo loops
onuryilmaz 49cea7d
fix(sync): nil-guard cluster, context, and authinfo map entries in di…
onuryilmaz 2727982
fix(sync): surface auth-provider name changes in diffs; correct authI…
onuryilmaz 1adc9d6
fix(output): render empty field values as <empty> in dry-run diff output
onuryilmaz 4b4f030
fix(sync): isManagedContext checks both old and new config to catch m…
onuryilmaz 8e375fb
fix(output): use symmetric Old/New in Credentials sentinel FieldChange
onuryilmaz 0dfc2d6
fix(sync): key authInfoMap by server authinfo name to prevent collision
onuryilmaz 4c22887
fix(sync): handle hash collision and nil serverAuth in context fallback
onuryilmaz ad1902d
fix(diff): treat nil authinfo in newCfg as removed in diffAuthInfos
onuryilmaz 76060a5
fix(output): print only present side for Exec Args add/remove diffs
onuryilmaz 42e2ee1
fix(output): categorize Auth provider field as credentials in modifie…
onuryilmaz 262b498
fix(sync): use cluster-ref guard for stale context cleanup instead of…
onuryilmaz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.