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
11 changes: 2 additions & 9 deletions cli/azd/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"os"
"time"

"github.com/azure/azure-dev/cli/azd/cmd/actions"
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
11 changes: 2 additions & 9 deletions cli/azd/cmd/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"os"
"time"

"github.com/azure/azure-dev/cli/azd/cmd/actions"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 1 addition & 9 deletions cli/azd/cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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{}

Expand Down
10 changes: 1 addition & 9 deletions cli/azd/internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -245,14 +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
}

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.
Expand Down
10 changes: 1 addition & 9 deletions cli/azd/internal/cmd/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions cli/azd/pkg/project/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions cli/azd/pkg/project/service_condition_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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"]
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"]
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"]
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) {
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, 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,
)
})
}
}
34 changes: 34 additions & 0 deletions cli/azd/pkg/project/service_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package project

import (
"fmt"
"path/filepath"

"github.com/azure/azure-dev/cli/azd/pkg/apphost"
Expand Down Expand Up @@ -55,6 +56,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.
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The comment should list all accepted truthy values for consistency with the implementation. The implementation accepts "yes", "YES", and "Yes", but the comment only lists "yes". Consider updating to: "is a truthy boolean (1, true, TRUE, True, yes, YES, Yes)".

Suggested change
// is a truthy boolean (1, true, TRUE, True, yes). If not defined, the service is enabled by default.
// is a truthy boolean (1, true, TRUE, True, yes, YES, Yes). If not defined, the service is enabled by default.

Copilot uses AI. Check for mistakes.
Condition osutil.ExpandableString `yaml:"condition,omitempty"`

// AdditionalProperties captures any unknown YAML fields for extension support
AdditionalProperties map[string]interface{} `yaml:",inline"`
Expand Down Expand Up @@ -89,3 +93,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.
// 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, nil
}

value, err := sc.Condition.Envsubst(getenv)
if err != nil {
return false, fmt.Errorf("malformed deployment condition template: %w", err)
}

return isConditionTrue(value), nil
}

// 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
}
}
Loading
Loading