From 7c20d46e6a0e2bcfbbe858fd84b2589f213262dd Mon Sep 17 00:00:00 2001 From: Losev Valery Date: Sun, 18 Oct 2026 11:15:27 +0400 Subject: [PATCH] update the logic for creating the cluster-configuration secret Signed-off-by: Losev Valery --- .../cluster-configuration.go | 2 +- .../system/cmd/edit/editconfig/editconfig.go | 150 ++++++++++---- .../cmd/edit/editconfig/editconfig_test.go | 186 ++++++++++++++++++ .../provider-cluster-configuration.go | 2 +- .../static-cluster-configuration.go | 8 +- 5 files changed, 305 insertions(+), 43 deletions(-) create mode 100644 internal/system/cmd/edit/editconfig/editconfig_test.go diff --git a/internal/system/cmd/edit/cluster-configuration/cluster-configuration.go b/internal/system/cmd/edit/cluster-configuration/cluster-configuration.go index e3a4e756b..8bdd6112b 100644 --- a/internal/system/cmd/edit/cluster-configuration/cluster-configuration.go +++ b/internal/system/cmd/edit/cluster-configuration/cluster-configuration.go @@ -46,7 +46,7 @@ func NewCommand() *cobra.Command { } func editClusterConfig(cmd *cobra.Command, _ []string) error { - err := edit.BaseEditConfigCMD(cmd, "cluster-configuration", "d8-cluster-configuration", "cluster-configuration.yaml") + err := edit.BaseEditConfigCMD(cmd, "cluster-configuration", "d8-cluster-configuration", "cluster-configuration.yaml", nil) if err != nil { return fmt.Errorf("Error updating secret: %w", err) } diff --git a/internal/system/cmd/edit/editconfig/editconfig.go b/internal/system/cmd/edit/editconfig/editconfig.go index aa4bd0818..3be0be063 100644 --- a/internal/system/cmd/edit/editconfig/editconfig.go +++ b/internal/system/cmd/edit/editconfig/editconfig.go @@ -1,6 +1,7 @@ package edit import ( + "bytes" "context" "crypto/sha256" "encoding/base64" @@ -11,13 +12,26 @@ import ( "github.com/spf13/cobra" v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) -func BaseEditConfigCMD(cmd *cobra.Command, _ string, secret, dataKey string) error { +const secretNamespace = "kube-system" + +// CreateOptions allows the edited Secret to be created when it is absent. +// A nil *CreateOptions means a missing Secret is an error. +type CreateOptions struct { + Labels map[string]string +} + +// editFunc receives the current content of the data key and returns the edited one. +type editFunc func(content []byte) ([]byte, error) + +func BaseEditConfigCMD(cmd *cobra.Command, _ string, secret, dataKey string, createOpts *CreateOptions) error { editor, err := cmd.Flags().GetString("editor") if err != nil { return fmt.Errorf("Failed to get editor from --editor flag: %w", err) @@ -38,40 +52,45 @@ func BaseEditConfigCMD(cmd *cobra.Command, _ string, secret, dataKey string) err return fmt.Errorf("Failed to setup Kubernetes client: %w", err) } + return editSecret(context.Background(), kubeCl, secret, dataKey, createOpts, func(content []byte) ([]byte, error) { + return editInEditor(editor, content) + }) +} + +func editSecret( + ctx context.Context, + kubeCl kubernetes.Interface, + secret, dataKey string, + createOpts *CreateOptions, + edit editFunc, +) error { secretConfig, err := kubeCl.CoreV1(). - Secrets("kube-system"). - Get(context.Background(), secret, metav1.GetOptions{}) - if err != nil { + Secrets(secretNamespace). + Get(ctx, secret, metav1.GetOptions{}) + + absent := apierrors.IsNotFound(err) && createOpts != nil + if err != nil && !absent { return fmt.Errorf("Error fetching secret: %w", err) } - tempFile, err := writeSecretTmp(secretConfig, dataKey) - if err != nil { - return err + if absent { + secretConfig = newSecret(secret, createOpts.Labels) } - defer func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) - }() - - cmdExec := exec.Command(editor, tempFile.Name()) - cmdExec.Stdin = os.Stdin - cmdExec.Stdout = os.Stdout - cmdExec.Stderr = os.Stderr + currentContent := secretConfig.Data[dataKey] - err = cmdExec.Run() + updatedContent, err := edit(currentContent) if err != nil { - return fmt.Errorf("Error opening in editor: %w", err) + return err } - updatedContent, contentNotChanged, err := compareEditedSecret(tempFile, secretConfig, dataKey) - if err != nil { - return fmt.Errorf("Cannot open edited temp file: %w", err) + if sha256.Sum256(currentContent) == sha256.Sum256(updatedContent) { + fmt.Println("Configurations are equal. Nothing to update.") + return nil } - if contentNotChanged { - return nil + if absent { + return createSecret(ctx, kubeCl, secretConfig, dataKey, updatedContent) } encodedValue, err := encodeSecretTmp(updatedContent, dataKey) @@ -80,46 +99,97 @@ func BaseEditConfigCMD(cmd *cobra.Command, _ string, secret, dataKey string) err } _, err = kubeCl.CoreV1(). - Secrets("kube-system").Patch(context.TODO(), secret, types.MergePatchType, encodedValue, metav1.PatchOptions{}) + Secrets(secretNamespace).Patch(ctx, secret, types.MergePatchType, encodedValue, metav1.PatchOptions{}) if err != nil { return fmt.Errorf("Error updating secret: %w", err) } fmt.Println("Secret updated successfully") - return err + return nil } -func writeSecretTmp(secretConfig *v1.Secret, dataKey string) (*os.File, error) { - tempFile, err := os.CreateTemp(os.TempDir(), "secret.*.yaml") +func newSecret(name string, labels map[string]string) *v1.Secret { + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: secretNamespace, + Labels: labels, + }, + Type: v1.SecretTypeOpaque, + } +} + +func createSecret(ctx context.Context, kubeCl kubernetes.Interface, secretConfig *v1.Secret, dataKey string, content []byte) error { + if len(bytes.TrimSpace(content)) == 0 { + fmt.Println("Configuration is empty. Nothing to create.") + return nil + } + + secretConfig.Data = map[string][]byte{dataKey: content} + + _, err := kubeCl.CoreV1(). + Secrets(secretNamespace). + Create(ctx, secretConfig, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("Secret %s/%s was created while editing, run the command again to edit it: %w", + secretNamespace, secretConfig.Name, err) + } + if err != nil { - return nil, fmt.Errorf("can't save cluster configuration: %w", err) + return fmt.Errorf("Error creating secret: %w", err) } - _, err = tempFile.Write(secretConfig.Data[dataKey]) + fmt.Println("Secret created successfully") + + return nil +} + +func editInEditor(editor string, content []byte) ([]byte, error) { + tempFile, err := writeSecretTmp(content) if err != nil { - return nil, fmt.Errorf("Error writing decoded data to file: %w", err) + return nil, err } - if err = tempFile.Sync(); err != nil { - return nil, fmt.Errorf("Sync temp file buffers to disk: %w", err) + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + + cmdExec := exec.Command(editor, tempFile.Name()) + cmdExec.Stdin = os.Stdin + cmdExec.Stdout = os.Stdout + cmdExec.Stderr = os.Stderr + + err = cmdExec.Run() + if err != nil { + return nil, fmt.Errorf("Error opening in editor: %w", err) } - return tempFile, nil + updatedContent, err := os.ReadFile(tempFile.Name()) + if err != nil { + return nil, fmt.Errorf("Error reading updated file: %w", err) + } + + return updatedContent, nil } -func compareEditedSecret(tempFile *os.File, secretConfig *v1.Secret, dataKey string) ([]byte, bool, error) { - updatedContent, err := os.ReadFile(tempFile.Name()) +func writeSecretTmp(content []byte) (*os.File, error) { + tempFile, err := os.CreateTemp(os.TempDir(), "secret.*.yaml") if err != nil { - return nil, false, fmt.Errorf("Error reading updated file: %w", err) + return nil, fmt.Errorf("can't save cluster configuration: %w", err) } - if sha256.Sum256(secretConfig.Data[dataKey]) == sha256.Sum256(updatedContent) { - fmt.Println("Configurations are equal. Nothing to update.") - return nil, true, nil + _, err = tempFile.Write(content) + if err != nil { + return nil, fmt.Errorf("Error writing decoded data to file: %w", err) + } + + if err = tempFile.Sync(); err != nil { + return nil, fmt.Errorf("Sync temp file buffers to disk: %w", err) } - return updatedContent, false, nil + return tempFile, nil } func encodeSecretTmp(updatedContent []byte, dataKey string) ([]byte, error) { diff --git a/internal/system/cmd/edit/editconfig/editconfig_test.go b/internal/system/cmd/edit/editconfig/editconfig_test.go new file mode 100644 index 000000000..849ac90b2 --- /dev/null +++ b/internal/system/cmd/edit/editconfig/editconfig_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package edit + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +const ( + testSecret = "d8-static-cluster-configuration" + testDataKey = "static-cluster-configuration.yaml" +) + +var testCreateOpts = &CreateOptions{Labels: map[string]string{"name": testSecret}} + +func replaceWith(content string) editFunc { + return func([]byte) ([]byte, error) { + return []byte(content), nil + } +} + +func hasAction(client *fake.Clientset, verb string) bool { + for _, action := range client.Actions() { + if action.GetVerb() == verb { + return true + } + } + + return false +} + +func getSecret(t *testing.T, client *fake.Clientset) *v1.Secret { + t.Helper() + + secret, err := client.CoreV1().Secrets(secretNamespace).Get(context.Background(), testSecret, metav1.GetOptions{}) + require.NoError(t, err) + + return secret +} + +func TestEditSecretPatchesExisting(t *testing.T) { + existing := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSecret, + Namespace: secretNamespace, + Labels: map[string]string{"name": testSecret, "heritage": "deckhouse"}, + }, + Data: map[string][]byte{testDataKey: []byte("old: config\n")}, + } + client := fake.NewClientset(existing) + + var edited []byte + + err := editSecret(context.Background(), client, testSecret, testDataKey, testCreateOpts, func(content []byte) ([]byte, error) { + edited = content + return []byte("new: config\n"), nil + }) + require.NoError(t, err) + + assert.Equal(t, "old: config\n", string(edited)) + assert.True(t, hasAction(client, "patch")) + assert.False(t, hasAction(client, "create")) + + secret := getSecret(t, client) + assert.Equal(t, "new: config\n", string(secret.Data[testDataKey])) + assert.Equal(t, existing.Labels, secret.Labels) +} + +func TestEditSecretSkipsUnchangedExisting(t *testing.T) { + client := fake.NewClientset(&v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: testSecret, Namespace: secretNamespace}, + Data: map[string][]byte{testDataKey: []byte("same\n")}, + }) + + err := editSecret(context.Background(), client, testSecret, testDataKey, testCreateOpts, replaceWith("same\n")) + require.NoError(t, err) + + assert.False(t, hasAction(client, "patch")) + assert.False(t, hasAction(client, "create")) +} + +func TestEditSecretCreatesAbsent(t *testing.T) { + client := fake.NewClientset() + + var edited []byte + + err := editSecret(context.Background(), client, testSecret, testDataKey, testCreateOpts, func(content []byte) ([]byte, error) { + edited = content + return []byte("apiVersion: deckhouse.io/v1\nkind: StaticClusterConfiguration\n"), nil + }) + require.NoError(t, err) + + assert.Empty(t, edited) + assert.True(t, hasAction(client, "create")) + assert.False(t, hasAction(client, "patch")) + + secret := getSecret(t, client) + assert.Equal(t, secretNamespace, secret.Namespace) + assert.Equal(t, v1.SecretTypeOpaque, secret.Type) + assert.Equal(t, map[string]string{"name": testSecret}, secret.Labels) + assert.Equal(t, map[string][]byte{ + testDataKey: []byte("apiVersion: deckhouse.io/v1\nkind: StaticClusterConfiguration\n"), + }, secret.Data) +} + +func TestEditSecretSkipsEmptyAbsent(t *testing.T) { + for name, content := range map[string]string{ + "empty": "", + "whitespace": " \n\t\n", + } { + t.Run(name, func(t *testing.T) { + client := fake.NewClientset() + + err := editSecret(context.Background(), client, testSecret, testDataKey, testCreateOpts, replaceWith(content)) + require.NoError(t, err) + + assert.False(t, hasAction(client, "create")) + }) + } +} + +func TestEditSecretAbsentWithoutCreateOpts(t *testing.T) { + client := fake.NewClientset() + editCalled := false + + err := editSecret(context.Background(), client, testSecret, testDataKey, nil, func([]byte) ([]byte, error) { + editCalled = true + return nil, nil + }) + + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err)) + assert.False(t, editCalled) + assert.False(t, hasAction(client, "create")) +} + +func TestEditSecretGetErrorDoesNotCreate(t *testing.T) { + client := fake.NewClientset() + client.PrependReactor("get", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, testSecret, nil) + }) + + err := editSecret(context.Background(), client, testSecret, testDataKey, testCreateOpts, replaceWith("a: b\n")) + + require.Error(t, err) + assert.True(t, apierrors.IsForbidden(err)) + assert.False(t, hasAction(client, "create")) +} + +func TestEditSecretCreateConflict(t *testing.T) { + client := fake.NewClientset() + client.PrependReactor("create", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewAlreadyExists(schema.GroupResource{Resource: "secrets"}, testSecret) + }) + + err := editSecret(context.Background(), client, testSecret, testDataKey, testCreateOpts, replaceWith("a: b\n")) + + require.Error(t, err) + assert.True(t, apierrors.IsAlreadyExists(err)) + assert.False(t, hasAction(client, "patch")) +} diff --git a/internal/system/cmd/edit/provider-cluster-configuration/provider-cluster-configuration.go b/internal/system/cmd/edit/provider-cluster-configuration/provider-cluster-configuration.go index 2bccfe4f9..0a284d830 100644 --- a/internal/system/cmd/edit/provider-cluster-configuration/provider-cluster-configuration.go +++ b/internal/system/cmd/edit/provider-cluster-configuration/provider-cluster-configuration.go @@ -46,7 +46,7 @@ func NewCommand() *cobra.Command { } func editProviderClusterConfig(cmd *cobra.Command, _ []string) error { - err := edit.BaseEditConfigCMD(cmd, "provider-cluster-configuration", "d8-provider-cluster-configuration", "cloud-provider-cluster-configuration.yaml") + err := edit.BaseEditConfigCMD(cmd, "provider-cluster-configuration", "d8-provider-cluster-configuration", "cloud-provider-cluster-configuration.yaml", nil) if err != nil { return fmt.Errorf("Error updating secret: %w", err) } diff --git a/internal/system/cmd/edit/static-cluster-configuration/static-cluster-configuration.go b/internal/system/cmd/edit/static-cluster-configuration/static-cluster-configuration.go index dd33dce08..8f0d2a82a 100644 --- a/internal/system/cmd/edit/static-cluster-configuration/static-cluster-configuration.go +++ b/internal/system/cmd/edit/static-cluster-configuration/static-cluster-configuration.go @@ -45,8 +45,14 @@ func NewCommand() *cobra.Command { return staticClusterConfigurationCmd } +const staticClusterConfigurationSecret = "d8-static-cluster-configuration" + func editStaticClusterConfig(cmd *cobra.Command, _ []string) error { - err := edit.BaseEditConfigCMD(cmd, "static-cluster-configuration", "d8-static-cluster-configuration", "static-cluster-configuration.yaml") + createOpts := &edit.CreateOptions{ + Labels: map[string]string{"name": staticClusterConfigurationSecret}, + } + + err := edit.BaseEditConfigCMD(cmd, "static-cluster-configuration", staticClusterConfigurationSecret, "static-cluster-configuration.yaml", createOpts) if err != nil { return fmt.Errorf("Error updating secret: %w", err) }