diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index f85fcdc540f..3af740096e2 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -52,6 +52,17 @@ integration. | `AZD_BUILDER_IMAGE` | The builder docker image used to perform Dockerfile-less builds. | | `AZD_DEPLOY_TIMEOUT` | Timeout for deployment operations, parsed as an integer number of seconds (for example, `1200`). Defaults to `1200` seconds (20 minutes). | +### App Service Slot Deployments + +These variables control deployment slot behavior for Azure App Service targets. In all variable names, +`{SERVICE}` is the uppercase service name from `azure.yaml` with hyphens replaced by underscores +(e.g., service `my-api` → `MY_API`). + +| Variable | Description | +| --- | --- | +| `AZD_DEPLOY_{SERVICE}_SLOT_NAME` | When multiple deployment slots exist, auto-selects the named slot instead of prompting. The value must match an existing slot name. | +| `AZD_DEPLOY_{SERVICE}_IGNORE_SLOTS` | If true, bypasses all slot detection logic and deploys directly to the main app, even when deployment slots exist. Takes precedence over `AZD_DEPLOY_{SERVICE}_SLOT_NAME`. | + ## Extension Variables These variables are set and consumed by azd extension hosts (for example, IDE/editor integrations) diff --git a/cli/azd/pkg/project/service_target_appservice.go b/cli/azd/pkg/project/service_target_appservice.go index 03a3454c027..5064b42bc13 100644 --- a/cli/azd/pkg/project/service_target_appservice.go +++ b/cli/azd/pkg/project/service_target_appservice.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "github.com/azure/azure-dev/cli/azd/internal/mapper" @@ -14,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) @@ -228,6 +230,10 @@ type deploymentTarget struct { // based on deployment history and available slots. // // Deployment Strategy: +// - Override — AZD_DEPLOY_{SERVICE_NAME}_IGNORE_SLOTS (highest precedence): +// When set to a truthy boolean value, bypasses all slot detection and deploys directly +// to the main app. If AZD_DEPLOY_{SERVICE_NAME}_SLOT_NAME is also set, it is ignored +// and a warning is emitted. // - First deployment (no history): // Deploy to main app AND all slots to ensure consistency across all environments. // This prevents configuration drift and ensures all slots start with the same baseline. @@ -245,6 +251,33 @@ func (st *appServiceTarget) determineDeploymentTargets( targetResource *environment.TargetResource, progress *async.Progress[ServiceProgress], ) ([]deploymentTarget, error) { + // Check if slot deployment is explicitly disabled for this service + ignoreSlotsEnvVar := ignoreSlotsEnvVarNameForService(serviceConfig.Name) + if ignoreSlotsValue, hasIgnoreSlots := st.env.LookupEnv(ignoreSlotsEnvVar); hasIgnoreSlots { + ignoreSlots, err := strconv.ParseBool(ignoreSlotsValue) + if err != nil { + st.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "Ignoring invalid value %q for %s; expected a boolean value", + ignoreSlotsValue, ignoreSlotsEnvVar), + }) + } else if ignoreSlots { + // Warn if SLOT_NAME env var is also set, since it will be ignored + slotEnvVar := slotEnvVarNameForService(serviceConfig.Name) + if slotName := st.env.Getenv(slotEnvVar); slotName != "" { + st.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "%s is set but will be ignored because %s is enabled", + slotEnvVar, ignoreSlotsEnvVar), + }) + } + + progress.SetProgress(NewServiceProgress( + "Skipping slot deployment (deploying to main app)")) + return []deploymentTarget{{SlotName: ""}}, nil + } + } + progress.SetProgress(NewServiceProgress("Checking deployment history")) // Check if there are previous deployments @@ -335,8 +368,16 @@ func (st *appServiceTarget) determineDeploymentTargets( // for a given service. The format is AZD_DEPLOY_{SERVICE_NAME}_SLOT_NAME where the service name // is uppercase and any hyphens are replaced with underscores. func slotEnvVarNameForService(serviceName string) string { - normalizedName := strings.ToUpper(strings.ReplaceAll(serviceName, "-", "_")) - return fmt.Sprintf("AZD_DEPLOY_%s_SLOT_NAME", normalizedName) + return fmt.Sprintf("AZD_DEPLOY_%s_SLOT_NAME", environment.Key(serviceName)) +} + +// ignoreSlotsEnvVarNameForService returns the environment variable name for opting out of +// automatic slot deployment for a given service. The format is AZD_DEPLOY_{SERVICE_NAME}_IGNORE_SLOTS +// where the service name is uppercase and any hyphens are replaced with underscores. +// When set to a truthy boolean value, azd deploys directly to the main app, ignoring any +// configured deployment slots. +func ignoreSlotsEnvVarNameForService(serviceName string) string { + return fmt.Sprintf("AZD_DEPLOY_%s_IGNORE_SLOTS", environment.Key(serviceName)) } // Gets the exposed endpoints for the App Service, including any deployment slots diff --git a/cli/azd/pkg/project/service_target_appservice_test.go b/cli/azd/pkg/project/service_target_appservice_test.go index 65a438217c6..666326cedeb 100644 --- a/cli/azd/pkg/project/service_target_appservice_test.go +++ b/cli/azd/pkg/project/service_target_appservice_test.go @@ -4,11 +4,18 @@ package project import ( + "context" + "net/http" "strings" "testing" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" + "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockaccount" "github.com/stretchr/testify/require" ) @@ -53,3 +60,234 @@ func TestNewAppServiceTargetTypeValidation(t *testing.T) { }) } } + +func TestSlotEnvVarNameForService(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + serviceName string + expected string + }{ + "SimpleService": { + serviceName: "api", + expected: "AZD_DEPLOY_API_SLOT_NAME", + }, + "HyphenatedService": { + serviceName: "my-web-app", + expected: "AZD_DEPLOY_MY_WEB_APP_SLOT_NAME", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := slotEnvVarNameForService(tc.serviceName) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestIgnoreSlotsEnvVarNameForService(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + serviceName string + expected string + }{ + "SimpleService": { + serviceName: "api", + expected: "AZD_DEPLOY_API_IGNORE_SLOTS", + }, + "HyphenatedService": { + serviceName: "my-web-app", + expected: "AZD_DEPLOY_MY_WEB_APP_IGNORE_SLOTS", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := ignoreSlotsEnvVarNameForService(tc.serviceName) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestDetermineDeploymentTargets_IgnoreSlots(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + envVars map[string]string + hasDeployments bool + slots []string + expectedSlots []string // empty string = main app + expectedWarning string // substring expected in console output, empty = no warning + }{ + "IgnoreSlots_True_NoSlots": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "true"}, + hasDeployments: true, + slots: []string{}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_True_OneSlot": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "true"}, + hasDeployments: true, + slots: []string{"staging"}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_True_MultipleSlots": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "true"}, + hasDeployments: true, + slots: []string{"staging", "preview"}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_True_FirstDeployment_WithSlots": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "true"}, + hasDeployments: false, + slots: []string{"staging"}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_True_OverridesSlotName": { + envVars: map[string]string{ + "AZD_DEPLOY_API_IGNORE_SLOTS": "true", + "AZD_DEPLOY_API_SLOT_NAME": "staging", + }, + hasDeployments: true, + slots: []string{"staging", "preview"}, + expectedSlots: []string{""}, + expectedWarning: "AZD_DEPLOY_API_SLOT_NAME is set but will be ignored", + }, + "IgnoreSlots_False_OneSlot_SubsequentDeploy": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "false"}, + hasDeployments: true, + slots: []string{"staging"}, + expectedSlots: []string{"staging"}, + }, + "IgnoreSlots_Unset_OneSlot_SubsequentDeploy": { + envVars: map[string]string{}, + hasDeployments: true, + slots: []string{"staging"}, + expectedSlots: []string{"staging"}, + }, + "IgnoreSlots_Unset_NoSlots_SubsequentDeploy": { + envVars: map[string]string{}, + hasDeployments: true, + slots: []string{}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_Unset_FirstDeploy_WithSlots": { + envVars: map[string]string{}, + hasDeployments: false, + slots: []string{"staging"}, + expectedSlots: []string{"", "staging"}, + }, + "IgnoreSlots_TrueNumeric": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "1"}, + hasDeployments: true, + slots: []string{"staging"}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_TrueUppercase": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "TRUE"}, + hasDeployments: true, + slots: []string{"staging"}, + expectedSlots: []string{""}, + }, + "IgnoreSlots_InvalidValue_FallsBackToSlotLogic": { + envVars: map[string]string{"AZD_DEPLOY_API_IGNORE_SLOTS": "notabool"}, + hasDeployments: true, + slots: []string{"staging"}, + expectedSlots: []string{"staging"}, + expectedWarning: "Ignoring invalid value", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockContext := mocks.NewMockContext(t.Context()) + azCli := azapi.NewAzureClient( + mockaccount.SubscriptionCredentialProviderFunc( + func(_ context.Context, _ string) (azcore.TokenCredential, error) { + return mockContext.Credentials, nil + }, + ), + mockContext.ArmClientOptions, + ) + + // Mock deployment history + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, "/deployments") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + var deployments []*armappservice.Deployment + if tc.hasDeployments { + deployments = []*armappservice.Deployment{ + {ID: new("dep-1"), Name: new("dep-1")}, + } + } + response := armappservice.WebAppsClientListDeploymentsResponse{ + DeploymentCollection: armappservice.DeploymentCollection{ + Value: deployments, + }, + } + return mocks.CreateHttpResponseWithBody( + request, http.StatusOK, response) + }) + + // Mock slots + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, "/slots") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + sites := make([]*armappservice.Site, len(tc.slots)) + for i, slot := range tc.slots { + fullName := "WEB_APP_NAME/" + slot + sites[i] = &armappservice.Site{Name: &fullName} + } + response := armappservice.WebAppsClientListSlotsResponse{ + WebAppCollection: armappservice.WebAppCollection{ + Value: sites, + }, + } + return mocks.CreateHttpResponseWithBody( + request, http.StatusOK, response) + }) + + env := environment.NewWithValues("test", tc.envVars) + target := &appServiceTarget{ + env: env, + cli: azCli, + console: mockContext.Console, + } + + serviceConfig := &ServiceConfig{Name: "api"} + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", + string(azapi.AzureResourceTypeWebSite), + ) + progress := async.NewNoopProgress[ServiceProgress]() + + targets, err := target.determineDeploymentTargets( + *mockContext.Context, + serviceConfig, + targetResource, + progress, + ) + + require.NoError(t, err) + require.Len(t, targets, len(tc.expectedSlots)) + for i, expected := range tc.expectedSlots { + require.Equal(t, expected, targets[i].SlotName) + } + + // Verify warning messages when expected + consoleOutput := strings.Join(mockContext.Console.Output(), "\n") + if tc.expectedWarning != "" { + require.Contains(t, consoleOutput, tc.expectedWarning) + } else { + require.Empty(t, mockContext.Console.Output(), + "unexpected warning in console output: %s", consoleOutput) + } + }) + } +}