Skip to content
Open
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
96 changes: 84 additions & 12 deletions src/cmd/cli/command/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,15 @@ func SetupCommands(ctx context.Context, version string) {
configSetCmd.Flags().BoolP("name", "n", false, "name of the config (backwards compat)")
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("secret", true, "set a secret config")
Copy link
Member

Choose a reason for hiding this comment

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

Boolean flags with true as default are a little awkward since the only way to really use it is to say --secret=false, which can perhaps be considered a feature since you make it explicit that a config can be read?

configSetCmd.Flags().String("env-file", "", "load config values from an .env file")
_ = configSetCmd.Flags().MarkHidden("name")

configCmd.AddCommand(configSetCmd)

configGetCmd.Flags().BoolP("name", "n", false, "name of the config")
Copy link
Member

Choose a reason for hiding this comment

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

I'd do this using positional arg(s). For a while we only had -n so I had changed that to be a boolean, so it's effectively ignored.

Suggested change
configGetCmd.Flags().BoolP("name", "n", false, "name of the config")

configCmd.AddCommand(configGetCmd)

configDeleteCmd.Flags().BoolP("name", "n", false, "name of the config(s) (backwards compat)")
_ = configDeleteCmd.Flags().MarkHidden("name")
configCmd.AddCommand(configDeleteCmd)
Expand Down Expand Up @@ -639,15 +643,69 @@ var configCmd = &cobra.Command{
Short: "Add, update, or delete service config",
}

var configGetCmd = &cobra.Command{
Use: "get CONFIG", // like Docker
Annotations: authNeededAnnotation,
Args: cobra.ArbitraryArgs,
Aliases: []string{"show"},
Short: "Show config value",
RunE: func(cmd *cobra.Command, args []string) error {

// Make sure we have a project to set config for before asking for a value
loader := configureLoader(cmd)
provider, err := newProviderChecked(cmd.Context(), loader)
if err != nil {
return err
}

_, isPlayground := provider.(*cliClient.PlaygroundProvider)
if isPlayground {
return errors.New("non-secret configs are not supported in playground")
}

projectName, err := cliClient.LoadProjectNameWithFallback(cmd.Context(), loader, provider)
if err != nil {
return err
}

for _, name := range args {
if !pkg.IsValidSecretName(name) {
return fmt.Errorf("invalid config name: %q", name)
}
}

resp, err := cli.ConfigGet(cmd.Context(), projectName, args, provider)
if err != nil {
return err
}

for _, config := range resp.Configs {
if config.Type != defangv1.ConfigType_CONFIGTYPE_INSENSITIVE {
config.Value = "<value hidden>"
}

config.Name = config.Name[strings.LastIndex(config.Name, "/")+1:]
}

if len(resp.Configs) == 0 {
term.Info("No configs found")
} else {
term.Table(resp.Configs, "Name", "Value")
}
return nil
},
}

var configSetCmd = &cobra.Command{
Use: "create CONFIG [file|-]", // like Docker
Annotations: authNeededAnnotation,
Args: cobra.RangeArgs(0, 2), // Allow 0 args when using --env-file
Aliases: []string{"set", "add", "put"},
Short: "Adds or updates a sensitive config value",
Short: "Adds or updates a config value",
RunE: func(cmd *cobra.Command, args []string) error {
fromEnv, _ := cmd.Flags().GetBool("env")
random, _ := cmd.Flags().GetBool("random")
isSecret, _ := cmd.Flags().GetBool("secret") // intentional typo for backwards compat
envFile, _ := cmd.Flags().GetString("env-file")

// Make sure we have a project to set config for before asking for a value
Expand All @@ -657,6 +715,11 @@ var configSetCmd = &cobra.Command{
return err
}

_, isPlayground := provider.(*cliClient.PlaygroundProvider)
if !isSecret && isPlayground {
return errors.New("non-secret configs are not supported in playground")
}

projectName, err := cliClient.LoadProjectNameWithFallback(cmd.Context(), loader, provider)
if err != nil {
return err
Expand Down Expand Up @@ -688,7 +751,7 @@ var configSetCmd = &cobra.Command{
continue
}

if err := cli.ConfigSet(cmd.Context(), projectName, provider, name, value); err != nil {
if err := cli.ConfigSet(cmd.Context(), isSecret, projectName, provider, name, value); err != nil {
term.Warnf("Failed to set %q: %v", name, err)
} else {
term.Info("Updated value for", name)
Expand Down Expand Up @@ -753,19 +816,28 @@ var configSetCmd = &cobra.Command{
value = CreateRandomConfigValue()
term.Info("Generated random value: " + value)
} else {
// Prompt for sensitive value
var sensitivePrompt = &survey.Password{
Message: fmt.Sprintf("Enter value for %q:", name),
Help: "The value will be stored securely and cannot be retrieved later.",
}

err := survey.AskOne(sensitivePrompt, &value, survey.WithStdio(term.DefaultTerm.Stdio()))
if err != nil {
return err
if isSecret {
secretPrompt := &survey.Password{
Message: fmt.Sprintf("Enter value for %q:", name),
Help: "The value will be stored securely and cannot be retrieved later.",
}
err := survey.AskOne(secretPrompt, &value, survey.WithStdio(term.DefaultTerm.Stdio()))
if err != nil {
return err
}
} else {
var nonSecretPrompt = &survey.Input{
Message: fmt.Sprintf("Enter value for %q:", name),
Help: "The value will be stored securely.",
}
err := survey.AskOne(nonSecretPrompt, &value, survey.WithStdio(term.DefaultTerm.Stdio()))
if err != nil {
return err
}
}
}

if err := cli.ConfigSet(cmd.Context(), projectName, provider, name, value); err != nil {
if err := cli.ConfigSet(cmd.Context(), isSecret, projectName, provider, name, value); err != nil {
return err
}
term.Info("Updated value for", name)
Expand Down
62 changes: 60 additions & 2 deletions src/pkg/cli/client/byoc/aws/byoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/route53"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/aws/smithy-go"
"github.com/aws/smithy-go/ptr"
Expand Down Expand Up @@ -540,13 +541,67 @@ func (b *ByocAws) getSecretID(projectName, name string) string {
return b.StackDir(projectName, name) // same as defang_service.ts
}

func (b *ByocAws) GetConfigs(ctx context.Context, secret *defangv1.GetConfigsRequest) (*defangv1.GetConfigsResponse, error) {
resp := &defangv1.GetConfigsResponse{}

// gather unique project names to config name
projects, projectConfigs := getUniqueProjectConfigs(secret, b)

for project := range projects {
fqn := b.getSecretID(project, "")
term.Debugf("Getting parameter %q", fqn)
params, _ := b.driver.GetSecret(ctx, fqn)

ssmParamToGetConfigResponse(params, projectConfigs, resp, project)
}
return resp, nil
}

func ssmParamToGetConfigResponse(ssmParameters []ssmTypes.Parameter, projectConfigs map[string]struct{}, resp *defangv1.GetConfigsResponse, project string) {
for _, param := range ssmParameters {
if param.Name == nil || param.Value == nil {
continue
}
if _, found := projectConfigs[*param.Name]; found {
configType := defangv1.ConfigType_CONFIGTYPE_SENSITIVE
if param.Type == ssmTypes.ParameterTypeString {
configType = defangv1.ConfigType_CONFIGTYPE_INSENSITIVE
}
resp.Configs = append(resp.Configs, &defangv1.Config{
Project: project,
Name: *param.Name,
Value: *param.Value,
Type: configType,
})
}
}
}

func getUniqueProjectConfigs(secret *defangv1.GetConfigsRequest, b *ByocAws) (map[string]struct{}, map[string]struct{}) {
projects := make(map[string]struct{})
projectConfigs := make(map[string]struct{})

for _, config := range secret.Configs {
if _, found := projects[config.Project]; !found {
projects[config.Project] = struct{}{}
}
projectConfigs[b.getSecretID(config.Project, config.Name)] = struct{}{}
}
return projects, projectConfigs
}

func (b *ByocAws) PutConfig(ctx context.Context, secret *defangv1.PutConfigRequest) error {
if !pkg.IsValidSecretName(secret.Name) {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid config name; must be alphanumeric or _, cannot start with a number: %q", secret.Name))
}
fqn := b.getSecretID(secret.Project, secret.Name)
term.Debugf("Putting parameter %q", fqn)
err := b.driver.PutSecret(ctx, fqn, secret.Value)

encrypt := true
if secret.Type == defangv1.ConfigType_CONFIGTYPE_INSENSITIVE {
encrypt = false
}
err := b.driver.PutSecret(ctx, encrypt, fqn, secret.Value)
return AnnotateAwsError(err)
}

Expand Down Expand Up @@ -830,7 +885,10 @@ func (b *ByocAws) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) e
}
term.Debug("Deleting parameters", ids)
if err := b.driver.DeleteSecrets(ctx, ids...); err != nil {
return AnnotateAwsError(err)
var paramNotFoundErr *ssmTypes.ParameterNotFound
if !errors.As(err, &paramNotFoundErr) {
return AnnotateAwsError(err)
}
}
return nil
}
Expand Down
134 changes: 134 additions & 0 deletions src/pkg/cli/client/byoc/aws/byoc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/DefangLabs/defang/src/pkg/dns"
"github.com/DefangLabs/defang/src/pkg/types"
defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1"
ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
composeTypes "github.com/compose-spec/compose-go/v2/types"
)

Expand Down Expand Up @@ -173,3 +174,136 @@ func TestSubscribe(t *testing.T) {
})
}
}
func TestGetUniqueProjectConfigs(t *testing.T) {
b := &ByocAws{
ByocBaseClient: &byoc.ByocBaseClient{
PulumiStack: "test-stack",
},
}

req := &defangv1.GetConfigsRequest{
Configs: []*defangv1.ConfigKey{
{Project: "proj1", Name: "cfg1"},
{Project: "proj1", Name: "cfg2"},
{Project: "proj2", Name: "cfg1"},
{Project: "proj2", Name: "cfg3"},
{Project: "proj1", Name: "cfg1"}, // duplicate
},
}

projects, projectConfigs := getUniqueProjectConfigs(req, b)

// Check projects
expectedProjects := map[string]struct{}{
"proj1": {},
"proj2": {},
}
if len(projects) != len(expectedProjects) {
t.Errorf("expected %d projects, got %d", len(expectedProjects), len(projects))
}
for k := range expectedProjects {
if _, ok := projects[k]; !ok {
t.Errorf("expected project %q in result", k)
}
}

// Check projectConfigs
expectedConfigs := map[string]struct{}{
"/Defang/proj1/test-stack/cfg1": {},
"/Defang/proj1/test-stack/cfg2": {},
"/Defang/proj2/test-stack/cfg1": {},
"/Defang/proj2/test-stack/cfg3": {},
}
if len(projectConfigs) != len(expectedConfigs) {
t.Errorf("expected %d projectConfigs, got %d", len(expectedConfigs), len(projectConfigs))
}
for k := range expectedConfigs {
if _, ok := projectConfigs[k]; !ok {
t.Errorf("expected config %q in result", k)
}
}
}

func TestSsmParamToGetConfigResponse(t *testing.T) {
project := "proj1"
resp := &defangv1.GetConfigsResponse{}

paramName1 := "/Defang/proj1/test-stack/cfg1"
paramValue1 := "secret1"
paramName2 := "/Defang/proj1/test-stack/cfg2"
paramValue2 := "notsecret"
paramName3 := "/Defang/proj1/test-stack/other"
paramValue3 := "shouldskip"

// configs
ssmParameters := []ssmTypes.Parameter{
{
// sensitive parameter
Name: &paramName1,
Value: &paramValue1,
Type: ssmTypes.ParameterTypeSecureString,
},
{
// insensitive parameter
Name: &paramName2,
Value: &paramValue2,
Type: ssmTypes.ParameterTypeString,
},
{
// wll be skipped since Name is nil
Name: nil,
Value: &paramValue3,
Type: ssmTypes.ParameterTypeString,
},
{
// will be skipped since Value is nil
Name: &paramName3,
Value: nil,
Type: ssmTypes.ParameterTypeString,
},
}

projectConfigs := map[string]struct{}{
paramName1: {},
paramName2: {},
}

ssmParamToGetConfigResponse(ssmParameters, projectConfigs, resp, project)

if len(resp.Configs) != 2 {
t.Fatalf("expected 2 configs, got %d", len(resp.Configs))
}

expected := []*defangv1.Config{
{
Project: project,
Name: paramName1,
Value: paramValue1,
Type: defangv1.ConfigType_CONFIGTYPE_SENSITIVE,
},
{
Project: project,
Name: paramName2,
Value: paramValue2,
Type: defangv1.ConfigType_CONFIGTYPE_INSENSITIVE,
},
}

for i, expectedConfig := range expected {
actualConfig := resp.Configs[i]

// compare all fields
if actualConfig.Project != expectedConfig.Project {
t.Errorf("config[%d] project: got %q, want %q", i, actualConfig.Project, expectedConfig.Project)
}
if actualConfig.Name != expectedConfig.Name {
t.Errorf("config[%d] name: got %q, want %q", i, actualConfig.Name, expectedConfig.Name)
}
if actualConfig.Value != expectedConfig.Value {
t.Errorf("config[%d] value: got %q, want %q", i, actualConfig.Value, expectedConfig.Value)
}
if actualConfig.Type != expectedConfig.Type {
t.Errorf("config[%d] type: got %v, want %v", i, actualConfig.Type, expectedConfig.Type)
}
}
}
Loading
Loading