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
2 changes: 2 additions & 0 deletions cli/azd/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions cli/azd/pkg/azapi/webapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"log"
"maps"
"net/http"
"strings"
"time"
Expand Down Expand Up @@ -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|<imageName>"; infrastructure configuration (ACR auth,
// managed identity) must be set via IaC (bicep/terraform), not at deploy time.
func (cli *AzureClient) UpdateAppServiceContainerImage(
Comment thread
jongio marked this conversation as resolved.
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,
Expand Down
203 changes: 203 additions & 0 deletions cli/azd/pkg/azapi/webapp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package azapi
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
Expand Down Expand Up @@ -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")
})
}
5 changes: 3 additions & 2 deletions cli/azd/pkg/project/service_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading