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
36 changes: 31 additions & 5 deletions cli/azd/pkg/containerapps/container_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"slices"
"strings"
"sync"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
Expand All @@ -36,6 +38,7 @@ const (
pathTemplateRevisionSuffix = "properties.template.revisionSuffix"
pathTemplateContainers = "properties.template.containers"
pathConfigurationActiveRevisionsMode = "properties.configuration.activeRevisionsMode"
pathConfigurationDapr = "properties.configuration.dapr"
pathConfigurationSecrets = "properties.configuration.secrets"
pathConfigurationIngressTraffic = "properties.configuration.ingress.traffic"
pathConfigurationIngressFqdn = "properties.configuration.ingress.fqdn"
Expand Down Expand Up @@ -168,20 +171,34 @@ func (cas *containerAppService) persistSettings(
shouldPersistDomains := cas.alphaFeatureManager.IsEnabled(persistCustomDomainsFeature)
shouldPersistIngressSessionAffinity := cas.alphaFeatureManager.IsEnabled(persistIngressSessionAffinity)

if !shouldPersistDomains && !shouldPersistIngressSessionAffinity {
// Preserve existing Dapr configuration when the deployment YAML does not include it.
// This prevents Dapr config set externally (e.g. via Terraform) from being removed on deploy.
objConfig := config.NewConfig(obj)
_, hasDaprConfig := objConfig.Get(pathConfigurationDapr)
shouldPreserveDapr := !hasDaprConfig

if !shouldPersistDomains && !shouldPersistIngressSessionAffinity && !shouldPreserveDapr {
return obj, nil
}

aca, err := cas.getContainerApp(ctx, subscriptionId, resourceGroupName, appName, options)
if err != nil {
// On first deploy the app doesn't exist yet (404) — proceed without persisting.
var respErr *azcore.ResponseError
if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound {
return obj, nil
}

// For alpha-gated features, preserve existing behavior: log and continue.
// For Dapr preservation (correctness-critical), fail to prevent silent config wipe.
if shouldPreserveDapr {
return nil, fmt.Errorf("fetching existing container app to preserve Dapr config: %w", err)
}

log.Printf("failed getting current aca settings: %v. No settings will be persisted.", err)
// if the container app doesn't exist, there's nothing for us to update in the desired state,
// so we can just return the existing state as is.
return obj, nil
Comment thread
spboyer marked this conversation as resolved.
}

objConfig := config.NewConfig(obj)

if shouldPersistDomains {
customDomains, has := aca.GetSlice(pathConfigurationIngressCustomDomains)
if has {
Expand All @@ -200,6 +217,15 @@ func (cas *containerAppService) persistSettings(
}
}

if shouldPreserveDapr {
daprConfig, has := aca.Get(pathConfigurationDapr)
if has {
if err := objConfig.Set(pathConfigurationDapr, daprConfig); err != nil {
return nil, fmt.Errorf("setting dapr configuration: %w", err)
}
}
}

return objConfig.Raw(), nil
}

Expand Down
228 changes: 228 additions & 0 deletions cli/azd/pkg/containerapps/container_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -848,3 +848,231 @@ func Test_ContainerAppJob_UpdateImage_NilContainerElement(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "nil container entry")
}

// Test_ContainerApp_DeployYaml_PreservesDaprConfig verifies that when a deployment YAML does not include
// Dapr configuration, any existing Dapr configuration on the container app is preserved.
// This ensures that Dapr configuration set externally (e.g. via Terraform) is not removed on deploy.
func Test_ContainerApp_DeployYaml_PreservesDaprConfig(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

subscriptionId := "SUBSCRIPTION_ID"
location := "eastus2"
resourceGroup := "RESOURCE_GROUP"
appName := "APP_NAME"

// YAML does NOT include Dapr configuration
containerAppYaml := `
location: eastus2
name: APP_NAME
properties:
configuration:
activeRevisionsMode: Single
template:
containers:
- image: IMAGE_NAME
`

// Existing container app has Dapr enabled
existingApp := &armappcontainers.ContainerApp{
Location: new(location),
Name: new(appName),
Properties: &armappcontainers.ContainerAppProperties{
Configuration: &armappcontainers.Configuration{
ActiveRevisionsMode: to.Ptr(armappcontainers.ActiveRevisionsModeSingle),
Dapr: &armappcontainers.Dapr{
AppID: new("my-app"),
AppPort: to.Ptr[int32](8080),
Enabled: new(true),
},
},
Template: &armappcontainers.Template{
Containers: []*armappcontainers.Container{
{
Image: new("IMAGE_NAME"),
},
},
},
},
}

_ = mockazsdk.MockContainerAppGet(mockContext, subscriptionId, resourceGroup, appName, existingApp)
containerAppUpdateRequest := mockazsdk.MockContainerAppCreateOrUpdate(
mockContext, subscriptionId, resourceGroup, appName, existingApp,
)

cas := NewContainerAppService(
mockContext.SubscriptionCredentialProvider,
clock.NewMock(),
mockContext.ArmClientOptions,
mockContext.AlphaFeaturesManager,
)

err := cas.DeployYaml(*mockContext.Context, subscriptionId, resourceGroup, appName, []byte(containerAppYaml), nil)
require.NoError(t, err)

Comment thread
spboyer marked this conversation as resolved.
var actual *armappcontainers.ContainerApp
err = mocks.ReadHttpBody(containerAppUpdateRequest.Body, &actual)
require.NoError(t, err)

// Dapr configuration should be preserved from the existing container app
require.NotNil(t, actual.Properties.Configuration.Dapr)
require.Equal(t, "my-app", *actual.Properties.Configuration.Dapr.AppID)
require.Equal(t, int32(8080), *actual.Properties.Configuration.Dapr.AppPort)
require.Equal(t, true, *actual.Properties.Configuration.Dapr.Enabled)
}

// Test_ContainerApp_DeployYaml_YamlDaprConfigNotOverridden verifies that when a deployment YAML already
// includes Dapr configuration, the YAML's Dapr configuration is used (not the existing app's configuration).
func Test_ContainerApp_DeployYaml_YamlDaprConfigNotOverridden(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

subscriptionId := "SUBSCRIPTION_ID"
location := "eastus2"
resourceGroup := "RESOURCE_GROUP"
appName := "APP_NAME"

// YAML includes its own Dapr configuration
containerAppYaml := `
location: eastus2
name: APP_NAME
properties:
configuration:
activeRevisionsMode: Single
dapr:
appId: yaml-app
appPort: 9090
enabled: true
template:
containers:
- image: IMAGE_NAME
`

// Existing container app has different Dapr configuration
existingApp := &armappcontainers.ContainerApp{
Location: new(location),
Name: new(appName),
Properties: &armappcontainers.ContainerAppProperties{
Configuration: &armappcontainers.Configuration{
ActiveRevisionsMode: to.Ptr(armappcontainers.ActiveRevisionsModeSingle),
Dapr: &armappcontainers.Dapr{
AppID: new("existing-app"),
AppPort: to.Ptr[int32](8080),
Enabled: new(true),
},
},
Template: &armappcontainers.Template{
Containers: []*armappcontainers.Container{
{
Image: new("IMAGE_NAME"),
},
},
},
},
}

_ = mockazsdk.MockContainerAppGet(mockContext, subscriptionId, resourceGroup, appName, existingApp)
containerAppUpdateRequest := mockazsdk.MockContainerAppCreateOrUpdate(
mockContext, subscriptionId, resourceGroup, appName, existingApp,
)

cas := NewContainerAppService(
mockContext.SubscriptionCredentialProvider,
clock.NewMock(),
mockContext.ArmClientOptions,
mockContext.AlphaFeaturesManager,
)

err := cas.DeployYaml(*mockContext.Context, subscriptionId, resourceGroup, appName, []byte(containerAppYaml), nil)
require.NoError(t, err)

var actual *armappcontainers.ContainerApp
err = mocks.ReadHttpBody(containerAppUpdateRequest.Body, &actual)
require.NoError(t, err)

// Dapr configuration from the YAML should be used, not the existing app's
require.NotNil(t, actual.Properties.Configuration.Dapr)
require.Equal(t, "yaml-app", *actual.Properties.Configuration.Dapr.AppID)
require.Equal(t, int32(9090), *actual.Properties.Configuration.Dapr.AppPort)
}

// Test_ContainerApp_DeployYaml_FirstDeploy_NoDaprInjected verifies that on first deploy (GET returns 404),
// no Dapr configuration is injected into the deployment payload.
func Test_ContainerApp_DeployYaml_FirstDeploy_NoDaprInjected(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

subscriptionId := "SUBSCRIPTION_ID"
resourceGroup := "RESOURCE_GROUP"
appName := "NEW_APP"

// YAML does NOT include Dapr configuration
containerAppYaml := `
location: eastus2
name: NEW_APP
properties:
configuration:
activeRevisionsMode: Single
template:
containers:
- image: IMAGE_NAME
`

// Mock GET: first call returns 404 (app doesn't exist for persistSettings),
// subsequent calls return 200 (for CreateOrUpdate polling)
getCallCount := 0
createdApp := &armappcontainers.ContainerApp{
Location: new("eastus2"),
Name: new("NEW_APP"),
Properties: &armappcontainers.ContainerAppProperties{
Configuration: &armappcontainers.Configuration{
ActiveRevisionsMode: to.Ptr(armappcontainers.ActiveRevisionsModeSingle),
},
Template: &armappcontainers.Template{
Containers: []*armappcontainers.Container{
{Image: new("IMAGE_NAME")},
},
},
},
}

mockContext.HttpClient.When(func(request *http.Request) bool {
return request.Method == http.MethodGet && strings.Contains(
request.URL.Path,
fmt.Sprintf(
"/subscriptions/%s/resourceGroups/%s/providers/Microsoft.App/containerApps/%s",
subscriptionId,
resourceGroup,
appName,
),
)
}).RespondFn(func(request *http.Request) (*http.Response, error) {
getCallCount++
if getCallCount == 1 {
return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound)
}
response := armappcontainers.ContainerAppsClientGetResponse{
ContainerApp: *createdApp,
}
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
})

containerAppUpdateRequest := mockazsdk.MockContainerAppCreateOrUpdate(
mockContext, subscriptionId, resourceGroup, appName, createdApp,
)

cas := NewContainerAppService(
mockContext.SubscriptionCredentialProvider,
clock.NewMock(),
mockContext.ArmClientOptions,
mockContext.AlphaFeaturesManager,
)

err := cas.DeployYaml(*mockContext.Context, subscriptionId, resourceGroup, appName, []byte(containerAppYaml), nil)
require.NoError(t, err)

var actual *armappcontainers.ContainerApp
err = mocks.ReadHttpBody(containerAppUpdateRequest.Body, &actual)
require.NoError(t, err)

// No Dapr configuration should be injected on first deploy
require.Nil(t, actual.Properties.Configuration.Dapr)
}
Loading