diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 2e227d98e57..baf10279cf2 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- [[#8847]](https://github.com/Azure/azure-dev/pull/8847) Add container deployment support for App Service. Services configured with `host: appservice` and `language: docker` (or `docker.path`) now push the container image to ACR and update the site's container configuration, enabling Web App for Containers scenarios. + ### Breaking Changes ### Bugs Fixed diff --git a/cli/azd/pkg/azapi/webapp.go b/cli/azd/pkg/azapi/webapp.go index 93bb188448a..9348a28f0c1 100644 --- a/cli/azd/pkg/azapi/webapp.go +++ b/cli/azd/pkg/azapi/webapp.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + "maps" "net/http" "strings" "time" @@ -420,6 +421,141 @@ func (cli *AzureClient) GetAppServiceSlots( return slots, nil } +// UpdateAppServiceContainerImage updates the container image for a Linux Web App for Containers. +// It only sets linuxFxVersion to "DOCKER|"; infrastructure configuration (ACR auth, +// managed identity) must be set via IaC (bicep/terraform), not at deploy time. +func (cli *AzureClient) UpdateAppServiceContainerImage( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, + imageName string, +) error { + client, err := cli.createWebAppsClient(ctx, subscriptionId) + if err != nil { + return err + } + + linuxFxVersion := fmt.Sprintf("DOCKER|%s", imageName) + _, err = client.Update(ctx, resourceGroup, appName, armappservice.SitePatchResource{ + Properties: &armappservice.SitePatchResourceProperties{ + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: &linuxFxVersion, + }, + }, + }, nil) + if err != nil { + return fmt.Errorf("updating container image for app service %s: %w", appName, err) + } + + return nil +} + +// UpdateAppServiceSlotContainerImage updates the container image for a deployment slot. +// It only sets linuxFxVersion; infrastructure configuration must be set via IaC. +func (cli *AzureClient) UpdateAppServiceSlotContainerImage( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, + slotName string, + imageName string, +) error { + client, err := cli.createWebAppsClient(ctx, subscriptionId) + if err != nil { + return err + } + + linuxFxVersion := fmt.Sprintf("DOCKER|%s", imageName) + _, err = client.UpdateSlot(ctx, resourceGroup, appName, slotName, armappservice.SitePatchResource{ + Properties: &armappservice.SitePatchResourceProperties{ + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: &linuxFxVersion, + }, + }, + }, nil) + if err != nil { + return fmt.Errorf("updating container image for app service %s slot %s: %w", appName, slotName, err) + } + + return nil +} + +// ValidateAppServiceForContainerDeploy checks that the App Service is configured for container +// deployment (Linux kind with an existing DOCKER| linuxFxVersion). Returns an error with +// actionable suggestions if the site is not ready for container deployment. +func (cli *AzureClient) ValidateAppServiceForContainerDeploy( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, +) error { + response, err := cli.appService(ctx, subscriptionId, resourceGroup, appName) + if err != nil { + return err + } + + if response.Kind == nil || !strings.Contains(*response.Kind, "linux") { + return fmt.Errorf( + "app service '%s' is not configured as a Linux app. "+ + "Container deployment requires a Linux App Service Plan. "+ + "Set 'kind: linux' in your bicep/terraform configuration", + appName) + } + + if response.Properties == nil || response.Properties.SiteConfig == nil || + response.Properties.SiteConfig.LinuxFxVersion == nil || + !strings.HasPrefix(strings.ToUpper(*response.Properties.SiteConfig.LinuxFxVersion), "DOCKER|") { + return fmt.Errorf( + "app service '%s' is not configured for container deployment. "+ + "Ensure your infrastructure sets linuxFxVersion to a DOCKER| image "+ + "and configures ACR access (e.g., acrUseManagedIdentityCreds) in bicep/terraform. "+ + "azd deploy only updates the image reference; infrastructure configuration "+ + "must be provisioned first via 'azd provision'", + appName) + } + + return nil +} + +// UpdateAppServiceAppSettings merges the provided environment variables into the App Service's +// application settings. Existing settings not in the provided map are preserved. +func (cli *AzureClient) UpdateAppServiceAppSettings( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, + envVars map[string]string, +) error { + client, err := cli.createWebAppsClient(ctx, subscriptionId) + if err != nil { + return err + } + + // Get existing app settings to merge (preserve settings not managed by azd) + existing, err := client.ListApplicationSettings(ctx, resourceGroup, appName, nil) + if err != nil { + return fmt.Errorf("listing app settings for %s: %w", appName, err) + } + + // Merge: existing settings + new env vars (new values overwrite) + merged := make(map[string]*string) + if existing.Properties != nil { + maps.Copy(merged, existing.Properties) + } + for k, v := range envVars { + merged[k] = &v + } + + _, err = client.UpdateApplicationSettings(ctx, resourceGroup, appName, + armappservice.StringDictionary{Properties: merged}, nil) + if err != nil { + return fmt.Errorf("updating app settings for %s: %w", appName, err) + } + + return nil +} + // DeployAppServiceSlotZip deploys a zip file to a specific deployment slot. func (cli *AzureClient) DeployAppServiceSlotZip( ctx context.Context, diff --git a/cli/azd/pkg/azapi/webapp_test.go b/cli/azd/pkg/azapi/webapp_test.go index 106c5f72d4b..b9347e53d50 100644 --- a/cli/azd/pkg/azapi/webapp_test.go +++ b/cli/azd/pkg/azapi/webapp_test.go @@ -6,6 +6,7 @@ package azapi import ( "context" "errors" + "io" "net/http" "strings" "sync/atomic" @@ -190,3 +191,205 @@ func Test_AzureClient_GetAppServiceSlotProperties(t *testing.T) { require.NoError(t, err) assert.Contains(t, props.HostNames, "my-app-staging.azurewebsites.net") } + +func Test_AzureClient_UpdateAppServiceContainerImage(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + var capturedBody string + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodPatch && + strings.Contains(req.URL.Path, "/Microsoft.Web/sites/my-app") && + !strings.Contains(req.URL.Path, "/slots/") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + capturedBody = string(bodyBytes) + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.Site{ + ID: new("/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.Web/sites/my-app"), + Name: new("my-app"), + Location: new("eastus"), + Kind: new("app,linux,container"), + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("my-app.azurewebsites.net"), + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: new("DOCKER|myregistry.azurecr.io/myapp:v1"), + }, + }, + }) + }) + + err := client.UpdateAppServiceContainerImage( + *mockCtx.Context, "SUB", "RG", "my-app", "myregistry.azurecr.io/myapp:v1") + require.NoError(t, err) + assert.NotEmpty(t, capturedBody, "Update should have been called with a body") + assert.Contains(t, capturedBody, "DOCKER|myregistry.azurecr.io/myapp:v1", + "body should contain the correct linuxFxVersion") + assert.NotContains(t, capturedBody, "acrUseManagedIdentityCreds", + "should NOT set acrUseManagedIdentityCreds (IaC responsibility)") +} + +func Test_AzureClient_UpdateAppServiceContainerImage_Error(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodPatch && + strings.Contains(req.URL.Path, "/Microsoft.Web/sites/my-app") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateEmptyHttpResponse(req, http.StatusInternalServerError) + }) + + err := client.UpdateAppServiceContainerImage( + *mockCtx.Context, "SUB", "RG", "my-app", "myregistry.azurecr.io/myapp:v1") + require.Error(t, err) + assert.Contains(t, err.Error(), "updating container image") +} + +func Test_AzureClient_UpdateAppServiceSlotContainerImage(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + var capturedBody string + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodPatch && + strings.Contains(req.URL.Path, "/slots/staging") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + capturedBody = string(bodyBytes) + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.Site{ + ID: new("/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.Web/sites/my-app/slots/staging"), + Name: new("my-app/staging"), + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("my-app-staging.azurewebsites.net"), + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: new("DOCKER|myregistry.azurecr.io/myapp:v1"), + }, + }, + }) + }) + + err := client.UpdateAppServiceSlotContainerImage( + *mockCtx.Context, "SUB", "RG", "my-app", "staging", "myregistry.azurecr.io/myapp:v1") + require.NoError(t, err) + assert.NotEmpty(t, capturedBody, "UpdateSlot should have been called with a body") + assert.Contains(t, capturedBody, "DOCKER|myregistry.azurecr.io/myapp:v1", + "body should contain the correct linuxFxVersion") + assert.NotContains(t, capturedBody, "acrUseManagedIdentityCreds", + "should NOT set acrUseManagedIdentityCreds (IaC responsibility)") +} + +func Test_AzureClient_ValidateAppServiceForContainerDeploy(t *testing.T) { + t.Run("ValidLinuxContainer_NoError", func(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodGet && + strings.Contains(req.URL.Path, "/Microsoft.Web/sites/my-app") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.Site{ + Kind: new("app,linux,container"), + Properties: &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: new("DOCKER|myregistry.azurecr.io/myapp:v1"), + }, + }, + }) + }) + + err := client.ValidateAppServiceForContainerDeploy(*mockCtx.Context, "SUB", "RG", "my-app") + require.NoError(t, err) + }) + + t.Run("NotLinux_ReturnsError", func(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodGet && + strings.Contains(req.URL.Path, "/Microsoft.Web/sites/my-app") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.Site{ + Kind: new("app"), + Properties: &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{}, + }, + }) + }) + + err := client.ValidateAppServiceForContainerDeploy(*mockCtx.Context, "SUB", "RG", "my-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured as a Linux app") + }) + + t.Run("NoDockerFxVersion_ReturnsError", func(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodGet && + strings.Contains(req.URL.Path, "/Microsoft.Web/sites/my-app") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.Site{ + Kind: new("app,linux"), + Properties: &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: new("NODE|18-lts"), + }, + }, + }) + }) + + err := client.ValidateAppServiceForContainerDeploy(*mockCtx.Context, "SUB", "RG", "my-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured for container deployment") + }) +} + +func Test_AzureClient_UpdateAppServiceAppSettings(t *testing.T) { + t.Run("MergesWithExisting", func(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + client := newAzureClientFromMockContext(mockCtx) + + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodPost && + strings.Contains(req.URL.Path, "/config/appsettings/list") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.StringDictionary{ + Properties: map[string]*string{ + "EXISTING_KEY": new("existing_value"), + }, + }) + }) + + var capturedBody string + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodPut && + strings.Contains(req.URL.Path, "/config/appsettings") && + !strings.Contains(req.URL.Path, "/list") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + capturedBody = string(bodyBytes) + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, + armappservice.StringDictionary{ + Properties: map[string]*string{ + "EXISTING_KEY": new("existing_value"), + "NEW_KEY": new("new_value"), + }, + }) + }) + + err := client.UpdateAppServiceAppSettings( + *mockCtx.Context, "SUB", "RG", "my-app", + map[string]string{"NEW_KEY": "new_value"}) + require.NoError(t, err) + assert.Contains(t, capturedBody, "EXISTING_KEY", "should preserve existing settings") + assert.Contains(t, capturedBody, "NEW_KEY", "should include new settings") + }) +} diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index 3904d83445f..190534ed860 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -707,9 +707,10 @@ func (sm *serviceManager) GetFrameworkService(ctx context.Context, serviceConfig var compositeFramework CompositeFrameworkService // For hosts which run in containers, if the source project is not already a container, we need to wrap it in a docker - // project that handles the containerization. + // project that handles the containerization. Also applies when the user explicitly sets docker.path (opt-in + // containerization for any host, e.g., appservice with a Dockerfile). requiresLanguage := serviceConfig.Language != ServiceLanguageDocker && serviceConfig.Language != ServiceLanguageNone - if serviceConfig.Host.RequiresContainer() && requiresLanguage { + if (serviceConfig.Host.RequiresContainer() || serviceConfig.Docker.Path != "") && requiresLanguage { if err := sm.serviceLocator.ResolveNamed(string(ServiceLanguageDocker), &compositeFramework); err != nil { return nil, fmt.Errorf( "failed resolving composite framework service for '%s', language '%s': %w", diff --git a/cli/azd/pkg/project/service_target_appservice.go b/cli/azd/pkg/project/service_target_appservice.go index 6950f40d11a..b93376b4e11 100644 --- a/cli/azd/pkg/project/service_target_appservice.go +++ b/cli/azd/pkg/project/service_target_appservice.go @@ -6,6 +6,7 @@ package project import ( "context" "fmt" + "log" "os" "strconv" "strings" @@ -16,44 +17,65 @@ import ( "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/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" ) type appServiceTarget struct { - env *environment.Environment - cli *azapi.AzureClient - console input.Console + env *environment.Environment + envManager environment.Manager + containerHelper *ContainerHelper + cli *azapi.AzureClient + console input.Console } // NewAppServiceTarget creates a new instance of the AppServiceTarget func NewAppServiceTarget( env *environment.Environment, + envManager environment.Manager, + containerHelper *ContainerHelper, azCli *azapi.AzureClient, console input.Console, ) ServiceTarget { return &appServiceTarget{ - env: env, - cli: azCli, - console: console, + env: env, + envManager: envManager, + containerHelper: containerHelper, + cli: azCli, + console: console, } } // Gets the required external tools func (st *appServiceTarget) RequiredExternalTools(ctx context.Context, serviceConfig *ServiceConfig) []tools.ExternalTool { + if serviceConfig.Language == ServiceLanguageDocker || serviceConfig.Docker.Path != "" { + return st.containerHelper.RequiredExternalTools(ctx, serviceConfig) + } return []tools.ExternalTool{} } // Initializes the AppService target func (st *appServiceTarget) Initialize(ctx context.Context, serviceConfig *ServiceConfig) error { - return nil + return st.validateContainerConfig(serviceConfig) } -// Prepares a zip archive from the specified build output +// Package prepares deployment artifacts. For container deployments (language=docker, docker.path set, +// or a container artifact already present), no packaging is needed since the Publish phase handles +// image build and push. For non-container deployments, a zip archive is created. func (st *appServiceTarget) Package( ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, progress *async.Progress[ServiceProgress], ) (*ServicePackageResult, error) { + // Container deployment: no packaging needed. The Publish phase handles image build/push. + // This covers three scenarios: + // 1. Local docker build: container artifact already present from framework service + // 2. Remote build (docker.RemoteBuild): no artifacts from Package phase by design + // 3. Dotnet-publish docker: no artifacts from Package phase by design + if st.isContainerDeploy(serviceConfig, serviceContext) { + return &ServicePackageResult{}, nil + } + progress.SetProgress(NewServiceProgress("Compressing deployment artifacts")) // Get package path from the service context @@ -90,6 +112,7 @@ func (st *appServiceTarget) Package( }, nil } +// Publish pushes container images to ACR for container deployments. No-op for zip deployments. func (st *appServiceTarget) Publish( ctx context.Context, serviceConfig *ServiceConfig, @@ -98,10 +121,65 @@ func (st *appServiceTarget) Publish( progress *async.Progress[ServiceProgress], publishOptions *PublishOptions, ) (*ServicePublishResult, error) { - return &ServicePublishResult{}, nil + // Gate on service configuration, not just artifact presence. + // Remote build and dotnet-publish docker flows produce no package artifacts by design; + // ContainerHelper.Publish handles everything (build + push) in those modes. + if !st.isContainerDeploy(serviceConfig, serviceContext) { + return &ServicePublishResult{}, nil + } + + if err := st.validateTargetResource(targetResource); err != nil { + return nil, fmt.Errorf("validating target resource: %w", err) + } + + var publishResult *ServicePublishResult + var err error + + // Check if the package artifact is already a remote image reference + if artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)); found { + if parsedImage, parseErr := docker.ParseContainerImage(artifact.Location); parseErr == nil { + if parsedImage.Registry != "" { + publishResult = &ServicePublishResult{ + Artifacts: ArtifactCollection{ + { + Kind: ArtifactKindContainer, + Location: artifact.Location, + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + "registry": parsedImage.Registry, + "image": artifact.Location, + }, + }, + }, + } + } + } + } + + if publishResult == nil { + // Login, tag, and push container image to ACR + publishResult, err = st.containerHelper.Publish( + ctx, serviceConfig, serviceContext, targetResource, st.env, progress, publishOptions) + if err != nil { + return nil, err + } + } + + // Save the image name to the environment + log.Printf("writing image name to environment") + if remoteContainer, ok := publishResult.Artifacts.FindFirst(WithKind(ArtifactKindContainer)); ok { + st.env.SetServiceProperty(serviceConfig.Name, "IMAGE_NAME", remoteContainer.Location) + } + + if err := st.envManager.Save(ctx, st.env); err != nil { + return nil, fmt.Errorf("saving image name to environment: %w", err) + } + + return publishResult, nil } -// Deploys the prepared zip archive using Zip deploy to the Azure App Service resource +// Deploy deploys to the Azure App Service resource. For container deployments, it updates the +// site's container configuration (linuxFxVersion). For zip deployments, it uses zip deploy. func (st *appServiceTarget) Deploy( ctx context.Context, serviceConfig *ServiceConfig, @@ -113,7 +191,135 @@ func (st *appServiceTarget) Deploy( return nil, fmt.Errorf("validating target resource: %w", err) } - // Get zip file path from package artifacts + // Check if this is a container deployment by looking for a container artifact in the publish results + if artifact, found := serviceContext.Publish.FindFirst(WithKind(ArtifactKindContainer)); found { + imageName := artifact.Location + if imageName == "" { + return nil, fmt.Errorf( + "no container image found in publish artifacts for service: %s", serviceConfig.Name) + } + return st.containerDeploy(ctx, serviceConfig, targetResource, imageName, progress) + } + + return st.zipDeploy(ctx, serviceConfig, serviceContext, targetResource, progress) +} + +// containerDeploy updates the App Service container configuration with the published image. +// It respects slot selection via AZD_DEPLOY_{SERVICE}_SLOT_NAME, matching the zip deploy behavior. +func (st *appServiceTarget) containerDeploy( + ctx context.Context, + serviceConfig *ServiceConfig, + targetResource *environment.TargetResource, + imageName string, + progress *async.Progress[ServiceProgress], +) (*ServiceDeployResult, error) { + // Validate the App Service is configured for container deployment (Linux + DOCKER| linuxFxVersion). + // Infrastructure configuration (ACR auth, managed identity) must be set via IaC, not at deploy time. + progress.SetProgress(NewServiceProgress("Validating container configuration")) + if err := st.cli.ValidateAppServiceForContainerDeploy( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + targetResource.ResourceName(), + ); err != nil { + return nil, err + } + + // Determine deployment targets (main app or slots) using the same logic as zip deploy + deployTargets, err := st.determineDeploymentTargets(ctx, serviceConfig, targetResource, progress) + if err != nil { + return nil, fmt.Errorf("determining deployment targets: %w", err) + } + + for _, target := range deployTargets { + var progressMsg string + if target.SlotName == "" { + progressMsg = "Updating container image" + } else { + progressMsg = fmt.Sprintf("Updating container image for slot '%s'", target.SlotName) + } + progress.SetProgress(NewServiceProgress(progressMsg)) + + if target.SlotName == "" { + err = st.cli.UpdateAppServiceContainerImage( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + targetResource.ResourceName(), + imageName, + ) + } else { + err = st.cli.UpdateAppServiceSlotContainerImage( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + targetResource.ResourceName(), + target.SlotName, + imageName, + ) + } + if err != nil { + return nil, fmt.Errorf("deploying container to app service %s: %w", serviceConfig.Name, err) + } + } + + // Apply environment variables as App Settings if configured + if len(serviceConfig.Environment) > 0 { + progress.SetProgress(NewServiceProgress("Updating application settings")) + envVars, err := serviceConfig.Environment.Expand(st.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding environment variables: %w", err) + } + + if err := st.cli.UpdateAppServiceAppSettings( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + targetResource.ResourceName(), + envVars, + ); err != nil { + return nil, fmt.Errorf("updating app settings for service %s: %w", serviceConfig.Name, err) + } + } + + progress.SetProgress(NewServiceProgress("Fetching endpoints for app service")) + endpoints, err := st.Endpoints(ctx, serviceConfig, targetResource) + if err != nil { + return nil, err + } + + artifacts := ArtifactCollection{} + + for _, endpoint := range endpoints { + if err := artifacts.Add(&Artifact{ + Kind: ArtifactKindEndpoint, + Location: endpoint, + LocationKind: LocationKindRemote, + }); err != nil { + return nil, fmt.Errorf("failed to add endpoint artifact: %w", err) + } + } + + var resourceArtifact *Artifact + if err := mapper.Convert(targetResource, &resourceArtifact); err == nil { + if err := artifacts.Add(resourceArtifact); err != nil { + return nil, fmt.Errorf("failed to add resource artifact: %w", err) + } + } + + return &ServiceDeployResult{ + Artifacts: artifacts, + }, nil +} + +// zipDeploy deploys a zip archive to the App Service using zip deploy. +func (st *appServiceTarget) zipDeploy( + ctx context.Context, + serviceConfig *ServiceConfig, + serviceContext *ServiceContext, + targetResource *environment.TargetResource, + progress *async.Progress[ServiceProgress], +) (*ServiceDeployResult, error) { var zipFilePath string if artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindArchive)); found && artifact.Location != "" { zipFilePath = artifact.Location @@ -430,3 +636,22 @@ func (st *appServiceTarget) validateTargetResource( return nil } + +// isContainerDeploy returns true when the service is configured for container deployment. +// For App Service, container deployment is triggered by language=docker, docker.path set +// (polyglot containerization), or a pre-built image. +func (st *appServiceTarget) isContainerDeploy(serviceConfig *ServiceConfig, serviceContext *ServiceContext) bool { + if serviceConfig.Language == ServiceLanguageDocker || serviceConfig.Docker.Path != "" { + return true + } + if _, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)); found { + return true + } + return false +} + +// validateContainerConfig is a no-op; polyglot containerization is now supported via the +// composite docker framework in service_manager.go. +func (st *appServiceTarget) validateContainerConfig(_ *ServiceConfig) error { + return nil +} diff --git a/cli/azd/pkg/project/service_target_appservice_test.go b/cli/azd/pkg/project/service_target_appservice_test.go index ec3ba8f163a..00e1271ff2d 100644 --- a/cli/azd/pkg/project/service_target_appservice_test.go +++ b/cli/azd/pkg/project/service_target_appservice_test.go @@ -12,14 +12,17 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "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/tools/docker" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockazapi" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" ) type serviceTargetValidationTest struct { @@ -327,26 +330,386 @@ func Test_appServiceTarget_Package(t *testing.T) { } func Test_appServiceTarget_Publish(t *testing.T) { - target := &appServiceTarget{} - result, err := target.Publish(t.Context(), nil, nil, nil, nil, nil) - require.NoError(t, err) - require.NotNil(t, result) + t.Run("NoContainerArtifact_ReturnsEmpty", func(t *testing.T) { + target := &appServiceTarget{} + sctx := NewServiceContext() + result, err := target.Publish(t.Context(), &ServiceConfig{}, sctx, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("ContainerDeploy_RemoteImageShortCircuit", func(t *testing.T) { + // When a container artifact with a registry is present, Publish should + // short-circuit (no ContainerHelper call) and return the remote image reference. + mockContext := mocks.NewMockContext(t.Context()) + azCli := mockazapi.NewAzureClientFromMockContext(mockContext) + env := environment.New("test") + envManager := &mockenv.MockEnvManager{} + envManager.On("Save", mock.Anything, mock.Anything).Return(nil) + + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", string(azapi.AzureResourceTypeWebSite), + ) + + sctx := NewServiceContext() + require.NoError(t, sctx.Package.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "myregistry.azurecr.io/myapp:abc123", + LocationKind: LocationKindLocal, + })) + + st := &appServiceTarget{ + env: env, + envManager: envManager, + cli: azCli, + console: mockContext.Console, + } + + progress := async.NewNoopProgress[ServiceProgress]() + result, err := st.Publish(*mockContext.Context, &ServiceConfig{ + Name: "web", + Language: ServiceLanguageDocker, + }, sctx, targetResource, progress, nil) + + require.NoError(t, err) + require.NotNil(t, result) + + // Should have a remote container artifact + artifact, found := result.Artifacts.FindFirst(WithKind(ArtifactKindContainer)) + require.True(t, found, "should produce container artifact") + assert.Equal(t, "myregistry.azurecr.io/myapp:abc123", artifact.Location) + assert.Equal(t, LocationKindRemote, artifact.LocationKind) + + // Should have saved IMAGE_NAME to environment + envManager.AssertCalled(t, "Save", mock.Anything, mock.Anything) + assert.Equal(t, "myregistry.azurecr.io/myapp:abc123", + env.GetServiceProperty("web", "IMAGE_NAME")) + }) } func Test_NewAppServiceTarget(t *testing.T) { env := environment.NewWithValues("test-env", nil) - target := NewAppServiceTarget(env, nil, nil) + target := NewAppServiceTarget(env, nil, nil, nil, nil) require.NotNil(t, target) } func Test_appServiceTarget_RequiredExternalTools(t *testing.T) { - target := NewAppServiceTarget(nil, nil, nil) - result := target.RequiredExternalTools(t.Context(), nil) - assert.Empty(t, result) + t.Run("NonDocker_ReturnsEmpty", func(t *testing.T) { + target := NewAppServiceTarget(nil, nil, nil, nil, nil) + result := target.RequiredExternalTools(t.Context(), &ServiceConfig{ + Language: ServiceLanguagePython, + }) + assert.Empty(t, result) + }) } func Test_appServiceTarget_Initialize(t *testing.T) { - target := NewAppServiceTarget(nil, nil, nil) - err := target.Initialize(t.Context(), nil) - require.NoError(t, err) + t.Run("NonDocker_NoError", func(t *testing.T) { + target := NewAppServiceTarget(nil, nil, nil, nil, nil) + err := target.Initialize(t.Context(), &ServiceConfig{Language: ServiceLanguagePython}) + require.NoError(t, err) + }) + + t.Run("Docker_NoError", func(t *testing.T) { + target := NewAppServiceTarget(nil, nil, nil, nil, nil) + err := target.Initialize(t.Context(), &ServiceConfig{Language: ServiceLanguageDocker}) + require.NoError(t, err) + }) + + t.Run("PolyglotDocker_NoError", func(t *testing.T) { + // Polyglot containerization (python + docker.path) is now supported + target := NewAppServiceTarget(nil, nil, nil, nil, nil) + err := target.Initialize(t.Context(), &ServiceConfig{ + Language: ServiceLanguagePython, + Docker: DockerProjectOptions{Path: "./Dockerfile"}, + }) + require.NoError(t, err) + }) +} + +func Test_appServiceTarget_Package_ContainerArtifact(t *testing.T) { + t.Run("ContainerArtifact_PassesThrough", func(t *testing.T) { + sc := &ServiceConfig{ + Name: "web", + Language: ServiceLanguageDocker, + Project: &ProjectConfig{Path: t.TempDir()}, + } + + sctx := NewServiceContext() + require.NoError(t, sctx.Package.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "myimage:latest", + LocationKind: LocationKindLocal, + })) + + st := &appServiceTarget{} + progress := async.NewProgress[ServiceProgress]() + go func() { + for range progress.Progress() { + } + }() + + result, err := st.Package(t.Context(), sc, sctx, progress) + progress.Done() + + require.NoError(t, err) + require.NotNil(t, result) + // Container artifacts pass through; no zip should be created + _, foundZip := result.Artifacts.FindFirst(WithKind(ArtifactKindArchive)) + assert.False(t, foundZip, "should not create zip for container deployments") + }) + + t.Run("RemoteBuild_NoArtifacts_NoopNoError", func(t *testing.T) { + // Remote build and dotnet-publish docker flows produce no package artifacts. + // Package() should be a no-op (no zip, no error) when service is configured for docker. + sc := &ServiceConfig{ + Name: "web", + Language: ServiceLanguageDocker, + Docker: DockerProjectOptions{RemoteBuild: true}, + Project: &ProjectConfig{Path: t.TempDir()}, + } + + sctx := NewServiceContext() + // Empty package artifacts (simulates remote build behavior) + + st := &appServiceTarget{} + progress := async.NewProgress[ServiceProgress]() + go func() { + for range progress.Progress() { + } + }() + + result, err := st.Package(t.Context(), sc, sctx, progress) + progress.Done() + + require.NoError(t, err, "should not error for remote-build docker service with no artifacts") + require.NotNil(t, result) + assert.Empty(t, result.Artifacts, "should produce no artifacts for remote build") + }) +} + +func Test_appServiceTarget_Deploy_ContainerPath(t *testing.T) { + t.Run("ContainerArtifact_UpdatesContainerConfig", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + azCli := mockazapi.NewAzureClientFromMockContext(mockContext) + + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", string(azapi.AzureResourceTypeWebSite), + ) + + // Mock the Update call for container config + updateCalled := false + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodPatch && + strings.Contains(request.URL.Path, "/sites/WEB_APP_NAME") && + !strings.Contains(request.URL.Path, "/slots") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + updateCalled = true + site := armappservice.Site{ + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("webapp.azurewebsites.net"), + }, + } + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, site) + }) + + // Mock GetAppServiceProperties (for Validation + Endpoints) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, "/sites/WEB_APP_NAME") && + !strings.Contains(request.URL.Path, "/slots") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + response := armappservice.WebAppsClientGetResponse{ + Site: armappservice.Site{ + Kind: new("app,linux,container"), + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("webapp.azurewebsites.net"), + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: new("DOCKER|placeholder:latest"), + }, + }, + }, + } + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + // Mock slots (empty) + mockSlotsResponse(mockContext, []string{}) + + sctx := NewServiceContext() + require.NoError(t, sctx.Publish.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "myregistry.azurecr.io/myapp:abc123", + LocationKind: LocationKindRemote, + })) + + st := &appServiceTarget{ + env: environment.New("test"), + cli: azCli, + console: mockContext.Console, + } + + progress := async.NewNoopProgress[ServiceProgress]() + result, err := st.Deploy(*mockContext.Context, &ServiceConfig{Name: "web"}, sctx, targetResource, progress) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, updateCalled, "should have called Update to set container config") + + // Verify endpoints are returned + endpoints := result.Artifacts.Find(WithKind(ArtifactKindEndpoint)) + assert.NotEmpty(t, endpoints, "should have endpoint artifacts") + }) +} + +func Test_appServiceTarget_Deploy_ZipPath(t *testing.T) { + t.Run("ZipArtifact_UsesZipDeploy", func(t *testing.T) { + // Verify that the zip deploy path is still used when no container artifact is present + sctx := NewServiceContext() + // No container artifact in Publish, and no zip in Package means error + st := &appServiceTarget{ + env: environment.New("test"), + console: nil, + } + + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", string(azapi.AzureResourceTypeWebSite), + ) + + progress := async.NewNoopProgress[ServiceProgress]() + _, err := st.Deploy(t.Context(), &ServiceConfig{Name: "web"}, sctx, targetResource, progress) + + // Should fail because there are no zip artifacts (proving it took the zip path, not container path) + require.Error(t, err) + assert.Contains(t, err.Error(), "no zip artifacts found") + }) +} + +func Test_appServiceTarget_Deploy_ContainerSlotPath(t *testing.T) { + t.Run("ContainerArtifact_DeploysToSlot", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + azCli := mockazapi.NewAzureClientFromMockContext(mockContext) + + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", string(azapi.AzureResourceTypeWebSite), + ) + + // Mock the slot PATCH call for container config + slotUpdateCalled := false + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodPatch && + strings.Contains(request.URL.Path, "/slots/staging") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + slotUpdateCalled = true + site := armappservice.Site{ + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("webapp-staging.azurewebsites.net"), + }, + } + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, site) + }) + + // Mock GetAppServiceProperties (for Validation + Endpoints) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, "/sites/WEB_APP_NAME") && + !strings.Contains(request.URL.Path, "/slots") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + response := armappservice.WebAppsClientGetResponse{ + Site: armappservice.Site{ + Kind: new("app,linux,container"), + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("webapp.azurewebsites.net"), + SiteConfig: &armappservice.SiteConfig{ + LinuxFxVersion: new("DOCKER|placeholder:latest"), + }, + }, + }, + } + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + // Mock slots response with "staging" slot + mockSlotsResponse(mockContext, []string{"staging"}) + + // Mock GetAppServiceSlotProperties (for Endpoints) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, "/slots/staging") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + site := armappservice.Site{ + Properties: &armappservice.SiteProperties{ + DefaultHostName: new("webapp-staging.azurewebsites.net"), + }, + } + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, site) + }) + + // Set slot name env var to target the staging slot + env := environment.New("test") + env.DotenvSet("AZD_DEPLOY_WEB_SLOT_NAME", "staging") + + sctx := NewServiceContext() + require.NoError(t, sctx.Publish.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "myregistry.azurecr.io/myapp:abc123", + LocationKind: LocationKindRemote, + })) + + st := &appServiceTarget{ + env: env, + cli: azCli, + console: mockContext.Console, + } + + progress := async.NewNoopProgress[ServiceProgress]() + result, err := st.Deploy(*mockContext.Context, &ServiceConfig{Name: "web"}, sctx, targetResource, progress) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, slotUpdateCalled, "should have called slot PATCH to set container config") + }) +} + +func Test_appServiceTarget_RequiredExternalTools_Docker(t *testing.T) { + t.Run("DockerLanguage_DelegatesToContainerHelper", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + dockerCli := docker.NewCli(mockContext.CommandRunner) + containerHelper := NewContainerHelper( + nil, nil, nil, nil, dockerCli, nil, mockContext.Console, nil) + target := &appServiceTarget{ + containerHelper: containerHelper, + } + sc := &ServiceConfig{ + Language: ServiceLanguageDocker, + } + tools := target.RequiredExternalTools(*mockContext.Context, sc) + assert.NotEmpty(t, tools, "should return docker tools for docker language") + }) + + t.Run("DockerPath_WithNonDockerLanguage_DelegatesToContainerHelper", func(t *testing.T) { + // Polyglot containerization (non-docker language + docker.path) is now supported + mockContext := mocks.NewMockContext(t.Context()) + dockerCli := docker.NewCli(mockContext.CommandRunner) + containerHelper := NewContainerHelper( + nil, nil, nil, nil, dockerCli, nil, mockContext.Console, nil) + target := &appServiceTarget{ + containerHelper: containerHelper, + } + sc := &ServiceConfig{ + Language: ServiceLanguagePython, + Docker: DockerProjectOptions{Path: "./Dockerfile"}, + } + tools := target.RequiredExternalTools(*mockContext.Context, sc) + assert.NotEmpty(t, tools, "should return docker tools when docker.path is set") + }) + + t.Run("NonDocker_ReturnsEmpty", func(t *testing.T) { + target := &appServiceTarget{} + sc := &ServiceConfig{ + Language: ServiceLanguagePython, + } + tools := target.RequiredExternalTools(t.Context(), sc) + assert.Empty(t, tools, "should return no tools for non-docker language") + }) } diff --git a/docs/reference/azure-yaml-schema.md b/docs/reference/azure-yaml-schema.md index e4f4337555a..4d6c77efcbe 100644 --- a/docs/reference/azure-yaml-schema.md +++ b/docs/reference/azure-yaml-schema.md @@ -83,6 +83,29 @@ For example, `preprovision` runs before provisioning, `postdeploy` runs after de The full JSON schema for `azure.yaml` is maintained in the [schemas/](../../schemas/) directory and published for editor validation. +## Host-Specific Notes + +### App Service (`host: appservice`) + +App Service supports two deployment modes: + +- **Zip deploy** (default): When `language` is set to a non-Docker language (e.g., `python`, `js`, `dotnet`), azd builds the code, creates a zip archive, and deploys it via the Kudu zip deploy API. +- **Container deploy**: When `language: docker` is set, azd builds the container image, pushes it to ACR, and updates the site's `linuxFxVersion`. Currently Linux App Service only. Your infrastructure (bicep/terraform) must configure ACR access (e.g., managed identity ACR pull, identity assignment) before deploying. azd only updates the image reference at deploy time. + +**Note**: Container deployment supports both `language: docker` (with a Dockerfile) and polyglot containerization (e.g., `language: python` with `docker.path` pointing to a Dockerfile). You can also use a pre-built `image:` without local source. + +Example container deployment: + +```yaml +services: + web: + project: ./src/web + language: docker + host: appservice + docker: + path: ./Dockerfile +``` + ## See Also - [azure.yaml JSON Schema](../../schemas/) — Machine-readable schema definition diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index f4d3cc7177a..398df6e49d7 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -562,10 +562,10 @@ } }, { - "comment": "Traditional hosts - require project only, disable container-specific properties", + "comment": "Traditional hosts (non-container only) - require project, disable container-specific properties", "if": { "properties": { - "host": { "enum": ["appservice", "function", "springapp", "staticwebapp"] } + "host": { "enum": ["function", "springapp", "staticwebapp"] } } }, "then": { @@ -579,6 +579,20 @@ } } }, + { + "comment": "App Service supports docker/image/env for containers but not Kubernetes-specific properties", + "if": { + "properties": { + "host": { "const": "appservice" } + } + }, + "then": { + "properties": { + "k8s": false, + "apiVersion": false + } + } + }, { "comment": "remoteBuild is only valid for function host", "if": { diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 59a7ba5b7a9..fdeea9980ea 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -522,10 +522,10 @@ } }, { - "comment": "Traditional hosts - require project only, disable container-specific properties", + "comment": "Traditional hosts (non-container only) - require project, disable container-specific properties", "if": { "properties": { - "host": { "enum": ["appservice", "function", "springapp", "staticwebapp"] } + "host": { "enum": ["function", "springapp", "staticwebapp"] } } }, "then": { @@ -539,6 +539,20 @@ } } }, + { + "comment": "App Service supports docker/image/env for containers but not Kubernetes-specific properties", + "if": { + "properties": { + "host": { "const": "appservice" } + } + }, + "then": { + "properties": { + "k8s": false, + "apiVersion": false + } + } + }, { "comment": "remoteBuild is only valid for function host", "if": {