diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index a0a124764..d81765210 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -245,6 +245,7 @@ func SetupCommands(version string) { _ = configSetCmd.Flags().MarkHidden("name") configSetCmd.Flags().BoolP("env", "e", false, "set the config from an environment variable") configSetCmd.Flags().Bool("random", false, "set a secure randomly generated value for config") + configSetCmd.Flags().Bool("if-not-set", false, "set the config if it is not already set") configSetCmd.Flags().String("env-file", "", "load config values from an .env file") configSetCmd.MarkFlagFilename("env-file") diff --git a/src/cmd/cli/command/config.go b/src/cmd/cli/command/config.go index f054778e7..c96c61c8a 100644 --- a/src/cmd/cli/command/config.go +++ b/src/cmd/cli/command/config.go @@ -34,6 +34,7 @@ var configSetCmd = &cobra.Command{ ctx := cmd.Context() fromEnv, _ := cmd.Flags().GetBool("env") random, _ := cmd.Flags().GetBool("random") + ifNotSet, _ := cmd.Flags().GetBool("if-not-set") envFile, _ := cmd.Flags().GetString("env-file") // This command has several modes of operation: @@ -167,8 +168,13 @@ var configSetCmd = &cobra.Command{ var errs []error for name, value := range envMap { - if err := cli.ConfigSet(cmd.Context(), projectName, session.Provider, name, value); err != nil { + didSet, err := cli.ConfigSet(cmd.Context(), projectName, session.Provider, name, value, cli.ConfigSetOptions{ + IfNotSet: ifNotSet, + }) + if err != nil { errs = append(errs, err) + } else if ifNotSet && !didSet { + term.Info("Config", name, "is already set; skipping due to --if-not-set flag") } else { term.Info("Updated value for", name) } diff --git a/src/pkg/agent/tools/default_tool_cli.go b/src/pkg/agent/tools/default_tool_cli.go index 33fae50af..470b98f7a 100644 --- a/src/pkg/agent/tools/default_tool_cli.go +++ b/src/pkg/agent/tools/default_tool_cli.go @@ -33,7 +33,8 @@ func (DefaultToolCLI) CanIUseProvider(ctx context.Context, fabric *client.GrpcCl } func (DefaultToolCLI) ConfigSet(ctx context.Context, projectName string, provider client.Provider, name, value string) error { - return cli.ConfigSet(ctx, projectName, provider, name, value) + _, err := cli.ConfigSet(ctx, projectName, provider, name, value, cli.ConfigSetOptions{}) + return err } func (DefaultToolCLI) RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) { diff --git a/src/pkg/cli/configSet.go b/src/pkg/cli/configSet.go index d72968644..bfa7b2bb2 100644 --- a/src/pkg/cli/configSet.go +++ b/src/pkg/cli/configSet.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/DefangLabs/defang/src/pkg" - "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/term" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" @@ -19,16 +18,42 @@ func (e ErrInvalidConfigName) Error() string { return fmt.Sprintf("invalid config name; must be alphanumeric or _, cannot start with a number: %q", e.Name) } -func ConfigSet(ctx context.Context, projectName string, provider client.Provider, name string, value string) error { +type ConfigSetOptions struct { + IfNotSet bool +} + +type ConfigManager interface { + ListConfig(ctx context.Context, req *defangv1.ListConfigsRequest) (*defangv1.Secrets, error) + PutConfig(ctx context.Context, req *defangv1.PutConfigRequest) error +} + +func ConfigSet(ctx context.Context, projectName string, provider ConfigManager, name string, value string, options ConfigSetOptions) (bool, error) { term.Debugf("Setting config %q in project %q", name, projectName) if !pkg.IsValidSecretName(name) { - return ErrInvalidConfigName{Name: name} + return false, ErrInvalidConfigName{Name: name} } if dryrun.DoDryRun { - return dryrun.ErrDryRun + return false, dryrun.ErrDryRun } - return provider.PutConfig(ctx, &defangv1.PutConfigRequest{Project: projectName, Name: name, Value: value}) + if options.IfNotSet { + // Check if the config is already set + listResp, err := provider.ListConfig(ctx, &defangv1.ListConfigsRequest{Project: projectName}) + if err != nil { + return false, fmt.Errorf("failed to get existing config %q: %w", name, err) + } + for _, existingName := range listResp.Names { + if existingName == name { + return false, nil + } + } + } + + err := provider.PutConfig(ctx, &defangv1.PutConfigRequest{Project: projectName, Name: name, Value: value}) + if err != nil { + return false, err + } + return true, nil } diff --git a/src/pkg/cli/configSet_test.go b/src/pkg/cli/configSet_test.go index 3754c9021..e3b9486e9 100644 --- a/src/pkg/cli/configSet_test.go +++ b/src/pkg/cli/configSet_test.go @@ -2,42 +2,120 @@ package cli import ( "context" - "errors" "testing" - "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/dryrun" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) +type mockConfigManager struct { + mock.Mock +} + +func (m mockConfigManager) ListConfig(ctx context.Context, req *defangv1.ListConfigsRequest) (*defangv1.Secrets, error) { + args := m.Called(ctx, req) + secret, ok := args.Get(0).(*defangv1.Secrets) + if !ok && args.Get(0) != nil { + return nil, args.Error(1) + } + return secret, args.Error(1) +} + +func (m mockConfigManager) PutConfig(ctx context.Context, req *defangv1.PutConfigRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + func TestConfigSet(t *testing.T) { ctx := t.Context() - provider := MustHaveProjectNamePutConfigProvider{} + + provider := &mockConfigManager{} + + tests := []struct { + name string + configName string + existing []string + ifNotSet bool + expectedError error + expectedDidSet bool + }{ + { + name: "config not set, should set", + configName: "test", + existing: []string{}, + ifNotSet: true, + expectedDidSet: true, + }, + { + name: "config already set, should skip", + configName: "test", + existing: []string{"test"}, + ifNotSet: true, + expectedDidSet: false, + }, + { + name: "config already set, should overwrite", + configName: "test", + existing: []string{"test_name"}, + ifNotSet: false, + expectedDidSet: true, + }, + { + name: "invalid config name, should error", + configName: "123invalid", + expectedError: ErrInvalidConfigName{Name: "123invalid"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project := "test" + value := "test_value" + provider.ExpectedCalls = nil // Reset expectations + + provider.On("ListConfig", mock.Anything, &defangv1.ListConfigsRequest{Project: project}). + Maybe(). + Return(&defangv1.Secrets{Names: tt.existing}, nil) + + provider.On("PutConfig", mock.Anything, &defangv1.PutConfigRequest{ + Project: project, + Name: tt.configName, + Value: value, + }).Maybe().Return(nil) + + didSet, err := ConfigSet(ctx, project, provider, tt.configName, value, ConfigSetOptions{ + IfNotSet: tt.ifNotSet, + }) + + if tt.expectedError != nil { + require.ErrorContains(t, err, tt.expectedError.Error()) + } + assert.Equal(t, tt.expectedDidSet, didSet) + + provider.AssertExpectations(t) + }) + } t.Run("expect no error", func(t *testing.T) { - err := ConfigSet(ctx, "test", provider, "test_name", "test_value") - if err != nil { - t.Fatalf("ConfigSet() error = %v", err) - } + provider.ExpectedCalls = nil // Reset expectations + provider.On("PutConfig", mock.Anything, &defangv1.PutConfigRequest{ + Project: "test", + Name: "test_name", + Value: "test_value", + }).Return(nil) + + _, err := ConfigSet(ctx, "test", provider, "test_name", "test_value", ConfigSetOptions{}) + require.NoError(t, err) + provider.AssertExpectations(t) }) t.Run("expect error on DryRun", func(t *testing.T) { dryrun.DoDryRun = true t.Cleanup(func() { dryrun.DoDryRun = false }) - err := ConfigSet(ctx, "test", provider, "test_name", "test_value") - if err != dryrun.ErrDryRun { - t.Fatalf("Expected dryrun.ErrDryRun, got %v", err) - } + _, err := ConfigSet(ctx, "test", provider, "test_name", "test_value", ConfigSetOptions{}) + require.ErrorIs(t, err, dryrun.ErrDryRun) }) } - -type MustHaveProjectNamePutConfigProvider struct { - client.Provider -} - -func (m MustHaveProjectNamePutConfigProvider) PutConfig(ctx context.Context, req *defangv1.PutConfigRequest) error { - if req.Project == "" { - return errors.New("project name is missing") - } - return nil -}