Skip to content

Commit

Permalink
Support DataProtection Runtime feature in ACA
Browse files Browse the repository at this point in the history
This change does two things:

- Supports using a custom api-version of the ACA CreateOrUpdate
request by allowing the yaml deployment template to have an
`api-version` property at top level. When set, this value is used as
the `api-version` of the request. The request body is the object,
rendered as JSON, with the `api-version` key removed.

- Uses the above new feature to opt into the `2024-02-02-preview` in
Aspire and sets the
`configuration.runtime.dotnet.autoConfigureDataProtection` property to
`true`, but only when the alpha feature
`aspire.autoConfigureDataProtection` is turned on.

We are gating turning this feature on behind a feature flag the user
must opt-into instead of always sending this request, as we had done
in #3711, because currently regions in Azure are rejecting the
`2024-02-02-preview` requests (even though the error message implies
this should work).

This allows partners to test the end to end by running:

`azd config set alpha.aspire.autoConfigureDataProtection on`

And testing in a supported region, like `centraluseuap` (which can be
explicitly selected via `azd env set AZURE_LOCATION centraluseuap`.

Fixes #3538
  • Loading branch information
ellismg committed Apr 17, 2024
1 parent d2b5165 commit e8338e1
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 39 deletions.
7 changes: 5 additions & 2 deletions cli/azd/pkg/apphost/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func Dockerfiles(manifest *Manifest) map[string]genDockerfile {
// ContainerAppManifestTemplateForProject returns the container app manifest template for a given project.
// It can be used (after evaluation) to deploy the service to a container app environment.
func ContainerAppManifestTemplateForProject(
manifest *Manifest, projectName string) (string, error) {
manifest *Manifest, projectName string, autoConfigureDataProtection bool) (string, error) {
generator := newInfraGenerator()

if err := generator.LoadManifest(manifest); err != nil {
Expand All @@ -123,7 +123,10 @@ func ContainerAppManifestTemplateForProject(

var buf bytes.Buffer

err := genTemplates.ExecuteTemplate(&buf, "containerApp.tmpl.yaml", generator.containerAppTemplateContexts[projectName])
tmplCtx := generator.containerAppTemplateContexts[projectName]
tmplCtx.AutoConfigureDataProtection = autoConfigureDataProtection

err := genTemplates.ExecuteTemplate(&buf, "containerApp.tmpl.yaml", tmplCtx)
if err != nil {
return "", fmt.Errorf("executing template: %w", err)
}
Expand Down
6 changes: 3 additions & 3 deletions cli/azd/pkg/apphost/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func TestAspireEscaping(t *testing.T) {

for _, name := range []string{"api"} {
t.Run(name, func(t *testing.T) {
tmpl, err := ContainerAppManifestTemplateForProject(m, name)
tmpl, err := ContainerAppManifestTemplateForProject(m, name, false)
require.NoError(t, err)
snapshot.SnapshotT(t, tmpl)
})
Expand Down Expand Up @@ -162,7 +162,7 @@ func TestAspireBicepGeneration(t *testing.T) {

for _, name := range []string{"frontend"} {
t.Run(name, func(t *testing.T) {
tmpl, err := ContainerAppManifestTemplateForProject(m, name)
tmpl, err := ContainerAppManifestTemplateForProject(m, name, false)
require.NoError(t, err)
snapshot.SnapshotT(t, tmpl)
})
Expand All @@ -184,7 +184,7 @@ func TestAspireDockerGeneration(t *testing.T) {

for _, name := range []string{"nodeapp"} {
t.Run(name, func(t *testing.T) {
tmpl, err := ContainerAppManifestTemplateForProject(m, name)
tmpl, err := ContainerAppManifestTemplateForProject(m, name, false)
require.NoError(t, err)
snapshot.SnapshotT(t, tmpl)
})
Expand Down
13 changes: 7 additions & 6 deletions cli/azd/pkg/apphost/generate_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,13 @@ type genBicepTemplateContext struct {
}

type genContainerAppManifestTemplateContext struct {
Name string
Ingress *genContainerAppIngress
Env map[string]string
Secrets map[string]string
KeyVaultSecrets map[string]string
Dapr *genContainerAppManifestTemplateContextDapr
Name string
Ingress *genContainerAppIngress
Env map[string]string
Secrets map[string]string
KeyVaultSecrets map[string]string
Dapr *genContainerAppManifestTemplateContextDapr
AutoConfigureDataProtection bool
}

type genProjectFileContext struct {
Expand Down
137 changes: 120 additions & 17 deletions cli/azd/pkg/containerapps/container_app.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package containerapps

import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"slices"

"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"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3"
azdinternal "github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/account"
Expand Down Expand Up @@ -101,39 +108,92 @@ func (cas *containerAppService) GetIngressConfiguration(
}, nil
}

// apiVersionKey is the key that can be set in the root of a deployment yaml to control the API version used when creating
// or updating the container app. When unset, we use the default API version of the armappcontainers.ContainerAppsClient.
const apiVersionKey = "api-version"

func (cas *containerAppService) DeployYaml(
ctx context.Context,
subscriptionId string,
resourceGroupName string,
appName string,
containerAppYaml []byte,
) error {
appClient, err := cas.createContainerAppsClient(ctx, subscriptionId)
if err != nil {
return err
}

var obj map[string]any
if err := yaml.Unmarshal(containerAppYaml, &obj); err != nil {
return fmt.Errorf("decoding yaml: %w", err)
}

containerAppJson, err := json.Marshal(obj)
if err != nil {
panic("should not have failed")
}
var poller *runtime.Poller[armappcontainers.ContainerAppsClientCreateOrUpdateResponse]

var containerApp armappcontainers.ContainerApp
if err := json.Unmarshal(containerAppJson, &containerApp); err != nil {
return fmt.Errorf("converting to container app type: %w", err)
}
// The way we make the initial request depends on whether the apiVersion is specified in the YAML.
if apiVersion, ok := obj[apiVersionKey].(string); ok {
// When the apiVersion is specified, we need to use a custom policy to inject the apiVersion and body into the
// request. This is because the ContainerAppsClient is built for a specific api version and does not allow us to
// change it. The custom policy allows us to use the parts of the SDK around building the request URL and using
// the standard pipeline - but we have to use a policy to change the api-version header and inject the body since
// the armappcontainers.ContainerApp{} is also built for a specific api version.
customPolicy := &containerAppCustomApiVersionAndBodyPolicy{
apiVersion: apiVersion,
}

poller, err := appClient.BeginCreateOrUpdate(ctx, resourceGroupName, appName, containerApp, nil)
if err != nil {
return fmt.Errorf("applying manifest: %w", err)
appClient, err := cas.createContainerAppsClientWithPerCallPolicy(ctx, subscriptionId, customPolicy)
if err != nil {
return err
}

// Remove the apiVersion field from the object so it doesn't get injected into the request body. On the wire this
// is in a query parameter, not the body.
delete(obj, apiVersionKey)

containerAppJson, err := json.Marshal(obj)
if err != nil {
panic("should not have failed")
}

// Set the body injected by the policy to be the full container app JSON from the YAML.
customPolicy.body = (*json.RawMessage)(&containerAppJson)

// It doesn't matter what we configure here - the value is going to be overwritten by the custom policy. But we need
// to pass in a value, so use the zero value.
emptyApp := armappcontainers.ContainerApp{}

p, err := appClient.BeginCreateOrUpdate(ctx, resourceGroupName, appName, emptyApp, nil)
if err != nil {
return fmt.Errorf("applying manifest: %w", err)
}
poller = p

// Now that we've sent the request, clear the body so it is not injected on any subsequent requests (e.g. ones made
// by the poller when we poll).
customPolicy.body = nil
} else {
// When the apiVersion field is unset in the YAML, we can use the standard SDK to build the request and send it
// like normal.
appClient, err := cas.createContainerAppsClient(ctx, subscriptionId)
if err != nil {
return err
}

containerAppJson, err := json.Marshal(obj)
if err != nil {
panic("should not have failed")
}

var containerApp armappcontainers.ContainerApp
if err := json.Unmarshal(containerAppJson, &containerApp); err != nil {
return fmt.Errorf("converting to container app type: %w", err)
}

p, err := appClient.BeginCreateOrUpdate(ctx, resourceGroupName, appName, containerApp, nil)
if err != nil {
return fmt.Errorf("applying manifest: %w", err)
}

poller = p
}

_, err = poller.PollUntilDone(ctx, nil)
_, err := poller.PollUntilDone(ctx, nil)
if err != nil {
return fmt.Errorf("polling for container app update completion: %w", err)
}
Expand Down Expand Up @@ -337,6 +397,28 @@ func (cas *containerAppService) createContainerAppsClient(
return client, nil
}

func (cas *containerAppService) createContainerAppsClientWithPerCallPolicy(
ctx context.Context,
subscriptionId string,
policy policy.Policy,
) (*armappcontainers.ContainerAppsClient, error) {
credential, err := cas.credentialProvider.CredentialForSubscription(ctx, subscriptionId)
if err != nil {
return nil, err
}

// Clone the options so we don't modify the original - we don't want to inject this custom policy into every request.
options := *cas.armClientOptions
options.PerCallPolicies = append(slices.Clone(options.PerCallPolicies), policy)

client, err := armappcontainers.NewContainerAppsClient(subscriptionId, credential, &options)
if err != nil {
return nil, fmt.Errorf("creating ContainerApps client: %w", err)
}

return client, nil
}

func (cas *containerAppService) createRevisionsClient(
ctx context.Context,
subscriptionId string,
Expand All @@ -353,3 +435,24 @@ func (cas *containerAppService) createRevisionsClient(

return client, nil
}

type containerAppCustomApiVersionAndBodyPolicy struct {
apiVersion string
body *json.RawMessage
}

func (p *containerAppCustomApiVersionAndBodyPolicy) Do(req *policy.Request) (*http.Response, error) {
if p.body != nil {
reqQP := req.Raw().URL.Query()
reqQP.Set("api-version", p.apiVersion)
req.Raw().URL.RawQuery = reqQP.Encode()

log.Printf("setting body to %s", string(*p.body))

if err := req.SetBody(streaming.NopCloser(bytes.NewReader(*p.body)), "application/json"); err != nil {
return nil, fmt.Errorf("updating request body: %w", err)
}
}

return req.Next()
}
33 changes: 22 additions & 11 deletions cli/azd/pkg/project/dotnet_importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"sync"

"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/apphost"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/ext"
Expand All @@ -27,10 +28,11 @@ type hostCheckResult struct {

// DotNetImporter is an importer that is able to import projects and infrastructure from a manifest produced by a .NET App.
type DotNetImporter struct {
dotnetCli dotnet.DotNetCli
console input.Console
lazyEnv *lazy.Lazy[*environment.Environment]
lazyEnvManager *lazy.Lazy[environment.Manager]
dotnetCli dotnet.DotNetCli
console input.Console
lazyEnv *lazy.Lazy[*environment.Environment]
lazyEnvManager *lazy.Lazy[environment.Manager]
alphaFeatureManager *alpha.FeatureManager

// TODO(ellismg): This cache exists because we end up needing the same manifest multiple times for a single logical
// operation and it is expensive to generate. We should consider if this is the correct location for the cache or if
Expand All @@ -55,14 +57,16 @@ func NewDotNetImporter(
console input.Console,
lazyEnv *lazy.Lazy[*environment.Environment],
lazyEnvManager *lazy.Lazy[environment.Manager],
alphaFeatureManager *alpha.FeatureManager,
) *DotNetImporter {
return &DotNetImporter{
dotnetCli: dotnetCli,
console: console,
lazyEnv: lazyEnv,
lazyEnvManager: lazyEnvManager,
cache: make(map[manifestCacheKey]*apphost.Manifest),
hostCheck: make(map[string]hostCheckResult),
dotnetCli: dotnetCli,
console: console,
lazyEnv: lazyEnv,
lazyEnvManager: lazyEnvManager,
alphaFeatureManager: alphaFeatureManager,
cache: make(map[manifestCacheKey]*apphost.Manifest),
hostCheck: make(map[string]hostCheckResult),
}
}

Expand Down Expand Up @@ -94,6 +98,8 @@ func (ai *DotNetImporter) CanImport(ctx context.Context, projectPath string) (bo
return strings.TrimSpace(value) == "true", nil
}

var dataProtectionAlphaFeature = alpha.MustFeatureKey("aspire.autoConfigureDataProtection")

Check failure on line 101 in cli/azd/pkg/project/dotnet_importer.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

var `dataProtectionAlphaFeature` is unused (unused)

func (ai *DotNetImporter) ProjectInfrastructure(ctx context.Context, svcConfig *ServiceConfig) (*Infra, error) {
manifest, err := ai.ReadManifest(ctx, svcConfig)
if err != nil {
Expand Down Expand Up @@ -243,6 +249,8 @@ func (ai *DotNetImporter) Services(
return services, nil
}

var autoConfigureDataProtectionFeature = alpha.MustFeatureKey("aspire.autoConfigureDataProtection")

func (ai *DotNetImporter) SynthAllInfrastructure(
ctx context.Context, p *ProjectConfig, svcConfig *ServiceConfig,
) (fs.FS, error) {
Expand Down Expand Up @@ -291,11 +299,14 @@ func (ai *DotNetImporter) SynthAllInfrastructure(
return nil, err
}

autoConfigureDataProtection := ai.alphaFeatureManager.IsEnabled(autoConfigureDataProtectionFeature)

// writeManifestForResource writes the containerApp.tmpl.yaml for the given resource to the generated filesystem. The
// manifest is written to a file name "containerApp.tmpl.yaml" in the same directory as the project that produces the
// container we will deploy.
writeManifestForResource := func(name string, path string) error {
containerAppManifest, err := apphost.ContainerAppManifestTemplateForProject(manifest, name)
containerAppManifest, err := apphost.ContainerAppManifestTemplateForProject(
manifest, name, autoConfigureDataProtection)
if err != nil {
return fmt.Errorf("generating containerApp.tmpl.yaml for resource %s: %w", name, err)
}
Expand Down
7 changes: 7 additions & 0 deletions cli/azd/pkg/project/service_target_dotnet_containerapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"time"

"github.com/azure/azure-dev/cli/azd/internal/scaffold"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/apphost"
"github.com/azure/azure-dev/cli/azd/pkg/async"
"github.com/azure/azure-dev/cli/azd/pkg/azure"
Expand All @@ -37,6 +38,7 @@ type dotnetContainerAppTarget struct {
cosmosDbService cosmosdb.CosmosDbService
sqlDbService sqldb.SqlDbService
keyvaultService keyvault.KeyVaultService
alphaFeatureManager *alpha.FeatureManager
}

// NewDotNetContainerAppTarget creates the Service Target for a Container App that is written in .NET. Unlike
Expand All @@ -56,6 +58,7 @@ func NewDotNetContainerAppTarget(
cosmosDbService cosmosdb.CosmosDbService,
sqlDbService sqldb.SqlDbService,
keyvaultService keyvault.KeyVaultService,
alphaFeatureManager *alpha.FeatureManager,
) ServiceTarget {
return &dotnetContainerAppTarget{
env: env,
Expand All @@ -66,6 +69,7 @@ func NewDotNetContainerAppTarget(
cosmosDbService: cosmosDbService,
sqlDbService: sqlDbService,
keyvaultService: keyvaultService,
alphaFeatureManager: alphaFeatureManager,
}
}

Expand Down Expand Up @@ -159,6 +163,8 @@ func (at *dotnetContainerAppTarget) Deploy(
projectRoot = filepath.Dir(projectRoot)
}

autoConfigureDataProtection := at.alphaFeatureManager.IsEnabled(autoConfigureDataProtectionFeature)

manifestPath := filepath.Join(projectRoot, "manifests", "containerApp.tmpl.yaml")
if _, err := os.Stat(manifestPath); err == nil {
log.Printf("using container app manifest from %s", manifestPath)
Expand All @@ -178,6 +184,7 @@ func (at *dotnetContainerAppTarget) Deploy(
generatedManifest, err := apphost.ContainerAppManifestTemplateForProject(
serviceConfig.DotNetContainerApp.Manifest,
serviceConfig.DotNetContainerApp.ProjectName,
autoConfigureDataProtection,
)
if err != nil {
task.SetError(fmt.Errorf("generating container app manifest: %w", err))
Expand Down
2 changes: 2 additions & 0 deletions cli/azd/resources/alpha_features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
description: "Support infrastructure deployments at resource group scope."
- id: infraSynth
description: "Enable the `infra synth` command to write generated infrastructure to disk."
- id: aspire.autoConfigureDataProtection
description: "Automatically configure data protection for Aspire deployments. May not be supported in all regions."
- id: aks.helm
description: "Enable Helm support for AKS deployments."
- id: aks.kustomize
Expand Down
Loading

0 comments on commit e8338e1

Please sign in to comment.