cli/azd/pkg/containerapps/container_app.go (468 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 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/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/convert" "github.com/benbjohnson/clock" "github.com/braydonk/yaml" ) const ( pathLatestRevisionName = "properties.latestRevisionName" pathTemplate = "properties.template" pathTemplateRevisionSuffix = "properties.template.revisionSuffix" pathTemplateContainers = "properties.template.containers" pathConfigurationActiveRevisionsMode = "properties.configuration.activeRevisionsMode" pathConfigurationSecrets = "properties.configuration.secrets" pathConfigurationIngressTraffic = "properties.configuration.ingress.traffic" pathConfigurationIngressFqdn = "properties.configuration.ingress.fqdn" pathConfigurationIngressCustomDomains = "properties.configuration.ingress.customDomains" pathConfigurationIngressStickySessions = "properties.configuration.ingress.stickySessions" ) // ContainerAppService exposes operations for managing Azure Container Apps type ContainerAppService interface { // Gets the ingress configuration for the specified container app GetIngressConfiguration( ctx context.Context, subscriptionId, resourceGroup, appName string, options *ContainerAppOptions, ) (*ContainerAppIngressConfiguration, error) DeployYaml( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, containerAppYaml []byte, options *ContainerAppOptions, ) error // Adds and activates a new revision to the specified container app AddRevision( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, imageName string, options *ContainerAppOptions, ) error } // NewContainerAppService creates a new ContainerAppService func NewContainerAppService( credentialProvider account.SubscriptionCredentialProvider, clock clock.Clock, armClientOptions *arm.ClientOptions, alphaFeatureManager *alpha.FeatureManager, ) ContainerAppService { return &containerAppService{ credentialProvider: credentialProvider, clock: clock, armClientOptions: armClientOptions, alphaFeatureManager: alphaFeatureManager, } } type containerAppService struct { credentialProvider account.SubscriptionCredentialProvider clock clock.Clock armClientOptions *arm.ClientOptions alphaFeatureManager *alpha.FeatureManager } type ContainerAppOptions struct { ApiVersion string } type ContainerAppIngressConfiguration struct { HostNames []string } // Gets the ingress configuration for the specified container app func (cas *containerAppService) GetIngressConfiguration( ctx context.Context, subscriptionId string, resourceGroup string, appName string, options *ContainerAppOptions, ) (*ContainerAppIngressConfiguration, error) { containerApp, err := cas.getContainerApp(ctx, subscriptionId, resourceGroup, appName, options) if err != nil { return nil, fmt.Errorf("failed retrieving container app properties: %w", err) } var hostNames []string fqdn, has := containerApp.GetString(pathConfigurationIngressFqdn) if has { hostNames = []string{fqdn} } else { hostNames = []string{} } return &ContainerAppIngressConfiguration{ HostNames: hostNames, }, 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" var persistCustomDomainsFeature = alpha.MustFeatureKey("aca.persistDomains") var persistIngressSessionAffinity = alpha.MustFeatureKey("aca.persistIngressSessionAffinity") func (cas *containerAppService) persistSettings( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, obj map[string]any, options *ContainerAppOptions, ) (map[string]any, error) { shouldPersistDomains := cas.alphaFeatureManager.IsEnabled(persistCustomDomainsFeature) shouldPersistIngressSessionAffinity := cas.alphaFeatureManager.IsEnabled(persistIngressSessionAffinity) if !shouldPersistDomains && !shouldPersistIngressSessionAffinity { return obj, nil } aca, err := cas.getContainerApp(ctx, subscriptionId, resourceGroupName, appName, options) if err != nil { 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 { if err := objConfig.Set(pathConfigurationIngressCustomDomains, customDomains); err != nil { return nil, fmt.Errorf("setting custom domains: %w", err) } } } if shouldPersistIngressSessionAffinity { stickySessions, has := aca.Get(pathConfigurationIngressStickySessions) if has { if err := objConfig.Set(pathConfigurationIngressStickySessions, stickySessions); err != nil { return nil, fmt.Errorf("setting sticky sessions: %w", err) } } } return objConfig.Raw(), nil } func (cas *containerAppService) DeployYaml( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, containerAppYaml []byte, options *ContainerAppOptions, ) error { var obj map[string]any if err := yaml.Unmarshal(containerAppYaml, &obj); err != nil { return fmt.Errorf("decoding yaml: %w", err) } obj, err := cas.persistSettings(ctx, subscriptionId, resourceGroupName, appName, obj, options) if err != nil { return fmt.Errorf("persisting aca settings: %w", err) } var poller *runtime.Poller[armappcontainers.ContainerAppsClientCreateOrUpdateResponse] // 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, } appClient, err := cas.createContainerAppsClient(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 } 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, nil) 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) if err != nil { return fmt.Errorf("polling for container app update completion: %w", err) } return nil } // Adds and activates a new revision to the specified container app func (cas *containerAppService) AddRevision( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, imageName string, options *ContainerAppOptions, ) error { containerApp, err := cas.getContainerApp(ctx, subscriptionId, resourceGroupName, appName, options) if err != nil { return fmt.Errorf("getting container app: %w", err) } // Get the latest revision name currentRevisionName, has := containerApp.GetString(pathLatestRevisionName) if !has { return fmt.Errorf("getting latest revision name: %w", err) } apiVersionPolicy := createApiVersionPolicy(options) revisionsClient, err := cas.createRevisionsClient(ctx, subscriptionId, apiVersionPolicy) if err != nil { return err } var revisionResponse *http.Response ctx = policy.WithCaptureResponse(ctx, &revisionResponse) if _, err := revisionsClient.GetRevision(ctx, resourceGroupName, appName, currentRevisionName, nil); err != nil { return fmt.Errorf("getting revision '%s': %w", currentRevisionName, err) } var revisionMap map[string]any if err := convert.FromHttpResponse(revisionResponse, &revisionMap); err != nil { return err } revision := config.NewConfig(revisionMap) // Update the revision with the new image name and suffix if err := revision.Set(pathTemplateRevisionSuffix, fmt.Sprintf("azd-%d", cas.clock.Now().Unix())); err != nil { return fmt.Errorf("setting revision suffix: %w", err) } var containers []map[string]any if ok, err := revision.GetSection(pathTemplateContainers, &containers); !ok || err != nil { return fmt.Errorf("getting containers: %w", err) } containers[0]["image"] = imageName if err := revision.Set(pathTemplateContainers, containers); err != nil { return fmt.Errorf("setting containers: %w", err) } // Update the container app with the new revision revisionTemplate, ok := revision.GetMap(pathTemplate) if !ok { return fmt.Errorf("getting revision template: %w", err) } if err := containerApp.Set(pathTemplate, revisionTemplate); err != nil { return fmt.Errorf("setting template: %w", err) } containerApp, err = cas.syncSecrets(ctx, subscriptionId, resourceGroupName, appName, containerApp) if err != nil { return fmt.Errorf("syncing secrets: %w", err) } // Update the container app err = cas.updateContainerApp(ctx, subscriptionId, resourceGroupName, appName, containerApp, options) if err != nil { return fmt.Errorf("updating container app revision: %w", err) } revisionMode, ok := containerApp.GetString(pathConfigurationActiveRevisionsMode) if !ok { return fmt.Errorf("getting active revisions mode: %w", err) } // If the container app is in multiple revision mode, update the traffic to point to the new revision if revisionMode == string(armappcontainers.ActiveRevisionsModeMultiple) { revisionSuffix, ok := revision.GetString(pathTemplateRevisionSuffix) if !ok { return fmt.Errorf("getting revision suffix: %w", err) } newRevisionName := fmt.Sprintf("%s--%s", appName, revisionSuffix) err = cas.setTrafficWeights(ctx, subscriptionId, resourceGroupName, appName, containerApp, newRevisionName, options) if err != nil { return fmt.Errorf("setting traffic weights: %w", err) } } return nil } func (cas *containerAppService) syncSecrets( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, containerApp config.Config, ) (config.Config, error) { // If the container app doesn't have any existingSecrets, we don't need to do anything existingSecrets, ok := containerApp.GetSlice(pathConfigurationSecrets) if !ok || len(existingSecrets) == 0 { return containerApp, nil } appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, nil) if err != nil { return nil, err } // Copy the secret configuration from the current version // Secret values are not returned by the API, so we need to get them separately // to ensure the update call succeeds secretsResponse, err := appClient.ListSecrets(ctx, resourceGroupName, appName, nil) if err != nil { return nil, fmt.Errorf("listing secrets: %w", err) } secrets := secretsResponse.SecretsCollection.Value secretsJson, err := convert.ToJsonArray(secrets) if err != nil { return nil, err } err = containerApp.Set(pathConfigurationSecrets, secretsJson) if err != nil { return nil, fmt.Errorf("setting secrets: %w", err) } return containerApp, nil } func (cas *containerAppService) setTrafficWeights( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, containerApp config.Config, revisionName string, options *ContainerAppOptions, ) error { trafficWeights := []*armappcontainers.TrafficWeight{ { RevisionName: &revisionName, Weight: to.Ptr[int32](100), }, } trafficWeightsJson, err := convert.ToJsonArray(trafficWeights) if err != nil { return fmt.Errorf("converting traffic weights to JSON: %w", err) } if err := containerApp.Set(pathConfigurationIngressTraffic, trafficWeightsJson); err != nil { return fmt.Errorf("setting traffic weights: %w", err) } err = cas.updateContainerApp(ctx, subscriptionId, resourceGroupName, appName, containerApp, options) if err != nil { return fmt.Errorf("updating traffic weights: %w", err) } return nil } func (cas *containerAppService) getContainerApp( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, options *ContainerAppOptions, ) (config.Config, error) { apiVersionPolicy := createApiVersionPolicy(options) appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, apiVersionPolicy) if err != nil { return nil, err } var res *http.Response ctx = policy.WithCaptureResponse(ctx, &res) _, err = appClient.Get(ctx, resourceGroupName, appName, nil) if err != nil { return nil, fmt.Errorf("getting container app: %w", err) } var containAppMap map[string]any err = convert.FromHttpResponse(res, &containAppMap) if err != nil { return nil, err } containAppConfig := config.NewConfig(containAppMap) return containAppConfig, nil } func (cas *containerAppService) updateContainerApp( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, containerApp config.Config, options *ContainerAppOptions, ) error { containerAppJson, err := json.Marshal(containerApp.Raw()) if err != nil { return fmt.Errorf("marshalling container app: %w", err) } apiVersionPolicy := createApiVersionPolicy(options) if apiVersionPolicy != nil { apiVersionPolicy.body = (*json.RawMessage)(&containerAppJson) } appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, apiVersionPolicy) if err != nil { return err } // This container app BODY will be replaced by the custom policy when configured var containerAppResource armappcontainers.ContainerApp if apiVersionPolicy == nil { if err := json.Unmarshal(containerAppJson, &containerAppResource); err != nil { return fmt.Errorf("failed to unmarshal container app: %w", err) } } poller, err := appClient.BeginUpdate(ctx, resourceGroupName, appName, containerAppResource, nil) if err != nil { return fmt.Errorf("begin updating ingress traffic: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("polling for container app update completion: %w", err) } return nil } func (cas *containerAppService) createContainerAppsClient( ctx context.Context, subscriptionId string, customPolicy *containerAppCustomApiVersionAndBodyPolicy, ) (*armappcontainers.ContainerAppsClient, error) { credential, err := cas.credentialProvider.CredentialForSubscription(ctx, subscriptionId) if err != nil { return nil, err } options := *cas.armClientOptions if customPolicy != nil { // Clone the options so we don't modify the original - we don't want to inject this custom policy into every request. options.PerCallPolicies = append(slices.Clone(options.PerCallPolicies), customPolicy) } 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, customPolicy *containerAppCustomApiVersionAndBodyPolicy, ) (*armappcontainers.ContainerAppsRevisionsClient, error) { credential, err := cas.credentialProvider.CredentialForSubscription(ctx, subscriptionId) if err != nil { return nil, err } options := *cas.armClientOptions if customPolicy != nil { // Clone the options so we don't modify the original - we don't want to inject this custom policy into every request. options.PerCallPolicies = append(slices.Clone(options.PerCallPolicies), customPolicy) } client, err := armappcontainers.NewContainerAppsRevisionsClient(subscriptionId, credential, &options) if err != nil { return nil, fmt.Errorf("creating ContainerApps client: %w", err) } return client, nil } type containerAppCustomApiVersionAndBodyPolicy struct { apiVersion string body *json.RawMessage } func (p *containerAppCustomApiVersionAndBodyPolicy) Do(req *policy.Request) (*http.Response, error) { if p.apiVersion != "" { log.Printf("setting api-version to %s", p.apiVersion) reqQP := req.Raw().URL.Query() reqQP.Set("api-version", p.apiVersion) req.Raw().URL.RawQuery = reqQP.Encode() } if p.body != nil { 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) } // Reset the body on the policy so it doesn't get reused on the next request p.body = nil } return req.Next() } func createApiVersionPolicy(options *ContainerAppOptions) *containerAppCustomApiVersionAndBodyPolicy { if options == nil || options.ApiVersion == "" { return nil } return &containerAppCustomApiVersionAndBodyPolicy{ apiVersion: options.ApiVersion, } }