diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 7ebf0805137..2c4a0a3b91f 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -329,7 +329,7 @@ func (p *BicepProvider) State(ctx context.Context, options *provisioning.StateOp } } - state.Outputs = p.createOutputParameters( + state.Outputs = provisioning.OutputParametersFromArmOutputs( outputs, azapi.CreateDeploymentOutput(deployment.Outputs), ) @@ -591,7 +591,7 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, if !p.ignoreDeploymentState && parametersHashErr == nil { deploymentState, err := p.deploymentState(ctx, planned, deployment, currentParamsHash) if err == nil { - result.Outputs = p.createOutputParameters( + result.Outputs = provisioning.OutputParametersFromArmOutputs( planned.Template.Outputs, azapi.CreateDeploymentOutput(deploymentState.Outputs), ) @@ -678,7 +678,7 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, return nil, err } - result.Outputs = p.createOutputParameters( + result.Outputs = provisioning.OutputParametersFromArmOutputs( planned.Template.Outputs, azapi.CreateDeploymentOutput(deployResult.Outputs), ) @@ -925,7 +925,7 @@ func (p *BicepProvider) Destroy( } destroyResult := &provisioning.DestroyResult{ - InvalidatedEnvKeys: slices.Collect(maps.Keys(p.createOutputParameters( + InvalidatedEnvKeys: slices.Collect(maps.Keys(provisioning.OutputParametersFromArmOutputs( compileResult.Template.Outputs, azapi.CreateDeploymentOutput(mostRecentDeployment.Outputs), ))), @@ -1494,64 +1494,6 @@ func (p *BicepProvider) purgeAPIManagement( return nil } -func (p *BicepProvider) mapBicepTypeToInterfaceType(s string) provisioning.ParameterType { - switch s { - case "String", "string", "secureString", "securestring": - return provisioning.ParameterTypeString - case "Bool", "bool": - return provisioning.ParameterTypeBoolean - case "Int", "int": - return provisioning.ParameterTypeNumber - case "Object", "object", "secureObject", "secureobject": - return provisioning.ParameterTypeObject - case "Array", "array": - return provisioning.ParameterTypeArray - default: - panic(fmt.Sprintf("unexpected bicep type: '%s'", s)) - } -} - -// Creates a normalized view of the azure output parameters and resolves inconsistencies in the output parameter name -// casings. -func (p *BicepProvider) createOutputParameters( - templateOutputs azure.ArmTemplateOutputs, - azureOutputParams map[string]azapi.AzCliDeploymentOutput, -) map[string]provisioning.OutputParameter { - canonicalOutputCasings := make(map[string]string, len(templateOutputs)) - - for key := range templateOutputs { - canonicalOutputCasings[strings.ToLower(key)] = key - } - - outputParams := make(map[string]provisioning.OutputParameter, len(azureOutputParams)) - - for key, azureParam := range azureOutputParams { - if azureParam.Secured() { - // Secured output can't be retrieved, so we skip it. - // https://learn.microsoft.com/azure/azure-resource-manager/bicep/outputs?tabs=azure-powershell#secure-outputs - log.Println("Skipping secured output parameter:", key) - continue - } - var paramName string - canonicalCasing, found := canonicalOutputCasings[strings.ToLower(key)] - if found { - paramName = canonicalCasing - } else { - // To support BYOI (bring your own infrastructure) scenarios we will default to UPPER when canonical casing - // is not found in the parameters file to workaround strange azure behavior with OUTPUT values that look - // like `azurE_RESOURCE_GROUP` - paramName = strings.ToUpper(key) - } - - outputParams[paramName] = provisioning.OutputParameter{ - Type: p.mapBicepTypeToInterfaceType(azureParam.Type), - Value: azureParam.Value, - } - } - - return outputParams -} - type loadParametersResult struct { parameters map[string]azure.ArmParameter locationParams []string @@ -1852,14 +1794,14 @@ func (p *BicepProvider) convertToDeployment(bicepTemplate azure.ArmTemplate) pro for key, param := range bicepTemplate.Parameters { parameters[key] = provisioning.InputParameter{ - Type: string(p.mapBicepTypeToInterfaceType(param.Type)), + Type: string(provisioning.ParameterTypeFromArmType(param.Type)), DefaultValue: param.DefaultValue, } } for key, param := range bicepTemplate.Outputs { outputs[key] = provisioning.OutputParameter{ - Type: p.mapBicepTypeToInterfaceType(param.Type), + Type: provisioning.ParameterTypeFromArmType(param.Type), Value: param.Value, } } @@ -2022,7 +1964,7 @@ func (p *BicepProvider) ensureParameters( for _, key := range sortedKeys { param := template.Parameters[key] - parameterType := p.mapBicepTypeToInterfaceType(param.Type) + parameterType := provisioning.ParameterTypeFromArmType(param.Type) azdMetadata, hasMetadata := param.AzdMetadata() // If a value is explicitly configured via a parameters file, use it. diff --git a/cli/azd/pkg/infra/provisioning/bicep/prompt.go b/cli/azd/pkg/infra/provisioning/bicep/prompt.go index b56deb4c067..34865314352 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/prompt.go +++ b/cli/azd/pkg/infra/provisioning/bicep/prompt.go @@ -33,7 +33,7 @@ func (p *BicepProvider) promptDialogItemForParameter( param azure.ArmTemplateParameterDefinition, ) input.PromptDialogItem { help, _ := param.Description() - paramType := p.mapBicepTypeToInterfaceType(param.Type) + paramType := provisioning.ParameterTypeFromArmType(param.Type) var dialogItem input.PromptDialogItem dialogItem.ID = key @@ -239,7 +239,7 @@ func (p *BicepProvider) promptForParameter( msg := fmt.Sprintf("Enter a value for the '%s' infrastructure %s:", key, securedParam) help, _ := param.Description() azdMetadata, _ := param.AzdMetadata() - paramType := p.mapBicepTypeToInterfaceType(param.Type) + paramType := provisioning.ParameterTypeFromArmType(param.Type) var value any @@ -448,7 +448,7 @@ func (p *BicepProvider) promptForParameter( } value = userValue default: - panic(fmt.Sprintf("unknown parameter type: %s", p.mapBicepTypeToInterfaceType(param.Type))) + panic(fmt.Sprintf("unknown parameter type: %s", provisioning.ParameterTypeFromArmType(param.Type))) } } diff --git a/cli/azd/pkg/infra/provisioning/deployment.go b/cli/azd/pkg/infra/provisioning/deployment.go index e30b96073a0..3e5becc8371 100644 --- a/cli/azd/pkg/infra/provisioning/deployment.go +++ b/cli/azd/pkg/infra/provisioning/deployment.go @@ -4,8 +4,14 @@ package provisioning import ( + "fmt" + "log" "maps" "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azapi" + "github.com/azure/azure-dev/cli/azd/pkg/azure" ) type Deployment struct { @@ -23,6 +29,26 @@ const ( ParameterTypeArray ParameterType = "array" ) +// ParameterTypeFromArmType maps an ARM parameter type to a provisioning.ParameterType. +// +// Panics if the provided armType is not recognized. +func ParameterTypeFromArmType(armType string) ParameterType { + switch armType { + case "String", "string", "secureString", "securestring": + return ParameterTypeString + case "Bool", "bool": + return ParameterTypeBoolean + case "Int", "int": + return ParameterTypeNumber + case "Object", "object", "secureObject", "secureobject": + return ParameterTypeObject + case "Array", "array": + return ParameterTypeArray + default: + panic(fmt.Sprintf("unexpected arm type: '%s'", armType)) + } +} + type InputParameter struct { Type string DefaultValue interface{} @@ -34,6 +60,51 @@ type OutputParameter struct { Value interface{} } +// OutputParametersFromArmOutputs converts the outputs from an ARM deployment to a map of provisioning.OutputParameter. +// +// The casing of the output parameter names will match the casing in the provided templateOutputs. If an output is +// present in azureOutputParams but not in templateOutputs, the output name will be upper-cased to work around +// inconsistent casing behavior in Azure (e.g. `azurE_RESOURCE_GROUP`). +// +// Secured outputs are skipped and not included in the result. +func OutputParametersFromArmOutputs( + templateOutputs azure.ArmTemplateOutputs, + azureOutputParams map[string]azapi.AzCliDeploymentOutput) map[string]OutputParameter { + canonicalOutputCasings := make(map[string]string, len(templateOutputs)) + + for key := range templateOutputs { + canonicalOutputCasings[strings.ToLower(key)] = key + } + + outputParams := make(map[string]OutputParameter, len(azureOutputParams)) + + for key, azureParam := range azureOutputParams { + if azureParam.Secured() { + // Secured output can't be retrieved, so we skip it. + // https://learn.microsoft.com/azure/azure-resource-manager/bicep/outputs?tabs=azure-powershell#secure-outputs + log.Println("Skipping secured output parameter:", key) + continue + } + var paramName string + canonicalCasing, found := canonicalOutputCasings[strings.ToLower(key)] + if found { + paramName = canonicalCasing + } else { + // To support BYOI (bring your own infrastructure) scenarios we will default to UPPER when canonical casing + // is not found in the parameters file to workaround strange azure behavior with OUTPUT values that look + // like `azurE_RESOURCE_GROUP` + paramName = strings.ToUpper(key) + } + + outputParams[paramName] = OutputParameter{ + Type: ParameterTypeFromArmType(azureParam.Type), + Value: azureParam.Value, + } + } + + return outputParams +} + // State represents the "current state" of the infrastructure, which is the result of the most recent deployment. For ARM // this corresponds to information from the most recent deployment object. For Terraform, it's information from the state // file. diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 646653fb7af..ce1af603c52 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -39,6 +39,8 @@ type ServiceConfig struct { K8s AksOptions `yaml:"k8s,omitempty"` // The optional Azure Spring Apps options Spring SpringOptions `yaml:"spring,omitempty"` + // Infrastructure module path relative to the root infra folder + Module string `yaml:"module,omitempty"` // The infrastructure provisioning configuration Infra provisioning.Options `yaml:"infra,omitempty"` // Hook configuration for service diff --git a/cli/azd/pkg/project/service_target.go b/cli/azd/pkg/project/service_target.go index f9247bf66e0..ef3f1fa7e4f 100644 --- a/cli/azd/pkg/project/service_target.go +++ b/cli/azd/pkg/project/service_target.go @@ -163,7 +163,7 @@ func (st ServiceTargetKind) IgnoreFile() string { // As an example, ContainerAppTarget is able to provision the container app as part of deployment, // and thus returns true. func (st ServiceTargetKind) SupportsDelayedProvisioning() bool { - return st == AksTarget + return st == AksTarget || st == ContainerAppTarget } func checkResourceType(resource *environment.TargetResource, expectedResourceType azapi.AzureResourceType) error { diff --git a/cli/azd/pkg/project/service_target_containerapp.go b/cli/azd/pkg/project/service_target_containerapp.go index ac520c40ce2..f9b9ba08f4e 100644 --- a/cli/azd/pkg/project/service_target_containerapp.go +++ b/cli/azd/pkg/project/service_target_containerapp.go @@ -5,8 +5,11 @@ package project import ( "context" + "encoding/json" "fmt" "log" + "os" + "path/filepath" "strconv" "github.com/azure/azure-dev/cli/azd/pkg/async" @@ -14,7 +17,11 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/containerapps" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" ) @@ -24,6 +31,11 @@ type containerAppTarget struct { containerHelper *ContainerHelper containerAppService containerapps.ContainerAppService resourceManager ResourceManager + armDeployments *azapi.StandardDeployments + console input.Console + commandRunner exec.CommandRunner + + bicepCli func() (*bicep.Cli, error) } // NewContainerAppTarget creates the container app service target. @@ -36,6 +48,9 @@ func NewContainerAppTarget( containerHelper *ContainerHelper, containerAppService containerapps.ContainerAppService, resourceManager ResourceManager, + deploymentService *azapi.StandardDeployments, + console input.Console, + commandRunner exec.CommandRunner, ) ServiceTarget { return &containerAppTarget{ env: env, @@ -43,6 +58,9 @@ func NewContainerAppTarget( containerHelper: containerHelper, containerAppService: containerAppService, resourceManager: resourceManager, + armDeployments: deploymentService, + console: console, + commandRunner: commandRunner, } } @@ -148,25 +166,126 @@ func (at *containerAppTarget) Deploy( } imageName := containerDetails.RemoteImage - containerAppOptions := containerapps.ContainerAppOptions{ - ApiVersion: serviceConfig.ApiVersion, + // Default resource name and type + resourceName := targetResource.ResourceName() + resourceTypeContainer := azapi.AzureResourceTypeContainerApp + + // Check for the presence of a deployment module infra/ that is used to deploy the revisions. + // If present, build and deploy it. + controlledRevision := false + + moduleName := serviceConfig.Module + if moduleName == "" { + moduleName = serviceConfig.Name } - progress.SetProgress(NewServiceProgress("Updating container app revision")) - err := at.containerAppService.AddRevision( - ctx, - targetResource.SubscriptionId(), - targetResource.ResourceGroupName(), - targetResource.ResourceName(), - imageName, - &containerAppOptions, - ) - if err != nil { - return nil, fmt.Errorf("updating container app service: %w", err) + modulePath := filepath.Join(serviceConfig.Project.Infra.Path, moduleName) + bicepPath := modulePath + ".bicep" + bicepParametersPath := modulePath + ".parameters.json" + bicepParamPath := modulePath + ".bicepparam" + mainPath := bicepPath + + if _, err := os.Stat(bicepParamPath); err == nil { + controlledRevision = true + mainPath = bicepParamPath + } else if _, err := os.Stat(bicepPath); err == nil { + if _, err := os.Stat(bicepParametersPath); err == nil { + controlledRevision = true + } + } + + if controlledRevision { + fetchBicepCli := at.bicepCli + if fetchBicepCli == nil { + fetchBicepCli = func() (*bicep.Cli, error) { + return bicep.NewCli(ctx, at.console, at.commandRunner) + } + } + + bicepCli, err := fetchBicepCli() + if err != nil { + return nil, fmt.Errorf("acquiring bicep cli: %w", err) + } + + progress.SetProgress(NewServiceProgress("Building bicep")) + deployment, err := compileBicep(bicepCli, ctx, mainPath, at.env) + if err != nil { + return nil, fmt.Errorf("building bicep: %w", err) + } + + var template azure.ArmTemplate + if err := json.Unmarshal(deployment.Template, &template); err != nil { + log.Printf("failed unmarshalling arm template to JSON: %s: contents:\n%s", err, deployment.Template) + return nil, fmt.Errorf("failed unmarshalling arm template from json: %w", err) + } + + progress.SetProgress(NewServiceProgress("Deploying revision")) + deploymentResult, err := at.armDeployments.DeployToResourceGroup( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + at.armDeployments.GenerateDeploymentName(serviceConfig.Name), + deployment.Template, + deployment.Parameters, + nil, nil, + ) + if err != nil { + return nil, fmt.Errorf("deploying bicep template: %w", err) + } + + deploymentHostDetails, err := deploymentHost(deploymentResult) + if err != nil { + return nil, fmt.Errorf("getting deployment host type: %w", err) + } + resourceName = deploymentHostDetails.name + outputs := azapi.CreateDeploymentOutput(deploymentResult.Outputs) + + if len(outputs) > 0 { + outputParams := provisioning.OutputParametersFromArmOutputs(template.Outputs, outputs) + err := provisioning.UpdateEnvironment(ctx, outputParams, at.env, at.envManager) + if err != nil { + return nil, fmt.Errorf("updating environment: %w", err) + } + } + } else { + if resourceName == "" { + // Fetch the target resource explicitly + res, err := at.resourceManager.GetTargetResource(ctx, at.env.GetSubscriptionId(), serviceConfig) + if err != nil { + return nil, fmt.Errorf("fetching target resource: %w", err) + } + + targetResource = res + } + + // Fall back to only updating container image when no bicep infra is present + containerAppOptions := containerapps.ContainerAppOptions{ + ApiVersion: serviceConfig.ApiVersion, + } + + progress.SetProgress(NewServiceProgress("Updating container app revision")) + err := at.containerAppService.AddRevision( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + resourceName, + imageName, + &containerAppOptions, + ) + if err != nil { + return nil, fmt.Errorf("updating container app service: %w", err) + } } progress.SetProgress(NewServiceProgress("Fetching endpoints for container app service")) - endpoints, err := at.Endpoints(ctx, serviceConfig, targetResource) + + target := environment.NewTargetResource( + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + resourceName, + string(resourceTypeContainer)) + + endpoints, err := at.Endpoints(ctx, serviceConfig, target) if err != nil { return nil, err } @@ -177,7 +296,7 @@ func (at *containerAppTarget) Deploy( TargetResourceId: azure.ContainerAppRID( targetResource.SubscriptionId(), targetResource.ResourceGroupName(), - targetResource.ResourceName(), + resourceName, ), Kind: ContainerAppTarget, Endpoints: endpoints, diff --git a/cli/azd/pkg/project/service_target_containerapp_test.go b/cli/azd/pkg/project/service_target_containerapp_test.go index d09cf1820b1..52782e5e8e3 100644 --- a/cli/azd/pkg/project/service_target_containerapp_test.go +++ b/cli/azd/pkg/project/service_target_containerapp_test.go @@ -250,6 +250,9 @@ func createContainerAppServiceTarget( containerHelper, containerAppService, resourceManager, + deploymentService, + mockContext.Console, + mockContext.CommandRunner, ) } diff --git a/cli/azd/pkg/project/service_target_dotnet_containerapp.go b/cli/azd/pkg/project/service_target_dotnet_containerapp.go index a446176a4a9..19e101d74ef 100644 --- a/cli/azd/pkg/project/service_target_dotnet_containerapp.go +++ b/cli/azd/pkg/project/service_target_dotnet_containerapp.go @@ -30,6 +30,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" + "github.com/drone/envsubst" ) type dotnetContainerAppTarget struct { @@ -196,8 +197,6 @@ func (at *dotnetContainerAppTarget) Deploy( progress.SetProgress(NewServiceProgress("Updating application")) var manifestTemplate string - var armTemplate *azure.RawArmTemplate - var armParams azure.ArmParameters appHostRoot := serviceConfig.DotNetContainerApp.AppHostPath if f, err := os.Stat(appHostRoot); err == nil && !f.IsDir() { @@ -343,10 +342,10 @@ func (at *dotnetContainerAppTarget) Deploy( resourceName := serviceConfig.Name if useBicepForContainerApps { // Compile the bicep template - compiled, params, err := func() (azure.RawArmTemplate, azure.ArmParameters, error) { + deployment, err := func() (armDeployment, error) { tempFolder, err := os.MkdirTemp("", fmt.Sprintf("%s-build*", projectName)) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("creating temporary build folder: %w", err) + return armDeployment{}, fmt.Errorf("creating temporary build folder: %w", err) } defer func() { _ = os.RemoveAll(tempFolder) @@ -354,15 +353,15 @@ func (at *dotnetContainerAppTarget) Deploy( // write bicepparam content to a new file in the temp folder f, err := os.Create(filepath.Join(tempFolder, "main.bicepparam")) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("creating bicepparam file: %w", err) + return armDeployment{}, fmt.Errorf("creating bicepparam file: %w", err) } _, err = io.Copy(f, strings.NewReader(builder.String())) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("writing bicepparam file: %w", err) + return armDeployment{}, fmt.Errorf("writing bicepparam file: %w", err) } err = f.Close() if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("closing bicepparam file: %w", err) + return armDeployment{}, fmt.Errorf("closing bicepparam file: %w", err) } // copy module to same path as bicepparam so it can be compiled from the temp folder @@ -376,60 +375,40 @@ func (at *dotnetContainerAppTarget) Deploy( apphost.AppHostOptions{}, ) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("generating bicep file: %w", err) + return armDeployment{}, fmt.Errorf("generating bicep file: %w", err) } bicepContent = []byte(generatedBicep) } sourceFile, err := os.Create(filepath.Join(tempFolder, bicepSourceFileName)) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("creating bicep file: %w", err) + return armDeployment{}, fmt.Errorf("creating bicep file: %w", err) } _, err = io.Copy(sourceFile, strings.NewReader(string(bicepContent))) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("writing bicep file: %w", err) + return armDeployment{}, fmt.Errorf("writing bicep file: %w", err) } err = sourceFile.Close() if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("closing bicep file: %w", err) + return armDeployment{}, fmt.Errorf("closing bicep file: %w", err) } - res, err := at.bicepCli.BuildBicepParam(ctx, f.Name(), at.env.Environ()) + deployment, err := compileBicep(at.bicepCli, ctx, f.Name(), at.env) if err != nil { - return azure.RawArmTemplate{}, nil, fmt.Errorf("building container app bicep: %w", err) + return armDeployment{}, fmt.Errorf("building container app bicep: %w", err) } - type compiledBicepParamResult struct { - TemplateJson string `json:"templateJson"` - ParametersJson string `json:"parametersJson"` - } - var bicepParamOutput compiledBicepParamResult - if err := json.Unmarshal([]byte(res.Compiled), &bicepParamOutput); err != nil { - log.Printf( - "failed unmarshalling compiled bicepparam (err: %v), template contents:\n%s", err, res.Compiled) - return nil, nil, fmt.Errorf("failed unmarshalling arm template from json: %w", err) - } - var params azure.ArmParameterFile - if err := json.Unmarshal([]byte(bicepParamOutput.ParametersJson), ¶ms); err != nil { - log.Printf( - "failed unmarshalling compiled bicepparam parameters(err: %v), template contents:\n%s", - err, - res.Compiled) - return nil, nil, fmt.Errorf("failed unmarshalling arm parameters template from json: %w", err) - } - return azure.RawArmTemplate(bicepParamOutput.TemplateJson), params.Parameters, nil + return deployment, nil }() if err != nil { return nil, err } - armTemplate = &compiled - armParams = params deploymentResult, err := at.deploymentService.DeployToResourceGroup( ctx, at.env.GetSubscriptionId(), targetResource.ResourceGroupName(), at.deploymentService.GenerateDeploymentName(serviceConfig.Name), - *armTemplate, - armParams, + deployment.Template, + deployment.Parameters, nil, nil) if err != nil { return nil, fmt.Errorf("deploying bicep template: %w", err) @@ -528,6 +507,86 @@ func deploymentHost(deploymentResult *azapi.ResourceDeployment) (appDeploymentHo return appDeploymentHost{}, fmt.Errorf("didn't find any known application host from the deployment") } +// armDeployment represents the compiled ARM template and parameters +// that is ready to be deployed. +type armDeployment struct { + Template azure.RawArmTemplate + Parameters azure.ArmParameters +} + +// compileBicep compiles the specified Bicep module to an ARM template and parameters. +// +// The Bicep module can be either a main Bicep file (with .bicep extension) or a Bicep parameters +// file (with .bicepparam extension). +// +// If a Bicep file is provided, the corresponding parameters file must exist in the same directory with the same base name. +// The environment variables in the parameters file will be substituted using the provided +// environment before parsing. +// +// Returns the compiled ARM template and parameters, or an error if the compilation fails. +func compileBicep( + cli *bicep.Cli, + ctx context.Context, + bicepModulePath string, + env *environment.Environment, +) (armDeployment, error) { + var result armDeployment + + ext := filepath.Ext(bicepModulePath) + if ext != ".bicep" && ext != ".bicepparam" { + return result, fmt.Errorf("bicep module path must have .bicep or .bicepparam extension") + } + + if ext == ".bicep" { + paramFilePath := strings.TrimSuffix(bicepModulePath, ext) + ".parameters.json" + parametersBytes, err := os.ReadFile(paramFilePath) + if err != nil { + return result, fmt.Errorf("reading parameters file: %w", err) + } + + substituted, err := envsubst.Eval(string(parametersBytes), env.Getenv) + if err != nil { + return result, fmt.Errorf("performing env substitution: %w", err) + } + + var paramFile azure.ArmParameterFile + if err := json.Unmarshal([]byte(substituted), ¶mFile); err != nil { + return result, fmt.Errorf("unmarshaling file: %w", err) + } + + res, err := cli.Build(ctx, bicepModulePath) + if err != nil { + return result, fmt.Errorf("building bicep: %w", err) + } + + result.Template = azure.RawArmTemplate(res.Compiled) + result.Parameters = paramFile.Parameters + } else { + res, err := cli.BuildBicepParam(ctx, bicepModulePath, env.Environ()) + if err != nil { + return result, fmt.Errorf("building bicepparam: %w", err) + } + + type compiledBicepParamResult struct { + TemplateJson string `json:"templateJson"` + ParametersJson string `json:"parametersJson"` + } + var bicepParamOutput compiledBicepParamResult + if err := json.Unmarshal([]byte(res.Compiled), &bicepParamOutput); err != nil { + return result, fmt.Errorf("failed unmarshalling arm template from json: %w", err) + } + var params azure.ArmParameterFile + if err := json.Unmarshal([]byte(bicepParamOutput.ParametersJson), ¶ms); err != nil { + return result, fmt.Errorf("failed unmarshalling arm parameters template from json: %w", err) + } + + result.Template = azure.RawArmTemplate(bicepParamOutput.TemplateJson) + result.Parameters = params.Parameters + } + + return result, nil +} + // Gets endpoint for the container app service func (at *dotnetContainerAppTarget) Endpoints( ctx context.Context, diff --git a/cli/azd/test/functional/testdata/samples/containerapp/infra/main.bicep b/cli/azd/test/functional/testdata/samples/containerapp/infra/main.bicep index 1b89e4f060e..c1bd5e54ad1 100644 --- a/cli/azd/test/functional/testdata/samples/containerapp/infra/main.bicep +++ b/cli/azd/test/functional/testdata/samples/containerapp/infra/main.bicep @@ -29,19 +29,7 @@ module resources 'resources.bicep' = { } -module web 'web.bicep' = { - name: 'web' - scope: rg - params: { - containerRegistryName: resources.outputs.containerRegistryName - containerAppsEnvironmentName: resources.outputs.containerAppsEnvironmentName - environmentName: environmentName - location: location - imageName: 'nginx:latest' - } -} - output AZURE_CONTAINER_REGISTRY_NAME string = resources.outputs.containerRegistryName output AZURE_CONTAINER_ENVIRONMENT_NAME string = resources.outputs.containerAppsEnvironmentName output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.containerRegistryloginServer -output WEBSITE_URL string = web.outputs.WEBSITE_URL +output SERVICE_WEB_IDENTITY_ID string = resources.outputs.managedIdentityId diff --git a/cli/azd/test/functional/testdata/samples/containerapp/infra/main.parameters.json b/cli/azd/test/functional/testdata/samples/containerapp/infra/main.parameters.json index b522c3410e0..8f7787beb16 100644 --- a/cli/azd/test/functional/testdata/samples/containerapp/infra/main.parameters.json +++ b/cli/azd/test/functional/testdata/samples/containerapp/infra/main.parameters.json @@ -7,9 +7,6 @@ }, "location": { "value": "${AZURE_LOCATION}" - }, - "provisionContainerApp": { - "value": "${AZURE_PROVISION_CONTAINER_APP}" } } } \ No newline at end of file diff --git a/cli/azd/test/functional/testdata/samples/containerapp/infra/resources.bicep b/cli/azd/test/functional/testdata/samples/containerapp/infra/resources.bicep index a35555b6c66..bdf3a843c1d 100644 --- a/cli/azd/test/functional/testdata/samples/containerapp/infra/resources.bicep +++ b/cli/azd/test/functional/testdata/samples/containerapp/infra/resources.bicep @@ -3,7 +3,7 @@ param location string = resourceGroup().location var tags = { 'azd-env-name': environmentName } var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) -param adminUserEnabled bool = true +param adminUserEnabled bool = false param anonymousPullEnabled bool = false param dataEndpointEnabled bool = false param encryption object = { @@ -65,6 +65,23 @@ resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10 }) } +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'mi-${resourceToken}' + location: location + tags: tags +} + +resource caeMiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, managedIdentity.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + scope: containerRegistry + properties: { + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} + output containerRegistryName string = containerRegistry.name output containerAppsEnvironmentName string = containerAppsEnvironment.name output containerRegistryloginServer string = containerRegistry.properties.loginServer +output managedIdentityId string = managedIdentity.id diff --git a/cli/azd/test/functional/testdata/samples/containerapp/infra/web.bicep b/cli/azd/test/functional/testdata/samples/containerapp/infra/web.bicep index 2c372e545b4..aaf40b9326e 100644 --- a/cli/azd/test/functional/testdata/samples/containerapp/infra/web.bicep +++ b/cli/azd/test/functional/testdata/samples/containerapp/infra/web.bicep @@ -1,10 +1,18 @@ +@minLength(1) +@maxLength(64) +@description('Name of the the environment which is used to generate a short unique hash used in all resources.') param environmentName string -param location string = resourceGroup().location -param imageName string + +@minLength(1) +@description('Primary location for all resources') +param location string + param containerRegistryName string param containerAppsEnvironmentName string -var tags = { 'azd-env-name': environmentName } -var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +param imageName string + +param identityId string resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { name: containerRegistryName @@ -14,43 +22,39 @@ resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2022-03-01' name: containerAppsEnvironmentName } -resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { - name: 'ca-${resourceToken}' - location: location - tags: union(tags, { 'azd-service-name': 'web' }) - properties: { - managedEnvironmentId: containerAppsEnvironment.id - configuration: { - activeRevisionsMode: 'single' - ingress: { - external: true - targetPort: 8080 - transport: 'auto' - } - secrets: [ - { - name: 'registry-password' - value: containerRegistry.listCredentials().passwords[0].value - } - ] - registries: [ - { - server: '${containerRegistry.name}.azurecr.io' - username: containerRegistry.name - passwordSecretRef: 'registry-password' - } +module web 'br/public:avm/res/app/container-app:0.8.0' = { + name: 'web' + params: { + name: 'web' + ingressTargetPort: 8080 + scaleMinReplicas: 1 + scaleMaxReplicas: 10 + secrets: { + secureList: [ ] } - template: { - containers: [ - { - image: imageName - name: 'main' - env: [] + containers: [ + { + image: imageName + name: 'main' + resources: { + cpu: json('0.5') + memory: '1.0Gi' } - ] + } + ] + managedIdentities:{ + systemAssigned: false + userAssignedResourceIds: [identityId] } + registries:[ + { + server: containerRegistry.properties.loginServer + identity: identityId + } + ] + environmentResourceId: containerAppsEnvironment.id + location: location + tags: { 'azd-env-name': environmentName, 'azd-service-name': 'web' } } } - -output WEBSITE_URL string = 'https://${app.properties.configuration.ingress.fqdn}' diff --git a/cli/azd/test/functional/testdata/samples/containerapp/infra/web.parameters.json b/cli/azd/test/functional/testdata/samples/containerapp/infra/web.parameters.json index f3f79c79b02..1aa5cc6db82 100644 --- a/cli/azd/test/functional/testdata/samples/containerapp/infra/web.parameters.json +++ b/cli/azd/test/functional/testdata/samples/containerapp/infra/web.parameters.json @@ -8,14 +8,17 @@ "location": { "value": "${AZURE_LOCATION}" }, - "imageName": { - "value": "${SERVICE_WEB_IMAGE_NAME}" - }, "containerRegistryName": { "value": "${AZURE_CONTAINER_REGISTRY_NAME}" }, "containerAppsEnvironmentName": { "value": "${AZURE_CONTAINER_ENVIRONMENT_NAME}" + }, + "imageName": { + "value": "${SERVICE_WEB_IMAGE_NAME}" + }, + "identityId": { + "value": "${SERVICE_WEB_IDENTITY_ID}" } } } \ No newline at end of file diff --git a/cli/azd/test/functional/testdata/samples/containerapp/src/dotnet/Dockerfile b/cli/azd/test/functional/testdata/samples/containerapp/src/dotnet/Dockerfile deleted file mode 100644 index 964a5865b36..00000000000 --- a/cli/azd/test/functional/testdata/samples/containerapp/src/dotnet/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env -WORKDIR /app - -# Copy everything -COPY . ./ -# Restore as distinct layers -RUN dotnet restore -# Build and publish a release -RUN dotnet publish -c Release -o out - -# Build runtime image -FROM mcr.microsoft.com/dotnet/aspnet:8.0 -WORKDIR /app -COPY --from=build-env /app/out . -EXPOSE 8080 -ENV ASPNETCORE_URLS=http://+:8080 - -ENTRYPOINT ["dotnet", "webapp.dll"] \ No newline at end of file diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 0d22252566a..0b3e2ebc687 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -219,8 +219,8 @@ }, "module": { "type": "string", - "title": "(DEPRECATED) Path of the infrastructure module used to deploy the service relative to the root infra folder", - "description": "If omitted, the CLI will assume the module name is the same as the service name. This property will be deprecated in a future release." + "title": "Path of the infrastructure module used to deploy the service relative to the root infra folder", + "description": "If omitted, the CLI will assume the module name is the same as the service name." }, "dist": { "type": "string", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 52eafc80307..99e5edcb487 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -126,8 +126,8 @@ }, "module": { "type": "string", - "title": "(DEPRECATED) Path of the infrastructure module used to deploy the service relative to the root infra folder", - "description": "If omitted, the CLI will assume the module name is the same as the service name. This property will be deprecated in a future release." + "title": "Path of the infrastructure module used to deploy the service relative to the root infra folder", + "description": "If omitted, the CLI will assume the module name is the same as the service name." }, "dist": { "type": "string",