From 5ac5ccf7e94df829032b47c5fc0c4c8f235dd7fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 04:17:42 +0000 Subject: [PATCH 1/6] Initial plan From 94d344109ffd7ccfd355f448396dcb6e9ad0db37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 04:23:13 +0000 Subject: [PATCH 2/6] Add condition field to ServiceConfig with tests Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- cli/azd/internal/cmd/deploy.go | 18 ++ cli/azd/pkg/project/service_config.go | 33 ++++ cli/azd/pkg/project/service_config_test.go | 194 +++++++++++++++++++++ 3 files changed, 245 insertions(+) diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index 8cbed0de775..aeb40191eba 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -253,6 +253,24 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) continue } + // Check if the service condition is enabled + if !svc.IsEnabled(da.env.Getenv) { + // If a specific service was targeted but its condition is false, return an error + if targetServiceName != "" && targetServiceName == svc.Name { + da.console.StopSpinner(ctx, stepMessage, input.StepFailed) + conditionValue, _ := svc.Condition.Envsubst(da.env.Getenv) + return fmt.Errorf( + "service '%s' has a deployment condition that evaluated to '%s'. "+ + "The service requires a truthy value (1, true, TRUE, True, yes, YES, Yes) to be deployed", + svc.Name, + conditionValue, + ) + } + // If deploying all services, skip this one silently + da.console.StopSpinner(ctx, stepMessage, input.StepSkipped) + continue + } + if alphaFeatureId, isAlphaFeature := alpha.IsFeatureKey(string(svc.Host)); isAlphaFeature { // alpha feature on/off detection for host is done during initialization. // This is just for displaying the warning during deployment. diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 6e0402a463b..9932d5f22a1 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -55,6 +55,9 @@ type ServiceConfig struct { useDotNetPublishForDockerBuild *bool // Environment variables to set for the service Environment osutil.ExpandableMap `yaml:"env,omitempty"` + // Condition for deploying the service. When evaluated, the service is only deployed if the value + // is a truthy boolean (1, true, TRUE, True, yes). If not defined, the service is enabled by default. + Condition osutil.ExpandableString `yaml:"condition,omitempty"` // AdditionalProperties captures any unknown YAML fields for extension support AdditionalProperties map[string]interface{} `yaml:",inline"` @@ -89,3 +92,33 @@ func (sc *ServiceConfig) Path() string { } return filepath.Join(sc.Project.Path, sc.RelativePath) } + +// IsEnabled evaluates the service condition and returns whether the service should be deployed. +// If no condition is specified, the service is enabled by default. +// The condition is evaluated as a boolean where truthy values are: 1, true, TRUE, True, yes, YES, Yes +// All other values are considered false. +func (sc *ServiceConfig) IsEnabled(getenv func(string) string) bool { + if sc.Condition.Empty() { + return true + } + + value, err := sc.Condition.Envsubst(getenv) + if err != nil { + // If condition can't be evaluated, consider it disabled + return false + } + + return isConditionTrue(value) +} + +// isConditionTrue parses a string value as a boolean condition. +// Returns true for: "1", "true", "TRUE", "True", "yes", "YES", "Yes" +// Returns false for all other values. +func isConditionTrue(value string) bool { + switch value { + case "1", "true", "TRUE", "True", "yes", "YES", "Yes": + return true + default: + return false + } +} diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index 03496bfbbdb..bb71b0281be 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" ) @@ -312,6 +313,199 @@ func TestServiceConfigEventHandlerReceivesServiceContext(t *testing.T) { require.True(t, handlerCalled) } +func TestServiceConfigConditionEvaluation(t *testing.T) { + tests := []struct { + name string + condition string + envVars map[string]string + expectEnabled bool + }{ + // No condition - should be enabled by default + { + name: "NoCondition", + condition: "", + envVars: map[string]string{}, + expectEnabled: true, + }, + // Truthy values + { + name: "ConditionTrue", + condition: "true", + envVars: map[string]string{}, + expectEnabled: true, + }, + { + name: "ConditionTRUE", + condition: "TRUE", + envVars: map[string]string{}, + expectEnabled: true, + }, + { + name: "ConditionTrue_MixedCase", + condition: "True", + envVars: map[string]string{}, + expectEnabled: true, + }, + { + name: "ConditionYes", + condition: "yes", + envVars: map[string]string{}, + expectEnabled: true, + }, + { + name: "ConditionYES", + condition: "YES", + envVars: map[string]string{}, + expectEnabled: true, + }, + { + name: "ConditionYes_MixedCase", + condition: "Yes", + envVars: map[string]string{}, + expectEnabled: true, + }, + { + name: "ConditionOne", + condition: "1", + envVars: map[string]string{}, + expectEnabled: true, + }, + // Falsy values + { + name: "ConditionFalse", + condition: "false", + envVars: map[string]string{}, + expectEnabled: false, + }, + { + name: "ConditionFALSE", + condition: "FALSE", + envVars: map[string]string{}, + expectEnabled: false, + }, + { + name: "ConditionNo", + condition: "no", + envVars: map[string]string{}, + expectEnabled: false, + }, + { + name: "ConditionZero", + condition: "0", + envVars: map[string]string{}, + expectEnabled: false, + }, + { + name: "ConditionRandomString", + condition: "random", + envVars: map[string]string{}, + expectEnabled: false, + }, + { + name: "ConditionEmptyString", + condition: "", + envVars: map[string]string{}, + expectEnabled: true, // No condition means enabled + }, + // Environment variable expansion + { + name: "ConditionFromEnvVarTrue", + condition: "${DEPLOY_SERVICE}", + envVars: map[string]string{"DEPLOY_SERVICE": "true"}, + expectEnabled: true, + }, + { + name: "ConditionFromEnvVarFalse", + condition: "${DEPLOY_SERVICE}", + envVars: map[string]string{"DEPLOY_SERVICE": "false"}, + expectEnabled: false, + }, + { + name: "ConditionFromEnvVarOne", + condition: "${DEPLOY_SERVICE}", + envVars: map[string]string{"DEPLOY_SERVICE": "1"}, + expectEnabled: true, + }, + { + name: "ConditionFromEnvVarZero", + condition: "${DEPLOY_SERVICE}", + envVars: map[string]string{"DEPLOY_SERVICE": "0"}, + expectEnabled: false, + }, + { + name: "ConditionFromMissingEnvVar", + condition: "${DEPLOY_SERVICE}", + envVars: map[string]string{}, + expectEnabled: false, // Empty string after expansion is falsy + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &ServiceConfig{ + Name: "test-service", + Condition: osutil.NewExpandableString(tt.condition), + } + + getenv := func(key string) string { + return tt.envVars[key] + } + + enabled := service.IsEnabled(getenv) + require.Equal(t, tt.expectEnabled, enabled) + }) + } +} + +func TestServiceConfigConditionYamlParsing(t *testing.T) { + const testProj = ` +name: test-proj +services: + conditionalService: + project: src/api + language: js + host: containerapp + condition: ${DEPLOY_SERVICE} + unconditionalService: + project: src/web + language: js + host: appservice +` + + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse(*mockContext.Context, testProj) + require.Nil(t, err) + require.NotNil(t, projectConfig) + + conditionalService := projectConfig.Services["conditionalService"] + require.NotNil(t, conditionalService) + require.False(t, conditionalService.Condition.Empty()) + + unconditionalService := projectConfig.Services["unconditionalService"] + require.NotNil(t, unconditionalService) + require.True(t, unconditionalService.Condition.Empty()) + + // Test with environment variable set to true + getenvTrue := func(key string) string { + if key == "DEPLOY_SERVICE" { + return "true" + } + return "" + } + require.True(t, conditionalService.IsEnabled(getenvTrue)) + require.True(t, unconditionalService.IsEnabled(getenvTrue)) + + // Test with environment variable set to false + getenvFalse := func(key string) string { + if key == "DEPLOY_SERVICE" { + return "false" + } + return "" + } + require.False(t, conditionalService.IsEnabled(getenvFalse)) + require.True(t, unconditionalService.IsEnabled(getenvFalse)) +} + func createTestServiceConfig(path string, host ServiceTargetKind, language ServiceLanguageKind) *ServiceConfig { return &ServiceConfig{ Name: "api", From 34963ae33807a74b3e4f7df604be8100663ccc34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 04:26:02 +0000 Subject: [PATCH 3/6] Add integration tests for service condition Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../service_condition_integration_test.go | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 cli/azd/pkg/project/service_condition_integration_test.go diff --git a/cli/azd/pkg/project/service_condition_integration_test.go b/cli/azd/pkg/project/service_condition_integration_test.go new file mode 100644 index 00000000000..29f06a5c94a --- /dev/null +++ b/cli/azd/pkg/project/service_condition_integration_test.go @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( +"context" +"testing" + +"github.com/azure/azure-dev/cli/azd/pkg/environment" +"github.com/azure/azure-dev/cli/azd/test/mocks" +"github.com/stretchr/testify/require" +) + +func TestServiceCondition_Integration(t *testing.T) { +const testProj = ` +name: test-proj +services: + always-enabled: + project: src/api + language: js + host: containerapp + conditional-enabled: + project: src/web + language: js + host: appservice + condition: ${DEPLOY_WEB} + conditional-disabled: + project: src/worker + language: python + host: containerapp + condition: ${DEPLOY_WORKER} +` + +mockContext := mocks.NewMockContext(context.Background()) +projectConfig, err := Parse(*mockContext.Context, testProj) +require.Nil(t, err) +require.NotNil(t, projectConfig) +require.Len(t, projectConfig.Services, 3) + +// Create environment with condition variables +env := environment.NewWithValues("test-env", map[string]string{ +"DEPLOY_WEB": "true", +"DEPLOY_WORKER": "false", +}) + +// Test that conditions are evaluated correctly +alwaysEnabled := projectConfig.Services["always-enabled"] +require.True(t, alwaysEnabled.IsEnabled(env.Getenv), "Service without condition should be enabled") + +conditionalEnabled := projectConfig.Services["conditional-enabled"] +require.True(t, conditionalEnabled.IsEnabled(env.Getenv), "Service with condition=true should be enabled") + +conditionalDisabled := projectConfig.Services["conditional-disabled"] +require.False(t, conditionalDisabled.IsEnabled(env.Getenv), "Service with condition=false should be disabled") +} + +func TestServiceCondition_WithDifferentValues(t *testing.T) { +tests := []struct { +name string +envValue string +shouldEnable bool +}{ +{"true", "true", true}, +{"TRUE", "TRUE", true}, +{"True", "True", true}, +{"1", "1", true}, +{"yes", "yes", true}, +{"YES", "YES", true}, +{"Yes", "Yes", true}, +{"false", "false", false}, +{"0", "0", false}, +{"no", "no", false}, +{"random", "random", false}, +{"empty", "", false}, +} + +for _, tt := range tests { +t.Run(tt.name, func(t *testing.T) { +const testProj = ` +name: test-proj +services: + test-service: + project: src/api + language: js + host: containerapp + condition: ${DEPLOY_SERVICE} +` + +mockContext := mocks.NewMockContext(context.Background()) +projectConfig, err := Parse(*mockContext.Context, testProj) +require.Nil(t, err) + +env := environment.NewWithValues("test-env", map[string]string{ +"DEPLOY_SERVICE": tt.envValue, +}) + +service := projectConfig.Services["test-service"] +enabled := service.IsEnabled(env.Getenv) +require.Equal(t, tt.shouldEnable, enabled, "Condition value %s should result in enabled=%v", tt.envValue, tt.shouldEnable) +}) +} +} From 7c5b67b7466b43efc06701d99916678a6e70c9e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 04:29:44 +0000 Subject: [PATCH 4/6] Code review and security scan complete Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../service_condition_integration_test.go | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/cli/azd/pkg/project/service_condition_integration_test.go b/cli/azd/pkg/project/service_condition_integration_test.go index 29f06a5c94a..312432a546d 100644 --- a/cli/azd/pkg/project/service_condition_integration_test.go +++ b/cli/azd/pkg/project/service_condition_integration_test.go @@ -4,16 +4,16 @@ package project import ( -"context" -"testing" + "context" + "testing" -"github.com/azure/azure-dev/cli/azd/pkg/environment" -"github.com/azure/azure-dev/cli/azd/test/mocks" -"github.com/stretchr/testify/require" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" ) func TestServiceCondition_Integration(t *testing.T) { -const testProj = ` + const testProj = ` name: test-proj services: always-enabled: @@ -32,52 +32,52 @@ services: condition: ${DEPLOY_WORKER} ` -mockContext := mocks.NewMockContext(context.Background()) -projectConfig, err := Parse(*mockContext.Context, testProj) -require.Nil(t, err) -require.NotNil(t, projectConfig) -require.Len(t, projectConfig.Services, 3) + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse(*mockContext.Context, testProj) + require.Nil(t, err) + require.NotNil(t, projectConfig) + require.Len(t, projectConfig.Services, 3) -// Create environment with condition variables -env := environment.NewWithValues("test-env", map[string]string{ -"DEPLOY_WEB": "true", -"DEPLOY_WORKER": "false", -}) + // Create environment with condition variables + env := environment.NewWithValues("test-env", map[string]string{ + "DEPLOY_WEB": "true", + "DEPLOY_WORKER": "false", + }) -// Test that conditions are evaluated correctly -alwaysEnabled := projectConfig.Services["always-enabled"] -require.True(t, alwaysEnabled.IsEnabled(env.Getenv), "Service without condition should be enabled") + // Test that conditions are evaluated correctly + alwaysEnabled := projectConfig.Services["always-enabled"] + require.True(t, alwaysEnabled.IsEnabled(env.Getenv), "Service without condition should be enabled") -conditionalEnabled := projectConfig.Services["conditional-enabled"] -require.True(t, conditionalEnabled.IsEnabled(env.Getenv), "Service with condition=true should be enabled") + conditionalEnabled := projectConfig.Services["conditional-enabled"] + require.True(t, conditionalEnabled.IsEnabled(env.Getenv), "Service with condition=true should be enabled") -conditionalDisabled := projectConfig.Services["conditional-disabled"] -require.False(t, conditionalDisabled.IsEnabled(env.Getenv), "Service with condition=false should be disabled") + conditionalDisabled := projectConfig.Services["conditional-disabled"] + require.False(t, conditionalDisabled.IsEnabled(env.Getenv), "Service with condition=false should be disabled") } func TestServiceCondition_WithDifferentValues(t *testing.T) { -tests := []struct { -name string -envValue string -shouldEnable bool -}{ -{"true", "true", true}, -{"TRUE", "TRUE", true}, -{"True", "True", true}, -{"1", "1", true}, -{"yes", "yes", true}, -{"YES", "YES", true}, -{"Yes", "Yes", true}, -{"false", "false", false}, -{"0", "0", false}, -{"no", "no", false}, -{"random", "random", false}, -{"empty", "", false}, -} + tests := []struct { + name string + envValue string + shouldEnable bool + }{ + {"true", "true", true}, + {"TRUE", "TRUE", true}, + {"True", "True", true}, + {"1", "1", true}, + {"yes", "yes", true}, + {"YES", "YES", true}, + {"Yes", "Yes", true}, + {"false", "false", false}, + {"0", "0", false}, + {"no", "no", false}, + {"random", "random", false}, + {"empty", "", false}, + } -for _, tt := range tests { -t.Run(tt.name, func(t *testing.T) { -const testProj = ` + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + const testProj = ` name: test-proj services: test-service: @@ -87,17 +87,17 @@ services: condition: ${DEPLOY_SERVICE} ` -mockContext := mocks.NewMockContext(context.Background()) -projectConfig, err := Parse(*mockContext.Context, testProj) -require.Nil(t, err) + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse(*mockContext.Context, testProj) + require.Nil(t, err) -env := environment.NewWithValues("test-env", map[string]string{ -"DEPLOY_SERVICE": tt.envValue, -}) + env := environment.NewWithValues("test-env", map[string]string{ + "DEPLOY_SERVICE": tt.envValue, + }) -service := projectConfig.Services["test-service"] -enabled := service.IsEnabled(env.Getenv) -require.Equal(t, tt.shouldEnable, enabled, "Condition value %s should result in enabled=%v", tt.envValue, tt.shouldEnable) -}) -} + service := projectConfig.Services["test-service"] + enabled := service.IsEnabled(env.Getenv) + require.Equal(t, tt.shouldEnable, enabled, "Condition value %s should result in enabled=%v", tt.envValue, tt.shouldEnable) + }) + } } From c8fa45a94ecfb2ca2cf9395e0b249d207facdebc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:02:33 +0000 Subject: [PATCH 5/6] Apply condition filtering in ServiceStableFiltered for all service commands - Move condition checking from deploy.go into ImportManager.ServiceStableFiltered - Apply filtering to all service commands (deploy, package, restore, build, publish) - Return errors for malformed condition templates instead of silently disabling - When targeting a specific service with false condition, return clear error - Remove spinner "skipped" messages for disabled services - Update IsEnabled to return (bool, error) to surface malformed templates - Add test for malformed condition templates - Update all tests to handle new IsEnabled signature Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- cli/azd/cmd/build.go | 11 +--- cli/azd/cmd/package.go | 11 +--- cli/azd/cmd/restore.go | 10 +--- cli/azd/internal/cmd/deploy.go | 28 +--------- cli/azd/internal/cmd/publish.go | 10 +--- cli/azd/pkg/project/importer.go | 55 +++++++++++++++++++ .../service_condition_integration_test.go | 15 +++-- cli/azd/pkg/project/service_config.go | 11 ++-- cli/azd/pkg/project/service_config_test.go | 34 ++++++++++-- 9 files changed, 108 insertions(+), 77 deletions(-) diff --git a/cli/azd/cmd/build.go b/cli/azd/cmd/build.go index 20720fb9c0a..5fd20dada38 100644 --- a/cli/azd/cmd/build.go +++ b/cli/azd/cmd/build.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "os" "time" "github.com/azure/azure-dev/cli/azd/cmd/actions" @@ -162,7 +163,7 @@ func (ba *buildAction) Run(ctx context.Context) (*actions.ActionResult, error) { return nil, err } - stableServices, err := ba.importManager.ServiceStable(ctx, ba.projectConfig) + stableServices, err := ba.importManager.ServiceStableFiltered(ctx, ba.projectConfig, targetServiceName, os.Getenv) if err != nil { return nil, err } @@ -178,14 +179,6 @@ func (ba *buildAction) Run(ctx context.Context) (*actions.ActionResult, error) { stepMessage := fmt.Sprintf("Building service %s", svc.Name) ba.console.ShowSpinner(ctx, stepMessage, input.Step) - // Skip this service if both cases are true: - // 1. The user specified a service name - // 2. This service is not the one the user specified - if targetServiceName != "" && targetServiceName != svc.Name { - ba.console.StopSpinner(ctx, stepMessage, input.StepSkipped) - continue - } - buildResult, err := async.RunWithProgress( func(buildProgress project.ServiceProgress) { progressMessage := fmt.Sprintf("Building service %s (%s)", svc.Name, buildProgress.Message) diff --git a/cli/azd/cmd/package.go b/cli/azd/cmd/package.go index 4e42a5f6c8a..27143203477 100644 --- a/cli/azd/cmd/package.go +++ b/cli/azd/cmd/package.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "os" "time" "github.com/azure/azure-dev/cli/azd/cmd/actions" @@ -142,7 +143,7 @@ func (pa *packageAction) Run(ctx context.Context) (*actions.ActionResult, error) return nil, err } - serviceTable, err := pa.importManager.ServiceStable(ctx, pa.projectConfig) + serviceTable, err := pa.importManager.ServiceStableFiltered(ctx, pa.projectConfig, targetServiceName, os.Getenv) if err != nil { return nil, err } @@ -194,14 +195,6 @@ func (pa *packageAction) Run(ctx context.Context) (*actions.ActionResult, error) stepMessage := fmt.Sprintf("Packaging service %s", svc.Name) pa.console.ShowSpinner(ctx, stepMessage, input.Step) - // Skip this service if both cases are true: - // 1. The user specified a service name - // 2. This service is not the one the user specified - if targetServiceName != "" && targetServiceName != svc.Name { - pa.console.StopSpinner(ctx, stepMessage, input.StepSkipped) - continue - } - options := &project.PackageOptions{OutputPath: pa.flags.outputPath} packageResult, err := async.RunWithProgress( func(packageProgress project.ServiceProgress) { diff --git a/cli/azd/cmd/restore.go b/cli/azd/cmd/restore.go index bea9dc31b77..9e353467454 100644 --- a/cli/azd/cmd/restore.go +++ b/cli/azd/cmd/restore.go @@ -154,7 +154,7 @@ func (ra *restoreAction) Run(ctx context.Context) (*actions.ActionResult, error) return nil, err } - stableServices, err := ra.importManager.ServiceStable(ctx, ra.projectConfig) + stableServices, err := ra.importManager.ServiceStableFiltered(ctx, ra.projectConfig, targetServiceName, ra.env.Getenv) if err != nil { return nil, err } @@ -170,14 +170,6 @@ func (ra *restoreAction) Run(ctx context.Context) (*actions.ActionResult, error) stepMessage := fmt.Sprintf("Restoring service %s", svc.Name) ra.console.ShowSpinner(ctx, stepMessage, input.Step) - // Skip this service if both cases are true: - // 1. The user specified a service name - // 2. This service is not the one the user specified - if targetServiceName != "" && targetServiceName != svc.Name { - ra.console.StopSpinner(ctx, stepMessage, input.StepSkipped) - continue - } - // Initialize service context for restore operation serviceContext := &project.ServiceContext{} diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index aeb40191eba..4147b29d701 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -229,7 +229,7 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) startTime := time.Now() - stableServices, err := da.importManager.ServiceStable(ctx, da.projectConfig) + stableServices, err := da.importManager.ServiceStableFiltered(ctx, da.projectConfig, targetServiceName, da.env.Getenv) if err != nil { return nil, err } @@ -245,32 +245,6 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) stepMessage := fmt.Sprintf("Deploying service %s", svc.Name) da.console.ShowSpinner(ctx, stepMessage, input.Step) - // Skip this service if both cases are true: - // 1. The user specified a service name - // 2. This service is not the one the user specified - if targetServiceName != "" && targetServiceName != svc.Name { - da.console.StopSpinner(ctx, stepMessage, input.StepSkipped) - continue - } - - // Check if the service condition is enabled - if !svc.IsEnabled(da.env.Getenv) { - // If a specific service was targeted but its condition is false, return an error - if targetServiceName != "" && targetServiceName == svc.Name { - da.console.StopSpinner(ctx, stepMessage, input.StepFailed) - conditionValue, _ := svc.Condition.Envsubst(da.env.Getenv) - return fmt.Errorf( - "service '%s' has a deployment condition that evaluated to '%s'. "+ - "The service requires a truthy value (1, true, TRUE, True, yes, YES, Yes) to be deployed", - svc.Name, - conditionValue, - ) - } - // If deploying all services, skip this one silently - da.console.StopSpinner(ctx, stepMessage, input.StepSkipped) - continue - } - if alphaFeatureId, isAlphaFeature := alpha.IsFeatureKey(string(svc.Host)); isAlphaFeature { // alpha feature on/off detection for host is done during initialization. // This is just for displaying the warning during deployment. diff --git a/cli/azd/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index 39cdbd6f3dc..7ca2fce005b 100644 --- a/cli/azd/internal/cmd/publish.go +++ b/cli/azd/internal/cmd/publish.go @@ -231,7 +231,7 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) startTime := time.Now() - stableServices, err := pa.importManager.ServiceStable(ctx, pa.projectConfig) + stableServices, err := pa.importManager.ServiceStableFiltered(ctx, pa.projectConfig, targetServiceName, pa.env.Getenv) if err != nil { return nil, err } @@ -247,14 +247,6 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) stepMessage := fmt.Sprintf("Publishing service %s", svc.Name) pa.console.ShowSpinner(ctx, stepMessage, input.Step) - // Skip this service if both cases are true: - // 1. The user specified a service name - // 2. This service is not the one the user specified - if targetServiceName != "" && targetServiceName != svc.Name { - pa.console.StopSpinner(ctx, stepMessage, input.StepSkipped) - continue - } - if alphaFeatureId, isAlphaFeature := alpha.IsFeatureKey(string(svc.Host)); isAlphaFeature { // alpha feature on/off detection for host is done during initialization. // This is just for displaying the warning during publishing. diff --git a/cli/azd/pkg/project/importer.go b/cli/azd/pkg/project/importer.go index 84d9afa1658..4d7d4ad1b5f 100644 --- a/cli/azd/pkg/project/importer.go +++ b/cli/azd/pkg/project/importer.go @@ -96,6 +96,61 @@ func (im *ImportManager) ServiceStable(ctx context.Context, projectConfig *Proje return im.sortServicesByDependencies(allServicesSlice, projectConfig) } +// ServiceStableFiltered retrieves the list of services filtered by their condition status. +// It returns: +// - all enabled services when targetServiceName is empty +// - only the targeted service if enabled, or an error if disabled +// - error if the service condition template is malformed +func (im *ImportManager) ServiceStableFiltered( + ctx context.Context, + projectConfig *ProjectConfig, + targetServiceName string, + getenv func(string) string, +) ([]*ServiceConfig, error) { + allServices, err := im.ServiceStable(ctx, projectConfig) + if err != nil { + return nil, err + } + + // If targeting a specific service, check if it exists and is enabled + if targetServiceName != "" { + for _, svc := range allServices { + if svc.Name == targetServiceName { + enabled, err := svc.IsEnabled(getenv) + if err != nil { + return nil, fmt.Errorf("service '%s': %w", svc.Name, err) + } + if !enabled { + conditionValue, _ := svc.Condition.Envsubst(getenv) + return nil, fmt.Errorf( + "service '%s' has a deployment condition that evaluated to '%s'. "+ + "The service requires a truthy value (1, true, TRUE, True, yes, YES, Yes) to be enabled", + svc.Name, + conditionValue, + ) + } + return []*ServiceConfig{svc}, nil + } + } + // This shouldn't happen as getTargetServiceName already validates existence + return nil, fmt.Errorf("service '%s' not found", targetServiceName) + } + + // Filter services by condition + enabledServices := make([]*ServiceConfig, 0, len(allServices)) + for _, svc := range allServices { + enabled, err := svc.IsEnabled(getenv) + if err != nil { + return nil, fmt.Errorf("service '%s': %w", svc.Name, err) + } + if enabled { + enabledServices = append(enabledServices, svc) + } + } + + return enabledServices, nil +} + // sortServicesByDependencies performs a topological sort of services based on their dependencies. // Returns services in dependency order (dependencies first) with circular reference detection. // If no dependencies are defined, falls back to alphabetical ordering for backward compatibility. diff --git a/cli/azd/pkg/project/service_condition_integration_test.go b/cli/azd/pkg/project/service_condition_integration_test.go index 312432a546d..8b48472e117 100644 --- a/cli/azd/pkg/project/service_condition_integration_test.go +++ b/cli/azd/pkg/project/service_condition_integration_test.go @@ -46,13 +46,19 @@ services: // Test that conditions are evaluated correctly alwaysEnabled := projectConfig.Services["always-enabled"] - require.True(t, alwaysEnabled.IsEnabled(env.Getenv), "Service without condition should be enabled") + enabled, err := alwaysEnabled.IsEnabled(env.Getenv) + require.NoError(t, err, "Service without condition should not error") + require.True(t, enabled, "Service without condition should be enabled") conditionalEnabled := projectConfig.Services["conditional-enabled"] - require.True(t, conditionalEnabled.IsEnabled(env.Getenv), "Service with condition=true should be enabled") + enabled, err = conditionalEnabled.IsEnabled(env.Getenv) + require.NoError(t, err, "Service with valid condition should not error") + require.True(t, enabled, "Service with condition=true should be enabled") conditionalDisabled := projectConfig.Services["conditional-disabled"] - require.False(t, conditionalDisabled.IsEnabled(env.Getenv), "Service with condition=false should be disabled") + enabled, err = conditionalDisabled.IsEnabled(env.Getenv) + require.NoError(t, err, "Service with valid condition should not error") + require.False(t, enabled, "Service with condition=false should be disabled") } func TestServiceCondition_WithDifferentValues(t *testing.T) { @@ -96,7 +102,8 @@ services: }) service := projectConfig.Services["test-service"] - enabled := service.IsEnabled(env.Getenv) + enabled, err := service.IsEnabled(env.Getenv) + require.NoError(t, err) require.Equal(t, tt.shouldEnable, enabled, "Condition value %s should result in enabled=%v", tt.envValue, tt.shouldEnable) }) } diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 9932d5f22a1..eca62570bcb 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -4,6 +4,7 @@ package project import ( + "fmt" "path/filepath" "github.com/azure/azure-dev/cli/azd/pkg/apphost" @@ -97,18 +98,18 @@ func (sc *ServiceConfig) Path() string { // If no condition is specified, the service is enabled by default. // The condition is evaluated as a boolean where truthy values are: 1, true, TRUE, True, yes, YES, Yes // All other values are considered false. -func (sc *ServiceConfig) IsEnabled(getenv func(string) string) bool { +// Returns an error if the condition template is malformed. +func (sc *ServiceConfig) IsEnabled(getenv func(string) string) (bool, error) { if sc.Condition.Empty() { - return true + return true, nil } value, err := sc.Condition.Envsubst(getenv) if err != nil { - // If condition can't be evaluated, consider it disabled - return false + return false, fmt.Errorf("malformed deployment condition template: %w", err) } - return isConditionTrue(value) + return isConditionTrue(value), nil } // isConditionTrue parses a string value as a boolean condition. diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index bb71b0281be..25c800f6d61 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -451,12 +451,28 @@ func TestServiceConfigConditionEvaluation(t *testing.T) { return tt.envVars[key] } - enabled := service.IsEnabled(getenv) + enabled, err := service.IsEnabled(getenv) + require.NoError(t, err) require.Equal(t, tt.expectEnabled, enabled) }) } } +func TestServiceConfigConditionMalformed(t *testing.T) { + service := &ServiceConfig{ + Name: "test-service", + Condition: osutil.NewExpandableString("${UNCLOSED"), + } + + getenv := func(key string) string { + return "" + } + + _, err := service.IsEnabled(getenv) + require.Error(t, err) + require.Contains(t, err.Error(), "malformed deployment condition template") +} + func TestServiceConfigConditionYamlParsing(t *testing.T) { const testProj = ` name: test-proj @@ -492,8 +508,12 @@ services: } return "" } - require.True(t, conditionalService.IsEnabled(getenvTrue)) - require.True(t, unconditionalService.IsEnabled(getenvTrue)) + enabled, err := conditionalService.IsEnabled(getenvTrue) + require.NoError(t, err) + require.True(t, enabled) + enabled, err = unconditionalService.IsEnabled(getenvTrue) + require.NoError(t, err) + require.True(t, enabled) // Test with environment variable set to false getenvFalse := func(key string) string { @@ -502,8 +522,12 @@ services: } return "" } - require.False(t, conditionalService.IsEnabled(getenvFalse)) - require.True(t, unconditionalService.IsEnabled(getenvFalse)) + enabled, err = conditionalService.IsEnabled(getenvFalse) + require.NoError(t, err) + require.False(t, enabled) + enabled, err = unconditionalService.IsEnabled(getenvFalse) + require.NoError(t, err) + require.True(t, enabled) } func createTestServiceConfig(path string, host ServiceTargetKind, language ServiceLanguageKind) *ServiceConfig { From b0058a8826f522a7743efdf2e62f6b4122ab9584 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:06:36 +0000 Subject: [PATCH 6/6] Fix line length linter issue Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../pkg/project/service_condition_integration_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/project/service_condition_integration_test.go b/cli/azd/pkg/project/service_condition_integration_test.go index 8b48472e117..16137c16462 100644 --- a/cli/azd/pkg/project/service_condition_integration_test.go +++ b/cli/azd/pkg/project/service_condition_integration_test.go @@ -104,7 +104,14 @@ services: service := projectConfig.Services["test-service"] enabled, err := service.IsEnabled(env.Getenv) require.NoError(t, err) - require.Equal(t, tt.shouldEnable, enabled, "Condition value %s should result in enabled=%v", tt.envValue, tt.shouldEnable) + require.Equal( + t, + tt.shouldEnable, + enabled, + "Condition value %s should result in enabled=%v", + tt.envValue, + tt.shouldEnable, + ) }) } }