diff --git a/cli/azd/pkg/containerapps/container_app.go b/cli/azd/pkg/containerapps/container_app.go index 7e93494f009..9cdb9b3f1c2 100644 --- a/cli/azd/pkg/containerapps/container_app.go +++ b/cli/azd/pkg/containerapps/container_app.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -14,6 +15,7 @@ import ( "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" @@ -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" @@ -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 } - objConfig := config.NewConfig(obj) - if shouldPersistDomains { customDomains, has := aca.GetSlice(pathConfigurationIngressCustomDomains) if has { @@ -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 } diff --git a/cli/azd/pkg/containerapps/container_app_test.go b/cli/azd/pkg/containerapps/container_app_test.go index 25f976cfe5c..b235180a90c 100644 --- a/cli/azd/pkg/containerapps/container_app_test.go +++ b/cli/azd/pkg/containerapps/container_app_test.go @@ -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) + + 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) +}