diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index e4891ef19be..39c3852e7a3 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -141,6 +141,9 @@ overrides: words: - GHSA - gjrc + - filename: pkg/apphost/generate.go + words: + - alue ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/pkg/apphost/generate.go b/cli/azd/pkg/apphost/generate.go index e7e06c9dfb2..5fad0082bce 100644 --- a/cli/azd/pkg/apphost/generate.go +++ b/cli/azd/pkg/apphost/generate.go @@ -346,6 +346,10 @@ func BicepTemplate(name string, manifest *Manifest, options AppHostOptions) (*me // Note: azd is not checking or validating that Default.Generate and Default.Value are not both set. // The AppHost prevents this from happening by not allowing both to be set at the same time. } + if parameter.scope != nil { + metadataType = azure.AzdMetadataTypeResourceGroup + parameterMetadata = "{}" + } input := genInput{Name: key, Secret: parameter.Secret, Type: parameter.Type, Value: parameterDefaultValue} parameters = append(parameters, autoGenInput{ genInput: input, @@ -677,7 +681,7 @@ func (b *infraGenerator) LoadManifest(m *Manifest) error { if err := b.addInputParameter(name, comp); err != nil { return fmt.Errorf("adding bicep parameter from resource %s (%s): %w", name, comp.Type, err) } - case "azure.bicep.v0": + case "azure.bicep.v0", "azure.bicep.v1": if err := b.addBicep(name, comp); err != nil { return fmt.Errorf("adding bicep resource %s: %w", name, err) } @@ -827,7 +831,18 @@ func (b *infraGenerator) addBicep(name string, comp *Resource) error { stringParams["location"] = "location" } - b.bicepContext.BicepModules[name] = genBicepModules{Path: *comp.Path, Params: stringParams} + bicepScope := defaultBicepModuleScope + if comp.Scope != nil { + if comp.Scope.ResourceGroup == nil { + return fmt.Errorf("bicep resource %s has a scope without a resource group", name) + } + paramValue, _, err := injectValueForBicepParameter(name, "scope", *comp.Scope.ResourceGroup) + if err != nil { + return err + } + bicepScope = paramValue + } + b.bicepContext.BicepModules[name] = genBicepModules{Path: *comp.Path, Params: stringParams, Scope: bicepScope} return nil } @@ -846,6 +861,8 @@ const ( knownInjectedValueLogAnalytics string = "resources.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_ID" knownInjectedValueContainerEnvName string = "resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME" knownInjectedValueContainerEnvId string = "resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID" + + defaultBicepModuleScope string = "rg" ) // injectValueForBicepParameter checks for aspire-manifest and azd conventions rules for auto injecting values for @@ -1138,6 +1155,7 @@ func (b *infraGenerator) addDaprStateStoreComponent(name string) { var singleQuotedStringRegex = regexp.MustCompile(`'[^']*'`) var propertyNameRegex = regexp.MustCompile(`'([^']*)':`) var jsonSimpleKeyRegex = regexp.MustCompile(`"([a-zA-Z0-9]*)":`) +var resourceValueOnlyRefRegex = regexp.MustCompile(`^"{([a-zA-Z0-9\-]+)\.[vV]alue}"$`) type ingressDetails struct { // aca ingress definition @@ -1284,38 +1302,32 @@ func (b *infraGenerator) Compile() error { for moduleName, module := range b.bicepContext.BicepModules { for paramName, paramValue := range module.Params { - // bicep uses ' instead of " for strings, so we need to replace all " with ' - singleQuoted := strings.ReplaceAll(paramValue, "\"", "'") - - var evaluationError error - evaluatedString := singleQuotedStringRegex.ReplaceAllStringFunc(singleQuoted, func(s string) string { - evaluatedString, err := EvalString(s, func(s string) (string, error) { - return b.evalBindingRef(s, inputEmitTypeBicep) - }) - if err != nil { - evaluationError = fmt.Errorf("evaluating bicep module %s parameter %s: %w", moduleName, paramName, err) + value, err := b.resolveBicepReference(paramValue) + if err != nil { + return fmt.Errorf( + "resolving bicep module %s, param: %s, reference %s: %w", moduleName, paramName, paramValue, err) + } + module.Params[paramName] = value + } + if module.Scope != defaultBicepModuleScope { + rgScope := "resourceGroup" + if matches := resourceValueOnlyRefRegex.FindStringSubmatch(module.Scope); len(matches) == 2 { + manifestResourceName := matches[1] + // Check if the scope is an input + input, hasInput := b.bicepContext.InputParameters[manifestResourceName] + if hasInput { + input.scope = &rgScope + b.bicepContext.InputParameters[manifestResourceName] = input } - return evaluatedString - }) - if evaluationError != nil { - return evaluationError } - - // quick check to know if evaluatedString is only holding one only reference. If so, we don't need to use - // the form of '%{ref}' and we can directly use ref alone. - if isComplexExp, val := isComplexExpression(evaluatedString); !isComplexExp { - module.Params[paramName] = val - continue + scope, err := b.resolveBicepReference(module.Scope) + if err != nil { + return fmt.Errorf("resolving bicep module %s scope %s: %w", moduleName, module.Scope, err) } - - // Property names that are valid identifiers should be declared without quotation marks and accessed - // using dot notation. - evaluatedString = propertyNameRegex.ReplaceAllString(evaluatedString, "${1}:") - - // restore double {{ }} to single { } for bicep output - // we used double only during the evaluation to scape single brackets - module.Params[paramName] = strings.ReplaceAll(strings.ReplaceAll(evaluatedString, "'{{", "${"), "}}'", "}") + scope = fmt.Sprintf("%s(%s)", rgScope, scope) + module.Scope = scope } + b.bicepContext.BicepModules[moduleName] = module } for _, kv := range b.bicepContext.KeyVaults { @@ -1328,6 +1340,40 @@ func (b *infraGenerator) Compile() error { return nil } +// resolve a reference for bicep types. The reference can be from a parameter or from the scope +func (b *infraGenerator) resolveBicepReference(ref string) (string, error) { + // bicep uses ' instead of " for strings, so we need to replace all " with ' + singleQuoted := strings.ReplaceAll(ref, "\"", "'") + + var evaluationError error + evaluatedString := singleQuotedStringRegex.ReplaceAllStringFunc(singleQuoted, func(s string) string { + evaluatedString, err := EvalString(s, func(s string) (string, error) { + return b.evalBindingRef(s, inputEmitTypeBicep) + }) + if err != nil { + evaluationError = err + } + return evaluatedString + }) + if evaluationError != nil { + return "", evaluationError + } + + // quick check to know if evaluatedString is only holding one only reference. If so, we don't need to use + // the form of '%{ref}' and we can directly use ref alone. + if isComplexExp, val := isComplexExpression(evaluatedString); !isComplexExp { + return val, nil + } + + // Property names that are valid identifiers should be declared without quotation marks and accessed + // using dot notation. + evaluatedString = propertyNameRegex.ReplaceAllString(evaluatedString, "${1}:") + + // restore double {{ }} to single { } for bicep output + // we used double only during the evaluation to scape single brackets + return strings.ReplaceAll(strings.ReplaceAll(evaluatedString, "'{{", "${"), "}}'", "}"), nil +} + // isComplexExpression checks if the evaluatedString is in the form of '{{ expr }}' or if it is a complex expression like // 'foo {{ expr }} bar {{ expr2 }}' and returns true if it is a complex expression. // When the expression is not complex, it returns false and the evaluatedString without the special characters. @@ -1561,7 +1607,7 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string fmt.Errorf("malformed binding expression, expected "+ "bindings..[scheme|protocol|transport|external|host|targetPort|port|url] but was: %s", v) } - case targetType == "azure.bicep.v0": + case targetType == "azure.bicep.v0" || targetType == "azure.bicep.v1": if !strings.HasPrefix(prop, "outputs.") && !strings.HasPrefix(prop, "secretOutputs") { return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType) } diff --git a/cli/azd/pkg/apphost/generate_types.go b/cli/azd/pkg/apphost/generate_types.go index 6f49f6a862c..d9d40718862 100644 --- a/cli/azd/pkg/apphost/generate_types.go +++ b/cli/azd/pkg/apphost/generate_types.go @@ -128,6 +128,7 @@ type genOutputParameter struct { type genBicepModules struct { Path string Params map[string]string + Scope string } type genBicepTemplateContext struct { diff --git a/cli/azd/pkg/apphost/manifest.go b/cli/azd/pkg/apphost/manifest.go index f7007e0f77b..b93ddb84ec9 100644 --- a/cli/azd/pkg/apphost/manifest.go +++ b/cli/azd/pkg/apphost/manifest.go @@ -100,6 +100,14 @@ type Resource struct { // project.v1 and container.v1 uses deployment when the AppHost owns the ACA bicep definitions. Deployment *DeploymentMetadata `json:"deployment,omitempty"` + + // Present on bicep modules to control the scope of the module. + Scope *BicepModuleScope `json:"scope,omitempty"` +} + +// BicepModuleScope is the scope of a bicep module. +type BicepModuleScope struct { + ResourceGroup *string `json:"resourceGroup,omitempty"` } type DeploymentMetadata struct { @@ -182,6 +190,9 @@ type Input struct { Type string `json:"type"` Secret bool `json:"secret"` Default *InputDefault `json:"default,omitempty"` + // When the input is used to set a bicep module scope, the scope is set here. + // This allows generation to add azdMetadata to the bicep parameter. + scope *string } type InputDefaultGenerate struct { @@ -242,7 +253,7 @@ func ManifestFromAppHost( for resourceName, res := range manifest.Resources { if res.Path != nil { - if res.Type == "azure.bicep.v0" { + if res.Type == "azure.bicep.v0" || res.Type == "azure.bicep.v1" { e := manifest.BicepFiles.MkdirAll(resourceName, osutil.PermissionDirectory) if e != nil { return nil, e @@ -271,9 +282,9 @@ func ManifestFromAppHost( } if res.Deployment != nil { - if res.Deployment.Type != "azure.bicep.v0" { + if res.Deployment.Type != "azure.bicep.v0" && res.Deployment.Type != "azure.bicep.v1" { return nil, fmt.Errorf( - "unexpected deployment type %q. Supported types: [azure.bicep.v0]", res.Deployment.Type) + "unexpected deployment type %q. Supported types: [azure.bicep.v0, azure.bicep.v1]", res.Deployment.Type) } // use a folder with the name of the resource e := manifest.BicepFiles.MkdirAll(resourceName, osutil.PermissionDirectory) diff --git a/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap index 75b66a11166..b4c15a95f0a 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap @@ -16,6 +16,12 @@ param administrator_login string @secure() param administratorLoginPassword string param parameter string +@metadata({azd: { + type: 'resourceGroup' + config: {} + } +}) +param rg_scope string var tags = { 'azd-env-name': environmentName @@ -85,7 +91,7 @@ module sql 'sql/aspire.hosting.azure.bicep.sql.bicep' = { } module test 'test/test.bicep' = { name: 'test' - scope: rg + scope: resourceGroup(rg_scope) params: { host: 'frontend.internal.${resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}' location: location diff --git a/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.parameters.json.snap b/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.parameters.json.snap index ac0fdf332ab..ead0ea0b8c7 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.parameters.json.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.parameters.json.snap @@ -14,6 +14,9 @@ "parameter": { "value": "${AZURE_PARAMETER}" }, + "rg_scope": { + "value": "${AZURE_RG_SCOPE}" + }, "environmentName": { "value": "${AZURE_ENV_NAME}" }, diff --git a/cli/azd/pkg/apphost/testdata/aspire-bicep.json b/cli/azd/pkg/apphost/testdata/aspire-bicep.json index 9096222670b..9d137090fc2 100644 --- a/cli/azd/pkg/apphost/testdata/aspire-bicep.json +++ b/cli/azd/pkg/apphost/testdata/aspire-bicep.json @@ -8,8 +8,16 @@ } } }, + "rg-scope": { + "type": "parameter.v0", + "value": "{rg-scope.inputs.value}", + "inputs": { + "value": { + } + } + }, "test": { - "type": "azure.bicep.v0", + "type": "azure.bicep.v1", "path": "test.bicep", "params": { "test": "{parameter.value}", @@ -19,6 +27,9 @@ "one", "two" ] + }, + "scope": { + "resourceGroup": "{rg-scope.value}" } }, "administrator-login": { diff --git a/cli/azd/pkg/azure/arm_template.go b/cli/azd/pkg/azure/arm_template.go index ce97b814ebe..4a1bc7cf990 100644 --- a/cli/azd/pkg/azure/arm_template.go +++ b/cli/azd/pkg/azure/arm_template.go @@ -138,6 +138,7 @@ const AzdMetadataTypeLocation AzdMetadataType = "location" const AzdMetadataTypeGenerate AzdMetadataType = "generate" const AzdMetadataTypeGenerateOrManual AzdMetadataType = "generateOrManual" const AzdMetadataTypeNeedForDeploy AzdMetadataType = "needForDeploy" +const AzdMetadataTypeResourceGroup AzdMetadataType = "resourceGroup" type AzdMetadata struct { Type *AzdMetadataType `json:"type,omitempty"` diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 056960462b8..989c6f01f49 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -164,7 +164,7 @@ func (p *BicepProvider) EnsureEnv(ctx context.Context) error { if scope == azure.DeploymentScopeResourceGroup { if p.env.Getenv(environment.ResourceGroupEnvVarName) == "" { - rgName, err := p.prompters.PromptResourceGroup(ctx) + rgName, err := p.prompters.PromptResourceGroup(ctx, prompt.PromptResourceOptions{}) if err != nil { return err } diff --git a/cli/azd/pkg/infra/provisioning/bicep/prompt.go b/cli/azd/pkg/infra/provisioning/bicep/prompt.go index 2d067677b17..6799b24d5a0 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/prompt.go +++ b/cli/azd/pkg/infra/provisioning/bicep/prompt.go @@ -15,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/password" + "github.com/azure/azure-dev/cli/azd/pkg/prompt" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" ) @@ -102,6 +103,19 @@ func (p *BicepProvider) promptForParameter( return nil, err } value = location + } else if paramType == provisioning.ParameterTypeString && + azdMetadata.Type != nil && + *azdMetadata.Type == azure.AzdMetadataTypeResourceGroup { + + p.console.Message(ctx, fmt.Sprintf( + "Parameter %s requires an %s resource group.", output.WithUnderline("%s", key), output.WithBold("existing"))) + rgName, err := p.prompters.PromptResourceGroup(ctx, prompt.PromptResourceOptions{ + DisableCreateNew: true, + }) + if err != nil { + return nil, err + } + value = rgName } else if paramType == provisioning.ParameterTypeString && azdMetadata.Type != nil && *azdMetadata.Type == azure.AzdMetadataTypeGenerateOrManual { diff --git a/cli/azd/pkg/prompt/prompter.go b/cli/azd/pkg/prompt/prompter.go index e6a80a5937b..e49fce5a027 100644 --- a/cli/azd/pkg/prompt/prompter.go +++ b/cli/azd/pkg/prompt/prompter.go @@ -34,7 +34,7 @@ type Prompter interface { msg string, filter LocationFilterPredicate, defaultLocation *string) (string, error) - PromptResourceGroup(ctx context.Context) (string, error) + PromptResourceGroup(ctx context.Context, options PromptResourceOptions) (string, error) PromptResourceGroupFrom( ctx context.Context, subscriptionId string, location string, options PromptResourceGroupFromOptions) (string, error) } @@ -122,7 +122,11 @@ func (p *DefaultPrompter) PromptLocation( return loc, nil } -func (p *DefaultPrompter) PromptResourceGroup(ctx context.Context) (string, error) { +type PromptResourceOptions struct { + DisableCreateNew bool +} + +func (p *DefaultPrompter) PromptResourceGroup(ctx context.Context, options PromptResourceOptions) (string, error) { return p.PromptResourceGroupFrom( ctx, p.env.GetSubscriptionId(), @@ -131,7 +135,8 @@ func (p *DefaultPrompter) PromptResourceGroup(ctx context.Context) (string, erro Tags: map[string]string{ azure.TagKeyAzdEnvName: p.env.Name(), }, - DefaultName: fmt.Sprintf("rg-%s", p.env.Name()), + DefaultName: fmt.Sprintf("rg-%s", p.env.Name()), + DisableCreateNew: options.DisableCreateNew, }) } @@ -140,6 +145,7 @@ type PromptResourceGroupFromOptions struct { DefaultName string NewResourceGroupHelp string PickResourceGroupHelp string + DisableCreateNew bool } func (p *DefaultPrompter) PromptResourceGroupFrom( @@ -154,10 +160,17 @@ func (p *DefaultPrompter) PromptResourceGroupFrom( return strings.Compare(a.Name, b.Name) }) - choices := make([]string, len(groups)+1) - choices[0] = "Create a new resource group" + canCreateNeResourceGroup := !options.DisableCreateNew + + choices := make([]string, len(groups)) + canCreateOverride := 0 + if canCreateNeResourceGroup { + choices = make([]string, len(groups)+1) + choices[0] = "Create a new resource group" + canCreateOverride = 1 + } for idx, group := range groups { - choices[idx+1] = fmt.Sprintf("%d. %s", idx+1, group.Name) + choices[idx+canCreateOverride] = fmt.Sprintf("%d. %s", idx+1, group.Name) } choice, err := p.console.Select(ctx, input.ConsoleOptions{ @@ -169,6 +182,10 @@ func (p *DefaultPrompter) PromptResourceGroupFrom( return "", fmt.Errorf("selecting resource group: %w", err) } + if !canCreateNeResourceGroup { + return groups[choice].Name, nil + } + if choice > 0 { return groups[choice-1].Name, nil } diff --git a/cli/azd/resources/apphost/templates/main.bicept b/cli/azd/resources/apphost/templates/main.bicept index a4c77e4ce77..5fce7d3f1c7 100644 --- a/cli/azd/resources/apphost/templates/main.bicept +++ b/cli/azd/resources/apphost/templates/main.bicept @@ -54,7 +54,7 @@ module resources 'resources.bicep' = { {{ range $name, $module := .BicepModules }} module {{bicepParameterName $name}} '{{ fixBackSlash $module.Path }}' = { name: '{{$name}}' - scope: rg + scope: {{$module.Scope}} params: { {{- range $param, $value := $module.Params}} {{$param}}: {{$value}}