Skip to content
Merged
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: 11 additions & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 43 additions & 2 deletions cli/azd/pkg/project/service_target_appservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"

"github.com/azure/azure-dev/cli/azd/internal/mapper"
"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/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
)

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
238 changes: 238 additions & 0 deletions cli/azd/pkg/project/service_target_appservice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
})
}
}
Loading