Skip to content
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

app: home: Add delete button for clusters #2567

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 163 additions & 17 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,18 @@ func serveWithNoCacheHeader(fs http.Handler) http.HandlerFunc {
}
}

// defaultKubeConfigFile returns the default path to the kubeconfig file.
func defaultKubeConfigFile() (string, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIU, we have GetDefaultKubeConfigPath() in the config.go, we also have a way to retrive this from k8s client-go.

homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %v", err)
}

kubeConfigFile := filepath.Join(homeDir, ".kube", "config")

return kubeConfigFile, nil
}

// defaultKubeConfigPersistenceDir returns the default directory to store kubeconfig
// files of clusters that are loaded in Headlamp.
func defaultKubeConfigPersistenceDir() (string, error) {
Expand Down Expand Up @@ -1365,6 +1377,126 @@ func (c *HeadlampConfig) addContextsToStore(contexts []kubeconfig.Context, setup
return setupErrors
}

// collectMultiConfigPaths looks at the default dynamic directory
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit should be merged with the previous one AFAIU.

// (e.g. ~/.config/Headlamp/kubeconfigs) and returns any files found there.
// This is called from the 'else' block in deleteCluster().
func (c *HeadlampConfig) collectMultiConfigPaths() ([]string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to backend/pkg/config/config.go

dynamicDir, err := defaultKubeConfigPersistenceDir()
if err != nil {
return nil, fmt.Errorf("getting default kubeconfig persistence dir: %w", err)
}

entries, err := os.ReadDir(dynamicDir)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the function reads all files from the directory without checking their extensions or format. Can you please add validation for known kubeconfig extensions?

if err != nil {
return nil, fmt.Errorf("reading dynamic kubeconfig directory: %w", err)
}

var configPaths []string //nolint:prealloc
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove this linter and allocate it earlier


for _, entry := range entries {
// Optionally skip directories or non-kubeconfig files, if needed.
if entry.IsDir() {
continue
}

filePath := filepath.Join(dynamicDir, entry.Name())

configPaths = append(configPaths, filePath)
}

return configPaths, nil
}

func removeContextFromDefaultKubeConfig(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please move it to backend/pkg/config/config.go

w http.ResponseWriter,
contextName string,
configPaths ...string,
) error {
// If no specific paths passed, fallback to the default.
if len(configPaths) == 0 {
discoveredPath, err := defaultKubeConfigPersistenceFile()
if err != nil {
logger.Log(
logger.LevelError,
map[string]string{"cluster": contextName},
err,
"getting default kubeconfig persistence file",
)
http.Error(w, "getting default kubeconfig persistence file", http.StatusInternalServerError)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please do not http.Error here. This should be done at the top level function. This function should return only error message. This could lead to double error handling if the caller also tries to handle the error. Please remove this.


return err
}

configPaths = []string{discoveredPath}
}

// Hand off to a small helper function that handles multi-file iteration.
return removeContextFromConfigs(w, contextName, configPaths)
}

// removeContextFromConfigs does the real iteration over the configPaths.
func removeContextFromConfigs(w http.ResponseWriter, contextName string, configPaths []string) error {
var removed bool

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check whether contextName is empty or if configPath exists

if contextName == "" {
    return fmt.Errorf("context name cannot be empty")
}
if len(configPaths) == 0 {
    return fmt.Errorf("no config paths provided")
}

for _, filePath := range configPaths {
logger.Log(
logger.LevelInfo,
map[string]string{
"cluster": contextName,
"kubeConfigPersistenceFile": filePath,
},
nil,
"Trying to remove context from kubeconfig",
)

err := kubeconfig.RemoveContextFromFile(contextName, filePath)
if err == nil {
removed = true

logger.Log(logger.LevelInfo,
map[string]string{"cluster": contextName, "file": filePath},
nil, "Removed context from kubeconfig",
)

break
}

if strings.Contains(err.Error(), "context not found") {
logger.Log(logger.LevelInfo,
map[string]string{"cluster": contextName, "file": filePath},
nil, "Context not in this file; checking next.",
)

continue
}

logger.Log(logger.LevelError,
map[string]string{"cluster": contextName},
err, "removing cluster from kubeconfig",
)

http.Error(w, "removing cluster from kubeconfig", http.StatusInternalServerError)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again please remove this


return err
}

if !removed {
e := fmt.Errorf("context %q not found in any provided kubeconfig file(s)", contextName)

logger.Log(
logger.LevelError,
map[string]string{"cluster": contextName},
e,
"context not found in any file",
)
http.Error(w, e.Error(), http.StatusBadRequest)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same


return e
}

return nil
}

// deleteCluster deletes the cluster from the store and updates the kubeconfig file.
func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) {
if err := checkHeadlampBackendToken(w, r); err != nil {
Expand All @@ -1384,28 +1516,42 @@ func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) {
return
}

kubeConfigPersistenceFile, err := defaultKubeConfigPersistenceFile()
if err != nil {
logger.Log(logger.LevelError, map[string]string{"cluster": name},
err, "getting default kubeconfig persistence file")
http.Error(w, "getting default kubeconfig persistence file", http.StatusInternalServerError)
removeKubeConfig := r.URL.Query().Get("removeKubeConfig") == "true"

return
if removeKubeConfig {
kubeConfigFile, err := defaultKubeConfigFile()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think clusters can be loaded from multiple kubeconfig files, yet we only have a flag telling us whether the clusters from kubeconfig or dynamic. This is a limitation in our backend code (not your PR), but one that we have to handle before we consider this PR IMO.
Tied to this is the notion of what happens when we load a cluster with the same name from a different (or same) file.

if err != nil {
logger.Log(logger.LevelError, map[string]string{"cluster": name},
err, "failed to get default kubeconfig file path")
http.Error(w, "failed to get default kubeconfig file path", http.StatusInternalServerError)

return
}

err = kubeconfig.RemoveContextFromFile(name, kubeConfigFile)
if err != nil {
logger.Log(logger.LevelError, map[string]string{"cluster": name},
err, "removing context from default kubeconfig file")
http.Error(w, "removing context from default kubeconfig file", http.StatusInternalServerError)

return
}
}

logger.Log(logger.LevelInfo, map[string]string{
"cluster": name,
"kubeConfigPersistenceFile": kubeConfigPersistenceFile,
},
nil, "Removing cluster from kubeconfig")
if !removeKubeConfig {
configPathsList, pathErr := c.collectMultiConfigPaths()
if pathErr != nil {
logger.Log(logger.LevelError, map[string]string{"cluster": name},
pathErr, "collecting multi config paths")
http.Error(w, "collecting multi config paths", http.StatusInternalServerError)

err = kubeconfig.RemoveContextFromFile(name, kubeConfigPersistenceFile)
if err != nil {
logger.Log(logger.LevelError, map[string]string{"cluster": name},
err, "removing cluster from kubeconfig")
http.Error(w, "removing cluster from kubeconfig", http.StatusInternalServerError)
return
}

return
if err := removeContextFromDefaultKubeConfig(w, name, configPathsList...); err != nil {
// removeContextFromDefaultKubeConfig writes any needed http.Error if it fails
return
}
}

logger.Log(logger.LevelInfo, map[string]string{"cluster": name, "proxy": name},
Expand Down
28 changes: 28 additions & 0 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,34 @@ func TestRenameCluster(t *testing.T) {
}
}

func TestRemoveContextFromDefaultKubeConfig(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should also be moved. Thanks

// 1) Create a temp directory for our test kubeconfig
tmpDir := t.TempDir()
mockConfigFile := filepath.Join(tmpDir, "config")

// 2) Copy "kubeconfig_remove" (which includes 'kubedelta') into that file
testDataPath := filepath.Join("headlamp_testdata", "kubeconfig_remove")
testData, err := os.ReadFile(testDataPath)
require.NoError(t, err, "failed to read test data for 'kubeconfig_remove'")

err = os.WriteFile(mockConfigFile, testData, 0o600)
require.NoError(t, err, "failed to write test kubeconfig")

// 3) We need a fake http.ResponseWriter
w := httptest.NewRecorder()

// 4) Call removeContextFromDefaultKubeConfig with our mock path as the third param
err = removeContextFromDefaultKubeConfig(w, "kubedelta", mockConfigFile)
require.NoError(t, err, "removeContextFromDefaultKubeConfig should succeed")

// 5) Verify 'kubedelta' is removed from the file
updatedData, err := os.ReadFile(mockConfigFile)
require.NoError(t, err, "failed to read updated kubeconfig")

require.NotContains(t, string(updatedData), "kubedelta",
"Expected 'kubedelta' context to be removed from kubeconfig")
}

func TestFileExists(t *testing.T) {
// Test for existing file
assert.True(t, fileExists("./headlamp_testdata/kubeconfig"),
Expand Down
45 changes: 45 additions & 0 deletions backend/cmd/headlamp_testdata/kubeconfig_remove
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
apiVersion: v1
kind: Config
current-context: minikubetest
preferences: {}
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMvakNDQWVhZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJeU1EZ3lOakV4TURRMU0xb1hEVE15TURneU16RXhNRFExTTFvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTnk3Ci9kREMxV0w3TXNSWGV2Z2tUQXkzcFZHMVVLa1VQeXd4cS9ETHBPdmRzQmloQjZoVmN1bWNZUTkzYUxLbERzSXMKR0Q0QUJkUFM4cEFPMzhMb3RBWWVDeDIwcDFPem9LYVMvVkp6ZlJKQWVUSStCY3dzRjh2U1VXYU0reWZ4STBPUgpnalE0OVR0eUppYURyS2tzbnd4R3Y0K0U3aWFhZUVPMG55U01EcnpON1RvYkVyb1pObHRzNkdMN2tpTDB0TG5ZCkorNnNtSHlhSGh6WThaR0JZMFdWUXpzNENFMnJ0Q1k5eTV4N2F3bUlDUWE2anBXVFVQazNqa0RMcU93bEQyRmMKcHNkeXI4a1Z3UUhTUUVnRkg2Yzgwdnp3Ny9RSUVDdGRYNlZRRnE1bzYzOWlvc3hQcXVKV3ZtMGVjdkx5dC81cApxNXZpNzMxWThEb0VDMjFtS2NzQ0F3RUFBYU5aTUZjd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0hRWURWUjBPQkJZRUZJNFFkU2FSRFVodi8wWjk0ZzV5RmlVdWlMZHBNQlVHQTFVZEVRUU8KTUF5Q0NtdDFZbVZ5Ym1WMFpYTXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBQ3pWWUpBUzQ1UFBOSFVSaDJKWQpKWDFycmFMdGNTbzVuNG1DVy9oeE5YdHpCMlIzWkhPU0hnNmF2R3JNeFY4ZlpCdmtBdEJFaUYzM2JvRThzZzVhCjhhWHRFTjR5bzlZQ2FZc2ZXK2tNNlZDRUdtVWd5bm13aXltYTBzSW5USlZ1R3ZVbDVucVhjUHJJdW9OTVVrTUwKdCsrckxCb0NwY2xrN09VSTA0dXZvanpxc2hsQ0JiMURSOXRwT0s0Kys0UGdPait6OXZ0N3g0dzhMYlhvQmtvegozOEJyVEoyQ3NqbU0xS2ZqZXlpNWdHVmFjeE9YSXRjbXprNzRpQzZ0SjdqVm1MVmNacEc5ZElvcFk5WTBaTkQ0CmQzZjlmOGdCWkJzaXA0a3gxMmFxMlJ5dzFYNGVOaFY2dW5OaCtHVHNhNlFDSlJ0Zk9FK1Q4Njd2ZHlPbjZMb2wKYWQ4PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please change it simple base64 encoded value. Using actual certificate data in test files, even if they are expired or invalid, is not a best practice from a security perspective. I think we should update them in other places as well like https://github.com/headlamp-k8s/headlamp/blob/main/backend/pkg/kubeconfig/test_data/kubeconfig_partialcontextvalid#L31.

extensions:
- extension:
last-update: Mon, 26 Dec 2022 20:33:03 IST
provider: minikubetest.sigs.k8s.io
version: v1.28.0
name: cluster_info
server: https://127.0.0.1:60279
name: minikubetest
contexts:
- context:
cluster: minikubetest
namespace: default
user: minikubetest
extensions:
- extension:
last-update: Mon, 26 Dec 2022 20:33:03 IST
provider: minikubetest.sigs.k8s.io
version: v1.28.0
name: context_info
- extension:
creationTimestamp: null
customName: minikubetestworks
name: headlamp_info
name: minikubetest
- context:
cluster: minikubetest
namespace: default
user: kubedelta
name: kubedelta
users:
- name: minikubetest
user:
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJekNDQWd1Z0F3SUJBZ0lJZlJpZk1qZWl1eFV3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TWpBNE1qWXhNVEEwTlROYUZ3MHlOREF4TURJeE1ESXhNelZhTURZeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sc3dHUVlEVlFRREV4SmtiMk5yWlhJdFptOXlMV1JsCmMydDBiM0F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQzRLUmwrS0lsN0NJYVgKbzIwYjdBOVEvaURDbUN6dWFXMSs3WEJyelhiZHNmNkNaRzhVMWZYWTdVWXl3bXVhYkZldUFrUHRBT1hyWVg0YQpMTGZtWTRvdkZYc1RQWmtPUktJeWRFUmNnLy9hOStPd3d2c1ZCUUp4NFplbUtrN1NzaFYxcjl3WGVqVnJIUkFOCm5xQ3JIQVhFNHA5bmFKZHNkTXIyQWdDa0VIK01tTFNqTExNL1lWcnExdmJpRWRtUVFSWHduVnFwcmNyRXBIQzUKWWJJenl4cVZRWWZIZVdWc2N0SUxFeVdPMFQwMS9tYkZ2RVY4QW9BL3phekIycjF3Y0VaeUNSRXFXbExrS2RXTwpNYmU1WnlwMDNhQzlBOSs4cThQNFBEOUNnVXlrcVovN0xydGlja2k0TVBsK2VmaGFlUk9YSEJMSURuQmplTHJkCmJGdHpaOVhKQWdNQkFBR2pWakJVTUE0R0ExVWREd0VCL3dRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU1CZ05WSFJNQkFmOEVBakFBTUI4R0ExVWRJd1FZTUJhQUZJNFFkU2FSRFVodi8wWjk0ZzV5RmlVdQppTGRwTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBRjNvaTFvNVlNM1UvOWxPRElhaUpmaGllNzdieG1pN3NwCitiL0NEOGRCTXhIdWpPVnBSaTFNaHRJa2U3N2U1RVVuZEFGRzYvQTQwK3c2TGtCYXJFUEl5R2daRlBvZkttcSsKRGlIMGxPZHBYY0hFd3laTjhWSmdRd0JKUkhKcDhBc0p3TGFYWGplU1FQdmZyeHhLdUFGenRzeXNaYlBMUkxoYQpjeXZmeDNwTE91ZVJ4MDJqQVZUUlNJUGNPZEV4SERPa0FGWFFCdDV4TFo2eGFKTU1VQjZXNUYwcVpPelFuVUZsCk80QUNNOEhnOEdKc2xqLzFqZnpZaGlneWdwL2psQ0Jkd1Izb2c1ZXFqaC9ZRzlxWHVsU2Z0WUNhMURaOEp2QnAKaGRSYzZxOVM0ZFdtRW9zMmkxTDA1WUs3ZFBaQk5JVHRLNkVzQS9CRCs0VlVWRHczZldkNQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBdUNrWmZpaUpld2lHbDZOdEcrd1BVUDRnd3BnczdtbHRmdTF3YTgxMjNiSCtnbVJ2CkZOWDEyTzFHTXNKcm1teFhyZ0pEN1FEbDYyRitHaXkzNW1PS0x4VjdFejJaRGtTaU1uUkVYSVAvMnZmanNNTDcKRlFVQ2NlR1hwaXBPMHJJVmRhL2NGM28xYXgwUURaNmdxeHdGeE9LZloyaVhiSFRLOWdJQXBCQi9qSmkwb3l5egpQMkZhNnRiMjRoSFprRUVWOEoxYXFhM0t4S1J3dVdHeU04c2FsVUdIeDNsbGJITFNDeE1sanRFOU5mNW14YnhGCmZBS0FQODJzd2RxOWNIQkdjZ2tSS2xwUzVDblZqakczdVdjcWROMmd2UVBmdkt2RCtEdy9Rb0ZNcEttZit5NjcKWW5KSXVERDVmbm40V25rVGx4d1N5QTV3WTNpNjNXeGJjMmZWeVFJREFRQUJBb0lCQUdYMWUwTzV0Y1FFU0dBVAovd2lDZlVoZUtrMFNhMjNqdU5lWkpiREpwSkhCUmlOeTczMGRxR3Rka292djBCdEMrSmhDY05ENnVsRERQVW5JCmtGaGhxOU85bE5KbVBDTUdKTGJDWUViSVhoTWhRMUpONFMwV0JQQi84Ykh4b29wTVJrMU4vQkNUZkplOUUzSTIKN01WUFVuSmE5ZDRPcmlkQjBreTVkeGxlZVAraGFvT2NTejJGamhXbDEycnlqbm1ad0draU1BdnhHazBaejFkZgpxZ0QyUE5CRHYzMTdtMkJxYjdkcENaTmZsSi90MGtqQ3hGbm81UmRsUUl5NDhSeml4LytaV1ZSeWlsaVFjL2srCnY4UzRTWGljZjBDK1RzV0orZzBNU3NoaGs1SWV4OURzTmR1bmJrSTcxMDNoR2ZOcGZYbGlRVlZHTlZ2eHdNd2kKenV4eE9nRUNnWUVBeGFYbU9GSUhkbm1tU0tMbEp4KzhBc3pPcmdTNDFKM2RMemtUZUZhWU5STHY0Z1AyK05SQwpQcXAzYVRCRmNjNWFMTDFXcG1ZRkdGSkthejRUbm05dlRKN0Nhell6K1RZRWc4OWlidGJESmhFSlFaSGtRaDQxCnJXRlBHTERWL1ZVSk5kaStSRk9TU2xMVDVGY2gvS1NkdFFRazhkbStUempiWi94a29ZOVpKbmtDZ1lFQTdvZlQKRnQ0MytQY0w5T0ZoTGZjYnB2SUJkeDdzWlAyK09NVzF3eG92TnhTbElSelBSQi9QbXNsai9hUzV2VnFWOVlVdgp0YjVFaUM3cUVYN2JVS0lZT1hMY1c3N3ozcmxpODdMRW5CWkNDemFhdHk3Nmw1U2lneU16VDY3MS9DdjNSSVJYCkw5citoQ2ZUUThPVHBUVDR6R3ZhRWJrQldBcnRmeVZOTDhkVGxkRUNnWUF1TE44SFEyckk3QXpFSllKaHpKRXgKR2tZaTg2bDJ5dGJVNUlHKytJUWd5aWJPNTl3NEwrYTJHejlBak8xOGRCZ3ZJYUR2eVIvaG1jQVhJKzZUY2pkUApjRHU5cm5FZ0JOV3pNYTB1ZGZBcm9ZbEhEMTJEY09sYmMwTjJZa0hzS0lTNVZzVEUwNzEycmJraFBKWU5IeXhWCkVQM01udkZPTXR0WGhPakJzZXJEQ1FLQmdEOCtBOW1zVVdyUkZYcDN4eXhJdUN3clBmZzNXclhzRU9NOGlGU1MKUExKOTVzcEF1VE4ydTdSdWNQUnZHRS84Rklaa0thSW1NRVZyS3VRNG5pMWl6TWx1aXI1SWdxQXF4dkdXRkVyTwpHL1NkSmFncjdJVUVBNUtCWXJsZHlocHlEYjA4MldEMnowUjZ5cWpNMGZpYmN0dkFQTEUyUEFUNzRMdzFSNkhEClY0WUJBb0dCQUo4bUdVNWJnejBQUnJZL1hoUERER2tIa0Z3SHVocHhjL2tieWNweVVTZ2NDR2dsczFXTTdKOFUKQ05MV2pQc0pnSTNxeTJSN0xxaGxmNHJRK1orLy80WHdyNW1NYWpqSEVGZE93Z2xqbFZRdEljNWM4a2U3MzM1SwprbCtxblpJbUcwRy83R1hUZHhSOW9mQXBBZVFkT3pJTFY0K1YzZm5DRGNYMnBHWjZOOTJUCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==
- name: kubedelta
user:
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJekNDQWd1Z0F3SUJBZ0lJZlJpZk1qZWl1eFV3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TWpBNE1qWXhNVEEwTlROYUZ3MHlOREF4TURJeE1ESXhNelZhTURZeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sc3dHUVlEVlFRREV4SmtiMk5yWlhJdFptOXlMV1JsCmMydDBiM0F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQzRLUmwrS0lsN0NJYVgKbzIwYjdBOVEvaURDbUN6dWFXMSs3WEJyelhiZHNmNkNaRzhVMWZYWTdVWXl3bXVhYkZldUFrUHRBT1hyWVg0YQpMTGZtWTRvdkZYc1RQWmtPUktJeWRFUmNnLy9hOStPd3d2c1ZCUUp4NFplbUtrN1NzaFYxcjl3WGVqVnJIUkFOCm5xQ3JIQVhFNHA5bmFKZHNkTXIyQWdDa0VIK01tTFNqTExNL1lWcnExdmJpRWRtUVFSWHduVnFwcmNyRXBIQzUKWWJJenl4cVZRWWZIZVdWc2N0SUxFeVdPMFQwMS9tYkZ2RVY4QW9BL3phekIycjF3Y0VaeUNSRXFXbExrS2RXTwpNYmU1WnlwMDNhQzlBOSs4cThQNFBEOUNnVXlrcVovN0xydGlja2k0TVBsK2VmaGFlUk9YSEJMSURuQmplTHJkCmJGdHpaOVhKQWdNQkFBR2pWakJVTUE0R0ExVWREd0VCL3dRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU1CZ05WSFJNQkFmOEVBakFBTUI4R0ExVWRJd1FZTUJhQUZJNFFkU2FSRFVodi8wWjk0ZzV5RmlVdQppTGRwTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBRjNvaTFvNVlNM1UvOWxPRElhaUpmaGllNzdieG1pN3NwCitiL0NEOGRCTXhIdWpPVnBSaTFNaHRJa2U3N2U1RVVuZEFGRzYvQTQwK3c2TGtCYXJFUEl5R2daRlBvZkttcSsKRGlIMGxPZHBYY0hFd3laTjhWSmdRd0JKUkhKcDhBc0p3TGFYWGplU1FQdmZyeHhLdUFGenRzeXNaYlBMUkxoYQpjeXZmeDNwTE91ZVJ4MDJqQVZUUlNJUGNPZEV4SERPa0FGWFFCdDV4TFo2eGFKTU1VQjZXNUYwcVpPelFuVUZsCk80QUNNOEhnOEdKc2xqLzFqZnpZaGlneWdwL2psQ0Jkd1Izb2c1ZXFqaC9ZRzlxWHVsU2Z0WUNhMURaOEp2QnAKaGRSYzZxOVM0ZFdtRW9zMmkxTDA1WUs3ZFBaQk5JVHRLNkVzQS9CRCs0VlVWRHczZldkNQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBdUNrWmZpaUpld2lHbDZOdEcrd1BVUDRnd3BnczdtbHRmdTF3YTgxMjNiSCtnbVJ2CkZOWDEyTzFHTXNKcm1teFhyZ0pEN1FEbDYyRitHaXkzNW1PS0x4VjdFejJaRGtTaU1uUkVYSVAvMnZmanNNTDcKRlFVQ2NlR1hwaXBPMHJJVmRhL2NGM28xYXgwUURaNmdxeHdGeE9LZloyaVhiSFRLOWdJQXBCQi9qSmkwb3l5egpQMkZhNnRiMjRoSFprRUVWOEoxYXFhM0t4S1J3dVdHeU04c2FsVUdIeDNsbGJITFNDeE1sanRFOU5mNW14YnhGCmZBS0FQODJzd2RxOWNIQkdjZ2tSS2xwUzVDblZqakczdVdjcWROMmd2UVBmdkt2RCtEdy9Rb0ZNcEttZit5NjcKWW5KSXVERDVmbm40V25rVGx4d1N5QTV3WTNpNjNXeGJjMmZWeVFJREFRQUJBb0lCQUdYMWUwTzV0Y1FFU0dBVAovd2lDZlVoZUtrMFNhMjNqdU5lWkpiREpwSkhCUmlOeTczMGRxR3Rka292djBCdEMrSmhDY05ENnVsRERQVW5JCmtGaGhxOU85bE5KbVBDTUdKTGJDWUViSVhoTWhRMUpONFMwV0JQQi84Ykh4b29wTVJrMU4vQkNUZkplOUUzSTIKN01WUFVuSmE5ZDRPcmlkQjBreTVkeGxlZVAraGFvT2NTejJGamhXbDEycnlqbm1ad0draU1BdnhHazBaejFkZgpxZ0QyUE5CRHYzMTdtMkJxYjdkcENaTmZsSi90MGtqQ3hGbm81UmRsUUl5NDhSeml4LytaV1ZSeWlsaVFjL2srCnY4UzRTWGljZjBDK1RzV0orZzBNU3NoaGs1SWV4OURzTmR1bmJrSTcxMDNoR2ZOcGZYbGlRVlZHTlZ2eHdNd2kKenV4eE9nRUNnWUVBeGFYbU9GSUhkbm1tU0tMbEp4KzhBc3pPcmdTNDFKM2RMemtUZUZhWU5STHY0Z1AyK05SQwpQcXAzYVRCRmNjNWFMTDFXcG1ZRkdGSkthejRUbm05dlRKN0Nhell6K1RZRWc4OWlidGJESmhFSlFaSGtRaDQxCnJXRlBHTERWL1ZVSk5kaStSRk9TU2xMVDVGY2gvS1NkdFFRazhkbStUempiWi94a29ZOVpKbmtDZ1lFQTdvZlQKRnQ0MytQY0w5T0ZoTGZjYnB2SUJkeDdzWlAyK09NVzF3eG92TnhTbElSelBSQi9QbXNsai9hUzV2VnFWOVlVdgp0YjVFaUM3cUVYN2JVS0lZT1hMY1c3N3ozcmxpODdMRW5CWkNDemFhdHk3Nmw1U2lneU16VDY3MS9DdjNSSVJYCkw5citoQ2ZUUThPVHBUVDR6R3ZhRWJrQldBcnRmeVZOTDhkVGxkRUNnWUF1TE44SFEyckk3QXpFSllKaHpKRXgKR2tZaTg2bDJ5dGJVNUlHKytJUWd5aWJPNTl3NEwrYTJHejlBak8xOGRCZ3ZJYUR2eVIvaG1jQVhJKzZUY2pkUApjRHU5cm5FZ0JOV3pNYTB1ZGZBcm9ZbEhEMTJEY09sYmMwTjJZa0hzS0lTNVZzVEUwNzEycmJraFBKWU5IeXhWCkVQM01udkZPTXR0WGhPakJzZXJEQ1FLQmdEOCtBOW1zVVdyUkZYcDN4eXhJdUN3clBmZzNXclhzRU9NOGlGU1MKUExKOTVzcEF1VE4ydTdSdWNQUnZHRS84Rklaa0thSW1NRVZyS3VRNG5pMWl6TWx1aXI1SWdxQXF4dkdXRkVyTwpHL1NkSmFncjdJVUVBNUtCWXJsZHlocHlEYjA4MldEMnowUjZ5cWpNMGZpYmN0dkFQTEUyUEFUNzRMdzFSNkhEClY0WUJBb0dCQUo4bUdVNWJnejBQUnJZL1hoUERER2tIa0Z3SHVocHhjL2tieWNweVVTZ2NDR2dsczFXTTdKOFUKQ05MV2pQc0pnSTNxeTJSN0xxaGxmNHJRK1orLy80WHdyNW1NYWpqSEVGZE93Z2xqbFZRdEljNWM4a2U3MzM1SwprbCtxblpJbUcwRy83R1hUZHhSOW9mQXBBZVFkT3pJTFY0K1YzZm5DRGNYMnBHWjZOOTJUCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==
Loading
Loading