diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 120f3ac9a12..9364122e8f6 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -12,6 +12,7 @@ words: - azcloud - usgovcloudapi - chinacloudapi + - unmarshals languageSettings: - languageId: go ignoreRegExpList: diff --git a/cli/azd/pkg/config/config.go b/cli/azd/pkg/config/config.go index aae0b43895c..3d96a17cac5 100644 --- a/cli/azd/pkg/config/config.go +++ b/cli/azd/pkg/config/config.go @@ -29,12 +29,23 @@ type Config interface { Raw() map[string]any // similar to Raw() but it will resolve any vault references ResolvedRaw() map[string]any + // Get retrieves the value stored at the specified path Get(path string) (any, bool) + // GetString retrieves the value stored at the specified path as a string GetString(path string) (string, bool) + // GetSection retrieves the value stored at the specified path and unmarshals it into the provided section GetSection(path string, section any) (bool, error) + // GetMap retrieves the map stored at the specified path + GetMap(path string) (map[string]any, bool) + // GetSlice retrieves the slice stored at the specified path + GetSlice(path string) ([]any, bool) + // Set stores the value at the specified path Set(path string, value any) error + // SetSecret stores the secrets at the specified path within a local user vault SetSecret(path string, value string) error + // Unset removes the value stored at the specified path Unset(path string) error + // IsEmpty returns a value indicating whether the configuration is empty IsEmpty() bool } @@ -230,6 +241,28 @@ func (c *config) Get(path string) (any, bool) { return nil, false } +// GetMap retrieves the map stored at the specified path +func (c *config) GetMap(path string) (map[string]any, bool) { + value, ok := c.Get(path) + if !ok { + return nil, false + } + + node, ok := value.(map[string]any) + return node, ok +} + +// GetSlice retrieves the slice stored at the specified path +func (c *config) GetSlice(path string) ([]any, bool) { + value, ok := c.Get(path) + if !ok { + return nil, false + } + + node, ok := value.([]any) + return node, ok +} + // Gets the value stored at the specified location as a string func (c *config) GetString(path string) (string, bool) { value, ok := c.Get(path) diff --git a/cli/azd/pkg/containerapps/container_app.go b/cli/azd/pkg/containerapps/container_app.go index 377e1915c4c..8edd230b8cc 100644 --- a/cli/azd/pkg/containerapps/container_app.go +++ b/cli/azd/pkg/containerapps/container_app.go @@ -18,10 +18,24 @@ import ( "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" "gopkg.in/yaml.v3" ) +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 @@ -30,6 +44,7 @@ type ContainerAppService interface { subscriptionId, resourceGroup, appName string, + options *ContainerAppOptions, ) (*ContainerAppIngressConfiguration, error) DeployYaml( ctx context.Context, @@ -37,6 +52,7 @@ type ContainerAppService interface { resourceGroupName string, appName string, containerAppYaml []byte, + options *ContainerAppOptions, ) error // Adds and activates a new revision to the specified container app AddRevision( @@ -45,12 +61,8 @@ type ContainerAppService interface { resourceGroupName string, appName string, imageName string, + options *ContainerAppOptions, ) error - ListSecrets(ctx context.Context, - subscriptionId string, - resourceGroupName string, - appName string, - ) ([]*armappcontainers.ContainerAppSecret, error) } // NewContainerAppService creates a new ContainerAppService @@ -75,6 +87,10 @@ type containerAppService struct { alphaFeatureManager *alpha.FeatureManager } +type ContainerAppOptions struct { + ApiVersion string +} + type ContainerAppIngressConfiguration struct { HostNames []string } @@ -85,18 +101,17 @@ func (cas *containerAppService) GetIngressConfiguration( subscriptionId string, resourceGroup string, appName string, + options *ContainerAppOptions, ) (*ContainerAppIngressConfiguration, error) { - containerApp, err := cas.getContainerApp(ctx, subscriptionId, resourceGroup, appName) + 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 - if containerApp.Properties != nil && - containerApp.Properties.Configuration != nil && - containerApp.Properties.Configuration.Ingress != nil && - containerApp.Properties.Configuration.Ingress.Fqdn != nil { - hostNames = []string{*containerApp.Properties.Configuration.Ingress.Fqdn} + fqdn, has := containerApp.GetString(pathConfigurationIngressFqdn) + if has { + hostNames = []string{fqdn} } else { hostNames = []string{} } @@ -118,7 +133,9 @@ func (cas *containerAppService) persistSettings( subscriptionId string, resourceGroupName string, appName string, - obj map[string]any) (map[string]any, error) { + obj map[string]any, + options *ContainerAppOptions, +) (map[string]any, error) { shouldPersistDomains := cas.alphaFeatureManager.IsEnabled(persistCustomDomainsFeature) shouldPersistIngressSessionAffinity := cas.alphaFeatureManager.IsEnabled(persistIngressSessionAffinity) @@ -126,42 +143,32 @@ func (cas *containerAppService) persistSettings( return obj, nil } - aca, err := cas.getContainerApp(ctx, subscriptionId, resourceGroupName, appName) + 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 aca == nil || - aca.Properties == nil || - aca.Properties.Configuration == nil || - aca.Properties.Configuration.Ingress == nil { - // no settings to persist - return obj, nil - } + objConfig := config.NewConfig(obj) - if shouldPersistDomains && - aca.Properties.Configuration.Ingress.CustomDomains != nil { - acaAsConfig := config.NewConfig(obj) - err := acaAsConfig.Set( - "properties.configuration.ingress.customDomains", aca.Properties.Configuration.Ingress.CustomDomains) - if err != nil { - return nil, fmt.Errorf("failed to persist custom domains: %w", err) + 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) + } } - obj = acaAsConfig.Raw() } - if shouldPersistIngressSessionAffinity && - aca.Properties.Configuration.Ingress.StickySessions != nil { - acaAsConfig := config.NewConfig(obj) - err := acaAsConfig.Set( - "properties.configuration.ingress.stickySessions", aca.Properties.Configuration.Ingress.StickySessions) - if err != nil { - return nil, fmt.Errorf("failed to persist session affinity: %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) + } } - obj = acaAsConfig.Raw() } - return obj, nil + return objConfig.Raw(), nil } func (cas *containerAppService) DeployYaml( @@ -170,13 +177,14 @@ func (cas *containerAppService) DeployYaml( 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) + obj, err := cas.persistSettings(ctx, subscriptionId, resourceGroupName, appName, obj, options) if err != nil { return fmt.Errorf("persisting aca settings: %w", err) } @@ -194,7 +202,7 @@ func (cas *containerAppService) DeployYaml( apiVersion: apiVersion, } - appClient, err := cas.createContainerAppsClientWithPerCallPolicy(ctx, subscriptionId, customPolicy) + appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, customPolicy) if err != nil { return err } @@ -220,14 +228,10 @@ func (cas *containerAppService) DeployYaml( 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) + appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, nil) if err != nil { return err } @@ -265,46 +269,89 @@ func (cas *containerAppService) AddRevision( resourceGroupName string, appName string, imageName string, + options *ContainerAppOptions, ) error { - containerApp, err := cas.getContainerApp(ctx, subscriptionId, resourceGroupName, appName) + 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 := *containerApp.Properties.LatestRevisionName - revisionsClient, err := cas.createRevisionsClient(ctx, subscriptionId) + 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 } - revisionResponse, err := revisionsClient.GetRevision(ctx, resourceGroupName, appName, currentRevisionName, nil) - if err != nil { + 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 - revision := revisionResponse.Revision - revision.Properties.Template.RevisionSuffix = to.Ptr(fmt.Sprintf("azd-%d", cas.clock.Now().Unix())) - revision.Properties.Template.Containers[0].Image = to.Ptr(imageName) + 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 - containerApp.Properties.Template = revision.Properties.Template + 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) + 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 *containerApp.Properties.Configuration.ActiveRevisionsMode == armappcontainers.ActiveRevisionsModeMultiple { - newRevisionName := fmt.Sprintf("%s--%s", appName, *revision.Properties.Template.RevisionSuffix) - err = cas.setTrafficWeights(ctx, subscriptionId, resourceGroupName, appName, containerApp, newRevisionName) + 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) } @@ -313,38 +360,20 @@ func (cas *containerAppService) AddRevision( return nil } -func (cas *containerAppService) ListSecrets( - ctx context.Context, - subscriptionId string, - resourceGroupName string, - appName string, -) ([]*armappcontainers.ContainerAppSecret, error) { - appClient, err := cas.createContainerAppsClient(ctx, subscriptionId) - if err != nil { - return nil, err - } - - secretsResponse, err := appClient.ListSecrets(ctx, resourceGroupName, appName, nil) - if err != nil { - return nil, fmt.Errorf("listing secrets: %w", err) - } - - return secretsResponse.Value, nil -} - func (cas *containerAppService) syncSecrets( ctx context.Context, subscriptionId string, resourceGroupName string, appName string, - containerApp *armappcontainers.ContainerApp, -) (*armappcontainers.ContainerApp, error) { - // If the container app doesn't have any secrets, we don't need to do anything - if len(containerApp.Properties.Configuration.Secrets) == 0 { + 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) + appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, nil) if err != nil { return nil, err } @@ -357,17 +386,16 @@ func (cas *containerAppService) syncSecrets( return nil, fmt.Errorf("listing secrets: %w", err) } - secrets := []*armappcontainers.Secret{} - for _, secret := range secretsResponse.SecretsCollection.Value { - secrets = append(secrets, &armappcontainers.Secret{ - Name: secret.Name, - Value: secret.Value, - Identity: secret.Identity, - KeyVaultURL: secret.KeyVaultURL, - }) + secrets := secretsResponse.SecretsCollection.Value + secretsJson, err := convert.ToJsonArray(secrets) + if err != nil { + return nil, err } - containerApp.Properties.Configuration.Secrets = secrets + err = containerApp.Set(pathConfigurationSecrets, secretsJson) + if err != nil { + return nil, fmt.Errorf("setting secrets: %w", err) + } return containerApp, nil } @@ -377,17 +405,27 @@ func (cas *containerAppService) setTrafficWeights( subscriptionId string, resourceGroupName string, appName string, - containerApp *armappcontainers.ContainerApp, + containerApp config.Config, revisionName string, + options *ContainerAppOptions, ) error { - containerApp.Properties.Configuration.Ingress.Traffic = []*armappcontainers.TrafficWeight{ + trafficWeights := []*armappcontainers.TrafficWeight{ { RevisionName: &revisionName, Weight: to.Ptr[int32](100), }, } - err := cas.updateContainerApp(ctx, subscriptionId, resourceGroupName, appName, containerApp) + 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) } @@ -400,18 +438,32 @@ func (cas *containerAppService) getContainerApp( subscriptionId string, resourceGroupName string, appName string, -) (*armappcontainers.ContainerApp, error) { - appClient, err := cas.createContainerAppsClient(ctx, subscriptionId) + options *ContainerAppOptions, +) (config.Config, error) { + apiVersionPolicy := createApiVersionPolicy(options) + + appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, apiVersionPolicy) if err != nil { return nil, err } - containerAppResponse, err := appClient.Get(ctx, resourceGroupName, appName, nil) + 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) } - return &containerAppResponse.ContainerApp, nil + 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( @@ -419,14 +471,33 @@ func (cas *containerAppService) updateContainerApp( subscriptionId string, resourceGroupName string, appName string, - containerApp *armappcontainers.ContainerApp, + containerApp config.Config, + options *ContainerAppOptions, ) error { - appClient, err := cas.createContainerAppsClient(ctx, subscriptionId) + 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 } - poller, err := appClient.BeginUpdate(ctx, resourceGroupName, appName, *containerApp, nil) + // 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) } @@ -442,34 +513,20 @@ func (cas *containerAppService) updateContainerApp( 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 } - client, err := armappcontainers.NewContainerAppsClient(subscriptionId, credential, cas.armClientOptions) - if err != nil { - return nil, fmt.Errorf("creating ContainerApps client: %w", err) - } - - return client, nil -} + options := *cas.armClientOptions -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 + 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) } - // 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) @@ -481,13 +538,21 @@ func (cas *containerAppService) createContainerAppsClientWithPerCallPolicy( 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 } - client, err := armappcontainers.NewContainerAppsRevisionsClient(subscriptionId, credential, cas.armClientOptions) + 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) } @@ -501,17 +566,34 @@ type containerAppCustomApiVersionAndBodyPolicy struct { } func (p *containerAppCustomApiVersionAndBodyPolicy) Do(req *policy.Request) (*http.Response, error) { - if p.body != nil { + 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, + } +} diff --git a/cli/azd/pkg/containerapps/container_app_test.go b/cli/azd/pkg/containerapps/container_app_test.go index dfe467bbe76..662951b4ae5 100644 --- a/cli/azd/pkg/containerapps/container_app_test.go +++ b/cli/azd/pkg/containerapps/container_app_test.go @@ -43,7 +43,7 @@ func Test_ContainerApp_GetIngressConfiguration(t *testing.T) { mockContext.ArmClientOptions, mockContext.AlphaFeaturesManager, ) - ingressConfig, err := cas.GetIngressConfiguration(*mockContext.Context, subscriptionId, resourceGroup, appName) + ingressConfig, err := cas.GetIngressConfiguration(*mockContext.Context, subscriptionId, resourceGroup, appName, nil) require.NoError(t, err) require.NotNil(t, ingressConfig) @@ -138,7 +138,7 @@ func Test_ContainerApp_AddRevision(t *testing.T) { mockContext.ArmClientOptions, mockContext.AlphaFeaturesManager, ) - err := cas.AddRevision(*mockContext.Context, subscriptionId, resourceGroup, appName, updatedImageName) + err := cas.AddRevision(*mockContext.Context, subscriptionId, resourceGroup, appName, updatedImageName, nil) require.NoError(t, err) // Verify lastest revision is read @@ -160,3 +160,92 @@ func Test_ContainerApp_AddRevision(t *testing.T) { require.Equal(t, updatedImageName, *updatedContainerApp.Properties.Template.Containers[0].Image) require.Equal(t, "azd-0", *updatedContainerApp.Properties.Template.RevisionSuffix) } + +func Test_ContainerApp_DeployYaml(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + subscriptionId := "SUBSCRIPTION_ID" + location := "eastus2" + resourceGroup := "RESOURCE_GROUP" + appName := "APP_NAME" + + containerAppYaml := ` +location: eastus2 +name: APP_NAME +properties: + latestRevisionName: LATEST_REVISION_NAME + configuration: + activeRevisionsMode: Single + template: + containers: + - image: IMAGE_NAME +` + + expected := &armappcontainers.ContainerApp{ + Location: to.Ptr(location), + Name: to.Ptr(appName), + Properties: &armappcontainers.ContainerAppProperties{ + LatestRevisionName: to.Ptr("LATEST_REVISION_NAME"), + Configuration: &armappcontainers.Configuration{ + ActiveRevisionsMode: to.Ptr(armappcontainers.ActiveRevisionsModeSingle), + Ingress: &armappcontainers.Ingress{ + CustomDomains: []*armappcontainers.CustomDomain{ + { + Name: to.Ptr("DOMAIN_NAME"), + }, + }, + StickySessions: &armappcontainers.IngressStickySessions{ + Affinity: to.Ptr(armappcontainers.AffinitySticky), + }, + }, + }, + Template: &armappcontainers.Template{ + Containers: []*armappcontainers.Container{ + { + Image: to.Ptr("IMAGE_NAME"), + }, + }, + }, + }, + } + + containerAppGetRequest := mockazsdk.MockContainerAppGet( + mockContext, + subscriptionId, + resourceGroup, + appName, + expected, + ) + require.NotNil(t, containerAppGetRequest) + + containerAppUpdateRequest := mockazsdk.MockContainerAppCreateOrUpdate( + mockContext, + subscriptionId, + resourceGroup, + appName, + expected, + ) + require.NotNil(t, containerAppUpdateRequest) + + cas := NewContainerAppService( + mockContext.SubscriptionCredentialProvider, + clock.NewMock(), + mockContext.ArmClientOptions, + mockContext.AlphaFeaturesManager, + ) + + err := mockContext.Config.Set("alpha.aca.persistDomains", "on") + require.NoError(t, err) + err = mockContext.Config.Set("alpha.aca.persistIngressSessionAffinity", "on") + require.NoError(t, err) + + 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) + + require.Equal(t, expected.Properties.Configuration, actual.Properties.Configuration) + require.Equal(t, expected.Properties.Template, actual.Properties.Template) +} diff --git a/cli/azd/pkg/convert/util.go b/cli/azd/pkg/convert/util.go index f38c4ad4fac..6266a652c67 100644 --- a/cli/azd/pkg/convert/util.go +++ b/cli/azd/pkg/convert/util.go @@ -3,6 +3,8 @@ package convert import ( "encoding/json" "fmt" + "io" + "net/http" "reflect" "strings" "time" @@ -54,7 +56,7 @@ func ToMap(value any) (map[string]any, error) { return nil, fmt.Errorf("failed to convert value to json: %w", err) } - mapValue := map[string]any{} + var mapValue map[string]any if err := json.Unmarshal(jsonValue, &mapValue); err != nil { return nil, fmt.Errorf("failed to convert value to map: %w", err) } @@ -62,9 +64,43 @@ func ToMap(value any) (map[string]any, error) { return mapValue, nil } +// ToJsonArray converts the specified value (slice) to a json array +func ToJsonArray(value any) ([]any, error) { + if value == nil { + return nil, nil + } + + jsonValue, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("failed to convert value to json: %w", err) + } + + var sliceValue []any + if err := json.Unmarshal(jsonValue, &sliceValue); err != nil { + return nil, fmt.Errorf("failed to convert value to slice: %w", err) + } + + return sliceValue, nil +} + func ParseDuration(value string) (time.Duration, error) { value = strings.ReplaceAll(value, "PT", "") value = strings.ToLower(value) return time.ParseDuration(value) } + +// FromHttpResponse reads the response body from the specified http response and converts it into the specified value +func FromHttpResponse(res *http.Response, v any) error { + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if err := json.Unmarshal(body, &v); err != nil { + return fmt.Errorf("failed to unmarshal response body: %w", err) + } + + return nil +} diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 8b615968d00..b1af653b911 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -18,6 +18,8 @@ type ServiceConfig struct { ResourceGroupName osutil.ExpandableString `yaml:"resourceGroup,omitempty"` // The name used to override the default azure resource name ResourceName osutil.ExpandableString `yaml:"resourceName,omitempty"` + // The ARM api version to use for the service. If not specified, the latest version is used. + ApiVersion string `yaml:"apiVersion,omitempty"` // The relative path to the project folder from the project root RelativePath string `yaml:"project"` // The azure hosting model to use, ex) appservice, function, containerapp diff --git a/cli/azd/pkg/project/service_target_containerapp.go b/cli/azd/pkg/project/service_target_containerapp.go index 9bc04399f5c..af015116555 100644 --- a/cli/azd/pkg/project/service_target_containerapp.go +++ b/cli/azd/pkg/project/service_target_containerapp.go @@ -86,6 +86,10 @@ func (at *containerAppTarget) Deploy( return nil, err } + containerAppOptions := containerapps.ContainerAppOptions{ + ApiVersion: serviceConfig.ApiVersion, + } + imageName := at.env.GetServiceProperty(serviceConfig.Name, "IMAGE_NAME") progress.SetProgress(NewServiceProgress("Updating container app revision")) err = at.containerAppService.AddRevision( @@ -94,6 +98,7 @@ func (at *containerAppTarget) Deploy( targetResource.ResourceGroupName(), targetResource.ResourceName(), imageName, + &containerAppOptions, ) if err != nil { return nil, fmt.Errorf("updating container app service: %w", err) @@ -123,11 +128,16 @@ func (at *containerAppTarget) Endpoints( serviceConfig *ServiceConfig, targetResource *environment.TargetResource, ) ([]string, error) { + containerAppOptions := containerapps.ContainerAppOptions{ + ApiVersion: serviceConfig.ApiVersion, + } + if ingressConfig, err := at.containerAppService.GetIngressConfiguration( ctx, targetResource.SubscriptionId(), targetResource.ResourceGroupName(), targetResource.ResourceName(), + &containerAppOptions, ); err != nil { return nil, fmt.Errorf("fetching service properties: %w", err) } else { diff --git a/cli/azd/pkg/project/service_target_dotnet_containerapp.go b/cli/azd/pkg/project/service_target_dotnet_containerapp.go index e09b72168cc..f21f68335d9 100644 --- a/cli/azd/pkg/project/service_target_dotnet_containerapp.go +++ b/cli/azd/pkg/project/service_target_dotnet_containerapp.go @@ -254,12 +254,17 @@ func (at *dotnetContainerAppTarget) Deploy( return nil, fmt.Errorf("failed executing template file: %w", err) } + containerAppOptions := containerapps.ContainerAppOptions{ + ApiVersion: serviceConfig.ApiVersion, + } + err = at.containerAppService.DeployYaml( ctx, targetResource.SubscriptionId(), targetResource.ResourceGroupName(), serviceConfig.Name, []byte(builder.String()), + &containerAppOptions, ) if err != nil { return nil, fmt.Errorf("updating container app service: %w", err) @@ -296,11 +301,16 @@ func (at *dotnetContainerAppTarget) Endpoints( serviceConfig *ServiceConfig, targetResource *environment.TargetResource, ) ([]string, error) { + containerAppOptions := containerapps.ContainerAppOptions{ + ApiVersion: serviceConfig.ApiVersion, + } + if ingressConfig, err := at.containerAppService.GetIngressConfiguration( ctx, targetResource.SubscriptionId(), targetResource.ResourceGroupName(), targetResource.ResourceName(), + &containerAppOptions, ); err != nil { return nil, fmt.Errorf("fetching service properties: %w", err) } else { diff --git a/cli/azd/test/mocks/mockazsdk/container_apps.go b/cli/azd/test/mocks/mockazsdk/container_apps.go index 1d9e6d641c5..e61597ce302 100644 --- a/cli/azd/test/mocks/mockazsdk/container_apps.go +++ b/cli/azd/test/mocks/mockazsdk/container_apps.go @@ -41,6 +41,36 @@ func MockContainerAppGet( return mockRequest } +func MockContainerAppCreateOrUpdate( + mockContext *mocks.MockContext, + subscriptionId string, + resourceGroup string, + appName string, + containerApp *armappcontainers.ContainerApp, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodPut && 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) { + *mockRequest = *request + + response := armappcontainers.ContainerAppsClientCreateOrUpdateResponse{} + + return mocks.CreateHttpResponseWithBody(request, http.StatusCreated, response) + }) + + return mockRequest +} + func MockContainerAppUpdate( mockContext *mocks.MockContext, subscriptionId string, diff --git a/cli/azd/test/mocks/util.go b/cli/azd/test/mocks/util.go index 8b41c35f177..cbf7b54d7ae 100644 --- a/cli/azd/test/mocks/util.go +++ b/cli/azd/test/mocks/util.go @@ -31,3 +31,15 @@ func CreateEmptyHttpResponse(request *http.Request, statusCode int) (*http.Respo Body: http.NoBody, }, nil } + +// ReadHttpBody reads the body of an HTTP request or response and converts it into the specified object +func ReadHttpBody(body io.ReadCloser, v any) error { + defer body.Close() + + jsonBytes, err := io.ReadAll(body) + if err != nil { + return err + } + + return json.Unmarshal(jsonBytes, v) +}