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
1 change: 1 addition & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ integration.
| `AZD_ALLOW_NON_EMPTY_FOLDER` | If set, allows `azd init` to run in a non-empty directory without prompting. |
| `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). |
| `AZD_DEPLOY_{SERVICE}_SLOT_NAME` | Sets the App Service deployment slot target for a service. Replace `{SERVICE}` with the uppercase service name (hyphens become underscores). Set to `production` to deploy to the main app, or a slot name (e.g., `staging`). When slots exist and this is not set, `--no-prompt` mode fails with an error listing available targets. |

## Extension Variables

Expand Down
28 changes: 20 additions & 8 deletions cli/azd/extensions/azure.appservice/internal/cmd/swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ func newSwapCommand(rootFlags rootFlagsDefinition) *cobra.Command {
This command allows you to swap the content between two deployment slots,
or between a slot and the production environment.

Use @main to refer to the production slot.`,
Use "production" to refer to the main app (production slot).`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSwap(cmd.Context(), flags, rootFlags)
},
}

cmd.Flags().StringVar(&flags.service, "service", "", "The name of the service to swap slots for.")
cmd.Flags().StringVar(&flags.src, "src", "", "The source slot name. Use @main for production.")
cmd.Flags().StringVar(&flags.dst, "dst", "", "The destination slot name. Use @main for production.")
cmd.Flags().StringVar(&flags.src, "src", "", "The source slot name. Use 'production' for main app.")
cmd.Flags().StringVar(&flags.dst, "dst", "", "The destination slot name. Use 'production' for main app.")

return cmd
}
Expand Down Expand Up @@ -206,6 +206,11 @@ func runSwap(ctx context.Context, flags *swapFlags, rootFlags rootFlagsDefinitio
return fmt.Errorf("swap operation requires a service with at least one deployment slot")
}

// Warn once if @main is used (deprecated in favor of "production")
if strings.EqualFold(flags.src, "@main") || strings.EqualFold(flags.dst, "@main") {
color.Yellow("WARNING: @main is deprecated. Use 'production' instead to refer to the main app.")
}

// Normalize src and dst flags
srcSlot := normalizeSlotName(flags.src)
dstSlot := normalizeSlotName(flags.dst)
Expand Down Expand Up @@ -252,7 +257,7 @@ func runSwap(ctx context.Context, flags *swapFlags, rootFlags rootFlagsDefinitio
if !srcProvided || !dstProvided {
// Prompt for source slot
if !srcProvided {
srcChoices := []*azdext.SelectChoice{{Value: "", Label: "@main (production)"}}
srcChoices := []*azdext.SelectChoice{{Value: "", Label: "production (main app)"}}
for _, slot := range slots {
srcChoices = append(srcChoices, &azdext.SelectChoice{Value: slot, Label: slot})
}
Expand All @@ -278,7 +283,7 @@ func runSwap(ctx context.Context, flags *swapFlags, rootFlags rootFlagsDefinitio
if !dstProvided {
dstChoices := []*azdext.SelectChoice{}
if srcSlot != "" {
dstChoices = append(dstChoices, &azdext.SelectChoice{Value: "", Label: "@main (production)"})
dstChoices = append(dstChoices, &azdext.SelectChoice{Value: "", Label: "production (main app)"})
}
for _, slot := range slots {
if slot != srcSlot {
Expand Down Expand Up @@ -321,11 +326,11 @@ func runSwap(ctx context.Context, flags *swapFlags, rootFlags rootFlagsDefinitio
// Get display names for confirmation
srcDisplay := srcSlot
if srcDisplay == "" {
srcDisplay = "@main (production)"
srcDisplay = "production (main app)"
}
dstDisplay := dstSlot
if dstDisplay == "" {
dstDisplay = "@main (production)"
dstDisplay = "production (main app)"
}

// Confirm the swap unless --no-prompt is set
Expand Down Expand Up @@ -358,7 +363,14 @@ func runSwap(ctx context.Context, flags *swapFlags, rootFlags rootFlagsDefinitio
}

func normalizeSlotName(slot string) string {
// Normalize "@main" to empty string (internal representation for main app/production slot)
// "production" and "@production" both map to the main app (empty string).
// This aligns with the Azure platform convention where "production" is the
// reserved name for the main app slot.
if strings.EqualFold(slot, "production") || strings.EqualFold(slot, "@production") {
return ""
}

// "@main" maps to the main app but is deprecated.
if strings.EqualFold(slot, "@main") {
return ""
}
Expand Down
4 changes: 1 addition & 3 deletions cli/azd/magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,7 @@ func runPlaybackTests(azdDir string) error {
// excludedPlaybackTests lists tests whose recordings are known to be stale.
// These are excluded from automatic playback so they don't block preflight.
// Re-record the test to remove it from this list.
var excludedPlaybackTests = map[string]string{
"Test_CLI_Deploy_SlotDeployment": "stale recording - re-record to include",
}
var excludedPlaybackTests = map[string]string{}

// discoverPlaybackTests scans the recordings directory for .yaml files and
// subdirectories, returning unique top-level Go test function names.
Expand Down
24 changes: 0 additions & 24 deletions cli/azd/pkg/azapi/webapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,30 +226,6 @@ func (cli *AzureClient) createZipDeployClient(
return client, nil
}

// HasAppServiceDeployments checks if the web app has at least one previous deployment.
func (cli *AzureClient) HasAppServiceDeployments(
ctx context.Context,
subscriptionId string,
resourceGroup string,
appName string,
) (bool, error) {
client, err := cli.createWebAppsClient(ctx, subscriptionId)
if err != nil {
return false, err
}

pager := client.NewListDeploymentsPager(resourceGroup, appName, nil)
if pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return false, fmt.Errorf("listing webapp deployments: %w", err)
}
return len(page.Value) > 0, nil
}

return false, nil
}

// AppServiceSlot represents an App Service deployment slot.
type AppServiceSlot struct {
Name string
Expand Down
62 changes: 0 additions & 62 deletions cli/azd/pkg/azapi/webapp_slots_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,68 +17,6 @@ import (
"github.com/stretchr/testify/require"
)

// Test HasAppServiceDeployments
func Test_HasAppServiceDeployments(t *testing.T) {
t.Run("HasDeployments", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azCli := newAzureClientFromMockContext(mockContext)

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) {
response := armappservice.WebAppsClientListDeploymentsResponse{
DeploymentCollection: armappservice.DeploymentCollection{
Value: []*armappservice.Deployment{
{
ID: new("deployment-1"),
Name: new("deployment-1"),
},
},
},
}
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
})

hasDeployments, err := azCli.HasAppServiceDeployments(
*mockContext.Context,
"SUBSCRIPTION_ID",
"RESOURCE_GROUP_ID",
"WEB_APP_NAME",
)

require.NoError(t, err)
require.True(t, hasDeployments)
})

t.Run("NoDeployments", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azCli := newAzureClientFromMockContext(mockContext)

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) {
response := armappservice.WebAppsClientListDeploymentsResponse{
DeploymentCollection: armappservice.DeploymentCollection{
Value: []*armappservice.Deployment{},
},
}
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
})

hasDeployments, err := azCli.HasAppServiceDeployments(
*mockContext.Context,
"SUBSCRIPTION_ID",
"RESOURCE_GROUP_ID",
"WEB_APP_NAME",
)

require.NoError(t, err)
require.False(t, hasDeployments)
})
}

// Test GetAppServiceSlots
func Test_GetAppServiceSlots(t *testing.T) {
t.Run("WithSlots", func(t *testing.T) {
Expand Down
136 changes: 71 additions & 65 deletions cli/azd/pkg/project/service_target_appservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,48 +219,73 @@ func (st *appServiceTarget) Deploy(
}, nil
}

// productionSlotName is the reserved platform name for the main app.
// Azure does not allow creating a deployment slot named "production" —
// the ARM API rejects it with: "Slot name: 'Production' is reserved."
// This was verified via: az webapp deployment slot create --slot production
// Azure CLI, PowerShell, and the Azure Portal all use "production" to refer to the main app.
const productionSlotName = "production"

// deploymentTarget represents a target for deployment (main app or a slot)
type deploymentTarget struct {
SlotName string // Empty string means main app
}

// determineDeploymentTargets determines which targets (main app and/or slots) to deploy to
// based on deployment history and available slots.
// determineDeploymentTargets determines which targets (main app and/or slots) to deploy to.
//
// Deployment Strategy:
// - 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.
// - Subsequent deployments with no slots:
// Deploy to main app only (standard production deployment).
// - Subsequent deployments with exactly one slot:
// Deploy to that slot only (typical staging workflow before swap to production).
// - Subsequent deployments with multiple slots:
// Check for AZD_DEPLOY_{SERVICE_NAME}_SLOT_NAME environment variable to auto-select a slot.
// If not set, prompt user to select a target, allowing explicit control
// over which environment receives the deployment.
// Deployment target selection:
// 1. SLOT_NAME takes highest precedence — explicit intent always wins.
// "production" means the main app. Any other value must match an existing slot.
// 2. No slots exist — deploy to main app.
// 3. Slots exist + interactive — prompt user to select (includes "production" for main app).
// 4. Slots exist + --no-prompt — fail with error listing available targets.
func (st *appServiceTarget) determineDeploymentTargets(
ctx context.Context,
serviceConfig *ServiceConfig,
targetResource *environment.TargetResource,
progress *async.Progress[ServiceProgress],
) ([]deploymentTarget, error) {
progress.SetProgress(NewServiceProgress("Checking deployment history"))
slotEnvVarName := slotEnvVarNameForService(serviceConfig.Name)

// Check if there are previous deployments
hasDeployments, err := st.cli.HasAppServiceDeployments(
ctx,
targetResource.SubscriptionId(),
targetResource.ResourceGroupName(),
targetResource.ResourceName(),
)
if err != nil {
return nil, fmt.Errorf("checking deployment history: %w", err)
// Check SLOT_NAME first — explicit intent always wins
if slotName := st.env.Getenv(slotEnvVarName); slotName != "" {
// "production" is the platform-reserved name for the main app
if strings.EqualFold(slotName, productionSlotName) {
progress.SetProgress(NewServiceProgress("Deploying to production (main app)"))
return []deploymentTarget{{SlotName: ""}}, nil
}

// Validate that the specified slot exists
progress.SetProgress(NewServiceProgress("Checking deployment slots"))
slots, err := st.cli.GetAppServiceSlots(
ctx,
targetResource.SubscriptionId(),
targetResource.ResourceGroupName(),
targetResource.ResourceName(),
)
if err != nil {
return nil, fmt.Errorf("getting deployment slots: %w", err)
}

for _, slot := range slots {
if strings.EqualFold(slot.Name, slotName) {
return []deploymentTarget{{SlotName: slot.Name}}, nil
}
}

availableSlots := make([]string, len(slots))
for i, slot := range slots {
availableSlots[i] = slot.Name
}
return nil, fmt.Errorf(
"slot '%s' specified in %s not found. Available slots: [%s]. "+
"Use '%s=%s' to deploy to the main app",
slotName, slotEnvVarName, strings.Join(availableSlots, ", "),
slotEnvVarName, productionSlotName)
}

// No SLOT_NAME set — check if slots exist
progress.SetProgress(NewServiceProgress("Checking deployment slots"))

// Get available slots
slots, err := st.cli.GetAppServiceSlots(
ctx,
targetResource.SubscriptionId(),
Expand All @@ -271,64 +296,45 @@ func (st *appServiceTarget) determineDeploymentTargets(
return nil, fmt.Errorf("getting deployment slots: %w", err)
}

// If no previous deployments, always deploy to main app and all slots
if !hasDeployments {
targets := []deploymentTarget{{SlotName: ""}} // Main app
for _, slot := range slots {
targets = append(targets, deploymentTarget{SlotName: slot.Name})
}
return targets, nil
}

// Has previous deployments
// No slots — deploy to main app
if len(slots) == 0 {
// No slots, deploy to main app only
return []deploymentTarget{{SlotName: ""}}, nil
}

if len(slots) == 1 {
// Exactly one slot, deploy to that slot only
return []deploymentTarget{{SlotName: slots[0].Name}}, nil
}

// Multiple slots, prompt user to select
slotEnvVarName := slotEnvVarNameForService(serviceConfig.Name)

// Check if slot name is set via environment variable (checks azd env first, then system env)
if slotName := st.env.Getenv(slotEnvVarName); slotName != "" {
// Validate that the slot exists
// Slots exist + --no-prompt — fail with clear error
if st.console.IsNoPromptMode() {
availableTargets := []string{productionSlotName}
for _, slot := range slots {
if slot.Name == slotName {
return []deploymentTarget{{SlotName: slotName}}, nil
}
}
// Slot not found, return error with available slots
availableSlots := make([]string, len(slots))
for i, slot := range slots {
availableSlots[i] = slot.Name
availableTargets = append(availableTargets, slot.Name)
}
return nil, fmt.Errorf(
"slot '%s' specified in %s not found. Available slots: [%s]. "+
"Please update the environment variable with a valid slot name",
slotName, slotEnvVarName, strings.Join(availableSlots, ", "))
"deployment slots detected but no target specified. "+
"Set %s to one of: [%s] ('production' = main app)",
slotEnvVarName, strings.Join(availableTargets, ", "))
Comment thread
rajeshkamal5050 marked this conversation as resolved.
}

slotOptions := make([]string, len(slots))
for i, slot := range slots {
slotOptions[i] = slot.Name
// Slots exist + interactive — prompt user including main app option
slotOptions := []string{fmt.Sprintf("%s (main app)", productionSlotName)}
for _, slot := range slots {
slotOptions = append(slotOptions, slot.Name)
}

selectedIndex, err := st.console.Select(ctx, input.ConsoleOptions{
Message: fmt.Sprintf(
"Select a deployment slot\nNote: skip this prompt with '%s=<slotName>'\n",
"Select a deployment target\nNote: skip this prompt with '%s=<target>'\n",
slotEnvVarName),
Options: slotOptions,
})
if err != nil {
return nil, fmt.Errorf("selecting deployment slot: %w", err)
return nil, fmt.Errorf("selecting deployment target: %w", err)
}

// Index 0 = production (main app)
if selectedIndex == 0 {
return []deploymentTarget{{SlotName: ""}}, nil
}

return []deploymentTarget{{SlotName: slots[selectedIndex].Name}}, nil
return []deploymentTarget{{SlotName: slots[selectedIndex-1].Name}}, nil
}

// slotEnvVarNameForService returns the environment variable name for setting the deployment slot
Expand Down
Loading
Loading