From 7fb15e55ed0342d2b35eb36c3fbfc8347654a564 Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 18:23:46 -0800 Subject: [PATCH 1/8] working resolution command --- src/cmd/cli/command/commands.go | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 1f8999a1d..b64951cb8 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -298,6 +298,8 @@ func SetupCommands(ctx context.Context, version string) { configCmd.AddCommand(configListCmd) + configCmd.AddCommand(configResolveCmd) + RootCmd.AddCommand(configCmd) RootCmd.AddCommand(setupComposeCommand()) @@ -970,6 +972,142 @@ var configListCmd = &cobra.Command{ }, } +type serviceName string +type configOutput struct { + Service string `json:"service"` + Name string `json:"name"` + Value string `json:"value,omitempty"` + Source Source `json:"source,omitempty"` +} + +type Source int + +const ( + SourceUnknown Source = iota + SourceComposeFile + SourceEnvFile + SourceDefangConfig + SourceDefangAndComposeFile +) + +var sourceNames = map[Source]string{ + SourceUnknown: "unknown", + SourceComposeFile: "compose_file", + SourceEnvFile: "env_file", + SourceDefangConfig: "defang_config", + SourceDefangAndComposeFile: "compose_file and defang_config", +} + +func (s Source) String() string { + if name, ok := sourceNames[s]; ok { + return name + } + return sourceNames[SourceUnknown] +} + +func isdefangConfigReplaced(value string, defangConfigs map[string]string) map[string]bool { + result := make(map[string]bool) + // Match ${...} pattern to extract variable names + re := regexp.MustCompile(`\$\{([^}]+)\}`) + matches := re.FindAllStringSubmatch(value, -1) + + // Check if all extracted variables exist in defangConfigs + for _, match := range matches { + if len(match) > 1 { + varName := match[1] + if _, exists := defangConfigs[varName]; exists { + result[varName] = true + } else { + result[varName] = false + } + } + } + + return result +} + +const configMaskedValue = "*****" + +var configResolveCmd = &cobra.Command{ + Use: "resolve", + Annotations: authNeededAnnotation, + Args: cobra.NoArgs, + Aliases: []string{"final"}, + Short: "Show the final resolved config for the project", + RunE: func(cmd *cobra.Command, args []string) error { + loader := configureLoader(cmd) + + provider, err := newProviderChecked(cmd.Context(), loader) + if err != nil { + return err + } + + project, err := loader.LoadProject(cmd.Context()) + if err != nil { + return err + } + + config, err := provider.ListConfig(cmd.Context(), &defangv1.ListConfigsRequest{Project: project.Name}) + if err != nil { + return err + } + + configset := make(map[string]string) + for _, name := range config.Names { + configset[name] = "" + } + + projectEnvVars := []configOutput{} + + for serviceName, service := range project.Services { + // Process each environment variable for this service + for envKey, envValue := range service.Environment { + if _, ok := configset[envKey]; ok { + projectEnvVars = append(projectEnvVars, configOutput{ + Service: serviceName, + Name: envKey, + Value: configMaskedValue, + Source: SourceDefangConfig, + }) + } else { + value := "" + if envValue != nil { + value = *envValue + defangConfigMap := isdefangConfigReplaced(*envValue, configset) + // Check if any extracted ${...} variables are defang configs + hasDefangRefs := false + for _, exists := range defangConfigMap { + if exists { + hasDefangRefs = true + break + } + } + if hasDefangRefs { + // Mixed value from defang config and compose file + projectEnvVars = append(projectEnvVars, configOutput{ + Service: serviceName, + Name: envKey, + Value: value, + Source: SourceDefangAndComposeFile, + }) + continue + } + } + projectEnvVars = append(projectEnvVars, configOutput{ + Service: serviceName, + Name: envKey, + Value: value, + Source: SourceComposeFile, + }) + } + + } + } + + return term.Table(projectEnvVars, "Service", "Name", "Value", "Source") + }, +} + var debugCmd = &cobra.Command{ Use: "debug [SERVICE...]", Annotations: authNeededAnnotation, From 6505133b2d36a096a115276d4347e70a27eda1c2 Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 19:24:25 -0800 Subject: [PATCH 2/8] Update function to return a bool if defang config is used --- src/cmd/cli/command/commands.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index b64951cb8..58539be31 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -972,7 +972,6 @@ var configListCmd = &cobra.Command{ }, } -type serviceName string type configOutput struct { Service string `json:"service"` Name string `json:"name"` @@ -985,7 +984,6 @@ type Source int const ( SourceUnknown Source = iota SourceComposeFile - SourceEnvFile SourceDefangConfig SourceDefangAndComposeFile ) @@ -993,7 +991,6 @@ const ( var sourceNames = map[Source]string{ SourceUnknown: "unknown", SourceComposeFile: "compose_file", - SourceEnvFile: "env_file", SourceDefangConfig: "defang_config", SourceDefangAndComposeFile: "compose_file and defang_config", } @@ -1005,20 +1002,19 @@ func (s Source) String() string { return sourceNames[SourceUnknown] } -func isdefangConfigReplaced(value string, defangConfigs map[string]string) map[string]bool { - result := make(map[string]bool) +// containsDefangConfigRefs checks if the value contains any ${...} references +// that match keys in the defangConfigs map +func containsDefangConfigRefs(value string, defangConfigs map[string]string) bool { // Match ${...} pattern to extract variable names re := regexp.MustCompile(`\$\{([^}]+)\}`) matches := re.FindAllStringSubmatch(value, -1) - // Check if all extracted variables exist in defangConfigs + // Check if any extracted variable exists in defangConfigs for _, match := range matches { if len(match) > 1 { varName := match[1] if _, exists := defangConfigs[varName]; exists { - result[varName] = true - } else { - result[varName] = false + return true } } } From 09952c2985b0301aa3c20b4d65ead9977a181739 Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 19:25:25 -0800 Subject: [PATCH 3/8] helper to determine the source of the config --- src/cmd/cli/command/commands.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 58539be31..a9cd5ac65 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -1019,7 +1019,29 @@ func containsDefangConfigRefs(value string, defangConfigs map[string]string) boo } } - return result + return false +} + +// determineConfigSource determines the source of an environment variable +// and returns the appropriate source type and value to display +func determineConfigSource(envKey string, envValue *string, defangConfigs map[string]string) (Source, string) { + // If the key itself is a defang config, mask it + if _, isDefangConfig := defangConfigs[envKey]; isDefangConfig { + return SourceDefangConfig, configMaskedValue + } + + // If value is nil, it's from the compose file with empty value + if envValue == nil { + return SourceComposeFile, "" + } + + // Check if the value contains references to defang configs + if containsDefangConfigRefs(*envValue, defangConfigs) { + return SourceDefangAndComposeFile, *envValue + } + + // Otherwise, it's from the compose file + return SourceComposeFile, *envValue } const configMaskedValue = "*****" From fd61d86170c71daa073849e4ca212d7be6e69fde Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 19:26:16 -0800 Subject: [PATCH 4/8] Sort and simplify logic --- src/cmd/cli/command/commands.go | 56 ++++++++++----------------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index a9cd5ac65..f6ef8d322 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" @@ -1078,50 +1079,25 @@ var configResolveCmd = &cobra.Command{ projectEnvVars := []configOutput{} for serviceName, service := range project.Services { - // Process each environment variable for this service for envKey, envValue := range service.Environment { - if _, ok := configset[envKey]; ok { - projectEnvVars = append(projectEnvVars, configOutput{ - Service: serviceName, - Name: envKey, - Value: configMaskedValue, - Source: SourceDefangConfig, - }) - } else { - value := "" - if envValue != nil { - value = *envValue - defangConfigMap := isdefangConfigReplaced(*envValue, configset) - // Check if any extracted ${...} variables are defang configs - hasDefangRefs := false - for _, exists := range defangConfigMap { - if exists { - hasDefangRefs = true - break - } - } - if hasDefangRefs { - // Mixed value from defang config and compose file - projectEnvVars = append(projectEnvVars, configOutput{ - Service: serviceName, - Name: envKey, - Value: value, - Source: SourceDefangAndComposeFile, - }) - continue - } - } - projectEnvVars = append(projectEnvVars, configOutput{ - Service: serviceName, - Name: envKey, - Value: value, - Source: SourceComposeFile, - }) - } - + source, value := determineConfigSource(envKey, envValue, configset) + projectEnvVars = append(projectEnvVars, configOutput{ + Service: serviceName, + Name: envKey, + Value: value, + Source: source, + }) } } + // Sort by Service, then by Name within each service + sort.Slice(projectEnvVars, func(i, j int) bool { + if projectEnvVars[i].Service != projectEnvVars[j].Service { + return projectEnvVars[i].Service < projectEnvVars[j].Service + } + return projectEnvVars[i].Name < projectEnvVars[j].Name + }) + return term.Table(projectEnvVars, "Service", "Name", "Value", "Source") }, } From 7e862fdf0a21a4f35f58ac76bc0877b5fba75305 Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 21:19:55 -0800 Subject: [PATCH 5/8] Extract out func for detecting interpolated vars --- src/pkg/cli/compose/validation.go | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/pkg/cli/compose/validation.go b/src/pkg/cli/compose/validation.go index 41c41b2f9..96a39aeeb 100644 --- a/src/pkg/cli/compose/validation.go +++ b/src/pkg/cli/compose/validation.go @@ -435,6 +435,20 @@ func getResourceReservations(r composeTypes.Resources) *composeTypes.Resource { // Copied from shared/utils.ts but slightly modified to remove the negative-lookahead assertion var interpolationRegex = regexp.MustCompile(`(?i)\$(\$)|\$(?:{([^}]+)}|([_a-z][_a-z0-9]*))|([^$]+)`) // [1] escaped dollar, [2] curly braces, [3] variable name, [4] literal +func DetectInterpolationVariables(value string) []string { + var names []string + // check for variables used during interpolation + for _, match := range interpolationRegex.FindAllStringSubmatch(value, -1) { + if match[2] != "" { + names = append(names, match[2]) + } + if match[3] != "" { + names = append(names, match[3]) + } + } + return names +} + func ValidateProjectConfig(ctx context.Context, composeProject *composeTypes.Project, listConfigNamesFunc ListConfigNamesFunc) error { var names []string // make list of secrets @@ -444,15 +458,8 @@ func ValidateProjectConfig(ctx context.Context, composeProject *composeTypes.Pro names = append(names, key) continue } - // check for variables used during interpolation - for _, match := range interpolationRegex.FindAllStringSubmatch(*value, -1) { - if match[2] != "" { - names = append(names, match[2]) - } - if match[3] != "" { - names = append(names, match[3]) - } - } + detectedNames := DetectInterpolationVariables(*value) + names = append(names, detectedNames...) } } @@ -465,6 +472,11 @@ func ValidateProjectConfig(ctx context.Context, composeProject *composeTypes.Pro return err } + err = PrintConfigResolutionSummary(*composeProject, configs) + if err != nil { + return err + } + // Deduplicate (sort + uniq) slices.Sort(names) names = slices.Compact(names) From 2deda339e797f9fec05d255e3002926b1e570b72 Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 21:20:43 -0800 Subject: [PATCH 6/8] Move logic to compose package --- src/cmd/cli/command/commands.go | 104 +----------------------- src/pkg/cli/compose/configResolution.go | 99 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 103 deletions(-) create mode 100644 src/pkg/cli/compose/configResolution.go diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index f6ef8d322..68d61d536 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "regexp" - "sort" "strings" "time" @@ -973,80 +972,6 @@ var configListCmd = &cobra.Command{ }, } -type configOutput struct { - Service string `json:"service"` - Name string `json:"name"` - Value string `json:"value,omitempty"` - Source Source `json:"source,omitempty"` -} - -type Source int - -const ( - SourceUnknown Source = iota - SourceComposeFile - SourceDefangConfig - SourceDefangAndComposeFile -) - -var sourceNames = map[Source]string{ - SourceUnknown: "unknown", - SourceComposeFile: "compose_file", - SourceDefangConfig: "defang_config", - SourceDefangAndComposeFile: "compose_file and defang_config", -} - -func (s Source) String() string { - if name, ok := sourceNames[s]; ok { - return name - } - return sourceNames[SourceUnknown] -} - -// containsDefangConfigRefs checks if the value contains any ${...} references -// that match keys in the defangConfigs map -func containsDefangConfigRefs(value string, defangConfigs map[string]string) bool { - // Match ${...} pattern to extract variable names - re := regexp.MustCompile(`\$\{([^}]+)\}`) - matches := re.FindAllStringSubmatch(value, -1) - - // Check if any extracted variable exists in defangConfigs - for _, match := range matches { - if len(match) > 1 { - varName := match[1] - if _, exists := defangConfigs[varName]; exists { - return true - } - } - } - - return false -} - -// determineConfigSource determines the source of an environment variable -// and returns the appropriate source type and value to display -func determineConfigSource(envKey string, envValue *string, defangConfigs map[string]string) (Source, string) { - // If the key itself is a defang config, mask it - if _, isDefangConfig := defangConfigs[envKey]; isDefangConfig { - return SourceDefangConfig, configMaskedValue - } - - // If value is nil, it's from the compose file with empty value - if envValue == nil { - return SourceComposeFile, "" - } - - // Check if the value contains references to defang configs - if containsDefangConfigRefs(*envValue, defangConfigs) { - return SourceDefangAndComposeFile, *envValue - } - - // Otherwise, it's from the compose file - return SourceComposeFile, *envValue -} - -const configMaskedValue = "*****" - var configResolveCmd = &cobra.Command{ Use: "resolve", Annotations: authNeededAnnotation, @@ -1071,34 +996,7 @@ var configResolveCmd = &cobra.Command{ return err } - configset := make(map[string]string) - for _, name := range config.Names { - configset[name] = "" - } - - projectEnvVars := []configOutput{} - - for serviceName, service := range project.Services { - for envKey, envValue := range service.Environment { - source, value := determineConfigSource(envKey, envValue, configset) - projectEnvVars = append(projectEnvVars, configOutput{ - Service: serviceName, - Name: envKey, - Value: value, - Source: source, - }) - } - } - - // Sort by Service, then by Name within each service - sort.Slice(projectEnvVars, func(i, j int) bool { - if projectEnvVars[i].Service != projectEnvVars[j].Service { - return projectEnvVars[i].Service < projectEnvVars[j].Service - } - return projectEnvVars[i].Name < projectEnvVars[j].Name - }) - - return term.Table(projectEnvVars, "Service", "Name", "Value", "Source") + return compose.PrintConfigResolutionSummary(*project, config.Names) }, } diff --git a/src/pkg/cli/compose/configResolution.go b/src/pkg/cli/compose/configResolution.go new file mode 100644 index 000000000..3d212f902 --- /dev/null +++ b/src/pkg/cli/compose/configResolution.go @@ -0,0 +1,99 @@ +package compose + +import ( + "sort" + + "github.com/DefangLabs/defang/src/pkg/term" +) + +type configOutput struct { + Service string `json:"service"` + Name string `json:"name"` + Value string `json:"value,omitempty"` + Source Source `json:"source,omitempty"` +} + +type Source int + +const ( + SourceUnknown Source = iota + SourceComposeFile + SourceDefangConfig + SourceDefangAndComposeFile +) + +var sourceNames = map[Source]string{ + SourceUnknown: "unknown", + SourceComposeFile: "compose_file", + SourceDefangConfig: "defang_config", + SourceDefangAndComposeFile: "compose_file and defang_config", +} + +func (s Source) String() string { + if name, ok := sourceNames[s]; ok { + return name + } + return sourceNames[SourceUnknown] +} + +// determineConfigSource determines the source of an environment variable +// and returns the appropriate source type and value to display +func determineConfigSource(envKey string, envValue *string, defangConfigs map[string]string) (Source, string) { + // If the key itself is a defang config, mask it + if _, isDefangConfig := defangConfigs[envKey]; isDefangConfig { + return SourceDefangConfig, configMaskedValue + } + + // If value is nil, it's from the compose file with empty value + if envValue == nil { + return SourceComposeFile, "" + } + + // Check if the value contains references to defang configs + interpolatedVariables := DetectInterpolationVariables(*envValue) + if len(interpolatedVariables) > 0 { + for _, varName := range interpolatedVariables { + if _, isDefangConfig := defangConfigs[varName]; isDefangConfig { + return SourceDefangAndComposeFile, *envValue + } + } + } + + // Otherwise, it's from the compose file + return SourceComposeFile, *envValue +} + +const configMaskedValue = "*****" + +func PrintConfigResolutionSummary(project Project, defangConfig []string) error { + configset := make(map[string]string) + for _, name := range defangConfig { + configset[name] = "" + } + + projectEnvVars := []configOutput{} + + for serviceName, service := range project.Services { + for envKey, envValue := range service.Environment { + source, value := determineConfigSource(envKey, envValue, configset) + projectEnvVars = append(projectEnvVars, configOutput{ + Service: serviceName, + Name: envKey, + Value: value, + Source: source, + }) + } + } + + // Sort by Service, then by Name within each service + sort.Slice(projectEnvVars, func(i, j int) bool { + if projectEnvVars[i].Service != projectEnvVars[j].Service { + return projectEnvVars[i].Service < projectEnvVars[j].Service + } + return projectEnvVars[i].Name < projectEnvVars[j].Name + }) + + term.Println("\033[1mENVIRONMENT VARIABLES RESOLUTION SUMMARY:\033[0m") + + return term.Table(projectEnvVars, "Service", "Name", "Value", "Source") +} From 1bf4bb02d053bc0f3eec279d0bcfc0503a38a77a Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 21:50:58 -0800 Subject: [PATCH 7/8] Print summary after validate and update test --- src/cmd/cli/command/commands.go | 4 +--- src/pkg/cli/compose/validation.go | 14 ++------------ src/pkg/cli/compose/validation_test.go | 20 +++++++++----------- src/pkg/cli/composeUp.go | 13 ++++++++++++- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 68d61d536..6b97102f1 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -744,9 +744,7 @@ func collectUnsetEnvVars(project *composeTypes.Project) []string { if project == nil { return nil // in case loading failed } - err := compose.ValidateProjectConfig(context.TODO(), project, func(ctx context.Context) ([]string, error) { - return nil, nil // assume no config - }) + err := compose.ValidateProjectConfig(context.TODO(), project, []string{}) var missingConfig compose.ErrMissingConfig if errors.As(err, &missingConfig) { return missingConfig diff --git a/src/pkg/cli/compose/validation.go b/src/pkg/cli/compose/validation.go index 96a39aeeb..8f3adb011 100644 --- a/src/pkg/cli/compose/validation.go +++ b/src/pkg/cli/compose/validation.go @@ -449,7 +449,7 @@ func DetectInterpolationVariables(value string) []string { return names } -func ValidateProjectConfig(ctx context.Context, composeProject *composeTypes.Project, listConfigNamesFunc ListConfigNamesFunc) error { +func ValidateProjectConfig(ctx context.Context, composeProject *composeTypes.Project, listConfigNames []string) error { var names []string // make list of secrets for _, service := range composeProject.Services { @@ -467,23 +467,13 @@ func ValidateProjectConfig(ctx context.Context, composeProject *composeTypes.Pro return nil // no secrets to check } - configs, err := listConfigNamesFunc(ctx) - if err != nil { - return err - } - - err = PrintConfigResolutionSummary(*composeProject, configs) - if err != nil { - return err - } - // Deduplicate (sort + uniq) slices.Sort(names) names = slices.Compact(names) errMissingConfig := ErrMissingConfig{} for _, name := range names { - if !slices.Contains(configs, name) { + if !slices.Contains(listConfigNames, name) { errMissingConfig = append(errMissingConfig, name) } } diff --git a/src/pkg/cli/compose/validation_test.go b/src/pkg/cli/compose/validation_test.go index d64b210b3..7ccecedba 100644 --- a/src/pkg/cli/compose/validation_test.go +++ b/src/pkg/cli/compose/validation_test.go @@ -56,7 +56,11 @@ func TestValidationAndConvert(t *testing.T) { logs.WriteString("Error: " + err.Error() + "\n") // no coverage! } - if err := ValidateProjectConfig(t.Context(), project, listConfigNamesFunc); err != nil { + listConfigNames, err := listConfigNamesFunc(t.Context()) + if err != nil { + t.Fatal(err) + } + if err := ValidateProjectConfig(t.Context(), project, listConfigNames); err != nil { t.Logf("Project config validation failed: %v", err) logs.WriteString("Error: " + err.Error() + "\n") } @@ -82,12 +86,6 @@ func TestValidationAndConvert(t *testing.T) { }) } -func makeListConfigNamesFunc(configs ...string) func(context.Context) ([]string, error) { - return func(context.Context) ([]string, error) { - return configs, nil - } -} - func TestValidateConfig(t *testing.T) { ctx := t.Context() @@ -101,7 +99,7 @@ func TestValidateConfig(t *testing.T) { } testProject.Services["service1"] = composeTypes.ServiceConfig{Environment: env} - if err := ValidateProjectConfig(ctx, &testProject, makeListConfigNamesFunc()); err != nil { + if err := ValidateProjectConfig(ctx, &testProject, []string{}); err != nil { t.Fatal(err) } }) @@ -116,7 +114,7 @@ func TestValidateConfig(t *testing.T) { testProject.Services["service1"] = composeTypes.ServiceConfig{Environment: env} var missing ErrMissingConfig - if err := ValidateProjectConfig(ctx, &testProject, makeListConfigNamesFunc()); !errors.As(err, &missing) { + if err := ValidateProjectConfig(ctx, &testProject, []string{}); !errors.As(err, &missing) { t.Fatalf("expected ErrMissingConfig, got: %v", err) } else { if len(missing) != 3 { @@ -139,7 +137,7 @@ func TestValidateConfig(t *testing.T) { } testProject.Services["service1"] = composeTypes.ServiceConfig{Environment: env} - if err := ValidateProjectConfig(ctx, &testProject, makeListConfigNamesFunc(CONFIG_VAR)); err != nil { + if err := ValidateProjectConfig(ctx, &testProject, []string{CONFIG_VAR}); err != nil { t.Fatal(err) } }) @@ -151,7 +149,7 @@ func TestValidateConfig(t *testing.T) { testProject.Services["service1"] = composeTypes.ServiceConfig{Environment: env} var missing ErrMissingConfig - if err := ValidateProjectConfig(ctx, &testProject, makeListConfigNamesFunc()); !errors.As(err, &missing) { + if err := ValidateProjectConfig(ctx, &testProject, []string{}); !errors.As(err, &missing) { t.Fatalf("expected ErrMissingConfig, got: %v", err) } else { if len(missing) != 1 { diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index 4a31c8a0d..65e52116f 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -61,9 +61,20 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider cliClie // Ignore missing configs in preview mode, because we don't want to fail the preview if some configs are missing. if upload != compose.UploadModeEstimate { - if err := compose.ValidateProjectConfig(ctx, project, listConfigNamesFunc); err != nil { + listConfigNames, err := listConfigNamesFunc(ctx) + if err != nil { + return nil, project, err + } + + if err := compose.ValidateProjectConfig(ctx, project, listConfigNames); err != nil { return nil, project, &ComposeError{err} } + + // Print config resolution summary + err = compose.PrintConfigResolutionSummary(*project, listConfigNames) + if err != nil { + return nil, project, err + } } } From 19b90ba2679d4d7d051158cb9d64f26e64226e8c Mon Sep 17 00:00:00 2001 From: Kevin Vo Date: Fri, 19 Dec 2025 22:02:10 -0800 Subject: [PATCH 8/8] compact the slices --- src/pkg/cli/compose/configResolution.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pkg/cli/compose/configResolution.go b/src/pkg/cli/compose/configResolution.go index 3d212f902..bc56b0042 100644 --- a/src/pkg/cli/compose/configResolution.go +++ b/src/pkg/cli/compose/configResolution.go @@ -1,6 +1,7 @@ package compose import ( + "slices" "sort" "github.com/DefangLabs/defang/src/pkg/term" @@ -93,6 +94,8 @@ func PrintConfigResolutionSummary(project Project, defangConfig []string) error return projectEnvVars[i].Name < projectEnvVars[j].Name }) + projectEnvVars = slices.Compact(projectEnvVars) + term.Println("\033[1mENVIRONMENT VARIABLES RESOLUTION SUMMARY:\033[0m") return term.Table(projectEnvVars, "Service", "Name", "Value", "Source")