diff --git a/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md b/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md index 37f78bf7146..1842523293a 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md +++ b/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md @@ -40,6 +40,11 @@ infra: The existing `infra/main.bicep` remains unchanged. +For a service that sets `endpoint:`, Bicep eject keeps the +`microsoft.foundry` provider. The provider compiles the generated `main.bicep`, +so ejected and embedded provisioning use the same resource graph. Terraform +eject uses the built-in `terraform` provider. + ## Existing files Eject never overwrites generated-file collisions. @@ -71,6 +76,27 @@ that it created the group and the live Azure group is tagged for the current environment. Otherwise teardown intentionally refuses deletion so a user-owned group is not removed. +For an existing-project Bicep eject, the account and project resource group are +never created by the generated template. Teardown can remove adjunct resources +created by that template, but does not own or delete the reused account or project. + +## Existing container registries + +Eject preserves the registry choice made during init: + +- No registry required: no registry resources are managed. +- No existing registry selected: create a registry, `AcrPull` assignment, and + project connection. +- Existing registry without a project connection: reference the registry and + create only `AcrPull` plus the project connection. +- Existing project connection selected: reference its registry and connection + without managing either one. + +The generated files never import or take ownership of an existing registry. +When registry work is required, the generated file is consistently named +`modules/container-registry.bicep` or `container-registry.tf`, whether the +registry is created or reused. + ## Layer dependencies The Foundry layer is independent by default. Azd analyzes generated parameter @@ -100,7 +126,9 @@ value. - Terraform eject does not support a service with a private `network:` block; use Bicep for private networking. -- Brownfield services that set `endpoint:` reuse an existing Foundry project - and cannot eject infrastructure for that externally owned resource. +- Services that set `endpoint:` to reuse an existing project can eject Bicep or Terraform. The + generated templates reference the existing account and project without taking + ownership and manage only declared model deployments, connections, and + adjunct resources such as ACR. - Eject preserves the existing root infrastructure mapping during migration; custom properties remain the project owner's responsibility. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index af9bce3a2b0..009be63fe50 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1226,6 +1226,13 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, --image myacr.azurecr.io/agents/my-agent:v1`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return exterrors.Internal(exterrors.CodeAzdClientFailed, fmt.Sprintf("failed to create azd client: %s", err)) + } + defer azdClient.Close() + flags.noPrompt = extCtx.NoPrompt if flags.env == "" { flags.env = extCtx.Environment @@ -1278,19 +1285,23 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if err := validateStandaloneEjectArgs(cmd, args); err != nil { return err } - return ejectInfra(gate.projectRoot, infraProvider) + var env map[string]string + needsEnv, err := infraEjectNeedsEnvironment(gate.projectRoot) + if err != nil { + return err + } + if needsEnv { + env, err = readInfraEjectEnvironment(ctx, azdClient) + if err != nil { + return err + } + } + return ejectInfra(gate.projectRoot, infraProvider, env) } } - ctx := azdext.WithAccessToken(cmd.Context()) flags.agentNameExplicit = cmd.Flags().Changed("agent-name") - azdClient, err := azdext.NewAzdClient() - if err != nil { - return exterrors.Internal(exterrors.CodeAzdClientFailed, fmt.Sprintf("failed to create azd client: %s", err)) - } - defer azdClient.Close() - if err := checkAiModelServiceAvailable(ctx, azdClient); err != nil { return err } @@ -1503,7 +1514,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, ); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return ejectInfraAfterInit(ctx, infraProvider, azdClient) } } } @@ -1552,7 +1563,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return ejectInfraAfterInit(ctx, infraProvider, azdClient) } } } @@ -1597,7 +1608,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } return err } - return ejectInfraAfterInit(infraProvider) + return ejectInfraAfterInit(ctx, infraProvider, azdClient) } return missingAgentServiceError(flags.manifestPointer) } @@ -1867,7 +1878,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // wrote azure.yaml, chain the eject step. Skip silently when init // didn't produce a foundry-bearing azure.yaml (cancelled or // non-foundry flow) to avoid a confusing "nothing to eject" error. - return ejectInfraAfterInit(infraProvider) + return ejectInfraAfterInit(ctx, infraProvider, azdClient) }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 4dbf273c034..3f70bcbfcbb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -14,7 +14,9 @@ import ( "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" @@ -37,6 +39,7 @@ type FoundryProjectInfo struct { // NetworkInjected is true when the owning Foundry account has VNET network // injection (agent scenario); used to disable remote build. NetworkInjected bool + PrincipalId string } // Endpoint returns the Foundry project data-plane endpoint derived from the @@ -177,6 +180,9 @@ func updateFoundryProjectInfo(project *FoundryProjectInfo, resource *armcognitiv if resource.Location != nil { project.Location = *resource.Location } + if resource.Identity != nil && resource.Identity.PrincipalID != nil { + project.PrincipalId = *resource.Identity.PrincipalID + } } // listFoundryProjects enumerates all Foundry projects in a subscription by listing @@ -373,6 +379,39 @@ func listAcrResourceIds( return resourceIds, nil } +const acrPullRoleDefinitionID = "7f951dda-4ed3-4680-a7ca-43fe172d538d" + +func hasAcrPullAssignment( + ctx context.Context, + credential azcore.TokenCredential, + resourceID string, + principalID string, +) (bool, error) { + subscriptionID := extractSubscriptionId(resourceID) + client, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, credential, azure.NewArmClientOptions()) + if err != nil { + return false, fmt.Errorf("create role assignments client: %w", err) + } + filter := fmt.Sprintf("assignedTo('%s')", principalID) + pager := client.NewListForScopePager(resourceID, &armauthorization.RoleAssignmentsClientListForScopeOptions{ + Filter: &filter, + }) + roleSuffix := "/roleDefinitions/" + acrPullRoleDefinitionID + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return false, fmt.Errorf("list ACR role assignments: %w", err) + } + for _, assignment := range page.Value { + if assignment.Properties != nil && assignment.Properties.RoleDefinitionID != nil && + strings.HasSuffix(*assignment.Properties.RoleDefinitionID, roleSuffix) { + return true, nil + } + } + } + return false, nil +} + // configureFoundryProjectEnv sets all Foundry project environment variables and discovers // ACR and AppInsights connections. This is the shared implementation used by both init flows. // When skipACR is true, ACR connection discovery and configuration is skipped (used for code deploy). @@ -426,7 +465,7 @@ func configureFoundryProjectEnv( // The provisioning provider owns ACR/AppInsights for a new project, but a // container agent on an existing project needs a registry it won't create. if skipACR { - return nil + return setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "none") } return configureExistingProjectAcr(ctx, azdClient, credential, envName, project, subscriptionId) } @@ -467,6 +506,8 @@ func configureFoundryProjectEnv( if err := configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections); err != nil { return err } + } else if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "none"); err != nil { + return err } if err := configureAppInsightsConnection(ctx, azdClient, envName, appInsightsConnections); err != nil { @@ -506,7 +547,8 @@ func configureExistingProjectAcr( } } - return configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections) + return configureAcrConnectionWithPrincipal( + ctx, azdClient, credential, envName, subscriptionId, acrConnections, project.PrincipalId) } // configureAcrConnection handles ACR connection selection and env var setting. @@ -517,9 +559,22 @@ func configureAcrConnection( envName string, subscriptionId string, acrConnections []azure.Connection, +) error { + return configureAcrConnectionWithPrincipal(ctx, azdClient, credential, envName, subscriptionId, acrConnections, "") +} + +func configureAcrConnectionWithPrincipal( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + envName string, + subscriptionId string, + acrConnections []azure.Connection, + projectPrincipalID string, ) error { return configureAcrConnectionWithRegistryLoader( ctx, azdClient, credential, envName, subscriptionId, acrConnections, listAcrResourceIds, + projectPrincipalID, ) } @@ -538,7 +593,16 @@ func configureAcrConnectionWithRegistryLoader( subscriptionId string, acrConnections []azure.Connection, loadRegistries acrRegistryLoader, + projectPrincipalIDs ...string, ) error { + previousValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{Name: envName}) + if err != nil { + return fmt.Errorf("reading existing ACR ownership state: %w", err) + } + previous := make(map[string]string, len(previousValues.KeyValues)) + for _, value := range previousValues.KeyValues { + previous[value.Key] = strings.TrimSpace(value.Value) + } resourceIds, err := loadRegistries(ctx, credential, subscriptionId) if err != nil { return fmt.Errorf("listing container registries for connection validation: %w", err) @@ -597,9 +661,22 @@ func configureAcrConnectionWithRegistryLoader( if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", ""); err != nil { return err } + assigned := false + if len(projectPrincipalIDs) > 0 && projectPrincipalIDs[0] != "" { + assigned, err = hasAcrPullAssignment(ctx, credential, resourceId, projectPrincipalIDs[0]) + if err != nil { + return fmt.Errorf("check existing AcrPull assignment: %w", err) + } + } + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_PULL_ASSIGNED", fmt.Sprint(assigned)); err != nil { + return err + } if err := updatePendingACRSignal(ctx, azdClient, envName, true); err != nil { log.Printf("warning: failed to update acr provision signal: %v", err) } + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "reuse-connect"); err != nil { + return err + } } else { for _, key := range []string{ "AZURE_CONTAINER_REGISTRY_ENDPOINT", @@ -613,6 +690,12 @@ func configureAcrConnectionWithRegistryLoader( if err := updatePendingACRSignal(ctx, azdClient, envName, false); err != nil { log.Printf("warning: failed to update acr provision signal: %v", err) } + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "create"); err != nil { + return err + } + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_PULL_ASSIGNED", "false"); err != nil { + return err + } } return nil } @@ -681,10 +764,46 @@ func configureAcrConnectionWithRegistryLoader( if err := updatePendingACRSignal(ctx, azdClient, envName, true); err != nil { log.Printf("warning: failed to update acr provision signal: %v", err) } + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "already-connected"); err != nil { + return err + } + if shouldPreserveCreatedAcrMode(previous, *selectedConnection) { + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_MODE", "create"); err != nil { + return err + } + } + if err := setEnvValue(ctx, azdClient, envName, "AZD_FOUNDRY_ACR_PULL_ASSIGNED", "true"); err != nil { + return err + } return nil } +func shouldPreserveCreatedAcrMode(previous map[string]string, selected validatedAcrConnection) bool { + if previous["AZD_FOUNDRY_ACR_MODE"] != "create" { + return false + } + resourceID := strings.TrimSuffix(strings.TrimSpace(selected.resourceId), "/") + previousResourceID := strings.TrimSuffix(previous["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"], "/") + if resourceID == "" || !strings.EqualFold(resourceID, previousResourceID) { + return false + } + registry, err := arm.ParseResourceID(resourceID) + if err != nil || registry.ResourceType.String() != "Microsoft.ContainerRegistry/registries" { + return false + } + if !resourceGroupIDMatches( + previous["AZD_FOUNDRY_RESOURCE_GROUP_ID"], registry.SubscriptionID, registry.ResourceGroupName) { + return false + } + return registry.Name != "" && strings.EqualFold(selected.connection.Name, registry.Name+"-conn") +} + +func resourceGroupIDMatches(resourceID, subscriptionID, resourceGroup string) bool { + wanted := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", subscriptionID, resourceGroup) + return resourceID != "" && strings.EqualFold(strings.TrimSuffix(resourceID, "/"), wanted) +} + // tracingOverviewURL points to an overview of agent tracing/telemetry behavior. const tracingOverviewURL = "https://aka.ms/tracing-overview" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go index 124cbf71442..e661bfa0448 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go @@ -867,6 +867,23 @@ func TestConfigureAcrConnection_ValidatesDiscoveredConnections(t *testing.T) { "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceId, }, }, + { + name: "re-init preserves create mode for the owned registry connection", + connections: []azure.Connection{{ + Name: "valid-conn", Target: "valid.azurecr.io", + }}, + registries: map[string]string{"valid.azurecr.io": resourceId}, + initial: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "create", + "AZD_FOUNDRY_RESOURCE_GROUP_ID": "/subscriptions/sub/resourceGroups/rg", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceId, + }, + wantValues: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "create", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "valid-conn", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceId, + }, + }, { name: "stale sole connection falls back to create on provision and clears stale values", connections: []azure.Connection{{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index 3143acda96d..a18d5cdc990 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -5,6 +5,8 @@ package cmd import ( "bytes" + "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -78,6 +80,15 @@ type infraLayer struct { effectiveProvider string } +type infraEjectAcrMode string + +const ( + infraEjectAcrNone infraEjectAcrMode = "none" + infraEjectAcrCreate infraEjectAcrMode = "create" + infraEjectAcrReuseConnect infraEjectAcrMode = "reuse-connect" + infraEjectAcrAlreadyConnected infraEjectAcrMode = "already-connected" +) + // validateStandaloneEjectArgs refuses init-driving inputs that the // standalone-eject branch would silently drop. `--infra` on a project that // already declares a Foundry service runs eject only; honoring a positional @@ -446,7 +457,7 @@ func resolveInfraGate(provider string) (infraGate, error) { // ejectInfraAfterInit ejects from the azd project containing the current // directory. Init may create or discover a project above cwd, so use the same // upward project resolution as the rest of azd. -func ejectInfraAfterInit(provider string) error { +func ejectInfraAfterInit(ctx context.Context, provider string, clients ...*azdext.AzdClient) error { if provider == "" { return nil } @@ -467,7 +478,38 @@ func ejectInfraAfterInit(provider string) error { return nil } - return ejectInfra(projectRoot, provider) + var env map[string]string + needsEnv, err := infraEjectNeedsEnvironment(projectRoot) + if err != nil { + return err + } + if needsEnv && len(clients) > 0 && clients[0] != nil { + env, err = readInfraEjectEnvironment(ctx, clients[0]) + if err != nil { + return err + } + } + return ejectInfra(projectRoot, provider, env) +} + +func infraEjectNeedsEnvironment(projectRoot string) (bool, error) { + rawYAML, err := readProjectAzureYAML(projectRoot) + if err != nil { + return false, err + } + serviceName, err := findFoundryServiceForEject(rawYAML) + if err != nil { + return false, err + } + endpoint, err := synthesis.ProjectEndpoint(rawYAML, serviceName, projectRoot) + if err != nil { + return false, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("read endpoint for foundry project service %q: %s", serviceName, err), + "check the endpoint field under your azure.ai.project service", + ) + } + return endpoint != "", nil } // ejectInfra synthesizes infrastructure templates from azure.yaml. A project @@ -487,7 +529,7 @@ func ejectInfraAfterInit(provider string) error { // - a generated destination file already exists -> CodeInfraEjectExists // // On success it prints the summary block and returns nil. -func ejectInfra(projectRoot, provider string) error { +func ejectInfra(projectRoot, provider string, environments ...map[string]string) error { yamlPath := filepath.Join(projectRoot, "azure.yaml") rawYAML, err := readProjectAzureYAML(projectRoot) if err != nil { @@ -498,7 +540,23 @@ func ejectInfra(projectRoot, provider string) error { if err != nil { return err } - plan, err := planInfraEject(projectRoot, rawYAML, provider) + endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("read endpoint for foundry project service %q: %s", svcName, err), + "check the endpoint field under your azure.ai.project service", + ) + } + existingProject := endpoint != "" + if existingProject && len(environments) > 0 { + if err := validateExistingProjectEjectEnvironment(endpoint, environments[0]); err != nil { + return err + } + } + + layerProvider := foundryLayerProvider(provider) + plan, err := planInfraEject(projectRoot, rawYAML, provider, layerProvider) if err != nil { return err } @@ -506,7 +564,7 @@ func ejectInfra(projectRoot, provider string) error { return err } - res, err := synthesis.Synthesize(synthesis.Input{ + synthesisInput := synthesis.Input{ RawAzureYAML: rawYAML, ServiceName: svcName, AcceptedHosts: project.FoundryProvisioningServiceHosts, @@ -515,21 +573,14 @@ func ejectInfra(projectRoot, provider string) error { // the ejected main.parameters.json stays environment-portable; the // on-disk provision flow resolves them from the azd environment. PreserveVarRefs: true, - }) + } + var res *synthesis.Result + if existingProject { + res, err = synthesis.SynthesizeExistingProject(synthesisInput) + } else { + res, err = synthesis.Synthesize(synthesisInput) + } if err != nil { - // A brownfield (endpoint:) project provisions through the extension's - // brownfield path, which never compiles ./infra/. Ejecting IaC for it - // would be misleading, so refuse with a clear message instead of the - // raw synthesizer error. - if errors.Is(err, synthesis.ErrEndpointBrownfield) { - return exterrors.Validation( - exterrors.CodeInfraEjectBrownfieldUnsupported, - "`azd ai agent init --infra` is not supported for a project that reuses an existing "+ - "Foundry resource (the azure.ai.project service sets endpoint:)", - "remove --infra: the extension provisions the existing project (and any required "+ - "container registry) directly with `azd provision`", - ) - } // Reuse the provider's vocabulary so eject and provision report // consistent codes for the same azure.yaml problems. return exterrors.Validation( @@ -538,7 +589,28 @@ func ejectInfra(projectRoot, provider string) error { "check the endpoint, deployments, and network fields under your azure.ai.project service", ) } - + acrMode := infraEjectAcrNone + if existingProject { + var values map[string]string + if len(environments) > 0 { + values = environments[0] + } + acrMode, err = resolveInfraEjectAcrMode(res.Parameters, values) + if err != nil { + return err + } + if provider == project.TerraformProviderName && acrMode == infraEjectAcrCreate && + (strings.TrimSpace(values["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]) != "" || + strings.TrimSpace(values["AZURE_CONTAINER_REGISTRY_ENDPOINT"]) != "" || + strings.TrimSpace(values["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"]) != "" || + strings.TrimSpace(values["AZD_FOUNDRY_RESOURCE_GROUP_ID"]) != "") { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "Terraform eject cannot adopt the container registry previously created by microsoft.foundry", + "run `azd down` before ejecting Terraform, or keep Bicep/microsoft.foundry for the existing resources", + ) + } + } if provider == project.TerraformProviderName { // Private networking is Bicep-only today: the Terraform module has no // VNet / private-endpoint / DNS / networkInjections resources, so ejecting @@ -568,8 +640,14 @@ func ejectInfra(projectRoot, provider string) error { defer os.RemoveAll(stageDir) var written []ejectArtifact - if provider == project.TerraformProviderName { + if existingProject && provider == project.TerraformProviderName { + written, err = ejectExistingProjectTerraform( + stageDir, plan.targetPath, plan.module, res.Parameters, acrMode, environments) + } else if provider == project.TerraformProviderName { written, err = ejectTerraform(stageDir, plan.targetPath, plan.module, plan.layer, res.Parameters) + } else if existingProject { + written, err = ejectExistingProjectBicep( + stageDir, plan.targetPath, plan.module, res.Parameters, acrMode, environments) } else { written, err = ejectBicep(stageDir, plan.targetPath, plan.module, plan.layer, res.Parameters) } @@ -643,10 +721,48 @@ func ejectBicep( return written, nil } +func ejectExistingProjectBicep( + infraDir string, + artifactRoot string, + module string, + params map[string]any, + acrMode infraEjectAcrMode, + environments []map[string]string, +) ([]ejectArtifact, error) { + acrPullAssigned := len(environments) > 0 && strings.EqualFold( + strings.TrimSpace(environments[0]["AZD_FOUNDRY_ACR_PULL_ASSIGNED"]), "true") + written, err := writeExistingProjectBicepTemplates( + infraDir, artifactRoot, module, acrMode, acrPullAssigned) + if err != nil { + return nil, err + } + params["projectResourceId"] = "${AZURE_AI_PROJECT_ID}" + params["projectEndpoint"] = "${FOUNDRY_PROJECT_ENDPOINT}" + delete(params, "includeAcr") + if acrMode == infraEjectAcrCreate { + params["resourceGroupName"] = "${AZURE_FOUNDRY_RESOURCE_GROUP=rg-${AZURE_ENV_NAME}-foundry}" + params["location"] = "${AZURE_LOCATION}" + params["resourceTokenSalt"] = "${AZD_RESOURCE_TOKEN_SALT}" + params["tags"] = map[string]string{"azd-env-name": "${AZURE_ENV_NAME}"} + } else if (acrMode == infraEjectAcrReuseConnect || acrMode == infraEjectAcrAlreadyConnected) && + len(environments) > 0 { + params["existingAcrEndpoint"] = environments[0]["AZURE_CONTAINER_REGISTRY_ENDPOINT"] + params["existingAcrResourceId"] = environments[0]["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] + if acrMode == infraEjectAcrAlreadyConnected { + params["existingAcrConnectionName"] = environments[0]["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"] + } + } + paramsArtifact, err := writeParametersFile(infraDir, artifactRoot, module, false, params) + if err != nil { + return nil, err + } + return append(written, paramsArtifact), nil +} + // ejectTerraform writes the embedded Terraform module plus the generated // tfvars file into infraDir. // -// acr.tf is written only when an agent uses docker: (includeAcr). outputs.tf is +// container-registry.tf is written only when an agent uses docker: (includeAcr). outputs.tf is // generated to match: the ACR outputs are included only when acr.tf is present, // and omitted entirely otherwise. func ejectTerraform( @@ -682,6 +798,104 @@ func ejectTerraform( return written, nil } +func ejectExistingProjectTerraform( + infraDir string, + artifactRoot string, + module string, + params map[string]any, + acrMode infraEjectAcrMode, + environments []map[string]string, +) ([]ejectArtifact, error) { + acrPullAssigned := len(environments) > 0 && strings.EqualFold( + strings.TrimSpace(environments[0]["AZD_FOUNDRY_ACR_PULL_ASSIGNED"]), "true") + written, err := writeExistingProjectTerraformTemplates( + infraDir, artifactRoot, acrMode, acrPullAssigned) + if err != nil { + return nil, err + } + deploymentsArtifact, err := writeExistingProjectTerraformDeployments( + infraDir, artifactRoot, params) + if err != nil { + return nil, err + } + written = append(written, deploymentsArtifact) + markerArtifact, err := writeFoundryTerraformMarker(infraDir, artifactRoot) + if err != nil { + return nil, err + } + written = append(written, markerArtifact) + outputsArtifact, err := writeTerraformOutputsFile( + infraDir, + artifactRoot, + acrMode != infraEjectAcrNone, + "templates/terraform-existing-project/outputs.tf.tmpl", + synthesis.ExistingProjectTerraformTemplatesFS(), + false, + string(acrMode), + ) + if err != nil { + return nil, err + } + written = append(written, outputsArtifact) + tfvarsArtifact, err := writeExistingProjectTfvarsFile( + infraDir, artifactRoot, module, params, environments) + if err != nil { + return nil, err + } + return append(written, tfvarsArtifact), nil +} + +func writeExistingProjectTerraformDeployments( + infraDir string, + artifactRoot string, + params map[string]any, +) (ejectArtifact, error) { + deployments, ok := params["deployments"].([]synthesis.Deployment) + if !ok { + return ejectArtifact{}, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("deployments parameter has unexpected type %T", params["deployments"]), + ) + } + + var contents strings.Builder + contents.WriteString("# Model deployments are serialized because Cognitive Services throttles concurrent updates.\n") + resourceNames := make([]string, len(deployments)) + for i, deployment := range deployments { + resourceNames[i] = fmt.Sprintf("model_deployment_%x", sha256.Sum256([]byte(deployment.Name)))[:33] + } + for i := range deployments { + if i > 0 { + contents.WriteString("\n") + } + fmt.Fprintf(&contents, "resource \"azapi_resource\" %q {\n", resourceNames[i]) + fmt.Fprintf(&contents, " type = \"Microsoft.CognitiveServices/accounts/deployments@2025-06-01\"\n") + fmt.Fprintf(&contents, " name = var.deployments[%d].name\n", i) + contents.WriteString(" parent_id = local.foundry_account_id\n\n") + contents.WriteString(" body = {\n") + fmt.Fprintf(&contents, " properties = { model = var.deployments[%d].model }\n", i) + fmt.Fprintf(&contents, " sku = var.deployments[%d].sku\n", i) + contents.WriteString(" }\n") + if i > 0 { + fmt.Fprintf(&contents, "\n depends_on = [azapi_resource.%s]\n", resourceNames[i-1]) + } + contents.WriteString("}\n") + } + + dst := filepath.Join(infraDir, "model-deployments.tf") + //nolint:gosec // G306: ejected Terraform sources are intended to be human-readable + if err := os.WriteFile(dst, []byte(contents.String()), 0o644); err != nil { + return ejectArtifact{}, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("write model-deployments.tf: %s", err), + ) + } + return ejectArtifact{ + relPath: filepath.ToSlash(filepath.Join(artifactRoot, "model-deployments.tf")), + bytes: contents.Len(), + }, nil +} + func writeFoundryTerraformMarker(infraDir, artifactRoot string) (ejectArtifact, error) { dst := filepath.Join(infraDir, foundryTerraformMarker) //nolint:gosec // G306: generated marker is intended to be readable by project tooling @@ -697,7 +911,12 @@ func writeFoundryTerraformMarker(infraDir, artifactRoot string) (ejectArtifact, }, nil } -func planInfraEject(projectRoot string, rawYAML []byte, provider string) (*infraEjectPlan, error) { +func planInfraEject( + projectRoot string, + rawYAML []byte, + provider string, + layerProvider string, +) (*infraEjectPlan, error) { if provider != project.BicepProviderName && provider != project.TerraformProviderName { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, @@ -710,9 +929,9 @@ func planInfraEject(projectRoot string, rawYAML []byte, provider string) (*infra return nil, err } if config.layersNode == nil { - return planRootInfraEject(projectRoot, config, provider) + return planRootInfraEject(projectRoot, config, layerProvider) } - return planLayeredInfraEject(projectRoot, config, provider) + return planLayeredInfraEject(projectRoot, config, layerProvider) } func parseInfraConfig(rawYAML []byte) (*infraConfig, error) { @@ -794,7 +1013,11 @@ func parseInfraConfig(rawYAML []byte) (*infraConfig, error) { return config, nil } -func planRootInfraEject(projectRoot string, config *infraConfig, provider string) (*infraEjectPlan, error) { +func planRootInfraEject( + projectRoot string, + config *infraConfig, + layerProvider string, +) (*infraEjectPlan, error) { root := infraLayer{ node: config.infra, name: valueOrDefault(mappingScalar(config.infra, "name"), defaultInfraPath), @@ -812,7 +1035,7 @@ func planRootInfraEject(projectRoot string, config *infraConfig, provider string return nil, err } if !userOwned { - wanted := foundryLayerProvider(provider) + wanted := layerProvider changed := root.provider != wanted if changed { setMappingScalar(config.infra, "provider", wanted) @@ -836,7 +1059,7 @@ func planRootInfraEject(projectRoot string, config *infraConfig, provider string setMappingScalar(existingLayer, "path", filepath.ToSlash(root.path)) setMappingScalar(existingLayer, "provider", root.effectiveProvider) removeMappingKey(existingLayer, "layers") - foundryLayer := newInfraLayerNode(foundryInfraLayerName, foundryInfraLayerPath, foundryLayerProvider(provider)) + foundryLayer := newInfraLayerNode(foundryInfraLayerName, foundryInfraLayerPath, layerProvider) config.infra.Content = []*yaml.Node{ newScalarNode("layers"), {Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{existingLayer, foundryLayer}}, @@ -853,7 +1076,11 @@ func planRootInfraEject(projectRoot string, config *infraConfig, provider string return plan, err } -func planLayeredInfraEject(projectRoot string, config *infraConfig, provider string) (*infraEjectPlan, error) { +func planLayeredInfraEject( + projectRoot string, + config *infraConfig, + layerProvider string, +) (*infraEjectPlan, error) { var foundry *infraLayer for i := range config.layers { layer := &config.layers[i] @@ -884,22 +1111,22 @@ func planLayeredInfraEject(projectRoot string, config *infraConfig, provider str changed := false newLayer := foundry == nil if foundry == nil { - node := newInfraLayerNode(foundryInfraLayerName, foundryInfraLayerPath, foundryLayerProvider(provider)) + node := newInfraLayerNode(foundryInfraLayerName, foundryInfraLayerPath, layerProvider) config.layersNode.Content = append(config.layersNode.Content, node) config.layers = append(config.layers, infraLayer{ node: node, name: foundryInfraLayerName, path: foundryInfraLayerPath, - provider: foundryLayerProvider(provider), effectiveProvider: foundryLayerProvider(provider), + provider: layerProvider, effectiveProvider: layerProvider, }) foundry = &config.layers[len(config.layers)-1] changed = true } - wantedProvider := foundryLayerProvider(provider) + wantedProvider := layerProvider if foundry.effectiveProvider == "" { return nil, invalidInfraForEject( fmt.Sprintf("Foundry infrastructure layer %q must declare provider explicitly", foundry.name), ) } - if foundry.effectiveProvider != wantedProvider { + if foundry.effectiveProvider != wantedProvider && foundry.effectiveProvider != project.FoundryProviderName { return nil, invalidInfraForEject( fmt.Sprintf("Foundry infrastructure layer %q already uses provider %q", foundry.name, foundry.effectiveProvider), ) @@ -932,6 +1159,120 @@ func planLayeredInfraEject(projectRoot string, config *infraConfig, provider str return plan, err } +func writeExistingProjectBicepTemplates( + infraDir string, + artifactRoot string, + module string, + acrMode infraEjectAcrMode, + acrPullAssigned bool, +) ([]ejectArtifact, error) { + if acrMode != infraEjectAcrNone && acrMode != infraEjectAcrCreate && + acrMode != infraEjectAcrReuseConnect && acrMode != infraEjectAcrAlreadyConnected { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("unsupported existing-project ACR mode %q", acrMode), + ) + } + //nolint:gosec // generated infrastructure must be readable by project tooling + if err := os.MkdirAll(infraDir, 0o755); err != nil { + return nil, infraInstallError("create infrastructure directory", err) + } + entrypoint, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/existing-project-eject.bicep.tmpl") + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("read existing-project Bicep template: %s", err), + ) + } + tmpl, err := template.New("existing-project.bicep").Parse(string(entrypoint)) + if err != nil { + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("parse existing-project Bicep template: %s", err)) + } + var rendered bytes.Buffer + renderData := struct { + AcrMode string + AcrPullAssigned bool + }{AcrMode: string(acrMode), AcrPullAssigned: acrPullAssigned} + if err := tmpl.Execute(&rendered, renderData); err != nil { + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("render existing-project Bicep template: %s", err)) + } + entrypointPath := filepath.Join(infraDir, module+".bicep") + //nolint:gosec // G306: ejected Bicep sources are intended to be human-readable + if err := os.WriteFile(entrypointPath, rendered.Bytes(), 0o644); err != nil { + return nil, infraInstallError("write existing-project Bicep entrypoint", err) + } + artifacts := []ejectArtifact{{ + relPath: filepath.ToSlash(filepath.Join(artifactRoot, module+".bicep")), + bytes: rendered.Len(), + }} + + files := []struct { + source string + target string + }{ + {"templates/modules/foundry-project.bicep", "modules/foundry-project.bicep"}, + } + if acrMode == infraEjectAcrCreate || (acrMode == infraEjectAcrReuseConnect && !acrPullAssigned) { + registrySource, err := fs.ReadFile( + synthesis.TemplatesFS(), "templates/modules/container-registry-eject.bicep.tmpl") + if err != nil { + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("read container registry Bicep template: %s", err)) + } + registryTemplate, err := template.New("container-registry.bicep").Parse(string(registrySource)) + if err != nil { + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("parse container registry Bicep template: %s", err)) + } + var registry bytes.Buffer + if err := registryTemplate.Execute(®istry, renderData); err != nil { + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("render container registry Bicep template: %s", err)) + } + registryPath := filepath.Join(infraDir, "modules", "container-registry.bicep") + //nolint:gosec // G301: ejected infra directories must be readable/traversable by IDEs, Git, and CI + if err := os.MkdirAll(filepath.Dir(registryPath), 0o755); err != nil { + return nil, infraInstallError("create existing-project Bicep module directory", err) + } + //nolint:gosec // G306: ejected Bicep sources are intended to be human-readable + if err := os.WriteFile(registryPath, registry.Bytes(), 0o644); err != nil { + return nil, infraInstallError("write container registry Bicep module", err) + } + artifacts = append(artifacts, ejectArtifact{ + relPath: filepath.ToSlash(filepath.Join(artifactRoot, "modules/container-registry.bicep")), + bytes: registry.Len(), + }) + } + for _, file := range files { + data, err := fs.ReadFile(synthesis.TemplatesFS(), file.source) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("read existing-project Bicep template %s: %s", file.source, err), + ) + } + destination := filepath.Join(infraDir, filepath.FromSlash(file.target)) + //nolint:gosec // generated infrastructure directories must be readable by project tooling + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return nil, infraInstallError("create existing-project Bicep module directory", err) + } + //nolint:gosec // generated Bicep is intended to be human-readable + if err := os.WriteFile(destination, data, 0o644); err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("write existing-project Bicep template %s: %s", file.target, err), + ) + } + artifacts = append(artifacts, ejectArtifact{ + relPath: filepath.ToSlash(filepath.Join(artifactRoot, file.target)), + bytes: len(data), + }) + } + return artifacts, nil +} + func rootInfraUserOwned(layer infraLayer, target infraTargetState) (bool, error) { hasEntrypoint := hasInfrastructureEntrypoint(target.dir, layer.effectiveProvider, layer.module) if layer.provider == project.FoundryProviderName && hasEntrypoint { @@ -1077,6 +1418,9 @@ func mergeStagedInfra(stageDir string, plan *infraEjectPlan) (func(), error) { return err } dst := filepath.Join(plan.targetDir, rel) + if err := rejectSymlinkedParents(plan.targetDir, filepath.Dir(dst)); err != nil { + return err + } if _, err := os.Lstat(dst); err == nil { return ejectExistsError(filepath.ToSlash(filepath.Join(plan.targetPath, rel))) } else if !errors.Is(err, fs.ErrNotExist) { @@ -1111,6 +1455,40 @@ func mergeStagedInfra(stageDir string, plan *infraEjectPlan) (func(), error) { return rollback, nil } +func rejectSymlinkedParents(root, parent string) error { + rootInfo, err := os.Lstat(root) + if err == nil && rootInfo.Mode()&os.ModeSymlink != 0 { + return invalidInfraForEject(fmt.Sprintf("infrastructure destination root %q is a symbolic link", root)) + } + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("inspect infrastructure destination root %s: %w", root, err) + } + rel, err := filepath.Rel(root, parent) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return invalidInfraForEject(fmt.Sprintf("infrastructure destination %q escapes its target directory", parent)) + } + current := root + for _, component := range strings.FieldsFunc(rel, func(r rune) bool { return r == '/' || r == '\\' }) { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return fmt.Errorf("inspect infrastructure destination parent %s: %w", current, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return invalidInfraForEject( + fmt.Sprintf("infrastructure destination parent %q is a symbolic link", current), + ) + } + if !info.IsDir() { + return invalidInfraForEject(fmt.Sprintf("infrastructure destination parent %q is not a directory", current)) + } + } + return nil +} + func infraInstallError(action string, err error) error { return exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, fmt.Sprintf("%s: %s", action, err)) } @@ -1403,14 +1781,11 @@ func findFoundryServiceForEject(raw []byte) (string, error) { // templates/ root into infraDir, preserving the relative tree, and returns the // files written (with sizes). On any error it removes the partial infraDir. // -// Three files are skipped: +// Provider-runtime and existing-project files are skipped: // - main.arm.json (the pre-compiled ARM JSON): would be stale once the user // edits main.bicep. -// - brownfield.bicep and brownfield.arm.json: unreachable in a greenfield -// eject. ejectInfra already refuses to eject a brownfield (endpoint:) -// project, main.bicep never references brownfield.bicep, and the -// provider's brownfield path always loads the embedded -// synthesis.BrownfieldARMTemplate() instead of anything under infra/. +// - existing-project.bicep and its modules: emitted only by the dedicated +// existing-project writer. func writeEmbeddedTemplates( infraDir string, artifactRoot string, @@ -1461,8 +1836,20 @@ func writeEmbeddedTemplates( return nil } - switch filepath.Base(p) { - case "main.arm.json", "brownfield.bicep", "brownfield.arm.json": + base := filepath.Base(p) + if base == "existing-project-eject.bicep.tmpl" { + return nil + } + if strings.HasPrefix(base, "container-registry-") { + return nil + } + if base == "container-registry-eject.bicep.tmpl" { + return nil + } + switch base { + case "main.arm.json", "existing-project.bicep", + "existing-project-eject.bicep", "existing-project.arm.json", + "container-registry.bicep", "foundry-project.bicep": return nil } @@ -1538,7 +1925,7 @@ func writeParametersFile( // submodules) and returns the files written. On any error it removes the // partial infraDir. // -// acr.tf is copied only when includeAcr is true (an agent uses docker:); +// container-registry.tf is copied only when includeAcr is true (an agent uses docker:); // otherwise it is omitted and outputs.tf carries no ACR outputs. // // Files that are not verbatim copies are skipped here and produced elsewhere: @@ -1585,8 +1972,8 @@ func writeEmbeddedTerraformTemplates( if !strings.HasSuffix(name, ".tf") { continue } - // acr.tf is omitted unless an agent uses docker:. - if name == "acr.tf" && !includeAcr { + // container-registry.tf is omitted unless an agent uses docker:. + if name == "container-registry.tf" && !includeAcr { continue } data, err := fs.ReadFile(tfs, templatesRoot+"/"+name) @@ -1612,6 +1999,116 @@ func writeEmbeddedTerraformTemplates( return artifacts, nil } +func writeExistingProjectTerraformTemplates( + infraDir string, + artifactRoot string, + acrMode infraEjectAcrMode, + acrPullAssigned bool, +) ([]ejectArtifact, error) { + artifacts, err := writeTerraformTemplateSet( + infraDir, + artifactRoot, + acrMode == infraEjectAcrCreate, + "templates/terraform-existing-project", + synthesis.ExistingProjectTerraformTemplatesFS(), + ) + if err != nil { + return nil, err + } + if acrMode == infraEjectAcrReuseConnect { + source := "templates/terraform-existing-project/container-registry-reuse.tf" + if acrPullAssigned { + source = "templates/terraform-existing-project/container-registry-connect.tf" + } + artifact, err := copyTerraformTemplate( + infraDir, artifactRoot, source, + "container-registry.tf", + synthesis.ExistingProjectTerraformTemplatesFS()) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + return artifacts, nil +} + +func copyTerraformTemplate( + infraDir, artifactRoot, source, target string, + tfs fs.FS, +) (ejectArtifact, error) { + data, err := fs.ReadFile(tfs, source) + if err != nil { + return ejectArtifact{}, infraInstallError("read Terraform template", err) + } + //nolint:gosec // generated Terraform is intended to be human-readable + if err := os.WriteFile(filepath.Join(infraDir, target), data, 0o644); err != nil { + return ejectArtifact{}, infraInstallError("write Terraform template", err) + } + return ejectArtifact{relPath: filepath.ToSlash(filepath.Join(artifactRoot, target)), bytes: len(data)}, nil +} + +func writeTerraformTemplateSet( + infraDir string, + artifactRoot string, + includeAcr bool, + templatesRoot string, + tfs fs.FS, +) (_ []ejectArtifact, retErr error) { + //nolint:gosec // generated infrastructure must be readable by project tooling + if err := os.MkdirAll(infraDir, 0o755); err != nil { + return nil, infraInstallError("create Terraform infrastructure directory", err) + } + defer func() { + if retErr != nil { + _ = os.RemoveAll(infraDir) + } + }() + entries, err := fs.ReadDir(tfs, templatesRoot) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("read Terraform templates: %s", err), + ) + } + var artifacts []ejectArtifact + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".tf") || + name == "container-registry-reuse.tf" || name == "container-registry-connect.tf" || + name == "container-registry-create.tf" { + continue + } + data, err := fs.ReadFile(tfs, templatesRoot+"/"+name) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("read Terraform template %s: %s", name, err), + ) + } + //nolint:gosec // generated Terraform is intended to be human-readable + if err := os.WriteFile(filepath.Join(infraDir, name), data, 0o644); err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("write Terraform template %s: %s", name, err), + ) + } + artifacts = append(artifacts, ejectArtifact{ + relPath: filepath.ToSlash(filepath.Join(artifactRoot, name)), + bytes: len(data), + }) + } + if includeAcr { + artifact, err := copyTerraformTemplate( + infraDir, artifactRoot, templatesRoot+"/container-registry-create.tf", + "container-registry.tf", tfs) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + return artifacts, nil +} + // writeOutputsFile renders infra/outputs.tf from the embedded outputs.tf.tmpl. // The ACR outputs are included only when includeAcr is true (acr.tf was // written); otherwise they are omitted entirely, since Terraform resolves @@ -1622,8 +2119,27 @@ func writeOutputsFile( includeAcr bool, layer bool, ) (ejectArtifact, error) { - const tmplPath = "templates/terraform/outputs.tf.tmpl" - raw, err := fs.ReadFile(synthesis.TerraformTemplatesFS(), tmplPath) + return writeTerraformOutputsFile( + infraDir, + artifactRoot, + includeAcr, + "templates/terraform/outputs.tf.tmpl", + synthesis.TerraformTemplatesFS(), + layer, + "", + ) +} + +func writeTerraformOutputsFile( + infraDir string, + artifactRoot string, + includeAcr bool, + tmplPath string, + tfs fs.FS, + layer bool, + acrMode string, +) (ejectArtifact, error) { + raw, err := fs.ReadFile(tfs, tmplPath) if err != nil { return ejectArtifact{}, exterrors.Internal( exterrors.CodeInfraEjectWriteFailed, @@ -1643,7 +2159,8 @@ func writeOutputsFile( if err := tmpl.Execute(&buf, struct { IncludeAcr bool Layer bool - }{IncludeAcr: includeAcr, Layer: layer}); err != nil { + AcrMode string + }{IncludeAcr: includeAcr, Layer: layer, AcrMode: acrMode}); err != nil { return ejectArtifact{}, exterrors.Internal( exterrors.CodeInfraEjectWriteFailed, fmt.Sprintf("render outputs template: %s", err), @@ -1663,6 +2180,189 @@ func writeOutputsFile( }, nil } +func writeExistingProjectTfvarsFile( + infraDir string, + artifactRoot string, + module string, + params map[string]any, + environments []map[string]string, +) (ejectArtifact, error) { + doc := map[string]any{ //nolint:gosec // environment placeholders, not credentials + "subscription_id": "${AZURE_SUBSCRIPTION_ID}", + "tenant_id": "${AZURE_TENANT_ID}", + "project_resource_id": "${AZURE_AI_PROJECT_ID}", + "project_endpoint": "${FOUNDRY_PROJECT_ENDPOINT}", + "location": "${AZURE_LOCATION}", + "resource_group_name": "${AZURE_FOUNDRY_RESOURCE_GROUP=rg-${AZURE_ENV_NAME}-foundry}", + "environment_name": "${AZURE_ENV_NAME}", + "resource_token_salt": "${AZD_RESOURCE_TOKEN_SALT}", + } + if len(environments) > 0 { + doc["existing_acr_endpoint"] = environments[0]["AZURE_CONTAINER_REGISTRY_ENDPOINT"] + doc["existing_acr_resource_id"] = environments[0]["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] + doc["existing_acr_connection_name"] = environments[0]["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"] + } + if v, ok := params["deployments"]; ok { + doc["deployments"] = v + } + connections, ok := params["connections"].([]synthesis.Connection) + if !ok { + return ejectArtifact{}, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("connections parameter has unexpected type %T", params["connections"]), + ) + } + credentials, ok := params["connectionCredentials"].(map[string]map[string]any) + if !ok { + return ejectArtifact{}, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("connectionCredentials parameter has unexpected type %T", params["connectionCredentials"]), + ) + } + doc["connections"] = synthesis.JoinConnectionCredentials(connections, credentials) + return writeJSONArtifact(infraDir, artifactRoot, module+".tfvars.json", doc) +} + +func readInfraEjectEnvironment(ctx context.Context, azdClient *azdext.AzdClient) (map[string]string, error) { + current, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("read active azd environment for infrastructure eject: %w", err) + } + if current == nil || current.Environment == nil || current.Environment.Name == "" { + return nil, nil + } + values, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{Name: current.Environment.Name}) + if err != nil { + return nil, fmt.Errorf("read active azd environment for infrastructure eject: %w", err) + } + result := make(map[string]string, len(values.KeyValues)) + for _, item := range values.KeyValues { + result[item.Key] = item.Value + } + return result, nil +} + +func validateExistingProjectEjectEnvironment(endpoint string, values map[string]string) error { + envEndpoint := strings.TrimSpace(values["FOUNDRY_PROJECT_ENDPOINT"]) + if envEndpoint == "" || !sameFoundryProject(endpoint, envEndpoint) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "FOUNDRY_PROJECT_ENDPOINT does not match the existing project configured in azure.yaml", + "re-run `azd ai agent init` against the configured existing project", + ) + } + + account, projectName := foundryEndpointIdentity(endpoint) + idAccount, idProject := foundryResourceIDIdentity(values["AZURE_AI_PROJECT_ID"]) + if idAccount == "" || !strings.EqualFold(account, idAccount) || !strings.EqualFold(projectName, idProject) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "AZURE_AI_PROJECT_ID does not match the existing project configured in azure.yaml", + "re-run `azd ai agent init` against the configured existing project", + ) + } + return nil +} + +func sameFoundryProject(a, b string) bool { + aAccount, aProject := foundryEndpointIdentity(a) + bAccount, bProject := foundryEndpointIdentity(b) + return aAccount != "" && aProject != "" && + strings.EqualFold(aAccount, bAccount) && strings.EqualFold(aProject, bProject) +} + +func foundryEndpointIdentity(endpoint string) (string, string) { + const projectSegment = "/projects/" + value := strings.TrimSpace(endpoint) + _, hostAndPath, ok := strings.Cut(value, "://") + if !ok { + return "", "" + } + pathStart := strings.Index(hostAndPath, "/") + if pathStart < 0 { + return "", "" + } + host := strings.ToLower(hostAndPath[:pathStart]) + const hostSuffix = ".services.ai.azure.com" + if !strings.HasSuffix(host, hostSuffix) { + return "", "" + } + path := hostAndPath[pathStart:] + projectStart := strings.Index(strings.ToLower(path), projectSegment) + if projectStart < 0 { + return "", "" + } + projectName := strings.Split(strings.Trim(path[projectStart+len(projectSegment):], "/"), "/")[0] + return strings.TrimSuffix(host, hostSuffix), projectName +} + +func foundryResourceIDIdentity(resourceID string) (string, string) { + parts := strings.Split(strings.Trim(strings.TrimSpace(resourceID), "/"), "/") + if len(parts) != 10 || !strings.EqualFold(parts[0], "subscriptions") || + !strings.EqualFold(parts[2], "resourceGroups") || !strings.EqualFold(parts[4], "providers") || + !strings.EqualFold(parts[5], "Microsoft.CognitiveServices") || !strings.EqualFold(parts[6], "accounts") || + !strings.EqualFold(parts[8], "projects") { + return "", "" + } + return parts[7], parts[9] +} + +func resolveInfraEjectAcrMode(params map[string]any, values map[string]string) (infraEjectAcrMode, error) { + includeAcr, _ := params["includeAcr"].(bool) + if !includeAcr || strings.EqualFold(strings.TrimSpace(values["AZD_AGENT_SKIP_ACR"]), "true") { + return infraEjectAcrNone, nil + } + mode := infraEjectAcrMode(strings.TrimSpace(values["AZD_FOUNDRY_ACR_MODE"])) + if mode != "" { + switch mode { + case infraEjectAcrNone, infraEjectAcrCreate, infraEjectAcrReuseConnect, infraEjectAcrAlreadyConnected: + default: + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("AZD_FOUNDRY_ACR_MODE has unsupported value %q", mode), + "re-run `azd ai agent init` to select the container registry behavior", + ) + } + } + endpoint := strings.TrimSpace(values["AZURE_CONTAINER_REGISTRY_ENDPOINT"]) + resourceID := strings.TrimSpace(values["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]) + connection := strings.TrimSpace(values["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"]) + if mode == infraEjectAcrReuseConnect || mode == infraEjectAcrAlreadyConnected { + if endpoint == "" || resourceID == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("%s requires both container registry endpoint and resource ID", mode), + "set AZURE_CONTAINER_REGISTRY_ENDPOINT and AZURE_CONTAINER_REGISTRY_RESOURCE_ID, then retry", + ) + } + if mode == infraEjectAcrAlreadyConnected && connection == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "already-connected requires AZURE_AI_PROJECT_ACR_CONNECTION_NAME", + "set AZURE_AI_PROJECT_ACR_CONNECTION_NAME to the existing project connection, then retry", + ) + } + return mode, nil + } + if mode != "" { + return mode, nil + } + if endpoint == "" && resourceID == "" && connection == "" { + return infraEjectAcrCreate, nil + } + if endpoint == "" || resourceID == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "existing container registry state is incomplete", + "set both AZURE_CONTAINER_REGISTRY_ENDPOINT and AZURE_CONTAINER_REGISTRY_RESOURCE_ID, or clear both", + ) + } + if connection == "" { + return infraEjectAcrReuseConnect, nil + } + return infraEjectAcrAlreadyConnected, nil +} + // writeTfvarsFile emits infra/main.tfvars.json. azd-core's Terraform provider // reads this file and substitutes the ${...} placeholders from the azd // environment at provision time. The synthesizer-known values `deployments` diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go index a274088f705..4b65c3f9dcb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go @@ -4,11 +4,14 @@ package cmd import ( + "crypto/sha256" "encoding/json" "errors" "fmt" "io" + "maps" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -789,6 +792,47 @@ services: assert.Equal(t, "keep me\n", string(readme)) } +func TestEjectInfra_RefusesSymlinkedMergeParent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + outside := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: bicep + layers: + - name: app + path: infra/app +services: + my-foundry: + host: azure.ai.project +`) + target := filepath.Join(dir, "infra", "foundry") + require.NoError(t, os.MkdirAll(target, 0o750)) + mustWriteFile(t, filepath.Join(target, "README.md"), "keep me\n") + if err := os.Symlink(outside, filepath.Join(target, "modules")); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + + err := ejectInfra(dir, "bicep") + require.Error(t, err) + assert.Contains(t, err.Error(), "symbolic link") + assert.NoFileExists(t, filepath.Join(outside, "foundry-project.bicep")) +} + +func TestRejectSymlinkedParentsRejectsRoot(t *testing.T) { + t.Parallel() + parent := t.TempDir() + outside := t.TempDir() + root := filepath.Join(parent, "foundry") + if err := os.Symlink(outside, root); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + + err := rejectSymlinkedParents(root, filepath.Join(root, "modules")) + require.Error(t, err) + assert.Contains(t, err.Error(), "destination root") +} + func TestEjectInfra_RefusesGeneratedFileConflictWithoutUpdatingAzureYaml(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -913,23 +957,459 @@ services: assert.Contains(t, localErr.Message, "[agent-a agent-b]") } -func TestEjectInfra_RefusesWhenBrownfieldEndpoint(t *testing.T) { +func TestEjectInfra_ExistingProjectWritesEditableBicep(t *testing.T) { t.Parallel() dir := t.TempDir() mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: microsoft.foundry services: ai-project: host: azure.ai.project endpoint: https://acct.services.ai.azure.com/api/projects/p1 + deployments: [] `) err := ejectInfra(dir, "bicep") + require.NoError(t, err) + + template, err := os.ReadFile(filepath.Join(dir, "infra", "main.bicep")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(template), "existing =") + assert.NotContains(t, string(template), "allowProjectManagement") + assert.NotContains(t, string(template), "param acrMode") + assert.NoFileExists(t, filepath.Join(dir, "infra", "modules", "container-registry.bicep")) + assert.FileExists(t, filepath.Join(dir, "infra", "modules", "foundry-project.bicep")) + assert.Contains(t, string(template), "output AZD_FOUNDRY_ACR_MODE string = 'none'") + + params, err := os.ReadFile(filepath.Join(dir, "infra", "main.parameters.json")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(params), "${AZURE_AI_PROJECT_ID}") + assert.NotContains(t, string(params), `"acrMode"`) + + raw, err := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(raw), "provider: microsoft.foundry") +} + +func TestValidateExistingProjectEjectEnvironment(t *testing.T) { + t.Parallel() + endpoint := "https://account.services.ai.azure.com/api/projects/project" + resourceID := "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/project" + require.NoError(t, validateExistingProjectEjectEnvironment(endpoint, map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": endpoint, + "AZURE_AI_PROJECT_ID": resourceID, + })) + + for _, values := range []map[string]string{ + {"AZURE_AI_PROJECT_ID": resourceID}, + {"FOUNDRY_PROJECT_ENDPOINT": endpoint}, + {"FOUNDRY_PROJECT_ENDPOINT": "https://account.services.ai.azure.com/api/projects/other"}, + {"FOUNDRY_PROJECT_ENDPOINT": endpoint, "AZURE_AI_PROJECT_ID": "/accounts/account/projects/project"}, + {"AZURE_AI_PROJECT_ID": strings.TrimSuffix(resourceID, "/project") + "/other"}, + } { + err := validateExistingProjectEjectEnvironment(endpoint, values) + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.Equal(t, exterrors.CodeInvalidParameter, localErr.Code) + } +} + +func TestEjectInfra_ExistingProjectBicepSelectsAcrModule(t *testing.T) { + t.Parallel() + + const projectYAML = `name: my-project +infra: + provider: microsoft.foundry +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + uses: [ai-project] + project: src/agent + docker: + path: Dockerfile +` + + tests := []struct { + name string + env map[string]string + wantMode string + wantMain string + wantRegistry string + expectRegistry bool + }{ + {name: "create", env: map[string]string{"AZD_FOUNDRY_ACR_MODE": "create"}, wantMode: "create", wantMain: "resource adjunctResourceGroup", wantRegistry: "resource registry ", expectRegistry: true}, + {name: "reuse connect", env: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "reuse-connect", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + }, wantMode: "reuse-connect", wantMain: "scope: resourceGroup(split(existingAcrResourceId", wantRegistry: "existing =", expectRegistry: true}, + {name: "reuse connect with AcrPull", env: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "reuse-connect", + "AZD_FOUNDRY_ACR_PULL_ASSIGNED": "true", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + }, wantMode: "reuse-connect", wantMain: "acrResourceId: existingAcrResourceId"}, + {name: "already connected", env: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "already-connected", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "registry-conn", + }, wantMode: "already-connected", wantMain: "existingAcrConnectionName"}, + {name: "none", env: map[string]string{"AZD_AGENT_SKIP_ACR": "true"}, wantMode: "none", wantMain: "output AZURE_CONTAINER_REGISTRY_ENDPOINT string = ''"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), projectYAML) + env := maps.Clone(tt.env) + env["FOUNDRY_PROJECT_ENDPOINT"] = "https://acct.services.ai.azure.com/api/projects/p1" + env["AZURE_AI_PROJECT_ID"] = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/projects/p1" + + require.NoError(t, ejectInfra(dir, "bicep", env)) + main, err := os.ReadFile(filepath.Join(dir, "infra", "main.bicep")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(main), "output AZD_FOUNDRY_ACR_MODE string = '"+tt.wantMode+"'") + assert.Contains(t, string(main), tt.wantMain) + registryPath := filepath.Join(dir, "infra", "modules", "container-registry.bicep") + if tt.expectRegistry { + registry, err := os.ReadFile(registryPath) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(registry), tt.wantRegistry) + } else { + assert.NoFileExists(t, registryPath) + } + + params, err := os.ReadFile(filepath.Join(dir, "infra", "main.parameters.json")) //nolint:gosec + require.NoError(t, err) + assert.NotContains(t, string(params), `"acrMode"`) + if tt.wantMode == "create" || tt.wantMode == "none" { + assert.NotContains(t, string(params), `"existingAcr`) + } + }) + } +} + +func TestEjectInfra_ExistingProjectBicepModesCompile(t *testing.T) { + t.Parallel() + bicep := lookupInstalledBicep() + if bicep == "" { + t.Skip("Bicep CLI not found; skipping generated Bicep compilation") + } + + const projectYAML = `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + uses: [ai-project] + project: src/agent + docker: + path: Dockerfile +` + environments := map[string]map[string]string{ + "create": {"AZD_FOUNDRY_ACR_MODE": "create"}, + "reuse-connect": { + "AZD_FOUNDRY_ACR_MODE": "reuse-connect", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + }, + "reuse-connect-with-acr-pull": { + "AZD_FOUNDRY_ACR_MODE": "reuse-connect", + "AZD_FOUNDRY_ACR_PULL_ASSIGNED": "true", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + }, + "already-connected": { + "AZD_FOUNDRY_ACR_MODE": "already-connected", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "registry-conn", + }, + "none": {"AZD_AGENT_SKIP_ACR": "true"}, + } + + for mode, env := range environments { + t.Run(mode, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), projectYAML) + env := maps.Clone(env) + env["FOUNDRY_PROJECT_ENDPOINT"] = "https://acct.services.ai.azure.com/api/projects/p1" + env["AZURE_AI_PROJECT_ID"] = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/projects/p1" + require.NoError(t, ejectInfra(dir, "bicep", env)) + + out := filepath.Join(t.TempDir(), "main.json") + cmd := exec.CommandContext(t.Context(), bicep, "build", + filepath.Join(dir, "infra", "main.bicep"), "--outfile", out) + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "generated %s Bicep failed to compile: %s", mode, output) + }) + } +} + +func lookupInstalledBicep() string { + if path, err := exec.LookPath("bicep"); err == nil { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + for _, name := range []string{"bicep", "bicep.exe"} { + path := filepath.Join(home, ".azure", "bin", name) + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +func TestEjectInfra_ExistingProjectAddsBicepLayerBesideExistingInfra(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: bicep +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 +`) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "infra"), 0o750)) + mustWriteFile(t, filepath.Join(dir, "infra", "main.bicep"), "// existing infrastructure\n") + + require.NoError(t, ejectInfra(dir, "bicep")) + assert.FileExists(t, filepath.Join(dir, "infra", "foundry", "main.bicep")) + + raw, err := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec + require.NoError(t, err) + var doc struct { + Infra struct { + Layers []struct { + Name string `yaml:"name"` + Provider string `yaml:"provider"` + } `yaml:"layers"` + } `yaml:"infra"` + } + require.NoError(t, yaml.Unmarshal(raw, &doc)) + require.Len(t, doc.Infra.Layers, 2) + assert.Equal(t, "foundry", doc.Infra.Layers[1].Name) + assert.Equal(t, "microsoft.foundry", doc.Infra.Layers[1].Provider) +} + +func TestEjectInfra_ExistingProjectWritesTerraform(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: microsoft.foundry +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + deployments: [] + agent: + host: azure.ai.agent + uses: [ai-project] + project: src/agent + docker: + path: Dockerfile +`) + + err := ejectInfra(dir, "terraform") + require.NoError(t, err) + + for _, name := range []string{ + "provider.tf", "variables.tf", "main.tf", "model-deployments.tf", "connections.tf", + "container-registry.tf", "outputs.tf", "main.tfvars.json", + } { + assert.FileExists(t, filepath.Join(dir, "infra", name)) + } + main, err := os.ReadFile(filepath.Join(dir, "infra", "main.tf")) //nolint:gosec + require.NoError(t, err) + assert.NotContains(t, string(main), `resource "azapi_resource" "foundry_account"`) + assert.NotContains(t, string(main), `resource "azapi_resource" "project"`) + assert.NotContains(t, string(main), `resource "azapi_resource" "model_deployment"`) + assert.Contains(t, string(main), "project_endpoint_matches") + deployments, err := os.ReadFile(filepath.Join(dir, "infra", "model-deployments.tf")) //nolint:gosec + require.NoError(t, err) + assert.NotContains(t, string(deployments), `resource "azapi_resource"`) + + tfvars, err := os.ReadFile(filepath.Join(dir, "infra", "main.tfvars.json")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(tfvars), "${AZURE_AI_PROJECT_ID}") + assert.Contains(t, string(tfvars), "${FOUNDRY_PROJECT_ENDPOINT}") + assert.NotContains(t, string(tfvars), `"acr_mode"`) + + outputs, err := os.ReadFile(filepath.Join(dir, "infra", "outputs.tf")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(outputs), `value = "create"`) + assert.NotContains(t, string(outputs), "var.acr_mode") + assert.Contains(t, string(outputs), "project_endpoint must identify the same Foundry project") + + variables, err := os.ReadFile(filepath.Join(dir, "infra", "variables.tf")) //nolint:gosec + require.NoError(t, err) + assert.NotContains(t, string(variables), `variable "acr_mode"`) + + assert.NotContains(t, string(main), "resource_token") + registry, err := os.ReadFile(filepath.Join(dir, "infra", "container-registry.tf")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(registry), "resource_token") + + raw, err := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(raw), "provider: terraform") + + provider, err := os.ReadFile(filepath.Join(dir, "infra", "provider.tf")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(provider), "subscription_id = local.project_subscription_id") + assert.Contains(t, string(provider), "tenant_id = var.tenant_id") + assert.Contains(t, string(tfvars), `"tenant_id": "${AZURE_TENANT_ID}"`) +} + +func TestEjectInfra_ExistingProjectTerraformWithoutDockerOmitsAcr(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + deployments: [] + agent: + host: azure.ai.agent + uses: [ai-project] + project: src/agent + image: registry.example.com/agent:latest +`) + + require.NoError(t, ejectInfra(dir, "terraform")) + assert.NoFileExists(t, filepath.Join(dir, "infra", "container-registry.tf")) + outputs, err := os.ReadFile(filepath.Join(dir, "infra", "outputs.tf")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(outputs), "AZURE_CONTAINER_REGISTRY_ENDPOINT") + assert.Contains(t, string(outputs), `value = ""`) + assert.Contains(t, string(outputs), "FOUNDRY_PROJECT_ENDPOINT") + assert.Contains(t, string(outputs), `value = "none"`) + assert.NotContains(t, string(outputs), "var.acr_mode") + + tfvars, err := os.ReadFile(filepath.Join(dir, "infra", "main.tfvars.json")) //nolint:gosec + require.NoError(t, err) + assert.NotContains(t, string(tfvars), `"acr_mode"`) +} + +func TestEjectInfra_ExistingProjectTerraformSerializesDeployments(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + deployments: + - name: first + model: { name: first-model, format: OpenAI, version: "1" } + sku: { name: GlobalStandard, capacity: 1 } + - name: second + model: { name: second-model, format: OpenAI, version: "1" } + sku: { name: GlobalStandard, capacity: 1 } +`) + + require.NoError(t, ejectInfra(dir, "terraform")) + data, err := os.ReadFile(filepath.Join(dir, "infra", "model-deployments.tf")) //nolint:gosec + require.NoError(t, err) + contents := string(data) + firstResource := fmt.Sprintf("model_deployment_%x", sha256.Sum256([]byte("first")))[:33] + secondResource := fmt.Sprintf("model_deployment_%x", sha256.Sum256([]byte("second")))[:33] + assert.Contains(t, contents, fmt.Sprintf(`resource "azapi_resource" %q`, firstResource)) + assert.Contains(t, contents, fmt.Sprintf(`resource "azapi_resource" %q`, secondResource)) + assert.Contains(t, contents, fmt.Sprintf("depends_on = [azapi_resource.%s]", firstResource)) +} + +func TestEjectInfra_ExistingProjectTerraformRejectsProvisionedCreateMode(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + uses: [project] + docker: { path: Dockerfile } +`) + env := map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "create", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p1", + "AZURE_AI_PROJECT_ID": "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/projects/p1", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/owned/providers/" + + "Microsoft.ContainerRegistry/registries/registry", + } + + err := ejectInfra(dir, "terraform", env) require.Error(t, err) + assert.Contains(t, err.Error(), "cannot adopt") + assert.NoDirExists(t, filepath.Join(dir, "infra")) +} - localErr, ok := errors.AsType[*azdext.LocalError](err) - require.True(t, ok) - assert.Equal(t, exterrors.CodeInfraEjectBrownfieldUnsupported, localErr.Code) - assert.Contains(t, localErr.Message, "endpoint:") +func TestResolveInfraEjectAcrMode(t *testing.T) { + t.Parallel() + params := map[string]any{"includeAcr": true} + tests := []struct { + name string + values map[string]string + want infraEjectAcrMode + fail bool + }{ + {name: "create", values: map[string]string{}, want: infraEjectAcrCreate}, + {name: "reuse and connect", values: map[string]string{ + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + }, want: infraEjectAcrReuseConnect}, + {name: "already connected", values: map[string]string{ + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "registry-conn", + }, want: infraEjectAcrAlreadyConnected}, + {name: "skip", values: map[string]string{"AZD_AGENT_SKIP_ACR": "true"}, want: infraEjectAcrNone}, + {name: "incomplete", values: map[string]string{ + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + }, fail: true}, + {name: "explicit reuse missing registry", values: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "reuse-connect", + }, fail: true}, + {name: "explicit connected missing connection", values: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "already-connected", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/registry", + }, fail: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := resolveInfraEjectAcrMode(params, tt.values) + if tt.fail { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } } func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { @@ -944,8 +1424,8 @@ func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { require.NoError(t, err) }) - // Every embedded template under templates/ (except main.arm.json and the - // dead-in-a-greenfield-eject brownfield.bicep/brownfield.arm.json) should + // Every project-creation template under templates/ (except compiled and + // provider-runtime assets) should // be on disk under ./infra/, plus the synthesized main.parameters.json. expected := []string{ filepath.Join("infra", "main.bicep"), @@ -970,11 +1450,11 @@ func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { "main.arm.json should be excluded from the ejected tree (it would be stale "+ "the moment the user edits main.bicep)") - // brownfield.bicep/brownfield.arm.json are excluded too: unreachable in a - // greenfield eject (see TestEjectInfra_RefusesWhenBrownfieldEndpoint). + // Existing-project templates are excluded when the project is created by azd. for _, rel := range []string{ - filepath.Join("infra", "brownfield.bicep"), - filepath.Join("infra", "brownfield.arm.json"), + filepath.Join("infra", "existing-project.bicep"), + filepath.Join("infra", "modules", "container-registry.bicep"), + filepath.Join("infra", "modules", "foundry-project.bicep"), } { _, err := os.Stat(filepath.Join(dir, rel)) assert.True(t, os.IsNotExist(err), @@ -1494,7 +1974,7 @@ services: t.Chdir(nestedDir) withCapturedStdout(t, func() { - require.NoError(t, ejectInfraAfterInit("bicep")) + require.NoError(t, ejectInfraAfterInit(t.Context(), "bicep")) }) assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.bicep")) @@ -1532,7 +2012,7 @@ func TestEjectInfraAfterInit_NoProject(t *testing.T) { t.Setenv("AZD_EXEC_PROJECT_DIR", "") t.Chdir(t.TempDir()) - assert.NoError(t, ejectInfraAfterInit("bicep")) + assert.NoError(t, ejectInfraAfterInit(t.Context(), "bicep")) } func TestEjectInfraAfterInit_SkipsProjectWithoutFoundryService(t *testing.T) { @@ -1545,7 +2025,7 @@ services: `), 0600)) t.Chdir(projectRoot) - assert.NoError(t, ejectInfraAfterInit("bicep")) + assert.NoError(t, ejectInfraAfterInit(t.Context(), "bicep")) assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) } @@ -1561,7 +2041,7 @@ services: `), 0600)) t.Chdir(projectRoot) - err := ejectInfraAfterInit("bicep") + err := ejectInfraAfterInit(t.Context(), "bicep") require.Error(t, err) localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok, "expected *azdext.LocalError, got %T", err) @@ -2307,7 +2787,7 @@ services: `) withCapturedStdout(t, func() { - require.NoError(t, ejectInfraAfterInit("bicep")) + require.NoError(t, ejectInfraAfterInit(t.Context(), "bicep")) }) assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.bicep")) @@ -2336,7 +2816,7 @@ func TestEjectInfra_Terraform_HappyPath_WritesExpectedFiles(t *testing.T) { filepath.Join("infra", "provider.tf"), filepath.Join("infra", "variables.tf"), filepath.Join("infra", "main.tf"), - filepath.Join("infra", "acr.tf"), + filepath.Join("infra", "container-registry.tf"), filepath.Join("infra", "connections.tf"), filepath.Join("infra", "outputs.tf"), filepath.Join("infra", "main.tfvars.json"), @@ -2513,7 +2993,6 @@ services: outputs, err := os.ReadFile(filepath.Join(dir, "infra", "outputs.tf")) //nolint:gosec // G304: test path from t.TempDir() require.NoError(t, err) assert.Contains(t, string(outputs), "AZURE_AI_PROJECT_CONNECTION_NAMES") - assert.Contains(t, string(outputs), "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT") } func TestEjectInfra_Terraform_NoDockerOmitsAcr(t *testing.T) { @@ -2535,7 +3014,7 @@ services: }) // acr.tf must NOT be written when no agent uses docker:. - _, err := os.Stat(filepath.Join(dir, "infra", "acr.tf")) + _, err := os.Stat(filepath.Join(dir, "infra", "container-registry.tf")) assert.True(t, os.IsNotExist(err), "acr.tf must be omitted when no agent uses docker:") // outputs.tf must not contain any ACR output at all when ACR is not used -- diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go index 65faf5b730c..6249656fe85 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go @@ -291,6 +291,10 @@ func validateFoundryProjectDependency(_ *azdext.ServiceConfig, env map[string]st } func validateFoundryConnectionDependency(service *azdext.ServiceConfig, env map[string]string) string { + connectionProject := strings.TrimSpace(env[envkey.ConnectionProjectEndpoint]) + if connectionProject != "" && !sameProjectEndpoint(connectionProject, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return fmt.Sprintf("%s does not match FOUNDRY_PROJECT_ENDPOINT", envkey.ConnectionProjectEndpoint) + } found := false for name := range strings.SplitSeq(env["AZURE_AI_PROJECT_CONNECTION_NAMES"], ",") { if strings.TrimSpace(name) == service.GetName() { @@ -301,13 +305,6 @@ func validateFoundryConnectionDependency(service *azdext.ServiceConfig, env map[ if !found { return "connection is not listed in AZURE_AI_PROJECT_CONNECTION_NAMES" } - // Older project extensions published connection names without a scope marker. - if strings.TrimSpace(env[envkey.ConnectionProjectEndpoint]) == "" { - return "" - } - if !sameProjectEndpoint(env[envkey.ConnectionProjectEndpoint], env["FOUNDRY_PROJECT_ENDPOINT"]) { - return fmt.Sprintf("%s does not match FOUNDRY_PROJECT_ENDPOINT", envkey.ConnectionProjectEndpoint) - } return "" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go index a9b3be98f11..50857bd5fa6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go @@ -163,6 +163,22 @@ func TestValidateFoundryDependencies(t *testing.T) { "AZURE_AI_PROJECT_CONNECTION_NAMES": "connection", }, }, + { + name: "connection readiness from another project fails", + uses: []string{"connection"}, + services: map[string]*azdext.ServiceConfig{ + "connection": {Name: "connection", Host: foundryConnectionHost}, + }, + env: map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://example.test/projects/current", + envkey.ConnectionProjectEndpoint: "https://example.test/projects/old", + "AZURE_AI_PROJECT_CONNECTION_NAMES": "connection", + }, + wantErr: true, + wantDetail: []string{ + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT does not match FOUNDRY_PROJECT_ENDPOINT", + }, + }, { name: "skill marker from another project fails", uses: []string{"summarize"}, @@ -385,15 +401,6 @@ func TestValidateFoundryDependenciesRejectsCrossProjectMarkers(t *testing.T) { }, detail: "TOOLBOX_DEP_PROJECT_ENDPOINT", }, - { - name: "connection", host: foundryConnectionHost, - env: map[string]string{ - "FOUNDRY_PROJECT_ENDPOINT": "https://current", - "AZURE_AI_PROJECT_CONNECTION_NAMES": "dep", - envkey.ConnectionProjectEndpoint: "https://old", - }, - detail: envkey.ConnectionProjectEndpoint, - }, { name: "agent", host: foundryAgentHost, env: map[string]string{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go index 56bc7904bb2..fabbdbe5de0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go @@ -120,22 +120,22 @@ func TestARMTemplate_MatchesBicepBuild(t *testing.T) { "--outfile main.arm.json` from the templates directory") } -// TestBrownfieldARMTemplate_MatchesBicepBuild is the brownfield.bicep counterpart +// TestExistingProjectARMTemplate_MatchesBicepBuild is the existing-project.bicep counterpart // of TestARMTemplate_MatchesBicepBuild: it catches a forgotten `bicep build` after -// editing the brownfield model-deployment template. Skipped when bicep is absent. -func TestBrownfieldARMTemplate_MatchesBicepBuild(t *testing.T) { +// editing the existing-project template. Skipped when bicep is absent. +func TestExistingProjectARMTemplate_MatchesBicepBuild(t *testing.T) { bicep := lookupBicep() if bicep == "" { t.Skip("bicep CLI not found on PATH; skipping ARM drift check") } templatesDir := "templates" - committed, err := os.ReadFile(filepath.Join(templatesDir, "brownfield.arm.json")) + committed, err := os.ReadFile(filepath.Join(templatesDir, "existing-project.arm.json")) require.NoError(t, err) - out := filepath.Join(t.TempDir(), "brownfield.arm.json") + out := filepath.Join(t.TempDir(), "existing-project.arm.json") cmd := exec.CommandContext(t.Context(), bicep, "build", - filepath.Join(templatesDir, "brownfield.bicep"), "--outfile", out) + filepath.Join(templatesDir, "existing-project.bicep"), "--outfile", out) var stderr bytes.Buffer cmd.Stderr = &stderr require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) @@ -147,8 +147,8 @@ func TestBrownfieldARMTemplate_MatchesBicepBuild(t *testing.T) { rebuiltNormalized := normalizeArmTemplate(t, rebuilt) assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), - "templates/brownfield.arm.json is stale; regenerate with `bicep build "+ - "brownfield.bicep --outfile brownfield.arm.json` from the templates directory") + "templates/existing-project.arm.json is stale; regenerate with `bicep build "+ + "existing-project.bicep --outfile existing-project.arm.json` from the templates directory") } // normalizeArmTemplate returns a stable JSON representation of an ARM template diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 67c4d536464..b6cb66a38fa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -327,6 +327,59 @@ func Synthesize(in Input) (*Result, error) { }, nil } +// SynthesizeExistingProject derives parameters for editable infrastructure that +// augments an existing Foundry project without taking ownership of it. +func SynthesizeExistingProject(in Input) (*Result, error) { + if len(in.RawAzureYAML) == 0 { + return nil, errors.New("synthesis: RawAzureYAML is empty") + } + if in.ServiceName == "" { + return nil, errors.New("synthesis: ServiceName is empty") + } + + var root projectFile + if err := yaml.Unmarshal(in.RawAzureYAML, &root); err != nil { + return nil, fmt.Errorf("parse azure.yaml: %w", err) + } + svc, err := loadProjectService(root.Services, in.ServiceName, in.ProjectRoot) + if err != nil { + return nil, err + } + if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { + return nil, ErrServiceNotFound + } + if strings.TrimSpace(svc.Endpoint) == "" { + return nil, errors.New("synthesis: existing Foundry project endpoint is empty") + } + + includeAcr, err := deriveIncludeAcr(root.Services, svc, in.ProjectRoot) + if err != nil { + return nil, err + } + connections, err := collectConnections( + root.Services, + in.Env, + in.ServiceEnvironments, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } + connections, connectionCredentials := SplitConnectionCredentials(connections) + deployments := svc.Deployments + if deployments == nil { + deployments = []Deployment{} + } + + return &Result{Parameters: map[string]any{ + "deployments": deployments, + "includeAcr": includeAcr, + "connections": connections, + "connectionCredentials": connectionCredentials, + }, NetworkMode: NetworkModeNone}, nil +} + // ConnectionEnvironmentScopes returns services that declare env. // An empty env block still establishes an isolated service scope. func ConnectionEnvironmentScopes( diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 5a725848dde..a8f0be115a7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -1231,7 +1231,7 @@ func TestTerraformTemplatesFS_Embedded(t *testing.T) { "templates/terraform/provider.tf", "templates/terraform/variables.tf", "templates/terraform/main.tf", - "templates/terraform/acr.tf", + "templates/terraform/container-registry.tf", "templates/terraform/connections.tf", "templates/terraform/outputs.tf.tmpl", } @@ -1242,10 +1242,6 @@ func TestTerraformTemplatesFS_Embedded(t *testing.T) { assert.NotEmpty(t, data, "%s should not be empty", p) }) } - outputs, err := fs.ReadFile("templates/terraform/outputs.tf.tmpl") - require.NoError(t, err) - assert.Contains(t, string(outputs), `output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT"`) - // outputs.tf is rendered from outputs.tf.tmpl at eject time, and // main.tfvars.json is generated -- neither is embedded as a final file // (otherwise they would go stale). diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json deleted file mode 100644 index 77cda60d3cf..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "5428399781259274778" - } - }, - "definitions": { - "deploymentsType": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "metadata": { - "description": "Shape of one model deployment entry in azure.yaml." - } - }, - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - }, - "metadata": { - "description": "Shape of a single model deployment." - } - }, - "connectionsType": { - "type": "array", - "items": { - "$ref": "#/definitions/connectionType" - }, - "metadata": { - "description": "Shape of a list of Foundry project connections." - } - }, - "connectionType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "category": { - "type": "string" - }, - "target": { - "type": "string" - }, - "authType": { - "type": "string" - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } - } - }, - "parameters": { - "accountName": { - "type": "string", - "minLength": 2, - "maxLength": 64, - "metadata": { - "description": "Name of the existing Foundry (AIServices) account." - } - }, - "projectName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true." - } - }, - "deployments": { - "$ref": "#/definitions/deploymentsType", - "defaultValue": [], - "metadata": { - "description": "Model deployments to create or update on the existing account." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for the container registry. Defaults to the resource group location." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to created resources." - } - }, - "includeAcr": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent." - } - }, - "acrName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true." - } - }, - "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], - "metadata": { - "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." - } - }, - "connectionCredentials": { - "type": "secureObject", - "defaultValue": {}, - "metadata": { - "description": "Credentials keyed by Foundry project connection name." - } - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" - }, - "resources": { - "foundryAccountPreview::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('accountName')]" - }, - "modelDeployments": { - "copy": { - "name": "modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]" - }, - "foundryAccountPreview": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]" - }, - "registry": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('acrName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - "acrConnection": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}-conn', parameters('accountName'), parameters('projectName'), parameters('acrName'))]", - "properties": { - "category": "ContainerRegistry", - "target": "[reference('registry').loginServer]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "foundryAcrPull", - "registry" - ] - }, - "projectConnections": { - "copy": { - "name": "projectConnections", - "count": "[length(parameters('connections'))]" - }, - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(contains(parameters('connectionCredentials'), parameters('connections')[copyIndex()].name), createObject('credentials', parameters('connectionCredentials')[parameters('connections')[copyIndex()].name]), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" - }, - "foundryAcrPull": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-acr-pull", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('acrName')]" - }, - "principalId": { - "value": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]" - }, - "roleDefinitionId": { - "value": "[variables('acrPullRoleId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "16037481882754055301" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving AcrPull on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "AcrPull role definition resource ID." - } - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "registry" - ] - } - }, - "outputs": { - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[if(parameters('includeAcr'), reference('registry').loginServer, '')]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[if(parameters('includeAcr'), resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[if(parameters('includeAcr'), format('{0}-conn', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_CONNECTION_NAMES": { - "type": "string", - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" - } - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep deleted file mode 100644 index 16c41c23f4d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep +++ /dev/null @@ -1,190 +0,0 @@ -// Resource-group-scoped template for an EXISTING Foundry (AIServices) account. -// The account and project are REFERENCED, never created. It reconciles model -// deployments declared in azure.yaml and, when includeAcr is true, creates a -// container registry wired to the project (AcrPull + ContainerRegistry -// connection) for a hosted container agent. Used by the brownfield path. - -targetScope = 'resourceGroup' - -// User-defined types (match the deploymentType in main.bicep). - -@description('Shape of one model deployment entry in azure.yaml.') -type deploymentsType = deploymentType[] - -@description('Shape of a single model deployment.') -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -@description('Shape of a list of Foundry project connections.') -type connectionsType = connectionType[] - -@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') -type connectionType = { - name: string - category: string - target: string - authType: string - metadata: object? -} - -// Parameters - -@description('Name of the existing Foundry (AIServices) account.') -@minLength(2) -@maxLength(64) -param accountName string - -@description('Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true.') -param projectName string = '' - -@description('Model deployments to create or update on the existing account.') -param deployments deploymentsType = [] - -@description('Azure region for the container registry. Defaults to the resource group location.') -param location string = resourceGroup().location - -@description('Tags applied to created resources.') -param tags object = {} - -@description('Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent.') -param includeAcr bool = false - -@description('Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true.') -param acrName string = '' - -@description('Foundry project connections to create on the existing project (host: azure.ai.connection services).') -param connections connectionsType = [] - -@description('Credentials keyed by Foundry project connection name.') -@secure() -param connectionCredentials object = {} - -// Resources - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: accountName -} - -// Sequential creation; ARM throttles concurrent deployments on one account. -// CreateOrUpdate is an idempotent upsert, so re-running reconciles an existing -// deployment rather than duplicating it. -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ - for d in deployments: { - parent: foundryAccount - name: d.name - properties: { - model: d.model - } - sku: d.sku - } -] - -// Existing project reference (preview API): exposes the project's system-assigned -// managed identity principal id, used as the AcrPull grantee and the connection -// credential identity. Pinned to 2025-04-01-preview to match acr.bicep; the GA -// API fails to resolve the projects/connections ContainerRegistry sub-resource. -resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} - -// Container registry for the hosted container agent. Premium SKU mirrors the -// greenfield acr.bicep. -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (includeAcr) { - name: acrName - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - zoneRedundancy: 'Disabled' - } -} - -// Built-in AcrPull role. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) - -// The nested module makes the runtime project principal a deployment -// parameter. The assignment name can then include that principal. -module foundryAcrPull 'modules/acr-pull-role-assignment.bicep' = if (includeAcr) { - name: 'foundry-acr-pull' - params: { - registryName: registry.name - principalId: foundryAccountPreview::project.identity.principalId - roleDefinitionId: acrPullRoleId - } -} - -// Project-scoped ContainerRegistry connection so Foundry can resolve the registry -// by name when running the hosted agent. -resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (includeAcr) { - name: '${accountName}/${projectName}/${acrName}-conn' - properties: { - category: 'ContainerRegistry' - target: registry!.properties.loginServer - authType: 'ManagedIdentity' - credentials: { - clientId: foundryAccountPreview::project.identity.principalId - resourceId: registry!.id - } - isSharedToAll: true - metadata: { - ResourceId: registry!.id - } - } - dependsOn: [ - foundryAcrPull - ] -} - -// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as -// host: azure.ai.connection services, created on the existing project at -// provision time. Optional properties (credentials / metadata) are emitted only -// when supplied so None / identity-token connections don't send empty objects. -resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connections: { - parent: foundryAccountPreview::project - name: c.name - properties: union( - { - category: c.category - target: c.target - authType: c.authType - }, - contains(connectionCredentials, c.name) - ? { credentials: connectionCredentials[c.name] } - : {}, - c.?metadata != null ? { metadata: c.?metadata } : {} - ) - } -] - -// Outputs - -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? registry!.properties.loginServer : '' -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? registry!.id : '' -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? '${acrName}-conn' : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl new file mode 100644 index 00000000000..6d83d055fb8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl @@ -0,0 +1,135 @@ +// Editable infrastructure for an existing Foundry project. The account and +// project are referenced only. ACR behavior was selected when this file was +// generated, so the graph contains no runtime mode switch. + +targetScope = 'subscription' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param projectResourceId string +param deployments deploymentType[] = [] +param projectEndpoint string +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} +{{- if eq .AcrMode "create" }} +param resourceGroupName string +param location string +param resourceTokenSalt string = '' +param tags object = {} +{{- else if eq .AcrMode "reuse-connect" }} +param existingAcrResourceId string +param existingAcrEndpoint string +{{- else if eq .AcrMode "already-connected" }} +param existingAcrResourceId string +param existingAcrEndpoint string +param existingAcrConnectionName string +{{- end }} + +var projectIdParts = split(projectResourceId, '/') +var projectSubscriptionId = projectIdParts[2] +var projectResourceGroupName = projectIdParts[4] +var accountName = projectIdParts[8] +var projectName = projectIdParts[10] +{{- if eq .AcrMode "create" }} +var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' +var acrName = 'cr${toLower(uniqueString(tokenSeed))}' +{{- else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }} +var acrName = last(split(existingAcrResourceId, '/')) +{{- end }} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} +{{- if eq .AcrMode "create" }} + +resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +module containerRegistry 'modules/container-registry.bicep' = { + name: 'container-registry' + scope: resourceGroup(resourceGroupName) + params: { + location: location + tags: tags + registryName: acrName + projectPrincipalId: foundryAccount::project.identity.principalId + } + dependsOn: [adjunctResourceGroup] +} +{{- else if and (eq .AcrMode "reuse-connect") (not .AcrPullAssigned) }} + +module containerRegistry 'modules/container-registry.bicep' = { + name: 'container-registry' + scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) + params: { + registryName: acrName + projectPrincipalId: foundryAccount::project.identity.principalId + } +} +{{- end }} + +module projectResources 'modules/foundry-project.bicep' = { + name: 'foundry-project-resources' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + accountName: accountName + projectName: projectName + deployments: deployments + connections: connections + connectionCredentials: connectionCredentials +{{- if eq .AcrMode "create" }} + acrName: containerRegistry.outputs.registryName + acrEndpoint: containerRegistry.outputs.endpoint + acrResourceId: containerRegistry.outputs.resourceId + createAcrConnection: true +{{- else if eq .AcrMode "reuse-connect" }} + acrName: acrName + acrEndpoint: existingAcrEndpoint + acrResourceId: {{ if .AcrPullAssigned }}existingAcrResourceId{{ else }}containerRegistry.outputs.resourceId{{ end }} + createAcrConnection: true +{{- else if eq .AcrMode "already-connected" }} + existingAcrConnectionName: existingAcrConnectionName +{{- end }} + } +} + +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = accountName +output AZURE_AI_PROJECT_NAME string = projectName +output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint +output AZURE_FOUNDRY_RESOURCE_GROUP string = {{ if eq .AcrMode "create" }}resourceGroupName{{ else }}''{{ end }} +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.endpoint{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrEndpoint{{ else }}''{{ end }} +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.resourceId{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrResourceId{{ else }}''{{ end }} +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName +output AZURE_AI_PROJECT_CONNECTION_NAMES string = projectResources.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = projectEndpoint +output AZD_FOUNDRY_ACR_MODE string = '{{ .AcrMode }}' diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json new file mode 100644 index 00000000000..5841aac4426 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json @@ -0,0 +1,715 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "11013753549236288148" + } + }, + "definitions": { + "deploymentType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "sku": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "capacity": { + "type": "int" + } + } + } + } + }, + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "target": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + } + } + } + }, + "parameters": { + "projectResourceId": { + "type": "string" + }, + "resourceGroupName": { + "type": "string" + }, + "location": { + "type": "string" + }, + "resourceTokenSalt": { + "type": "string", + "defaultValue": "" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "deployments": { + "type": "array", + "items": { + "$ref": "#/definitions/deploymentType" + }, + "defaultValue": [] + }, + "acrMode": { + "type": "string", + "defaultValue": "none", + "allowedValues": [ + "none", + "create", + "reuse-connect", + "already-connected" + ] + }, + "existingAcrResourceId": { + "type": "string", + "defaultValue": "" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, + "acrPullAssigned": { + "type": "bool", + "defaultValue": false + }, + "projectEndpoint": { + "type": "string" + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "defaultValue": [] + }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {} + } + }, + "variables": { + "projectIdParts": "[split(parameters('projectResourceId'), '/')]", + "projectSubscriptionId": "[variables('projectIdParts')[2]]", + "projectResourceGroupName": "[variables('projectIdParts')[4]]", + "accountName": "[variables('projectIdParts')[8]]", + "projectName": "[variables('projectIdParts')[10]]", + "tokenSeed": "[format('{0}{1}{2}', subscription().subscriptionId, parameters('resourceGroupName'), parameters('resourceTokenSalt'))]", + "acrName": "[format('cr{0}', toLower(uniqueString(variables('tokenSeed'))))]", + "createAcr": "[equals(parameters('acrMode'), 'create')]", + "reuseAcr": "[or(equals(parameters('acrMode'), 'reuse-connect'), equals(parameters('acrMode'), 'already-connected'))]", + "createAcrConnection": "[or(equals(parameters('acrMode'), 'create'), equals(parameters('acrMode'), 'reuse-connect'))]", + "effectiveAcrName": "[if(variables('createAcr'), variables('acrName'), if(variables('reuseAcr'), last(split(parameters('existingAcrResourceId'), '/')), ''))]" + }, + "resources": { + "foundryAccount::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "name": "[format('{0}/{1}', variables('accountName'), variables('projectName'))]" + }, + "adjunctResourceGroup": { + "condition": "[variables('createAcr')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2021-04-01", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "name": "[variables('accountName')]" + }, + "newContainerRegistry": { + "condition": "[variables('createAcr')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "container-registry", + "resourceGroup": "[parameters('resourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "mode": { + "value": "create" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "registryName": { + "value": "[variables('acrName')]" + }, + "projectPrincipalId": { + "value": "[reference('foundryAccount::project', '2025-04-01-preview', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "544637247330827061" + } + }, + "parameters": { + "mode": { + "type": "string", + "allowedValues": [ + "create", + "reuse-connect" + ] + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "registryName": { + "type": "string" + }, + "projectPrincipalId": { + "type": "string" + } + }, + "variables": { + "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('registryName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Premium" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "Enabled", + "zoneRedundancy": "Disabled" + } + }, + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" + ] + }, + { + "condition": "[equals(parameters('mode'), 'reuse-connect')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + } + } + ], + "outputs": { + "endpoint": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer)]" + }, + "resourceId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" + } + } + } + }, + "dependsOn": [ + "adjunctResourceGroup", + "foundryAccount::project" + ] + }, + "existingContainerRegistry": { + "condition": "[and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "container-registry", + "subscriptionId": "[split(parameters('existingAcrResourceId'), '/')[2]]", + "resourceGroup": "[split(parameters('existingAcrResourceId'), '/')[4]]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "mode": { + "value": "reuse-connect" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "registryName": { + "value": "[variables('effectiveAcrName')]" + }, + "projectPrincipalId": { + "value": "[reference('foundryAccount::project', '2025-04-01-preview', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "544637247330827061" + } + }, + "parameters": { + "mode": { + "type": "string", + "allowedValues": [ + "create", + "reuse-connect" + ] + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "registryName": { + "type": "string" + }, + "projectPrincipalId": { + "type": "string" + } + }, + "variables": { + "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('registryName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Premium" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "Enabled", + "zoneRedundancy": "Disabled" + } + }, + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" + ] + }, + { + "condition": "[equals(parameters('mode'), 'reuse-connect')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + } + } + ], + "outputs": { + "endpoint": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer)]" + }, + "resourceId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" + } + } + } + }, + "dependsOn": [ + "foundryAccount::project" + ] + }, + "projectResources": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-project-resources", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[variables('accountName')]" + }, + "projectName": { + "value": "[variables('projectName')]" + }, + "deployments": { + "value": "[parameters('deployments')]" + }, + "connections": { + "value": "[parameters('connections')]" + }, + "connectionCredentials": { + "value": "[parameters('connectionCredentials')]" + }, + "acrName": { + "value": "[variables('effectiveAcrName')]" + }, + "acrEndpoint": "[if(variables('createAcr'), createObject('value', reference('newContainerRegistry').outputs.endpoint.value), if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), createObject('value', reference('existingContainerRegistry').outputs.endpoint.value), createObject('value', parameters('existingAcrEndpoint'))))]", + "acrResourceId": "[if(variables('createAcr'), createObject('value', reference('newContainerRegistry').outputs.resourceId.value), if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), createObject('value', reference('existingContainerRegistry').outputs.resourceId.value), createObject('value', parameters('existingAcrResourceId'))))]", + "createAcrConnection": { + "value": "[variables('createAcrConnection')]" + }, + "existingAcrConnectionName": { + "value": "[parameters('existingAcrConnectionName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "13015898647040372786" + } + }, + "definitions": { + "deploymentType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "sku": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "capacity": { + "type": "int" + } + } + } + } + }, + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "target": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + } + } + } + }, + "parameters": { + "accountName": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "deployments": { + "type": "array", + "items": { + "$ref": "#/definitions/deploymentType" + }, + "defaultValue": [] + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "defaultValue": [] + }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {} + }, + "acrName": { + "type": "string", + "defaultValue": "" + }, + "acrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "acrResourceId": { + "type": "string", + "defaultValue": "" + }, + "createAcrConnection": { + "type": "bool", + "defaultValue": false + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + } + }, + "resources": { + "foundryAccountPreview::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[parameters('accountName')]" + }, + "modelDeployments": { + "copy": { + "name": "modelDeployments", + "count": "[length(parameters('deployments'))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", + "properties": { + "model": "[parameters('deployments')[copyIndex()].model]" + }, + "sku": "[parameters('deployments')[copyIndex()].sku]" + }, + "foundryAccountPreview": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('accountName')]" + }, + "acrConnection": { + "condition": "[parameters('createAcrConnection')]", + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), format('{0}-conn', parameters('acrName')))]", + "properties": { + "category": "ContainerRegistry", + "target": "[parameters('acrEndpoint')]", + "authType": "ManagedIdentity", + "credentials": { + "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", + "resourceId": "[parameters('acrResourceId')]" + }, + "isSharedToAll": true, + "metadata": { + "ResourceId": "[parameters('acrResourceId')]" + } + }, + "dependsOn": [ + "foundryAccountPreview::project" + ] + }, + "projectConnections": { + "copy": { + "name": "projectConnections", + "count": "[length(parameters('connections'))]" + }, + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connections')[copyIndex()].name)]", + "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(contains(parameters('connectionCredentials'), parameters('connections')[copyIndex()].name), createObject('credentials', parameters('connectionCredentials')[parameters('connections')[copyIndex()].name]), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + } + }, + "outputs": { + "acrConnectionName": { + "type": "string", + "value": "[if(parameters('createAcrConnection'), format('{0}-conn', parameters('acrName')), parameters('existingAcrConnectionName'))]" + }, + "connectionNames": { + "type": "string", + "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + } + } + } + }, + "dependsOn": [ + "existingContainerRegistry", + "newContainerRegistry" + ] + } + }, + "outputs": { + "AZURE_AI_PROJECT_ID": { + "type": "string", + "value": "[parameters('projectResourceId')]" + }, + "AZURE_AI_ACCOUNT_NAME": { + "type": "string", + "value": "[variables('accountName')]" + }, + "AZURE_AI_PROJECT_NAME": { + "type": "string", + "value": "[variables('projectName')]" + }, + "AZURE_OPENAI_ENDPOINT": { + "type": "string", + "value": "[format('https://{0}.openai.azure.com/', variables('accountName'))]" + }, + "FOUNDRY_PROJECT_ENDPOINT": { + "type": "string", + "value": "[parameters('projectEndpoint')]" + }, + "AZURE_FOUNDRY_RESOURCE_GROUP": { + "type": "string", + "value": "[if(variables('createAcr'), parameters('resourceGroupName'), '')]" + }, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": { + "type": "string", + "value": "[if(equals(parameters('acrMode'), 'none'), '', if(variables('createAcr'), reference('newContainerRegistry').outputs.endpoint.value, if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), reference('existingContainerRegistry').outputs.endpoint.value, parameters('existingAcrEndpoint'))))]" + }, + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { + "type": "string", + "value": "[if(equals(parameters('acrMode'), 'none'), '', if(variables('createAcr'), reference('newContainerRegistry').outputs.resourceId.value, if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), reference('existingContainerRegistry').outputs.resourceId.value, parameters('existingAcrResourceId'))))]" + }, + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { + "type": "string", + "value": "[reference('projectResources').outputs.acrConnectionName.value]" + }, + "AZURE_AI_PROJECT_CONNECTION_NAMES": { + "type": "string", + "value": "[reference('projectResources').outputs.connectionNames.value]" + }, + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": { + "type": "string", + "value": "[parameters('projectEndpoint')]" + }, + "AZD_FOUNDRY_ACR_MODE": { + "type": "string", + "value": "[parameters('acrMode')]" + } + } +} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep new file mode 100644 index 00000000000..c984fa9d7e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep @@ -0,0 +1,140 @@ +// Editable infrastructure for an existing Foundry project. The account and +// project are referenced only; scoped modules manage project children and an +// optional adjunct resource group without taking ownership of the project. + +targetScope = 'subscription' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param projectResourceId string +param resourceGroupName string +param location string +param resourceTokenSalt string = '' +param tags object = {} +param deployments deploymentType[] = [] +@allowed([ + 'none' + 'create' + 'reuse-connect' + 'already-connected' +]) +param acrMode string = 'none' +param existingAcrResourceId string = '' +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' +param acrPullAssigned bool = false +param projectEndpoint string +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} + +var projectIdParts = split(projectResourceId, '/') +var projectSubscriptionId = projectIdParts[2] +var projectResourceGroupName = projectIdParts[4] +var accountName = projectIdParts[8] +var projectName = projectIdParts[10] +var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' +var acrName = 'cr${toLower(uniqueString(tokenSeed))}' +var createAcr = acrMode == 'create' +var reuseAcr = acrMode == 'reuse-connect' || acrMode == 'already-connected' +var createAcrConnection = acrMode == 'create' || acrMode == 'reuse-connect' +var effectiveAcrName = createAcr ? acrName : (reuseAcr ? last(split(existingAcrResourceId, '/')) : '') +var effectiveAcrEndpoint = createAcr + ? newContainerRegistry!.outputs.endpoint + : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.endpoint : existingAcrEndpoint) +var effectiveAcrResourceId = createAcr + ? newContainerRegistry!.outputs.resourceId + : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.resourceId : existingAcrResourceId) + +resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createAcr) { + name: resourceGroupName + location: location + tags: tags +} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +module newContainerRegistry 'modules/container-registry.bicep' = if (createAcr) { + name: 'container-registry' + scope: resourceGroup(resourceGroupName) + params: { + mode: 'create' + location: location + tags: tags + registryName: acrName + projectPrincipalId: foundryAccount::project.identity.principalId + } + dependsOn: [adjunctResourceGroup] +} + +module existingContainerRegistry 'modules/container-registry.bicep' = if (acrMode == 'reuse-connect' && !acrPullAssigned) { + name: 'container-registry' + scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) + params: { + mode: 'reuse-connect' + location: location + tags: tags + registryName: effectiveAcrName + projectPrincipalId: foundryAccount::project.identity.principalId + } +} + +module projectResources 'modules/foundry-project.bicep' = { + name: 'foundry-project-resources' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + accountName: accountName + projectName: projectName + deployments: deployments + connections: connections + connectionCredentials: connectionCredentials + acrName: effectiveAcrName + acrEndpoint: effectiveAcrEndpoint + acrResourceId: effectiveAcrResourceId + createAcrConnection: createAcrConnection + existingAcrConnectionName: existingAcrConnectionName + } +} + +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = accountName +output AZURE_AI_PROJECT_NAME string = projectName +output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint +output AZURE_FOUNDRY_RESOURCE_GROUP string = createAcr ? resourceGroupName : '' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrMode == 'none' + ? '' + : effectiveAcrEndpoint +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = acrMode == 'none' + ? '' + : effectiveAcrResourceId +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName +output AZURE_AI_PROJECT_CONNECTION_NAMES string = projectResources.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = projectEndpoint +output AZD_FOUNDRY_ACR_MODE string = acrMode diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json index ab9db833c2e..3a9d7201bc3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json @@ -5,8 +5,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "1699321523334639873" + "version": "0.46.1.21595", + "templateHash": "6633403746269170516" } }, "definitions": { @@ -346,8 +346,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "8110487961768042532" + "version": "0.46.1.21595", + "templateHash": "9663028600979689998" } }, "definitions": { @@ -736,8 +736,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "18361164219559996781" + "version": "0.46.1.21595", + "templateHash": "10967961645819739964" } }, "parameters": { @@ -830,8 +830,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "11947348622491745192" + "version": "0.46.1.21595", + "templateHash": "10528013059105939935" } }, "parameters": { @@ -915,8 +915,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "11947348622491745192" + "version": "0.46.1.21595", + "templateHash": "10528013059105939935" } }, "parameters": { @@ -1050,8 +1050,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "1414665861706683761" + "version": "0.46.1.21595", + "templateHash": "16746221481092115316" } }, "parameters": { @@ -1221,8 +1221,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "7461045817315422644" + "version": "0.46.1.21595", + "templateHash": "2415286770463307805" } }, "parameters": { @@ -1448,8 +1448,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "14839319862176027437" + "version": "0.46.1.21595", + "templateHash": "13263232618571910387" } }, "definitions": { @@ -1669,6 +1669,10 @@ "type": "string", "value": "[reference('resources').outputs.AZURE_AI_PROJECT_CONNECTION_NAMES.value]" }, + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": { + "type": "string", + "value": "[reference('resources').outputs.FOUNDRY_PROJECT_ENDPOINT.value]" + }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", "value": "[reference('resources').outputs.AZURE_FOUNDRY_NETWORK_MODE.value]" diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep index 2c6d007385a..7d6176054a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep @@ -1,8 +1,8 @@ // Provisioning template for a Foundry project service. // // Inputs are derived from the host: azure.ai.project service body in -// azure.yaml by internal/synthesis. Greenfield only (no endpoint:); a -// brownfield path is handled by the provider before synthesis. +// azure.yaml by internal/synthesis. This entry point creates a new Foundry +// account and project; existing projects use the separate editable entry point. // // Subscription-scoped so the resource group is part of the deployment. This // keeps `azd provision --preview` side-effect free: the resource group shows @@ -171,5 +171,6 @@ output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAI output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME output AZURE_AI_PROJECT_CONNECTION_NAMES string = resources.outputs.AZURE_AI_PROJECT_CONNECTION_NAMES +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT output AZURE_FOUNDRY_NETWORK_MODE string = resources.outputs.AZURE_FOUNDRY_NETWORK_MODE output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = resources.outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl new file mode 100644 index 00000000000..4518ded44b1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl @@ -0,0 +1,48 @@ +targetScope = 'resourceGroup' + +param registryName string +param projectPrincipalId string +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) +{{- if eq .AcrMode "create" }} +param location string +param tags object = {} + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: registryName + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + zoneRedundancy: 'Disabled' + } +} +{{- else }} + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: registryName +} +{{- end }} + +resource registryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: registry + name: guid(registry.id, projectPrincipalId, acrPullRoleId) + properties: { + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +output registryName string = registry.name +output endpoint string = {{ if eq .AcrMode "create" }}registry.properties.loginServer{{ else }}''{{ end }} +output resourceId string = registry.id diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry.bicep new file mode 100644 index 00000000000..709df975d41 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry.bicep @@ -0,0 +1,60 @@ +targetScope = 'resourceGroup' + +@allowed([ + 'create' + 'reuse-connect' +]) +param mode string +param location string +param tags object = {} +param registryName string +param projectPrincipalId string + +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + +resource newRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (mode == 'create') { + name: registryName + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + zoneRedundancy: 'Disabled' + } +} + +resource existingRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = if (mode == 'reuse-connect') { + name: registryName +} + +resource newRegistryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (mode == 'create') { + scope: newRegistry + name: guid(newRegistry.id, projectPrincipalId, acrPullRoleId) + properties: { + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +resource existingRegistryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (mode == 'reuse-connect') { + scope: existingRegistry + name: guid(existingRegistry.id, projectPrincipalId, acrPullRoleId) + properties: { + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +output endpoint string = mode == 'create' ? newRegistry!.properties.loginServer : existingRegistry!.properties.loginServer +output resourceId string = mode == 'create' ? newRegistry!.id : existingRegistry!.id diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/foundry-project.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/foundry-project.bicep new file mode 100644 index 00000000000..0331938c976 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/foundry-project.bicep @@ -0,0 +1,95 @@ +targetScope = 'resourceGroup' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param accountName string +param projectName string +param deployments deploymentType[] = [] +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} +param acrName string = '' +param acrEndpoint string = '' +param acrResourceId string = '' +param createAcrConnection bool = false +param existingAcrConnectionName string = '' + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: accountName +} + +@batchSize(1) +resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for d in deployments: { + parent: foundryAccount + name: d.name + properties: { + model: d.model + } + sku: d.sku + } +] + +resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (createAcrConnection) { + parent: foundryAccountPreview::project + name: '${acrName}-conn' + properties: { + category: 'ContainerRegistry' + target: acrEndpoint + authType: 'ManagedIdentity' + credentials: { + clientId: foundryAccountPreview::project.identity.principalId + resourceId: acrResourceId + } + isSharedToAll: true + metadata: { + ResourceId: acrResourceId + } + } +} + +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccountPreview::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + contains(connectionCredentials, c.name) ? { credentials: connectionCredentials[c.name] } : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +output acrConnectionName string = createAcrConnection ? acrConnection!.name : existingAcrConnectionName +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/connections.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/connections.tf new file mode 100644 index 00000000000..eec41bd6baf --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/connections.tf @@ -0,0 +1,24 @@ +resource "azapi_resource" "connection" { + for_each = { for c in var.connections : c.name => c } + + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = each.value.name + parent_id = local.normalized_project_id + + body = { + properties = merge( + { + category = each.value.category + target = each.value.target + authType = each.value.authType + }, + each.value.metadata != null ? { metadata = each.value.metadata } : {} + ) + } + + sensitive_body = each.value.credentials != null ? { + properties = { + credentials = each.value.credentials + } + } : null +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf new file mode 100644 index 00000000000..45138d42cae --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf @@ -0,0 +1,32 @@ +data "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.normalized_project_id + response_export_values = ["identity.principalId"] +} + +locals { + existing_acr_name = element(reverse(split("/", var.existing_acr_resource_id)), 0) + project_principal_id = data.azapi_resource.project.output.identity.principalId +} + +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.existing_acr_name}-conn" + parent_id = local.normalized_project_id + + body = { + properties = { + category = "ContainerRegistry" + target = var.existing_acr_endpoint + authType = "ManagedIdentity" + credentials = { + clientId = local.project_principal_id + resourceId = var.existing_acr_resource_id + } + isSharedToAll = true + metadata = { + ResourceId = var.existing_acr_resource_id + } + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf new file mode 100644 index 00000000000..f72a118203a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf @@ -0,0 +1,73 @@ +locals { + resource_token = substr(sha1(join("-", compact([ + var.subscription_id, + var.resource_group_name, + var.location, + var.resource_token_salt, + ]))), 0, 13) + container_registry_name = "cr${local.resource_token}" + acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" +} + +resource "azurerm_resource_group" "adjunct" { + name = var.resource_group_name + location = var.location + tags = merge(var.tags, { "azd-env-name" = var.environment_name }) +} + +data "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.normalized_project_id + response_export_values = ["identity.principalId"] +} + +locals { + project_principal_id = data.azapi_resource.project.output.identity.principalId +} + +resource "azurerm_container_registry" "this" { + name = local.container_registry_name + resource_group_name = azurerm_resource_group.adjunct.name + location = azurerm_resource_group.adjunct.location + tags = var.tags + sku = "Premium" + admin_enabled = false + + identity { + type = "SystemAssigned" + } + + public_network_access_enabled = true + zone_redundancy_enabled = false +} + +resource "azurerm_role_assignment" "foundry_acr_pull" { + scope = azurerm_container_registry.this.id + role_definition_id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" + principal_id = local.project_principal_id + principal_type = "ServicePrincipal" +} + +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.container_registry_name}-conn" + parent_id = local.normalized_project_id + + body = { + properties = { + category = "ContainerRegistry" + target = azurerm_container_registry.this.login_server + authType = "ManagedIdentity" + credentials = { + clientId = local.project_principal_id + resourceId = azurerm_container_registry.this.id + } + isSharedToAll = true + metadata = { + ResourceId = azurerm_container_registry.this.id + } + } + } + + depends_on = [azurerm_role_assignment.foundry_acr_pull] +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf new file mode 100644 index 00000000000..9f8c1bf857d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf @@ -0,0 +1,50 @@ +data "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.normalized_project_id + response_export_values = ["identity.principalId"] +} + +provider "azurerm" { + alias = "existing_acr" + subscription_id = split("/", var.existing_acr_resource_id)[2] + tenant_id = var.tenant_id + features {} +} + +locals { + existing_acr_name = element(reverse(split("/", var.existing_acr_resource_id)), 0) + project_principal_id = data.azapi_resource.project.output.identity.principalId + acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" +} + +resource "azurerm_role_assignment" "foundry_acr_pull" { + provider = azurerm.existing_acr + scope = var.existing_acr_resource_id + role_definition_id = "/subscriptions/${split("/", var.existing_acr_resource_id)[2]}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" + principal_id = local.project_principal_id + principal_type = "ServicePrincipal" +} + +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.existing_acr_name}-conn" + parent_id = local.normalized_project_id + + body = { + properties = { + category = "ContainerRegistry" + target = var.existing_acr_endpoint + authType = "ManagedIdentity" + credentials = { + clientId = local.project_principal_id + resourceId = var.existing_acr_resource_id + } + isSharedToAll = true + metadata = { + ResourceId = var.existing_acr_resource_id + } + } + } + + depends_on = [azurerm_role_assignment.foundry_acr_pull] +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/main.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/main.tf new file mode 100644 index 00000000000..f800d5bacc8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/main.tf @@ -0,0 +1,15 @@ +locals { + project_id_parts = split("/", var.project_resource_id) + project_subscription_id = local.project_id_parts[2] + project_resource_group = local.project_id_parts[4] + foundry_account_name = local.project_id_parts[8] + foundry_project_name = local.project_id_parts[10] + foundry_account_id = join("/", slice(local.project_id_parts, 0, 9)) + normalized_project_id = join("/", local.project_id_parts) + project_endpoint_matches = regexall( + "(?i)^https://([^.]+)\\.services\\.ai\\.azure\\.com/(?:api/)?projects/([^/?#]+)/?$", + var.project_endpoint, + ) + project_endpoint_account = length(local.project_endpoint_matches) == 1 ? local.project_endpoint_matches[0][0] : "" + project_endpoint_project = length(local.project_endpoint_matches) == 1 ? local.project_endpoint_matches[0][1] : "" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl new file mode 100644 index 00000000000..a5c15b5d775 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl @@ -0,0 +1,54 @@ +output "AZURE_AI_PROJECT_ID" { + value = local.normalized_project_id +} + +output "AZURE_AI_ACCOUNT_NAME" { + value = local.foundry_account_name +} + +output "AZURE_AI_PROJECT_NAME" { + value = local.foundry_project_name +} + +output "AZURE_OPENAI_ENDPOINT" { + value = "https://${local.foundry_account_name}.openai.azure.com/" +} + +output "FOUNDRY_PROJECT_ENDPOINT" { + value = var.project_endpoint + + precondition { + condition = ( + lower(local.project_endpoint_account) == lower(local.foundry_account_name) && + lower(local.project_endpoint_project) == lower(local.foundry_project_name) + ) + error_message = "project_endpoint must identify the same Foundry project as project_resource_id." + } +} + +output "AZURE_AI_PROJECT_CONNECTION_NAMES" { + value = join(",", [for c in azapi_resource.connection : c.name]) +} + +output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT" { + value = var.project_endpoint +} +output "AZURE_FOUNDRY_RESOURCE_GROUP" { + value = {{ if eq .AcrMode "create" }}var.resource_group_name{{ else }}""{{ end }} +} + +output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { + value = {{ if eq .AcrMode "create" }}azurerm_container_registry.this.login_server{{ else if eq .AcrMode "none" }}""{{ else }}var.existing_acr_endpoint{{ end }} +} + +output "AZURE_CONTAINER_REGISTRY_RESOURCE_ID" { + value = {{ if eq .AcrMode "create" }}azurerm_container_registry.this.id{{ else if eq .AcrMode "none" }}""{{ else }}var.existing_acr_resource_id{{ end }} +} + +output "AZURE_AI_PROJECT_ACR_CONNECTION_NAME" { + value = {{ if eq .AcrMode "already-connected" }}var.existing_acr_connection_name{{ else if eq .AcrMode "none" }}""{{ else }}azapi_resource.acr_connection.name{{ end }} +} + +output "AZD_FOUNDRY_ACR_MODE" { + value = "{{ .AcrMode }}" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/provider.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/provider.tf new file mode 100644 index 00000000000..389c3626f0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/provider.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.3.0, < 2.0.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + azapi = { + source = "Azure/azapi" + version = "~> 2.0" + } + } +} + +provider "azurerm" { + subscription_id = var.subscription_id + tenant_id = var.tenant_id + features {} +} + +provider "azapi" { + subscription_id = local.project_subscription_id + tenant_id = var.tenant_id +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/variables.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/variables.tf new file mode 100644 index 00000000000..220faf22591 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/variables.tf @@ -0,0 +1,99 @@ +variable "subscription_id" { + description = "Subscription where adjunct resources are created." + type = string +} + +variable "tenant_id" { + description = "Microsoft Entra tenant that owns the target subscriptions." + type = string +} + +variable "project_resource_id" { + description = "ARM resource ID of the existing Foundry project." + type = string + + validation { + condition = can(regex( + "(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.CognitiveServices/accounts/[^/]+/projects/[^/]+$", + var.project_resource_id, + )) + error_message = "project_resource_id must be a Foundry project ARM resource ID." + } +} + +variable "project_endpoint" { + description = "Endpoint of the existing Foundry project." + type = string +} + +variable "location" { + description = "Azure region for adjunct resources." + type = string +} + +variable "resource_group_name" { + description = "Resource group to create for adjunct resources such as ACR." + type = string +} + +variable "environment_name" { + description = "azd environment name. Used to tag adjunct resources." + type = string +} + +variable "tags" { + description = "Tags applied to adjunct resources." + type = map(string) + default = {} +} + +variable "resource_token_salt" { + description = "Optional salt to vary adjunct resource names." + type = string + default = "" +} + +variable "deployments" { + description = "Model deployments to provision on the existing Foundry account." + type = list(object({ + name = string + model = object({ + name = string + format = string + version = string + }) + sku = object({ + name = string + capacity = number + }) + })) + default = [] +} + +variable "connections" { + description = "Connections to provision on the existing Foundry project." + type = list(object({ + name = string + category = string + target = string + authType = string + credentials = optional(any) + metadata = optional(map(string)) + })) + default = [] +} + +variable "existing_acr_endpoint" { + type = string + default = "" +} + +variable "existing_acr_resource_id" { + type = string + default = "" +} + +variable "existing_acr_connection_name" { + type = string + default = "" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/acr.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/container-registry.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/acr.tf rename to cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/container-registry.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl index ca8bafb4134..6b645246f8d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl @@ -31,13 +31,13 @@ output "FOUNDRY_PROJECT_ENDPOINT" { value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" } -output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT" { - value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" -} - output "AZURE_AI_PROJECT_CONNECTION_NAMES" { value = join(",", [for c in azapi_resource.connection : c.name]) } + +output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT" { + value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" +} {{ if .IncludeAcr }} output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { value = azurerm_container_registry.this.login_server diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go index 3cde22f6a66..66feb2ea6d3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go @@ -11,14 +11,16 @@ import "embed" // needs a bicep CLI at user runtime. // //go:generate bicep build templates/main.bicep --outfile templates/main.arm.json -//go:generate bicep build templates/brownfield.bicep --outfile templates/brownfield.arm.json +//go:generate bicep build templates/existing-project.bicep --outfile templates/existing-project.arm.json //go:embed templates/main.bicep //go:embed templates/main.arm.json -//go:embed templates/brownfield.bicep -//go:embed templates/brownfield.arm.json +//go:embed templates/existing-project.bicep +//go:embed templates/existing-project-eject.bicep.tmpl +//go:embed templates/existing-project.arm.json //go:embed templates/abbreviations.json //go:embed templates/modules/*.bicep +//go:embed templates/modules/*.bicep.tmpl var templatesFS embed.FS // terraformTemplatesFS holds the on-disk Terraform module emitted by @@ -26,15 +28,19 @@ var templatesFS embed.FS // no compile step (no ARM JSON to regenerate); azd-core's built-in Terraform // provider consumes the .tf files directly at `azd provision`. // -// acr.tf is copied only when an agent uses docker:; outputs.tf is generated +// container-registry.tf is copied only when an agent uses docker:; outputs.tf is generated // from outputs.tf.tmpl (text/template) so the ACR outputs reference the -// registry resources only when acr.tf is present. main.tfvars.json is likewise +// registry resources only when container-registry.tf is present. main.tfvars.json is likewise // generated at eject time. // //go:embed templates/terraform/*.tf //go:embed templates/terraform/outputs.tf.tmpl var terraformTemplatesFS embed.FS +//go:embed templates/terraform-existing-project/*.tf +//go:embed templates/terraform-existing-project/outputs.tf.tmpl +var existingProjectTerraformTemplatesFS embed.FS + // TemplatesFS exposes the embedded provisioning templates. Callers that // only need the ready-to-deploy ARM JSON should prefer ARMTemplate(). func TemplatesFS() embed.FS { return templatesFS } @@ -45,15 +51,15 @@ func TemplatesFS() embed.FS { return templatesFS } // generates main.tfvars.json alongside them. func TerraformTemplatesFS() embed.FS { return terraformTemplatesFS } +// ExistingProjectTerraformTemplatesFS exposes the Terraform module for an existing project. +func ExistingProjectTerraformTemplatesFS() embed.FS { return existingProjectTerraformTemplatesFS } + // ARMTemplate returns the compiled ARM JSON for main.bicep. func ARMTemplate() ([]byte, error) { return templatesFS.ReadFile("templates/main.arm.json") } -// BrownfieldARMTemplate returns the compiled ARM JSON for brownfield.bicep, which -// creates/upserts model deployments on an EXISTING Foundry account (referenced, -// not created). Used by the provider when the project sets endpoint: and declares -// deployments: to add to the existing project. -func BrownfieldARMTemplate() ([]byte, error) { - return templatesFS.ReadFile("templates/brownfield.arm.json") +// ExistingProjectARMTemplate returns the compiled editable existing-project graph. +func ExistingProjectARMTemplate() ([]byte, error) { + return templatesFS.ReadFile("templates/existing-project.arm.json") } diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go index 9a58ae710f8..97020c74a8e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -51,4 +51,6 @@ const ( OpCognitiveAccountPurge = "cognitive_account_purge" OpCognitiveDeploymentList = "cognitive_deployment_list" OpCognitiveDeploymentDelete = "cognitive_deployment_delete" + OpProjectConnectionDelete = "project_connection_delete" + OpProjectConnectionGet = "project_connection_get" ) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index d507dae7942..e899236db70 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -95,18 +95,16 @@ type FoundryProvisioningProvider struct { // brownfieldEndpoint is the existing project endpoint when the foundry // service sets endpoint: (bring-your-own). When non-empty the provider skips // provisioning and connects to that project instead of creating a new one. - brownfieldEndpoint string - - // brownfieldDeployments are the model deployments declared under a brownfield - // (endpoint:) project service. They are created/upserted on the existing - // account at Deploy time; the existing account itself is never re-created. - brownfieldDeployments []synthesis.Deployment - - // brownfieldConnections are the host: azure.ai.connection services declared - // alongside a brownfield (endpoint:) project. They are created/upserted on - // the existing project at Deploy time, mirroring greenfield synthesis, so - // the deploy-time connection service target does not have to create them. - brownfieldConnections []synthesis.Connection + brownfieldEndpoint string + existingProjectConnectionOnly bool + existingProjectID string + existingAcrMode string + existingAcrEndpoint string + existingAcrResourceID string + existingAcrConnectionName string + existingAcrPullAssigned bool + resourceTokenSalt string + resourceGroupState func(context.Context) (map[string]*string, bool, error) // Lazily constructed on first compile. nil until needed. bicepCliInstance bicepCompiler @@ -283,10 +281,8 @@ func (p *FoundryProvisioningProvider) Initialize( return err } - // endpoint: (brownfield) reuse connects to an existing project, - // so it needs no subscription or location. Detect it up front so - // the environment can be resolved before any service values are - // read. + // endpoint: selects the existing-project graph. Both project graphs deploy + // at subscription scope and use the same embedded/on-disk template pipeline. endpoint, err := foundryServiceEndpointAtRoot(rawYAML, projectRoot, svcName) if err != nil { return exterrors.Validation( @@ -300,7 +296,7 @@ func (p *FoundryProvisioningProvider) Initialize( } onDisk := p.onDiskTemplatePresent() - if !onDisk { + if !onDisk && endpoint == "" { // Validate embedded config before any interactive prompts. _, validationErr := synthesis.Synthesize(synthesis.Input{ RawAzureYAML: rawYAML, @@ -327,62 +323,56 @@ func (p *FoundryProvisioningProvider) Initialize( "fix the connection service configuration in azure.yaml", ) } + if endpoint != "" && !onDisk { + connectionOnlyResult, synthErr := synthesis.SynthesizeExistingProject(synthesis.Input{ + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + PreserveVarRefs: true, + ProjectRoot: projectRoot, + }) + if synthErr != nil { + return foundrySynthesisError(svcName, synthErr) + } + if !existingProjectHasMutations(connectionOnlyResult) { + p.brownfieldEndpoint = endpoint + p.existingProjectConnectionOnly = true + p.synthResult = connectionOnlyResult + p.foundryName = projectNameFromEndpoint(endpoint) + return p.resolveEnvName(ctx) + } + } // Resolve the environment before reading service values. azd core // expands ${VAR} in service env against the environment, so // reading them first would capture empty strings for values the // user is about to be prompted for, and connection synthesis // would provision those empty strings. - if endpoint != "" { - err = p.resolveEnvName(ctx) - } else { - err = p.resolveEnv(ctx) - } + err = p.resolveEnv(ctx) if err != nil { return err } + if endpoint != "" { + if err := p.resolveExistingProjectResourceGroup(ctx); err != nil { + return err + } + } p.serviceEnvironments, err = p.projectServiceEnvironments(ctx) if err != nil { return err } - // Detect on-disk Bicep before synthesizing. Stat-only; no compile here. - if onDisk { - log.Printf("[debug] foundry provider: on-disk Bicep detected under %s; "+ - "skipping synthesizer", p.infraPath) - // endpoint: (brownfield) reuse skips provisioning even on the on-disk - // path; connect to the existing project instead of compiling Bicep. - if endpoint != "" { - if err := warnNetworkIgnoredInBrownfield( - rawYAML, - projectRoot, - svcName, - ); err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("resolve Foundry service configuration: %s", err), - "fix the project service configuration in azure.yaml", - ) - } - p.brownfieldEndpoint = endpoint - return p.captureBrownfieldDeployments(ctx, rawYAML, svcName) - } - return nil - } - - res, err := synthesis.Synthesize(synthesis.Input{ + input := synthesis.Input{ RawAzureYAML: rawYAML, ServiceName: svcName, AcceptedHosts: FoundryProvisioningServiceHosts, Env: p.networkEnvMap(ctx), ServiceEnvironments: p.serviceEnvironments, ProjectRoot: projectRoot, - }) - switch { - case errors.Is(err, synthesis.ErrEndpointBrownfield): - // endpoint: reuse — connect to the existing project, skip provisioning. - // network: has no effect in brownfield mode; warn if both are present. + } + var res *synthesis.Result + if endpoint != "" { if err := warnNetworkIgnoredInBrownfield( rawYAML, projectRoot, @@ -395,13 +385,30 @@ func (p *FoundryProvisioningProvider) Initialize( ) } p.brownfieldEndpoint = endpoint - return p.captureBrownfieldDeployments(ctx, rawYAML, svcName) - case err != nil: + res, err = synthesis.SynthesizeExistingProject(input) + } else { + res, err = synthesis.Synthesize(input) + } + if err != nil { return foundrySynthesisError(svcName, err) } p.synthResult = res + if endpoint != "" { + if err := p.resolveExistingProjectInputs(ctx); err != nil { + return err + } + } + if onDisk { + log.Printf("[debug] foundry provider: on-disk Bicep detected under %s", p.infraPath) + return nil + } - tmplBytes, err := synthesis.ARMTemplate() + var tmplBytes []byte + if endpoint != "" { + tmplBytes, err = synthesis.ExistingProjectARMTemplate() + } else { + tmplBytes, err = synthesis.ARMTemplate() + } if err != nil { return exterrors.Internal( exterrors.CodeInvalidServiceConfig, @@ -420,6 +427,148 @@ func (p *FoundryProvisioningProvider) Initialize( return nil } +func existingProjectHasMutations(result *synthesis.Result) bool { + includeAcr, _ := result.Parameters["includeAcr"].(bool) + deployments, _ := result.Parameters["deployments"].([]synthesis.Deployment) + connections, _ := result.Parameters["connections"].([]synthesis.Connection) + return includeAcr || len(deployments) > 0 || len(connections) > 0 +} + +func (p *FoundryProvisioningProvider) resolveExistingProjectResourceGroup(ctx context.Context) error { + value, err := p.envValue(ctx, envKeyFoundryRG) + if err != nil { + return exterrors.Dependency( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("read %s from azd environment %q: %s", envKeyFoundryRG, p.envName, err), + "verify the azd environment is accessible, then retry", + ) + } + p.rgName = value + p.rgExplicit = value != "" + if p.rgName == "" { + p.rgName = defaultResourceGroupName(p.envName) + "-foundry" + } + owner, err := p.envValue(ctx, envKeyFoundryRGOwner) + if err != nil { + return err + } + p.foundryRGOwnerID = owner + return nil +} + +func (p *FoundryProvisioningProvider) resolveExistingProjectInputs(ctx context.Context) error { + projectID, err := p.envValue(ctx, "AZURE_AI_PROJECT_ID") + if err != nil || projectID == "" { + return exterrors.Dependency( + exterrors.CodeInvalidServiceConfig, + "AZURE_AI_PROJECT_ID is required for an existing Foundry project", + "re-run `azd ai agent init` against the existing project, or set AZURE_AI_PROJECT_ID", + ) + } + resourceID, err := arm.ParseResourceID(projectID) + if err != nil || resourceID.Parent == nil || resourceID.ResourceType.String() != + "Microsoft.CognitiveServices/accounts/projects" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("AZURE_AI_PROJECT_ID %q is not a Foundry project resource ID", projectID), + "set AZURE_AI_PROJECT_ID to /subscriptions//resourceGroups//providers/"+ + "Microsoft.CognitiveServices/accounts//projects/", + ) + } + p.existingProjectID = projectID + endpointAccount, endpointProject := existingProjectEndpointIdentity(p.brownfieldEndpoint) + if endpointAccount == "" || endpointProject == "" || + !strings.EqualFold(endpointAccount, resourceID.Parent.Name) || + !strings.EqualFold(endpointProject, resourceID.Name) { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_AI_PROJECT_ID does not identify the project configured by the azure.yaml endpoint", + "re-run `azd ai agent init` against the configured existing project", + ) + } + envEndpoint, err := p.envValue(ctx, "FOUNDRY_PROJECT_ENDPOINT") + if err != nil { + return fmt.Errorf("read FOUNDRY_PROJECT_ENDPOINT: %w", err) + } + if envEndpoint == "" || !sameExistingProjectEndpoint(p.brownfieldEndpoint, envEndpoint) { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "FOUNDRY_PROJECT_ENDPOINT does not match the existing project configured in azure.yaml", + "re-run `azd ai agent init` against the configured existing project", + ) + } + inputs := []struct { + key string + value *string + }{ + {"AZURE_CONTAINER_REGISTRY_ENDPOINT", &p.existingAcrEndpoint}, + {"AZURE_CONTAINER_REGISTRY_RESOURCE_ID", &p.existingAcrResourceID}, + {"AZURE_AI_PROJECT_ACR_CONNECTION_NAME", &p.existingAcrConnectionName}, + {"AZD_FOUNDRY_ACR_MODE", &p.existingAcrMode}, + {"AZD_RESOURCE_TOKEN_SALT", &p.resourceTokenSalt}, + } + acrPullAssigned, err := p.envValue(ctx, "AZD_FOUNDRY_ACR_PULL_ASSIGNED") + if err != nil { + return fmt.Errorf("read AZD_FOUNDRY_ACR_PULL_ASSIGNED: %w", err) + } + p.existingAcrPullAssigned = strings.EqualFold(acrPullAssigned, "true") + for _, input := range inputs { + value, err := p.envValue(ctx, input.key) + if err != nil { + return fmt.Errorf("read %s: %w", input.key, err) + } + *input.value = value + } + includeAcr, _ := p.synthResult.Parameters["includeAcr"].(bool) + if !includeAcr || strings.EqualFold(p.virtualEnv["AZD_AGENT_SKIP_ACR"], "true") { + p.existingAcrMode = "none" + } else if p.existingAcrMode == "" { + p.existingAcrMode = p.inferExistingProjectAcrMode() + } + switch p.existingAcrMode { + case "none", "create": + case "reuse-connect", "already-connected": + if p.existingAcrEndpoint == "" || p.existingAcrResourceID == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("%s requires both container registry endpoint and resource ID", p.existingAcrMode), + "re-run `azd ai agent init` to select the container registry", + ) + } + default: + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("AZD_FOUNDRY_ACR_MODE has unsupported value %q", p.existingAcrMode), + "re-run `azd ai agent init` to select the container registry behavior", + ) + } + if p.existingAcrMode == "already-connected" && p.existingAcrConnectionName == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "already-connected requires AZURE_AI_PROJECT_ACR_CONNECTION_NAME", + "re-run `azd ai agent init` and select the existing project connection", + ) + } + return nil +} + +func (p *FoundryProvisioningProvider) inferExistingProjectAcrMode() string { + includeAcr := false + if p.synthResult != nil { + includeAcr, _ = p.synthResult.Parameters["includeAcr"].(bool) + } + if !includeAcr || strings.EqualFold(p.virtualEnv["AZD_AGENT_SKIP_ACR"], "true") { + return "none" + } + if p.existingAcrEndpoint == "" && p.existingAcrResourceID == "" { + return "create" + } + if p.existingAcrConnectionName == "" { + return "reuse-connect" + } + return "already-connected" +} + func foundrySynthesisError(serviceName string, err error) error { if errors.Is(err, synthesis.ErrServiceNotFound) { return exterrors.Dependency( @@ -634,36 +783,6 @@ func foundryServiceEndpointAtRoot( return strings.TrimSpace(service.Endpoint), nil } -// resolveEnvName resolves just the active azd environment name. The brownfield -// (endpoint:) path uses it instead of resolveEnv because connecting to an -// existing project needs no subscription, location, or resource group. -func (p *FoundryProvisioningProvider) resolveEnvName(ctx context.Context) error { - currEnv, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil { - return exterrors.Dependency( - exterrors.CodeEnvironmentNotFound, - fmt.Sprintf("get current azd environment: %s", err), - "run 'azd env new' to create an environment", - ) - } - p.envName = currEnv.Environment.Name - return nil -} - -// brownfieldOutputs builds the provisioning outputs for a bring-your-own -// project: the endpoint downstream services consume, plus the project name -// parsed from it when present. -func brownfieldOutputs(endpoint string) map[string]*azdext.ProvisioningOutputParameter { - outputs := map[string]*azdext.ProvisioningOutputParameter{ - "FOUNDRY_PROJECT_ENDPOINT": {Type: "string", Value: endpoint}, - "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": {Type: "string", Value: endpoint}, - } - if name := projectNameFromEndpoint(endpoint); name != "" { - outputs["AZURE_AI_PROJECT_NAME"] = &azdext.ProvisioningOutputParameter{Type: "string", Value: name} - } - return outputs -} - // defaultResourceGroupName returns the default resource group azd provisions // into, matching azd's standard rg- convention. func defaultResourceGroupName(envName string) string { @@ -686,22 +805,21 @@ func (p *FoundryProvisioningProvider) withTenantOutput( outputs[envKeyTenantID] = &azdext.ProvisioningOutputParameter{Type: "string", Value: p.tenantID} } } - if endpoint, ok := outputs["FOUNDRY_PROJECT_ENDPOINT"]; ok && endpoint != nil { - outputs["AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT"] = &azdext.ProvisioningOutputParameter{ - Type: "string", Value: endpoint.Value, - } - } return outputs } func (p *FoundryProvisioningProvider) normalizeOutputs( outputs map[string]*azdext.ProvisioningOutputParameter, ) map[string]*azdext.ProvisioningOutputParameter { - if p.isLayer { + trackSupportingResourceGroup := p.isLayer || + (p.brownfieldEndpoint != "" && p.existingAcrMode == "create") + if trackSupportingResourceGroup { if outputs == nil { outputs = map[string]*azdext.ProvisioningOutputParameter{} } - delete(outputs, envKeyResourceGroup) + if p.isLayer { + delete(outputs, envKeyResourceGroup) + } outputs[envKeyFoundryRGOwner] = &azdext.ProvisioningOutputParameter{ Type: "string", Value: p.foundryRGOwnerID, } @@ -726,6 +844,26 @@ func projectNameFromEndpoint(endpoint string) string { return "" } +func existingProjectEndpointIdentity(endpoint string) (string, string) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return "", "" + } + const hostSuffix = ".services.ai.azure.com" + host := strings.ToLower(u.Hostname()) + if !strings.HasSuffix(host, hostSuffix) { + return "", "" + } + return strings.TrimSuffix(host, hostSuffix), projectNameFromEndpoint(endpoint) +} + +func sameExistingProjectEndpoint(a, b string) bool { + aAccount, aProject := existingProjectEndpointIdentity(a) + bAccount, bProject := existingProjectEndpointIdentity(b) + return aAccount != "" && aProject != "" && + strings.EqualFold(aAccount, bAccount) && strings.EqualFold(aProject, bProject) +} + // resolveEnv pulls the env values the provider needs from azd-core. It does // no Azure work; that is deferred to ensureCredential. func (p *FoundryProvisioningProvider) resolveEnv(ctx context.Context) error { @@ -850,6 +988,23 @@ func (p *FoundryProvisioningProvider) resolveEnv(ctx context.Context) error { return nil } +func (p *FoundryProvisioningProvider) resolveEnvName(ctx context.Context) error { + currEnv, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || currEnv.GetEnvironment().GetName() == "" { + message := "current azd environment is empty" + if err != nil { + message = err.Error() + } + return exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + fmt.Sprintf("get current azd environment: %s", message), + "run 'azd env new' to create an environment", + ) + } + p.envName = currEnv.GetEnvironment().GetName() + return nil +} + // promptSubscription asks the user to select an Azure subscription when // AZURE_SUBSCRIPTION_ID is not set, then persists the choice to the azd // environment and updates p.subID. This mirrors core `azd up`, which prompts @@ -991,13 +1146,13 @@ func (p *FoundryProvisioningProvider) State( ctx context.Context, options *azdext.ProvisioningStateOptions, ) (*azdext.ProvisioningStateResult, error) { - if p.brownfieldEndpoint != "" { - return &azdext.ProvisioningStateResult{ - State: &azdext.ProvisioningState{ - Outputs: p.normalizeOutputs(p.withTenantOutput(brownfieldOutputs(p.brownfieldEndpoint))), - Resources: []*azdext.ProvisioningResource{}, - }, - }, nil + if p.existingProjectConnectionOnly { + if err := p.resolveConnectionOnlyTenant(ctx); err != nil { + return nil, err + } + return &azdext.ProvisioningStateResult{State: &azdext.ProvisioningState{ + Outputs: p.existingProjectConnectionOutputs(), + }}, nil } client, err := p.deploymentsClient(ctx) if err != nil { @@ -1033,10 +1188,15 @@ func (p *FoundryProvisioningProvider) Deploy( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDeployResult, error) { - if p.brownfieldEndpoint != "" { - return p.deployBrownfield(ctx, progress) + if p.existingProjectConnectionOnly { + if err := p.resolveConnectionOnlyTenant(ctx); err != nil { + return nil, err + } + progress("Using existing Foundry project") + return &azdext.ProvisioningDeployResult{Deployment: &azdext.ProvisioningDeployment{ + Outputs: p.existingProjectConnectionOutputs(), + }}, nil } - progress("Preparing Foundry provisioning template...") // provision.network_mode telemetry: none | byo | managed. Lets us measure @@ -1071,7 +1231,9 @@ func (p *FoundryProvisioningProvider) Deploy( return nil, err } resourceGroupExisted := false - if p.isLayer && !resourceGroupIDMatches(p.foundryRGOwnerID, p.subID, p.rgName) { + trackSupportingResourceGroup := p.isLayer || + (p.brownfieldEndpoint != "" && p.existingProjectAcrMode(ctx) == "create") + if trackSupportingResourceGroup && !resourceGroupIDMatches(p.foundryRGOwnerID, p.subID, p.rgName) { resourceGroupExisted, err = p.resourceGroupExists(ctx) if err != nil { return nil, err @@ -1088,13 +1250,23 @@ func (p *FoundryProvisioningProvider) Deploy( resp, err := pollWithProgress(ctx, poller, progress, "Foundry deployment in progress") if err != nil { + if trackSupportingResourceGroup && !resourceGroupExisted { + if ownershipErr := p.persistCreatedResourceGroupOwnership(ctx); ownershipErr != nil { + log.Printf("[debug] recover Foundry resource-group ownership after deployment failure: %v", ownershipErr) + } + } return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) } progress("Foundry deployment complete") - if p.isLayer { + if trackSupportingResourceGroup { p.foundryRGOwnerID = resolveLayerResourceGroupOwnership( p.foundryRGOwnerID, p.subID, p.rgName, resourceGroupExisted, resp.Properties) + if p.foundryRGOwnerID == "" && !resourceGroupExisted { + if err := p.persistCreatedResourceGroupOwnership(ctx); err != nil { + return nil, err + } + } } return &azdext.ProvisioningDeployResult{ @@ -1105,347 +1277,36 @@ func (p *FoundryProvisioningProvider) Deploy( }, nil } -// captureBrownfieldDeployments records the model deployments declared on a -// brownfield (endpoint:) project service so Deploy can create them on the -// existing account. No-op (nil) when none are declared. -func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( - ctx context.Context, rawYAML []byte, svcName string, -) error { - deployments, err := synthesis.BrownfieldDeployments( - rawYAML, - svcName, - p.projectPath, - ) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("read deployments for existing Foundry project service %q: %s", svcName, err), - "check the deployments: list under your azure.ai.project service", - ) - } - p.brownfieldDeployments = deployments - - connections, err := synthesis.BrownfieldConnections( - rawYAML, - p.networkEnvMap(ctx), - p.serviceEnvironments, - p.projectPath, - ) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("read connections for existing Foundry project service %q: %s", svcName, err), - "check the host: azure.ai.connection services in azure.yaml", - ) - } - p.brownfieldConnections = connections - return nil -} - -// deployBrownfield handles the existing-project (endpoint:) Deploy path. Via a -// single resource-group-scoped ARM deployment against the existing (referenced, -// never re-created) account it reconciles declared model deployments and, when -// init flagged "acr" as pending provision, creates a container registry for the -// hosted agent. With neither needed it skips provisioning and only surfaces the -// endpoint (plus a best-effort tenant). -func (p *FoundryProvisioningProvider) deployBrownfield( - ctx context.Context, - progress grpcbroker.ProgressFunc, -) (*azdext.ProvisioningDeployResult, error) { - createACR := p.brownfieldACRRequested(ctx) - - if len(p.brownfieldDeployments) == 0 && !createACR && len(p.brownfieldConnections) == 0 { - progress("Using existing Foundry project (endpoint set); skipping provisioning") - // Best-effort tenant lookup so AZURE_TENANT_ID is still surfaced for the - // existing-project path (no resources are provisioned here). Log on - // failure so a stale login is visible in the debug trace rather than - // surfacing later as a confusing "AZURE_TENANT_ID is not set" error. - if err := p.ensureCredential(ctx); err != nil { - log.Printf("[debug] best-effort tenant lookup for brownfield deploy: %v", err) - } - return &azdext.ProvisioningDeployResult{ - Deployment: &azdext.ProvisioningDeployment{ - Outputs: p.normalizeOutputs(p.withTenantOutput(brownfieldOutputs(p.brownfieldEndpoint))), - }, - }, nil - } - - progress(brownfieldReconcileMessage(len(p.brownfieldDeployments) > 0, createACR, len(p.brownfieldConnections) > 0)) - - // Locate the existing account (subscription, resource group, account name). - // resolveBrownfieldTarget sets p.subID, which the deployments client needs. - rg, account, err := p.resolveBrownfieldTarget(ctx) - if err != nil { - return nil, err - } - - tmpl, err := brownfieldARMTemplate() - if err != nil { - return nil, err - } - params, err := p.brownfieldParams(ctx, account, rg, createACR) - if err != nil { - return nil, err - } - - dep := armresources.Deployment{ - Properties: &armresources.DeploymentProperties{ - Template: tmpl, - Parameters: params, - Mode: new(armresources.DeploymentModeIncremental), - }, - Tags: map[string]*string{ - "azd-env-name": new(p.envName), - }, - } - - client, err := p.deploymentsClient(ctx) - if err != nil { - return nil, err - } - - name := p.brownfieldDeploymentName() - progress(fmt.Sprintf("Starting deployment %q on %s...", name, account)) - - poller, err := client.BeginCreateOrUpdate(ctx, rg, name, dep, nil) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) - } - resp, err := pollWithProgress(ctx, poller, progress, "Brownfield deployment in progress") - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) - } - - progress("Existing Foundry project reconciled") - - // Merge endpoint/project outputs with any ACR outputs the template emitted, - // skipping empty values (includeAcr=false leg) so we don't clobber the env. - outputs := brownfieldOutputs(p.brownfieldEndpoint) - for k, v := range armOutputsToProto(deploymentOutputs(resp.Properties)) { - if v != nil && v.Value == "" { - continue - } - outputs[k] = v - } - - return &azdext.ProvisioningDeployResult{ - Deployment: &azdext.ProvisioningDeployment{ - Outputs: p.normalizeOutputs(p.withTenantOutput(outputs)), - }, - }, nil -} - -// brownfieldReconcileMessage builds the progress line for what deployBrownfield -// is about to reconcile. Callers reach it only when at least one argument is -// true (the caller's guard skips provisioning otherwise), so the message never -// claims work that isn't actually happening -- e.g. a brownfield project with -// only a pending connection no longer says "reconciling model deployments". -func brownfieldReconcileMessage(hasDeployments, createACR, hasConnections bool) string { - var parts []string - if hasDeployments { - parts = append(parts, "model deployments") - } - if createACR { - parts = append(parts, "container registry") - } - if hasConnections { - parts = append(parts, "connections") - } - return fmt.Sprintf("Using existing Foundry project; reconciling %s...", strings.Join(parts, ", ")) -} - -// brownfieldParams builds the ARM parameter set for brownfield.arm.json, shared -// by the Deploy and Preview paths. ACR params are added only when createACR. -func (p *FoundryProvisioningProvider) brownfieldParams( - ctx context.Context, account, rg string, createACR bool, -) (map[string]any, error) { - connections, connectionCredentials := synthesis.SplitConnectionCredentials( - p.brownfieldConnections, - ) - params := map[string]any{ - "accountName": map[string]any{"value": account}, - "deployments": map[string]any{"value": p.brownfieldDeployments}, - "connections": map[string]any{"value": connections}, - "connectionCredentials": map[string]any{"value": connectionCredentials}, - // projectName feeds the unconditional existing `foundryAccountPreview::project` - // resource, so it must always be set -- even on the model-deployments-only - // reconcile path. Omitting it collapses the resource name to "/" - // and fails ARM template validation with InvalidTemplate. - "projectName": map[string]any{"value": p.brownfieldProjectName()}, - } - if createACR { - params["includeAcr"] = map[string]any{"value": true} - params["acrName"] = map[string]any{"value": p.brownfieldACRName(account)} - params["tags"] = map[string]any{"value": map[string]string{"azd-env-name": p.envName}} - // Only set location when resolved; an empty value would override the - // template default (resourceGroup().location) and fail the deployment. - if loc := p.brownfieldLocation(ctx, rg); loc != "" { - params["location"] = map[string]any{"value": loc} - } - } - return params, nil -} - -// previewBrownfield runs a resource-group-scoped what-if on brownfield.arm.json -// so `azd provision --preview` shows the container registry and/or model -// deployments that Deploy would create on the existing account. With nothing to -// provision it reports an empty preview. -func (p *FoundryProvisioningProvider) previewBrownfield( - ctx context.Context, - progress grpcbroker.ProgressFunc, -) (*azdext.ProvisioningPreviewResult, error) { - createACR := p.brownfieldACRRequested(ctx) - if len(p.brownfieldDeployments) == 0 && !createACR && len(p.brownfieldConnections) == 0 { - progress("Using existing Foundry project (endpoint set); nothing to provision") - return &azdext.ProvisioningPreviewResult{ - Preview: &azdext.ProvisioningDeploymentPreview{}, - }, nil - } - - progress("Computing deployment plan...") - - rg, account, err := p.resolveBrownfieldTarget(ctx) - if err != nil { - return nil, err - } - tmpl, err := brownfieldARMTemplate() - if err != nil { - return nil, err - } - params, err := p.brownfieldParams(ctx, account, rg, createACR) - if err != nil { - return nil, err - } - - client, err := p.deploymentsClient(ctx) - if err != nil { - return nil, err - } - - whatIf := armresources.DeploymentWhatIf{ - Properties: &armresources.DeploymentWhatIfProperties{ - Template: tmpl, - Parameters: params, - Mode: new(armresources.DeploymentModeIncremental), - }, - } - - poller, err := client.BeginWhatIf(ctx, rg, p.brownfieldDeploymentName(), whatIf, nil) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentWhatIf) - } - resp, err := pollWithProgress(ctx, poller, progress, "What-if analysis in progress") - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentWhatIf) - } - if err := whatIfFailure(resp.WhatIfOperationResult); err != nil { - return nil, err - } - - return &azdext.ProvisioningPreviewResult{ - Preview: &azdext.ProvisioningDeploymentPreview{ - Summary: summarizeWhatIf(resp.WhatIfOperationResult), - Changes: whatIfChanges(resp.WhatIfOperationResult), - }, - }, nil +func (p *FoundryProvisioningProvider) existingProjectConnectionOutputs() map[string]*azdext.ProvisioningOutputParameter { + return p.withTenantOutput(map[string]*azdext.ProvisioningOutputParameter{ + "AZURE_AI_PROJECT_NAME": {Type: "string", Value: p.foundryName}, + "FOUNDRY_PROJECT_ENDPOINT": {Type: "string", Value: p.brownfieldEndpoint}, + }) } -// brownfieldACRRequested reports whether the brownfield Deploy should create a -// container registry: init flagged "acr" in AI_AGENT_PENDING_PROVISION and no -// AZURE_CONTAINER_REGISTRY_ENDPOINT is set yet. Best-effort; an env read error -// disables creation rather than failing the deploy. -func (p *FoundryProvisioningProvider) brownfieldACRRequested(ctx context.Context) bool { - if endpoint, _ := p.envValue(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT"); endpoint != "" { - return false - } - pending, err := p.envValue(ctx, "AI_AGENT_PENDING_PROVISION") +func (p *FoundryProvisioningProvider) resolveConnectionOnlyTenant(ctx context.Context) error { + subscriptionID, err := p.envValue(ctx, envKeySubscriptionID) if err != nil { - return false + return fmt.Errorf("read %s: %w", envKeySubscriptionID, err) } - for reason := range strings.SplitSeq(pending, ",") { - if strings.TrimSpace(reason) == "acr" { - return true + if subscriptionID == "" { + tenantID, err := p.envValue(ctx, envKeyTenantID) + if err != nil { + return fmt.Errorf("read %s: %w", envKeyTenantID, err) } + p.tenantID = tenantID + return nil } - return false -} - -// brownfieldProjectName returns the existing project name for project-scoped -// resources (the ACR connection and user connections), preferring the value -// parsed from the endpoint and falling back to p.foundryName. -func (p *FoundryProvisioningProvider) brownfieldProjectName() string { - if name := projectNameFromEndpoint(p.brownfieldEndpoint); name != "" { - return name - } - return p.foundryName -} - -// brownfieldACRName derives a deterministic, ARM-valid container registry name -// (alphanumeric, 5-50 chars, lowercase) for the brownfield ACR. The hash of the -// account + project + env keeps re-runs stable and avoids collisions across -// environments that reuse the same Foundry account. -func (p *FoundryProvisioningProvider) brownfieldACRName(account string) string { - h := fnv.New32a() - _, _ = h.Write([]byte(account + "|" + p.brownfieldProjectName() + "|" + p.envName)) - return fmt.Sprintf("acr%08x", h.Sum32()) -} - -// brownfieldLocation resolves the region for the new registry from AZURE_LOCATION -// (seeded by init), falling back to the existing resource group's location. -func (p *FoundryProvisioningProvider) brownfieldLocation(ctx context.Context, rg string) string { - if loc, _ := p.envValue(ctx, envKeyLocation); loc != "" { - return loc - } - return p.resourceGroupLocation(ctx, rg) -} - -// resourceGroupLocation returns the location of an existing resource group, or -// "" on any error (the caller falls back to another source). -func (p *FoundryProvisioningProvider) resourceGroupLocation(ctx context.Context, rg string) string { - if err := p.ensureCredential(ctx); err != nil { - return "" - } - factory, err := armresources.NewClientFactory(p.subID, p.credential, nil) + tenant, err := p.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{SubscriptionId: subscriptionID}) if err != nil { - return "" - } - resp, err := factory.NewResourceGroupsClient().Get(ctx, rg, nil) - if err != nil || resp.Location == nil { - return "" - } - return *resp.Location -} - -// resolveBrownfieldTarget locates the existing Foundry account to deploy models -// into, from AZURE_AI_PROJECT_ID (the canonical project ARM resource ID set by -// `azd ai agent init` against an existing project). It sets p.subID and returns -// the resource group and account name. -func (p *FoundryProvisioningProvider) resolveBrownfieldTarget(ctx context.Context) (string, string, error) { - projectID, err := p.envValue(ctx, "AZURE_AI_PROJECT_ID") - if err != nil || projectID == "" { - return "", "", exterrors.Dependency( - exterrors.CodeInvalidServiceConfig, - "AZURE_AI_PROJECT_ID is required to create model deployments on an "+ - "existing Foundry project but is not set in the azd environment", - "re-run `azd ai agent init` against the existing project, or set it with "+ - "`azd env set AZURE_AI_PROJECT_ID `", - ) - } - - resID, err := arm.ParseResourceID(projectID) - if err != nil || resID.Parent == nil || - resID.SubscriptionID == "" || resID.ResourceGroupName == "" || resID.Parent.Name == "" { - return "", "", exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("parse AZURE_AI_PROJECT_ID %q as a Foundry project resource ID", projectID), - "verify AZURE_AI_PROJECT_ID is a full project ARM resource ID of the form "+ - "/subscriptions//resourceGroups//providers/"+ - "Microsoft.CognitiveServices/accounts//projects/", + return exterrors.Auth( + exterrors.CodeTenantLookupFailed, + fmt.Sprintf("look up tenant for subscription %s: %s", subscriptionID, err), + "run 'azd auth login' and verify access to the subscription", ) } - - p.subID = resID.SubscriptionID - return resID.ResourceGroupName, resID.Parent.Name, nil + p.tenantID = tenant.TenantId + return nil } // envValue reads a single value from the active azd environment, trimmed. @@ -1463,26 +1324,6 @@ func (p *FoundryProvisioningProvider) envValue(ctx context.Context, key string) return strings.TrimSpace(resp.Value), nil } -// brownfieldARMTemplate loads and parses the embedded resource-group-scoped ARM -// template that creates model deployments on an existing Foundry account. -func brownfieldARMTemplate() (map[string]any, error) { - tmplBytes, err := synthesis.BrownfieldARMTemplate() - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("load embedded brownfield ARM template: %s", err), - ) - } - var tmpl map[string]any - if err := json.Unmarshal(tmplBytes, &tmpl); err != nil { - return nil, exterrors.Internal( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("parse embedded brownfield ARM template: %s", err), - ) - } - return tmpl, nil -} - // resolveTemplate returns the on-disk Bicep source if present, else the // embedded ARM JSON. Lazy: compiles on-disk Bicep on first call and caches // the result on the provider so re-runs skip the bicep CLI. @@ -1522,7 +1363,8 @@ func (p *FoundryProvisioningProvider) resolveTemplate( if p.onDiskSource != nil { log.Printf("[debug] foundry provider: using on-disk template at %s", p.onDiskSource.sourcePath) - merged := mergeParameters(p.onDiskSource.parameters, p.armParameters()) + hostParameters := parametersDeclaredByTemplate(p.armParameters(), p.onDiskSource.armTemplate) + merged := mergeParameters(p.onDiskSource.parameters, hostParameters) return &templateSource{ mode: p.onDiskSource.mode, armTemplate: p.onDiskSource.armTemplate, @@ -1636,10 +1478,6 @@ func (p *FoundryProvisioningProvider) Preview( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningPreviewResult, error) { - if p.brownfieldEndpoint != "" { - return p.previewBrownfield(ctx, progress) - } - progress("Computing deployment plan...") src, err := p.resolveTemplate(ctx, progress) @@ -1719,9 +1557,20 @@ func (p *FoundryProvisioningProvider) Destroy( progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDestroyResult, error) { if p.brownfieldEndpoint != "" { - progress("Foundry project is bring-your-own (endpoint set); azd did not " + - "create it, so azd down leaves it in place") - return &azdext.ProvisioningDestroyResult{}, nil + if p.existingProjectConnectionOnly { + progress("Existing Foundry project is not owned by azd; leaving it in place") + return &azdext.ProvisioningDestroyResult{}, nil + } + if p.existingProjectAcrMode(ctx) != "create" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "azd down cannot safely remove resources created inside an existing Foundry project", + "remove the declared deployments and connections from the existing project explicitly", + ) + } + // Only the ownership-tracked adjunct resource group can be deleted safely. + // The reused Foundry project and its children remain user-owned. + p.isLayer = true } // Fail closed when the active resource-group key was never set: rgName is the rg- @@ -1763,6 +1612,11 @@ func (p *FoundryProvisioningProvider) Destroy( if err := p.ensureCredential(ctx); err != nil { return nil, err } + if p.brownfieldEndpoint != "" { + if err := p.deleteExistingProjectAcrConnection(ctx, progress); err != nil { + return nil, err + } + } factory, err := armresources.NewClientFactory(p.subID, p.credential, nil) if err != nil { return nil, exterrors.Internal( @@ -1805,7 +1659,7 @@ func (p *FoundryProvisioningProvider) Destroy( // account from a prior incomplete cleanup is out of scope -- // the user can purge it manually via `az cognitiveservices // account purge`. - return invalidatedEnvKeysResult(), nil + return p.destroyResult(), nil } return nil, exterrors.ServiceFromAzure(err, exterrors.OpResourceGroupDelete) } @@ -1823,7 +1677,108 @@ func (p *FoundryProvisioningProvider) Destroy( } } - return invalidatedEnvKeysResult(), nil + return p.destroyResult(), nil +} + +func (p *FoundryProvisioningProvider) deleteExistingProjectAcrConnection( + ctx context.Context, + progress grpcbroker.ProgressFunc, +) error { + acrID, err := arm.ParseResourceID(p.existingAcrResourceID) + if err != nil || acrID.ResourceType.String() != "Microsoft.ContainerRegistry/registries" || + !strings.EqualFold(acrID.SubscriptionID, p.subID) || + !strings.EqualFold(acrID.ResourceGroupName, p.rgName) || acrID.Name == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID does not identify a registry in the azd-owned resource group", + "re-run `azd provision` to restore the create-mode registry state", + ) + } + expectedConnectionName := acrID.Name + "-conn" + connectionName := strings.TrimSpace(p.existingAcrConnectionName) + if connectionName != "" && !strings.EqualFold(connectionName, expectedConnectionName) { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME does not match the azd-created container registry", + "re-run `azd provision` to restore the create-mode registry connection state", + ) + } + connectionName = expectedConnectionName + projectID, err := arm.ParseResourceID(p.existingProjectID) + if err != nil || projectID.Parent == nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_AI_PROJECT_ID is not a valid Foundry project resource ID", + "re-run `azd ai agent init` against the configured existing project", + ) + } + client, err := armcognitiveservices.NewProjectConnectionsClient(projectID.SubscriptionID, p.credential, nil) + if err != nil { + return exterrors.Internal( + exterrors.CodeAzdClientFailed, + fmt.Sprintf("create Foundry project connections client: %s", err), + ) + } + connection, err := client.Get( + ctx, + projectID.ResourceGroupName, + projectID.Parent.Name, + projectID.Name, + connectionName, + nil, + ) + if err != nil { + if isNotFound(err) { + return nil + } + return exterrors.ServiceFromAzure(err, exterrors.OpProjectConnectionGet) + } + properties := connection.Properties.GetConnectionPropertiesV2() + resourceID := "" + if properties != nil && properties.Metadata != nil && properties.Metadata["ResourceId"] != nil { + resourceID = *properties.Metadata["ResourceId"] + } + if properties == nil || properties.Category == nil || + *properties.Category != armcognitiveservices.ConnectionCategoryContainerRegistry || + properties.AuthType == nil || + *properties.AuthType != armcognitiveservices.ConnectionAuthTypeManagedIdentity || + properties.Target == nil || + !strings.EqualFold(strings.TrimSpace(*properties.Target), strings.TrimSpace(p.existingAcrEndpoint)) || + !strings.EqualFold(strings.TrimSpace(resourceID), acrID.String()) { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("Foundry project connection %q no longer references the azd-created registry", connectionName), + "remove or rename the replacement connection, then retry cleanup", + ) + } + progress(fmt.Sprintf("Deleting Foundry project connection %s...", connectionName)) + _, err = client.Delete( + ctx, + projectID.ResourceGroupName, + projectID.Parent.Name, + projectID.Name, + connectionName, + nil, + ) + if err != nil && !isNotFound(err) { + return exterrors.ServiceFromAzure(err, exterrors.OpProjectConnectionDelete) + } + return nil +} + +func (p *FoundryProvisioningProvider) destroyResult() *azdext.ProvisioningDestroyResult { + if p.brownfieldEndpoint != "" { + return &azdext.ProvisioningDestroyResult{InvalidatedEnvKeys: []string{ + "AZURE_CONTAINER_REGISTRY_ENDPOINT", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", + "AZURE_FOUNDRY_RESOURCE_GROUP", + "AZD_FOUNDRY_ACR_MODE", + "AZD_FOUNDRY_ACR_PULL_ASSIGNED", + envKeyFoundryRGOwner, + }} + } + return invalidatedEnvKeysResult() } // confirmDestroy asks the user to confirm resource-group deletion when the @@ -1840,11 +1795,14 @@ func (p *FoundryProvisioningProvider) Destroy( // A user cancellation (Ctrl-C) or an explicit "no" both return (false, nil) so // the caller reports a clean cancellation rather than an error. func (p *FoundryProvisioningProvider) confirmDestroy(ctx context.Context) (bool, error) { + target := fmt.Sprintf("resource group %q and all resources inside it", p.rgName) + if p.brownfieldEndpoint != "" { + target += " plus its Container Registry connection inside the existing Foundry project" + } forceRequired := exterrors.Validation( exterrors.CodeDestroyRequiresForce, - fmt.Sprintf("microsoft.foundry destroy will delete resource group %q "+ - "and all resources inside it; no interactive prompt is available, "+ - "so --force is required", p.rgName), + fmt.Sprintf("microsoft.foundry destroy will delete %s; no interactive prompt is available, "+ + "so --force is required", target), "re-run with `azd down --force` (add `--purge` to also purge "+ "soft-deleted Cognitive Services accounts)", ) @@ -1856,8 +1814,7 @@ func (p *FoundryProvisioningProvider) confirmDestroy(ctx context.Context) (bool, resp, err := p.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ Message: fmt.Sprintf( - "microsoft.foundry will delete resource group %q and all resources "+ - "inside it. Are you sure you want to continue?", p.rgName), + "microsoft.foundry will delete %s. Are you sure you want to continue?", target), DefaultValue: new(false), }, }) @@ -2043,6 +2000,8 @@ func invalidatedEnvKeysResult() *azdext.ProvisioningDestroyResult { "AZURE_AI_PROJECT_CONNECTION_NAMES", "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", "AZURE_FOUNDRY_RESOURCE_GROUP", + "AZD_FOUNDRY_ACR_MODE", + "AZD_FOUNDRY_ACR_PULL_ASSIGNED", envKeyFoundryRGOwner, }, } @@ -2055,6 +2014,13 @@ func invalidatedEnvKeysResult() *azdext.ProvisioningDestroyResult { func (p *FoundryProvisioningProvider) Parameters( ctx context.Context, ) ([]*azdext.ProvisioningParameter, error) { + if p.brownfieldEndpoint != "" { + return []*azdext.ProvisioningParameter{ + {Name: "projectResourceId", Value: p.existingProjectID, EnvVarMapping: []string{"AZURE_AI_PROJECT_ID"}}, + {Name: "projectEndpoint", Value: p.brownfieldEndpoint, EnvVarMapping: []string{"FOUNDRY_PROJECT_ENDPOINT"}}, + {Name: "acrMode", Value: p.existingAcrMode, EnvVarMapping: []string{"AZD_FOUNDRY_ACR_MODE"}}, + }, nil + } out := []*azdext.ProvisioningParameter{ {Name: "location", Value: p.location, EnvVarMapping: []string{envKeyLocation}}, {Name: "foundryProjectName", Value: p.foundryName, EnvVarMapping: []string{envKeyProjectName}}, @@ -2074,8 +2040,14 @@ func (p *FoundryProvisioningProvider) Parameters( func (p *FoundryProvisioningProvider) PlannedOutputs( ctx context.Context, ) ([]*azdext.ProvisioningPlannedOutput, error) { - out := make([]*azdext.ProvisioningPlannedOutput, 0, len(canonicalOutputNames)) - for _, name := range canonicalOutputNames { + names := greenfieldOutputNames + if p.existingProjectConnectionOnly { + names = []string{"AZURE_AI_PROJECT_NAME", "FOUNDRY_PROJECT_ENDPOINT"} + } else if p.brownfieldEndpoint != "" { + names = existingProjectOutputNames + } + out := make([]*azdext.ProvisioningPlannedOutput, 0, len(names)) + for _, name := range names { if p.isLayer && name == envKeyResourceGroup { continue } @@ -2106,6 +2078,39 @@ var canonicalOutputNames = []string{ "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", "AZURE_FOUNDRY_NETWORK_MODE", "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE", + "AZD_FOUNDRY_ACR_MODE", +} + +var greenfieldOutputNames = []string{ + "AZURE_AI_PROJECT_ID", + "AZURE_AI_ACCOUNT_NAME", + "AZURE_AI_PROJECT_NAME", + "AZURE_RESOURCE_GROUP", + "AZURE_FOUNDRY_RESOURCE_GROUP", + "AZURE_OPENAI_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", + "AZURE_CONTAINER_REGISTRY_ENDPOINT", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", + "AZURE_AI_PROJECT_CONNECTION_NAMES", + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", + "AZURE_FOUNDRY_NETWORK_MODE", + "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE", +} + +var existingProjectOutputNames = []string{ + "AZURE_AI_PROJECT_ID", + "AZURE_AI_ACCOUNT_NAME", + "AZURE_AI_PROJECT_NAME", + "AZURE_FOUNDRY_RESOURCE_GROUP", + "AZURE_OPENAI_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", + "AZURE_CONTAINER_REGISTRY_ENDPOINT", + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", + "AZURE_AI_PROJECT_CONNECTION_NAMES", + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", + "AZD_FOUNDRY_ACR_MODE", } // --- helpers --- @@ -2146,25 +2151,14 @@ func (p *FoundryProvisioningProvider) deploymentName() string { return deploymentNamePrefix + p.envName[:keep] + hashTail } -// brownfieldDeploymentName is deploymentName plus a "-brownfield" suffix, capped -// at ARM's 64-character deployment-name limit. The trailing path hash and suffix -// are preserved (uniqueness and intent); only the env-name portion is truncated. -func (p *FoundryProvisioningProvider) brownfieldDeploymentName() string { - name := p.deploymentName() + "-brownfield" - if len(name) <= maxDeploymentNameLength { - return name - } - const suffix = "-brownfield" - hashTail := name[len(name)-len(suffix)-9 : len(name)-len(suffix)] // "-<8 hex>" - keep := maxDeploymentNameLength - len(hashTail) - len(suffix) - return name[:keep] + hashTail + suffix -} - // armParameters wraps the synthesizer-derived values in ARM's {"value": ...} // envelope and merges in provider-supplied params (location, principal, // project name). Nil-safe on p.synthResult: returns only host-derived // parameters when Initialize hasn't run (reachable only via tests). func (p *FoundryProvisioningProvider) armParameters() map[string]any { + if p.brownfieldEndpoint != "" { + return p.existingProjectArmParameters() + } out := map[string]any{ "location": map[string]any{"value": p.location}, "resourceGroupName": map[string]any{"value": p.rgName}, @@ -2182,6 +2176,34 @@ func (p *FoundryProvisioningProvider) armParameters() map[string]any { return out } +func (p *FoundryProvisioningProvider) existingProjectArmParameters() map[string]any { + out := map[string]any{ + "projectResourceId": map[string]any{"value": p.existingProjectID}, + "projectEndpoint": map[string]any{"value": p.brownfieldEndpoint}, + "resourceGroupName": map[string]any{"value": p.rgName}, + "location": map[string]any{"value": p.location}, + "resourceTokenSalt": map[string]any{"value": p.resourceTokenSalt}, + "tags": map[string]any{"value": map[string]string{"azd-env-name": p.envName}}, + "acrMode": map[string]any{"value": p.existingAcrMode}, + "existingAcrEndpoint": map[string]any{"value": p.existingAcrEndpoint}, + "existingAcrResourceId": map[string]any{"value": p.existingAcrResourceID}, + "existingAcrConnectionName": map[string]any{"value": p.existingAcrConnectionName}, + "acrPullAssigned": map[string]any{"value": p.existingAcrPullAssigned}, + } + if p.synthResult != nil { + for k, v := range p.synthResult.Parameters { + if k != "includeAcr" { + out[k] = map[string]any{"value": v} + } + } + } + return out +} + +func (p *FoundryProvisioningProvider) existingProjectAcrMode(_ context.Context) string { + return p.existingAcrMode +} + // findFoundryProjectService scans azure.yaml for a single azure.ai.project service and returns its name. func findFoundryProjectService(raw []byte) (string, error) { type svc struct { @@ -2336,21 +2358,51 @@ func resolveLayerResourceGroupOwnership( } func (p *FoundryProvisioningProvider) resourceGroupExists(ctx context.Context) (bool, error) { + _, found, err := p.lookupResourceGroupState(ctx) + return found, err +} + +func (p *FoundryProvisioningProvider) lookupResourceGroupState( + ctx context.Context, +) (map[string]*string, bool, error) { + if p.resourceGroupState != nil { + return p.resourceGroupState(ctx) + } factory, err := armresources.NewClientFactory(p.subID, p.credential, nil) if err != nil { - return false, exterrors.Internal( + return nil, false, exterrors.Internal( exterrors.CodeAzdClientFailed, fmt.Sprintf("create armresources client for resource-group existence check: %s", err), ) } - _, err = factory.NewResourceGroupsClient().Get(ctx, p.rgName, nil) + resp, err := factory.NewResourceGroupsClient().Get(ctx, p.rgName, nil) if err == nil { - return true, nil + return resp.Tags, true, nil } if isNotFound(err) { - return false, nil + return nil, false, nil + } + return nil, false, exterrors.ServiceFromAzure(err, exterrors.OpResourceGroupGet) +} + +func (p *FoundryProvisioningProvider) persistCreatedResourceGroupOwnership(ctx context.Context) error { + recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := p.ensureCredential(recoveryCtx); err != nil { + return err + } + tags, found, err := p.lookupResourceGroupState(recoveryCtx) + if err != nil { + return err + } + if !found { + return nil + } + if err := verifyLayerResourceGroupTags(tags, p.envName, p.rgName); err != nil { + return err } - return false, exterrors.ServiceFromAzure(err, exterrors.OpResourceGroupGet) + p.foundryRGOwnerID = fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", p.subID, p.rgName) + return p.setEnv(recoveryCtx, envKeyFoundryRGOwner, p.foundryRGOwnerID) } func (p *FoundryProvisioningProvider) verifyLayerResourceGroupAzureOwnership(ctx context.Context) error { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go deleted file mode 100644 index 877d900557d..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package provisioning - -import ( - "context" - "net" - "strings" - "testing" - - "azure.ai.projects/internal/synthesis" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" -) - -// kvEnvServer is an environment service stub that returns per-key values, -// used to drive brownfieldACRRequested's env reads. -type kvEnvServer struct { - azdext.UnimplementedEnvironmentServiceServer - values map[string]string -} - -func (s *kvEnvServer) GetValue( - _ context.Context, req *azdext.GetEnvRequest, -) (*azdext.KeyValueResponse, error) { - return &azdext.KeyValueResponse{Value: s.values[req.Key]}, nil -} - -func newKVEnvClient(t *testing.T, values map[string]string) *azdext.AzdClient { - t.Helper() - srv := grpc.NewServer() - azdext.RegisterEnvironmentServiceServer(srv, &kvEnvServer{values: values}) - - lis, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - go func() { _ = srv.Serve(lis) }() - t.Cleanup(func() { - srv.Stop() - _ = lis.Close() - }) - - client, err := azdext.NewAzdClient(azdext.WithAddress(lis.Addr().String())) - require.NoError(t, err) - t.Cleanup(func() { client.Close() }) - return client -} - -func TestBrownfieldACRRequested(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - values map[string]string - want bool - }{ - { - name: "acr pending and no endpoint => create", - values: map[string]string{"AI_AGENT_PENDING_PROVISION": "acr"}, - want: true, - }, - { - name: "acr pending among others and no endpoint => create", - values: map[string]string{ - "AI_AGENT_PENDING_PROVISION": "model_deployment,acr,app_insights", - }, - want: true, - }, - { - name: "endpoint already set => skip even if acr pending", - values: map[string]string{ - "AI_AGENT_PENDING_PROVISION": "acr", - "AZURE_CONTAINER_REGISTRY_ENDPOINT": "myreg.azurecr.io", - }, - want: false, - }, - { - name: "acr not pending => skip", - values: map[string]string{"AI_AGENT_PENDING_PROVISION": "model_deployment"}, - want: false, - }, - { - name: "empty pending => skip", - values: map[string]string{"AI_AGENT_PENDING_PROVISION": ""}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - p := &FoundryProvisioningProvider{ - envName: "dev", - azdClient: newKVEnvClient(t, tt.values), - } - assert.Equal(t, tt.want, p.brownfieldACRRequested(t.Context())) - }) - } -} - -func TestBrownfieldACRName(t *testing.T) { - t.Parallel() - - p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - } - name := p.brownfieldACRName("acct") - - // ACR names must be 5-50 chars, alphanumeric only. - assert.GreaterOrEqual(t, len(name), 5) - assert.LessOrEqual(t, len(name), 50) - for _, r := range name { - isLowerAlnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') - assert.True(t, isLowerAlnum, "ACR name %q must be lowercase alphanumeric, found %q", name, string(r)) - } - - // Deterministic across calls with the same inputs. - assert.Equal(t, name, p.brownfieldACRName("acct")) - - // Different env or account changes the name (collision avoidance). - other := &FoundryProvisioningProvider{ - envName: "prod", - brownfieldEndpoint: p.brownfieldEndpoint, - } - assert.NotEqual(t, name, other.brownfieldACRName("acct")) -} - -func TestBrownfieldProjectName(t *testing.T) { - t.Parallel() - - // Prefers the name parsed from the endpoint. - p := &FoundryProvisioningProvider{ - foundryName: "fallback", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - } - assert.Equal(t, "my-project", p.brownfieldProjectName()) - - // Falls back to foundryName when the endpoint has no project segment. - p2 := &FoundryProvisioningProvider{ - foundryName: "fallback", - brownfieldEndpoint: "https://acct.services.ai.azure.com/", - } - assert.Equal(t, "fallback", p2.brownfieldProjectName()) -} - -func TestBrownfieldDeploymentName(t *testing.T) { - t.Parallel() - - // Short env name: full "-brownfield" fits under 64 chars. - short := &FoundryProvisioningProvider{envName: "dev", projectPath: "/p"} - name := short.brownfieldDeploymentName() - assert.LessOrEqual(t, len(name), 64) - assert.True(t, strings.HasSuffix(name, "-brownfield"), "got %q", name) - assert.Equal(t, short.deploymentName()+"-brownfield", name) - - // Long env name: must be capped at 64 while keeping the suffix. - long := &FoundryProvisioningProvider{ - envName: "agent-framework-agent-basic-invocations-dev", - projectPath: "/some/long/project/path", - } - lname := long.brownfieldDeploymentName() - assert.LessOrEqual(t, len(lname), 64, "got %q (len %d)", lname, len(lname)) - assert.True(t, strings.HasSuffix(lname, "-brownfield"), "got %q", lname) -} - -func TestBrownfieldParams(t *testing.T) { - t.Parallel() - - deployments := []synthesis.Deployment{{Name: "gpt-4o-mini"}} - - t.Run("without ACR still carries projectName for the existing project resource", func(t *testing.T) { - t.Parallel() - // The brownfield template declares `foundryAccountPreview::project` as an - // unconditional existing resource, so projectName must be supplied even - // when no ACR is created (model-deployments-only reconcile). Regression - // test for the InvalidTemplate failure where the name collapsed to - // "/" because projectName was omitted. - p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - brownfieldDeployments: deployments, - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) - require.NoError(t, err) - - assert.Equal(t, map[string]any{"value": "acct"}, params["accountName"]) - assert.Equal(t, map[string]any{"value": deployments}, params["deployments"]) - assert.Equal(t, map[string]any{"value": []synthesis.Connection{}}, params["connections"]) - assert.Equal( - t, - map[string]any{"value": map[string]map[string]any{}}, - params["connectionCredentials"], - ) - assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) - assert.NotContains(t, params, "includeAcr") - assert.NotContains(t, params, "acrName") - }) - - t.Run("connections without ACR carry connections and set projectName", func(t *testing.T) { - t.Parallel() - conns := []synthesis.Connection{{ - Name: "search-conn", - Category: "CognitiveSearch", - Credentials: map[string]any{"key": "secret"}, - }} - p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - brownfieldConnections: conns, - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) - require.NoError(t, err) - - assert.Equal( - t, - map[string]any{"value": []synthesis.Connection{{ - Name: "search-conn", - Category: "CognitiveSearch", - }}}, - params["connections"], - ) - assert.Equal( - t, - map[string]any{"value": map[string]map[string]any{ - "search-conn": {"key": "secret"}, - }}, - params["connectionCredentials"], - ) - // Connections are project-scoped, so projectName must be supplied even - // without ACR. - assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) - assert.NotContains(t, params, "includeAcr") - }) - - t.Run("with ACR adds registry params", func(t *testing.T) { - t.Parallel() - p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - azdClient: newKVEnvClient(t, map[string]string{"AZURE_LOCATION": "westus2"}), - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) - require.NoError(t, err) - - assert.Equal(t, map[string]any{"value": true}, params["includeAcr"]) - assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) - assert.Equal(t, map[string]any{"value": "westus2"}, params["location"]) - assert.Equal(t, map[string]any{"value": p.brownfieldACRName("acct")}, params["acrName"]) - }) - - t.Run("omits location when unresolved so template default applies", func(t *testing.T) { - t.Parallel() - // AZURE_LOCATION unset and no usable credential => brownfieldLocation - // returns ""; the param must be omitted, not set to "". - p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - azdClient: newKVEnvClient(t, map[string]string{}), - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) - require.NoError(t, err) - - assert.Contains(t, params, "includeAcr") - assert.NotContains(t, params, "location") - }) -} - -// TestBrownfieldReconcileMessage covers every combination the caller can -// reach (deployBrownfield's own guard skips provisioning entirely when all -// three are false, so at least one is always true here). Regression guard -// for a live-tested bug: a brownfield project declaring only a connection -// (no deployments, no ACR) previously printed "reconciling declared model -// deployments..." even though zero deployments existed. -func TestBrownfieldReconcileMessage(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - hasDeployments bool - createACR bool - hasConnections bool - want string - }{ - { - name: "connections only (the live-tested regression case)", - hasConnections: true, - want: "Using existing Foundry project; reconciling connections...", - }, - { - name: "deployments only", - hasDeployments: true, - want: "Using existing Foundry project; reconciling model deployments...", - }, - { - name: "ACR only", - createACR: true, - want: "Using existing Foundry project; reconciling container registry...", - }, - { - name: "deployments and ACR", - hasDeployments: true, - createACR: true, - want: "Using existing Foundry project; reconciling model deployments, container registry...", - }, - { - name: "deployments and connections", - hasDeployments: true, - hasConnections: true, - want: "Using existing Foundry project; reconciling model deployments, connections...", - }, - { - name: "ACR and connections", - createACR: true, - hasConnections: true, - want: "Using existing Foundry project; reconciling container registry, connections...", - }, - { - name: "all three", - hasDeployments: true, - createACR: true, - hasConnections: true, - want: "Using existing Foundry project; reconciling model deployments, container registry, connections...", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := brownfieldReconcileMessage(tt.hasDeployments, tt.createACR, tt.hasConnections) - assert.Equal(t, tt.want, got) - // Never claim to reconcile something that isn't actually pending. - if !tt.hasDeployments { - assert.NotContains(t, got, "model deployments") - } - if !tt.createACR { - assert.NotContains(t, got, "container registry") - } - if !tt.hasConnections { - assert.NotContains(t, got, "connections") - } - }) - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 4411df1c3b2..1c53953bc63 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -4,6 +4,7 @@ package provisioning import ( + "context" "encoding/json" "errors" "os" @@ -15,6 +16,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/synthesis" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -728,6 +730,27 @@ func TestResolveLayerResourceGroupOwnership(t *testing.T) { "changing to an absent group may establish ownership after creation") } +func TestPersistCreatedResourceGroupOwnership(t *testing.T) { + t.Parallel() + env := &resolveEnvStubEnvServer{envName: "dev", get: map[string]string{}} + client := newResolveEnvTestClient(t, env, &resolveEnvStubPromptServer{}) + p := &FoundryProvisioningProvider{ + azdClient: client, + credential: &azidentity.AzureDeveloperCLICredential{}, + envName: "dev", + subID: "sub", + rgName: "rg-foundry", + resourceGroupState: func(context.Context) (map[string]*string, bool, error) { + return map[string]*string{"azd-env-name": new("dev")}, true, nil + }, + } + + require.NoError(t, p.persistCreatedResourceGroupOwnership(t.Context())) + want := "/subscriptions/sub/resourceGroups/rg-foundry" + assert.Equal(t, want, p.foundryRGOwnerID) + assert.Equal(t, want, env.set[envKeyFoundryRGOwner]) +} + func TestValidateFoundryProviderLayers(t *testing.T) { require.NoError(t, validateFoundryProviderLayers([]byte(`infra: provider: bicep @@ -1109,7 +1132,13 @@ func TestResolveTemplate_PrefersOnDiskWhenPresent(t *testing.T) { // (resolveTemplate skips the loadOnDiskTemplate call when // onDiskSource is already set; this lets the test exercise the // merge logic in isolation.) - armFromDisk := map[string]any{"$schema": "ondisk", "contentVersion": "1.0.0.0"} + armFromDisk := map[string]any{ + "$schema": "ondisk", "contentVersion": "1.0.0.0", + "parameters": map[string]any{ + "location": map[string]any{"type": "string"}, + "foundryProjectName": map[string]any{"type": "string"}, + }, + } p := &FoundryProvisioningProvider{ projectPath: dir, envName: "dev", @@ -1146,7 +1175,7 @@ func TestResolveTemplate_PrefersOnDiskWhenPresent(t *testing.T) { "user-supplied parameter wins over host-derived") // User-only key is present. require.Contains(t, got.parameters, "userOnly") - // Host-derived key (not in user params) still flows through. + // Host-derived key (declared by the template, not in user params) still flows through. require.Contains(t, got.parameters, "foundryProjectName", "host-derived parameter fills gap when user file doesn't declare it") // Synthesizer-derived key is ABSENT: per the design decision, @@ -1298,15 +1327,88 @@ func TestProjectNameFromEndpoint(t *testing.T) { assert.Equal(t, "", projectNameFromEndpoint("")) } -func TestBrownfieldOutputs(t *testing.T) { +func TestExistingProjectEndpointIdentity(t *testing.T) { + t.Parallel() + account, project := existingProjectEndpointIdentity( + "https://Account.services.ai.azure.com/api/projects/MyProject/", + ) + assert.Equal(t, "account", account) + assert.Equal(t, "MyProject", project) +} + +func TestSameExistingProjectEndpoint(t *testing.T) { + t.Parallel() + endpoint := "https://Account.services.ai.azure.com/api/projects/MyProject/" + assert.True(t, sameExistingProjectEndpoint( + endpoint, + "https://account.services.ai.azure.com/projects/myproject", + )) + assert.False(t, sameExistingProjectEndpoint( + endpoint, + "https://account.services.ai.azure.com/api/projects/other", + )) +} + +func TestPlannedOutputsMatchSelectedTemplate(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + provider FoundryProvisioningProvider + want string + doNotWant string + }{ + { + name: "greenfield", + want: "AZURE_FOUNDRY_NETWORK_MODE", + doNotWant: "AZD_FOUNDRY_ACR_MODE", + }, + { + name: "existing project", + provider: FoundryProvisioningProvider{ + brownfieldEndpoint: "https://account.services.ai.azure.com/api/projects/project", + }, + want: "AZD_FOUNDRY_ACR_MODE", + doNotWant: "AZURE_FOUNDRY_NETWORK_MODE", + }, + } { + t.Run(tt.name, func(t *testing.T) { + outputs, err := tt.provider.PlannedOutputs(t.Context()) + require.NoError(t, err) + names := make([]string, 0, len(outputs)) + for _, output := range outputs { + names = append(names, output.Name) + } + assert.Contains(t, names, tt.want) + assert.NotContains(t, names, tt.doNotWant) + assert.Contains(t, names, "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT") + }) + } +} + +func TestDestroyPreservesExistingProjectBindings(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{ + brownfieldEndpoint: "https://account.services.ai.azure.com/api/projects/project", + existingProjectConnectionOnly: true, + } + result, err := p.Destroy( + t.Context(), + &azdext.ProvisioningDestroyOptions{Force: true}, + func(string) {}, + ) + require.NoError(t, err) + assert.Empty(t, result.InvalidatedEnvKeys) +} + +func TestDestroyResultForExistingProjectOnlyClearsAdjunctState(t *testing.T) { t.Parallel() - outputs := brownfieldOutputs("https://acct.services.ai.azure.com/api/projects/my-project") - require.Contains(t, outputs, "FOUNDRY_PROJECT_ENDPOINT") - assert.Equal(t, - "https://acct.services.ai.azure.com/api/projects/my-project", - outputs["FOUNDRY_PROJECT_ENDPOINT"].Value) - require.Contains(t, outputs, "AZURE_AI_PROJECT_NAME") - assert.Equal(t, "my-project", outputs["AZURE_AI_PROJECT_NAME"].Value) + p := &FoundryProvisioningProvider{ + brownfieldEndpoint: "https://account.services.ai.azure.com/api/projects/project", + } + result := p.destroyResult() + assert.NotContains(t, result.InvalidatedEnvKeys, "AZURE_AI_PROJECT_ID") + assert.NotContains(t, result.InvalidatedEnvKeys, "FOUNDRY_PROJECT_ENDPOINT") + assert.Contains(t, result.InvalidatedEnvKeys, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID") } func TestDefaultResourceGroupName(t *testing.T) { @@ -1350,6 +1452,22 @@ func TestWithTenantOutput(t *testing.T) { }) } +func TestConnectionOnlyOutputsPreserveExistingTenant(t *testing.T) { + t.Parallel() + env := &resolveEnvStubEnvServer{envName: "dev", get: map[string]string{envKeyTenantID: "tenant-123"}} + client := newResolveEnvTestClient(t, env, &resolveEnvStubPromptServer{}) + p := &FoundryProvisioningProvider{ + azdClient: client, + envName: "dev", + foundryName: "project", + brownfieldEndpoint: "https://account.services.ai.azure.com/api/projects/project", + } + + require.NoError(t, p.resolveConnectionOnlyTenant(t.Context())) + outputs := p.existingProjectConnectionOutputs() + assert.Equal(t, "tenant-123", outputs[envKeyTenantID].Value) +} + func TestNormalizeOutputs_LayerOmitsRootResourceGroup(t *testing.T) { t.Parallel() p := &FoundryProvisioningProvider{ @@ -1373,6 +1491,21 @@ func TestNormalizeOutputs_LayerClearsStaleResourceGroupOwnership(t *testing.T) { assert.Equal(t, "", got[envKeyFoundryRGOwner].Value) } +func TestNormalizeOutputs_ExistingProjectCreateTracksSupportingResourceGroup(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{ + brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/project", + existingAcrMode: "create", + foundryRGOwnerID: "/subscriptions/sub/resourceGroups/rg-foundry", + } + got := p.normalizeOutputs(map[string]*azdext.ProvisioningOutputParameter{ + envKeyResourceGroup: {Type: "string", Value: "root-rg"}, + envKeyFoundryRG: {Type: "string", Value: "rg-foundry"}, + }) + assert.Contains(t, got, envKeyResourceGroup) + assert.Equal(t, p.foundryRGOwnerID, got[envKeyFoundryRGOwner].Value) +} + func TestEnvValues_IncludesCanonicalKeysEvenWithoutAzdClient(t *testing.T) { t.Parallel() // envValues must always include the canonical AZURE_* keys diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go index a1010144aaf..d29accbb1b5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go @@ -557,6 +557,20 @@ func mergeParameters(userParams, hostParams map[string]any) map[string]any { return out } +// parametersDeclaredByTemplate keeps host-derived values only when the +// compiled on-disk template declares the matching parameter. User-authored +// parameters are deliberately not filtered so ARM still reports misspellings. +func parametersDeclaredByTemplate(hostParams, armTemplate map[string]any) map[string]any { + declared, _ := armTemplate["parameters"].(map[string]any) + out := make(map[string]any, min(len(hostParams), len(declared))) + for name, value := range hostParams { + if _, ok := declared[name]; ok { + out[name] = value + } + } + return out +} + // unmarshalARMTemplate parses an ARM template JSON string into the untyped // map shape armresources.DeploymentProperties.Template expects. func unmarshalARMTemplate(raw, sourcePath string) (map[string]any, error) { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go index 5e4e4ba2962..927dc1d5c50 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go @@ -480,6 +480,33 @@ func TestMergeParameters_NilInputsAreSafe(t *testing.T) { assert.Equal(t, "v", got["k"]) } +func TestParametersDeclaredByTemplate_FiltersOnlyHostValues(t *testing.T) { + host := map[string]any{ + "projectResourceId": map[string]any{"value": "project"}, + "acrMode": map[string]any{"value": "reuse-connect"}, + "location": map[string]any{"value": "eastus"}, + } + template := map[string]any{"parameters": map[string]any{ + "projectResourceId": map[string]any{"type": "string"}, + "existingAcrEndpoint": map[string]any{"type": "string"}, + }} + + got := parametersDeclaredByTemplate(host, template) + + assert.Equal(t, map[string]any{ + "projectResourceId": map[string]any{"value": "project"}, + }, got) +} + +func TestParametersDeclaredByTemplate_NoDeclaredParameters(t *testing.T) { + got := parametersDeclaredByTemplate( + map[string]any{"location": map[string]any{"value": "eastus"}}, + map[string]any{}, + ) + + assert.Empty(t, got) +} + func TestTemplateMode_String(t *testing.T) { t.Parallel() // Strings end up in deployment tags and telemetry; lock the diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go index d342112d097..32f1b677f1a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go @@ -10,6 +10,7 @@ import ( "strings" "azure.ai.projects/internal/azure" + "azure.ai.projects/internal/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" @@ -93,16 +94,12 @@ func (c *ResourceGroupLocationCheck) Validate( return empty, nil } - // Skip brownfield (bring-your-own) projects. When the Foundry service sets - // `endpoint:`, the microsoft.foundry provider connects to that existing - // project and provisions nothing — it creates no resource group and derives - // its target from AZURE_AI_PROJECT_ID, ignoring AZURE_RESOURCE_GROUP. Running - // this check there would compare AZURE_LOCATION against an unrelated, stale - // resource group of the same name and could wrongly block provisioning while - // suggesting the deletion of a resource group that has nothing to do with the - // deployment. + // Existing projects only create a resource group when azd owns an adjunct ACR. if c.isBrownfieldFoundryProject(ctx) { - return empty, nil + if !c.existingProjectCreatesResourceGroup(ctx) { + return empty, nil + } + usesFoundryLayer = true } envClient := c.azdClient.Environment() @@ -176,6 +173,47 @@ func (c *ResourceGroupLocationCheck) Validate( ), nil } +func (c *ResourceGroupLocationCheck) existingProjectCreatesResourceGroup(ctx context.Context) bool { + current, err := c.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || current.GetEnvironment().GetName() == "" { + return false + } + envName := current.GetEnvironment().GetName() + mode := envValueOrEmpty(ctx, c.azdClient.Environment(), envName, "AZD_FOUNDRY_ACR_MODE") + if mode != "" { + return strings.EqualFold(mode, "create") + } + if envValueOrEmpty(ctx, c.azdClient.Environment(), envName, "AZURE_CONTAINER_REGISTRY_ENDPOINT") != "" || + envValueOrEmpty(ctx, c.azdClient.Environment(), envName, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID") != "" { + return false + } + + project, err := c.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || project.GetProject().GetPath() == "" { + return false + } + projectPath := project.GetProject().GetPath() + rawYAML, _, err := readProjectFile(projectPath) + if err != nil { + return false + } + serviceName, err := findFoundryProjectService(rawYAML) + if err != nil { + return false + } + result, err := synthesis.SynthesizeExistingProject(synthesis.Input{ + RawAzureYAML: rawYAML, + ServiceName: serviceName, + AcceptedHosts: FoundryProvisioningServiceHosts, + ProjectRoot: projectPath, + }) + if err != nil { + return false + } + includeAcr, _ := result.Parameters["includeAcr"].(bool) + return includeAcr +} + // armResourceGroupLocation is the production resourceGroupLocationLookup. It // resolves the resource group's region via ARM, using the azd credential scoped // to the subscription's tenant. Every failure mode is non-blocking: it returns diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go index 98039c947b0..3a87b718ce0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go @@ -170,6 +170,28 @@ services: assert.False(t, called, "resource group lookup must not run for a brownfield project") }) + t.Run("checks brownfield adjunct resource group in create mode", func(t *testing.T) { + proj := &validateStubProjectServer{project: &azdext.ProjectConfig{ + Path: writeAzureYAML(t, "https://acct.services.ai.azure.com/api/projects/p"), + Infra: &azdext.InfraOptions{Provider: FoundryProviderName}, + }} + env := &validateStubEnvServer{envName: "rgloc-test", get: map[string]string{ + "AZD_FOUNDRY_ACR_MODE": "create", + envKeyFoundryRG: "rg-adjunct", + }} + client := newValidateTestClient(t, proj, env) + + c := &ResourceGroupLocationCheck{azdClient: client} + c.resourceGroupLocation = func(_ context.Context, _, resourceGroup string) (string, bool, error) { + assert.Equal(t, "rg-adjunct", resourceGroup) + return "eastus", true, nil + } + + resp, err := c.Validate(t.Context(), provisionContext(sub, "westus2", "rg-x"), &azdext.ValidationCheckRequest{}) + require.NoError(t, err) + require.Len(t, resp.Results, 1) + }) + t.Run("skips brownfield project from azure.yml", func(t *testing.T) { root := t.TempDir() require.NoError(t, os.WriteFile( diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go index ec1a7b29052..94c9ec3b987 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go @@ -61,7 +61,6 @@ func readSynthesisFiles(t *testing.T, root string) map[string][]byte { //nolint:gosec // repository-controlled parity path data, err := os.ReadFile(path) require.NoError(t, err) - data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) files[filepath.ToSlash(rel)] = data return nil }, diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go index cf52f4536a9..ce20fc099d3 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go @@ -122,23 +122,23 @@ func TestARMTemplate_MatchesBicepBuild(t *testing.T) { "--outfile main.arm.json` from the templates directory") } -// TestBrownfieldARMTemplate_MatchesBicepBuild is the brownfield.bicep counterpart +// TestExistingProjectARMTemplate_MatchesBicepBuild is the existing-project.bicep counterpart // of TestARMTemplate_MatchesBicepBuild: it catches a forgotten `bicep build` after -// editing the brownfield model-deployment template. Skipped when bicep is absent. -func TestBrownfieldARMTemplate_MatchesBicepBuild(t *testing.T) { +// editing the existing-project template. Skipped when bicep is absent. +func TestExistingProjectARMTemplate_MatchesBicepBuild(t *testing.T) { bicep := lookupBicep() if bicep == "" { t.Skip("bicep CLI not found on PATH; skipping ARM drift check") } templatesDir := "templates" - committed, err := os.ReadFile(filepath.Join(templatesDir, "brownfield.arm.json")) + committed, err := os.ReadFile(filepath.Join(templatesDir, "existing-project.arm.json")) require.NoError(t, err) - out := filepath.Join(t.TempDir(), "brownfield.arm.json") + out := filepath.Join(t.TempDir(), "existing-project.arm.json") //nolint:gosec // bicep comes from PATH or the Azure CLI cmd := exec.CommandContext(t.Context(), bicep, "build", - filepath.Join(templatesDir, "brownfield.bicep"), "--outfile", out) + filepath.Join(templatesDir, "existing-project.bicep"), "--outfile", out) var stderr bytes.Buffer cmd.Stderr = &stderr require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) @@ -151,8 +151,8 @@ func TestBrownfieldARMTemplate_MatchesBicepBuild(t *testing.T) { rebuiltNormalized := normalizeArmTemplate(t, rebuilt) assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), - "templates/brownfield.arm.json is stale; regenerate with `bicep build "+ - "brownfield.bicep --outfile brownfield.arm.json` from the templates directory") + "templates/existing-project.arm.json is stale; regenerate with `bicep build "+ + "existing-project.bicep --outfile existing-project.arm.json` from the templates directory") } // normalizeArmTemplate returns a stable JSON representation of an ARM template diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index 67c4d536464..b6cb66a38fa 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -327,6 +327,59 @@ func Synthesize(in Input) (*Result, error) { }, nil } +// SynthesizeExistingProject derives parameters for editable infrastructure that +// augments an existing Foundry project without taking ownership of it. +func SynthesizeExistingProject(in Input) (*Result, error) { + if len(in.RawAzureYAML) == 0 { + return nil, errors.New("synthesis: RawAzureYAML is empty") + } + if in.ServiceName == "" { + return nil, errors.New("synthesis: ServiceName is empty") + } + + var root projectFile + if err := yaml.Unmarshal(in.RawAzureYAML, &root); err != nil { + return nil, fmt.Errorf("parse azure.yaml: %w", err) + } + svc, err := loadProjectService(root.Services, in.ServiceName, in.ProjectRoot) + if err != nil { + return nil, err + } + if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { + return nil, ErrServiceNotFound + } + if strings.TrimSpace(svc.Endpoint) == "" { + return nil, errors.New("synthesis: existing Foundry project endpoint is empty") + } + + includeAcr, err := deriveIncludeAcr(root.Services, svc, in.ProjectRoot) + if err != nil { + return nil, err + } + connections, err := collectConnections( + root.Services, + in.Env, + in.ServiceEnvironments, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } + connections, connectionCredentials := SplitConnectionCredentials(connections) + deployments := svc.Deployments + if deployments == nil { + deployments = []Deployment{} + } + + return &Result{Parameters: map[string]any{ + "deployments": deployments, + "includeAcr": includeAcr, + "connections": connections, + "connectionCredentials": connectionCredentials, + }, NetworkMode: NetworkModeNone}, nil +} + // ConnectionEnvironmentScopes returns services that declare env. // An empty env block still establishes an isolated service scope. func ConnectionEnvironmentScopes( diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go index 46d62e83583..234c33b0aa3 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go @@ -1191,7 +1191,7 @@ func TestTerraformTemplatesFS_Embedded(t *testing.T) { "templates/terraform/provider.tf", "templates/terraform/variables.tf", "templates/terraform/main.tf", - "templates/terraform/acr.tf", + "templates/terraform/container-registry.tf", "templates/terraform/connections.tf", "templates/terraform/outputs.tf.tmpl", } @@ -1202,10 +1202,6 @@ func TestTerraformTemplatesFS_Embedded(t *testing.T) { assert.NotEmpty(t, data, "%s should not be empty", p) }) } - outputs, err := fs.ReadFile("templates/terraform/outputs.tf.tmpl") - require.NoError(t, err) - assert.Contains(t, string(outputs), `output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT"`) - // outputs.tf is rendered from outputs.tf.tmpl at eject time, and // main.tfvars.json is generated -- neither is embedded as a final file // (otherwise they would go stale). @@ -1336,8 +1332,8 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { "private endpoint location must come from the customer VNet") } -func TestBrownfieldARMTemplate_SecuresConnectionCredentials(t *testing.T) { - data, err := BrownfieldARMTemplate() +func TestExistingProjectARMTemplate_SecuresConnectionCredentials(t *testing.T) { + data, err := ExistingProjectARMTemplate() require.NoError(t, err) var arm map[string]any @@ -1346,13 +1342,10 @@ func TestBrownfieldARMTemplate_SecuresConnectionCredentials(t *testing.T) { require.True(t, ok, "parameters must be an object") connections, ok := params["connections"].(map[string]any) require.True(t, ok, "connections param must be an object") - assert.Equal(t, "#/definitions/connectionsType", connections["$ref"]) + assert.Equal(t, "array", connections["type"]) credentials, ok := params["connectionCredentials"].(map[string]any) require.True(t, ok, "connectionCredentials param must be an object") assert.Equal(t, "secureObject", credentials["type"]) - assert.Contains(t, string(data), - "parameters('principalId'), parameters('roleDefinitionId')", - "ACR role assignment name must include the assigned principal") } func TestSynthesize_Network(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json deleted file mode 100644 index 77cda60d3cf..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "5428399781259274778" - } - }, - "definitions": { - "deploymentsType": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "metadata": { - "description": "Shape of one model deployment entry in azure.yaml." - } - }, - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - }, - "metadata": { - "description": "Shape of a single model deployment." - } - }, - "connectionsType": { - "type": "array", - "items": { - "$ref": "#/definitions/connectionType" - }, - "metadata": { - "description": "Shape of a list of Foundry project connections." - } - }, - "connectionType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "category": { - "type": "string" - }, - "target": { - "type": "string" - }, - "authType": { - "type": "string" - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } - } - }, - "parameters": { - "accountName": { - "type": "string", - "minLength": 2, - "maxLength": 64, - "metadata": { - "description": "Name of the existing Foundry (AIServices) account." - } - }, - "projectName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true." - } - }, - "deployments": { - "$ref": "#/definitions/deploymentsType", - "defaultValue": [], - "metadata": { - "description": "Model deployments to create or update on the existing account." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for the container registry. Defaults to the resource group location." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to created resources." - } - }, - "includeAcr": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent." - } - }, - "acrName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true." - } - }, - "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], - "metadata": { - "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." - } - }, - "connectionCredentials": { - "type": "secureObject", - "defaultValue": {}, - "metadata": { - "description": "Credentials keyed by Foundry project connection name." - } - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" - }, - "resources": { - "foundryAccountPreview::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('accountName')]" - }, - "modelDeployments": { - "copy": { - "name": "modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]" - }, - "foundryAccountPreview": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]" - }, - "registry": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('acrName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - "acrConnection": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}-conn', parameters('accountName'), parameters('projectName'), parameters('acrName'))]", - "properties": { - "category": "ContainerRegistry", - "target": "[reference('registry').loginServer]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "foundryAcrPull", - "registry" - ] - }, - "projectConnections": { - "copy": { - "name": "projectConnections", - "count": "[length(parameters('connections'))]" - }, - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(contains(parameters('connectionCredentials'), parameters('connections')[copyIndex()].name), createObject('credentials', parameters('connectionCredentials')[parameters('connections')[copyIndex()].name]), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" - }, - "foundryAcrPull": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-acr-pull", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('acrName')]" - }, - "principalId": { - "value": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]" - }, - "roleDefinitionId": { - "value": "[variables('acrPullRoleId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "16037481882754055301" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving AcrPull on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "AcrPull role definition resource ID." - } - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "registry" - ] - } - }, - "outputs": { - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[if(parameters('includeAcr'), reference('registry').loginServer, '')]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[if(parameters('includeAcr'), resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[if(parameters('includeAcr'), format('{0}-conn', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_CONNECTION_NAMES": { - "type": "string", - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" - } - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep deleted file mode 100644 index 16c41c23f4d..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep +++ /dev/null @@ -1,190 +0,0 @@ -// Resource-group-scoped template for an EXISTING Foundry (AIServices) account. -// The account and project are REFERENCED, never created. It reconciles model -// deployments declared in azure.yaml and, when includeAcr is true, creates a -// container registry wired to the project (AcrPull + ContainerRegistry -// connection) for a hosted container agent. Used by the brownfield path. - -targetScope = 'resourceGroup' - -// User-defined types (match the deploymentType in main.bicep). - -@description('Shape of one model deployment entry in azure.yaml.') -type deploymentsType = deploymentType[] - -@description('Shape of a single model deployment.') -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -@description('Shape of a list of Foundry project connections.') -type connectionsType = connectionType[] - -@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') -type connectionType = { - name: string - category: string - target: string - authType: string - metadata: object? -} - -// Parameters - -@description('Name of the existing Foundry (AIServices) account.') -@minLength(2) -@maxLength(64) -param accountName string - -@description('Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true.') -param projectName string = '' - -@description('Model deployments to create or update on the existing account.') -param deployments deploymentsType = [] - -@description('Azure region for the container registry. Defaults to the resource group location.') -param location string = resourceGroup().location - -@description('Tags applied to created resources.') -param tags object = {} - -@description('Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent.') -param includeAcr bool = false - -@description('Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true.') -param acrName string = '' - -@description('Foundry project connections to create on the existing project (host: azure.ai.connection services).') -param connections connectionsType = [] - -@description('Credentials keyed by Foundry project connection name.') -@secure() -param connectionCredentials object = {} - -// Resources - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: accountName -} - -// Sequential creation; ARM throttles concurrent deployments on one account. -// CreateOrUpdate is an idempotent upsert, so re-running reconciles an existing -// deployment rather than duplicating it. -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ - for d in deployments: { - parent: foundryAccount - name: d.name - properties: { - model: d.model - } - sku: d.sku - } -] - -// Existing project reference (preview API): exposes the project's system-assigned -// managed identity principal id, used as the AcrPull grantee and the connection -// credential identity. Pinned to 2025-04-01-preview to match acr.bicep; the GA -// API fails to resolve the projects/connections ContainerRegistry sub-resource. -resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} - -// Container registry for the hosted container agent. Premium SKU mirrors the -// greenfield acr.bicep. -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (includeAcr) { - name: acrName - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - zoneRedundancy: 'Disabled' - } -} - -// Built-in AcrPull role. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) - -// The nested module makes the runtime project principal a deployment -// parameter. The assignment name can then include that principal. -module foundryAcrPull 'modules/acr-pull-role-assignment.bicep' = if (includeAcr) { - name: 'foundry-acr-pull' - params: { - registryName: registry.name - principalId: foundryAccountPreview::project.identity.principalId - roleDefinitionId: acrPullRoleId - } -} - -// Project-scoped ContainerRegistry connection so Foundry can resolve the registry -// by name when running the hosted agent. -resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (includeAcr) { - name: '${accountName}/${projectName}/${acrName}-conn' - properties: { - category: 'ContainerRegistry' - target: registry!.properties.loginServer - authType: 'ManagedIdentity' - credentials: { - clientId: foundryAccountPreview::project.identity.principalId - resourceId: registry!.id - } - isSharedToAll: true - metadata: { - ResourceId: registry!.id - } - } - dependsOn: [ - foundryAcrPull - ] -} - -// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as -// host: azure.ai.connection services, created on the existing project at -// provision time. Optional properties (credentials / metadata) are emitted only -// when supplied so None / identity-token connections don't send empty objects. -resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connections: { - parent: foundryAccountPreview::project - name: c.name - properties: union( - { - category: c.category - target: c.target - authType: c.authType - }, - contains(connectionCredentials, c.name) - ? { credentials: connectionCredentials[c.name] } - : {}, - c.?metadata != null ? { metadata: c.?metadata } : {} - ) - } -] - -// Outputs - -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? registry!.properties.loginServer : '' -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? registry!.id : '' -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? '${acrName}-conn' : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl new file mode 100644 index 00000000000..6d83d055fb8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl @@ -0,0 +1,135 @@ +// Editable infrastructure for an existing Foundry project. The account and +// project are referenced only. ACR behavior was selected when this file was +// generated, so the graph contains no runtime mode switch. + +targetScope = 'subscription' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param projectResourceId string +param deployments deploymentType[] = [] +param projectEndpoint string +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} +{{- if eq .AcrMode "create" }} +param resourceGroupName string +param location string +param resourceTokenSalt string = '' +param tags object = {} +{{- else if eq .AcrMode "reuse-connect" }} +param existingAcrResourceId string +param existingAcrEndpoint string +{{- else if eq .AcrMode "already-connected" }} +param existingAcrResourceId string +param existingAcrEndpoint string +param existingAcrConnectionName string +{{- end }} + +var projectIdParts = split(projectResourceId, '/') +var projectSubscriptionId = projectIdParts[2] +var projectResourceGroupName = projectIdParts[4] +var accountName = projectIdParts[8] +var projectName = projectIdParts[10] +{{- if eq .AcrMode "create" }} +var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' +var acrName = 'cr${toLower(uniqueString(tokenSeed))}' +{{- else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }} +var acrName = last(split(existingAcrResourceId, '/')) +{{- end }} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} +{{- if eq .AcrMode "create" }} + +resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +module containerRegistry 'modules/container-registry.bicep' = { + name: 'container-registry' + scope: resourceGroup(resourceGroupName) + params: { + location: location + tags: tags + registryName: acrName + projectPrincipalId: foundryAccount::project.identity.principalId + } + dependsOn: [adjunctResourceGroup] +} +{{- else if and (eq .AcrMode "reuse-connect") (not .AcrPullAssigned) }} + +module containerRegistry 'modules/container-registry.bicep' = { + name: 'container-registry' + scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) + params: { + registryName: acrName + projectPrincipalId: foundryAccount::project.identity.principalId + } +} +{{- end }} + +module projectResources 'modules/foundry-project.bicep' = { + name: 'foundry-project-resources' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + accountName: accountName + projectName: projectName + deployments: deployments + connections: connections + connectionCredentials: connectionCredentials +{{- if eq .AcrMode "create" }} + acrName: containerRegistry.outputs.registryName + acrEndpoint: containerRegistry.outputs.endpoint + acrResourceId: containerRegistry.outputs.resourceId + createAcrConnection: true +{{- else if eq .AcrMode "reuse-connect" }} + acrName: acrName + acrEndpoint: existingAcrEndpoint + acrResourceId: {{ if .AcrPullAssigned }}existingAcrResourceId{{ else }}containerRegistry.outputs.resourceId{{ end }} + createAcrConnection: true +{{- else if eq .AcrMode "already-connected" }} + existingAcrConnectionName: existingAcrConnectionName +{{- end }} + } +} + +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = accountName +output AZURE_AI_PROJECT_NAME string = projectName +output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint +output AZURE_FOUNDRY_RESOURCE_GROUP string = {{ if eq .AcrMode "create" }}resourceGroupName{{ else }}''{{ end }} +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.endpoint{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrEndpoint{{ else }}''{{ end }} +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.resourceId{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrResourceId{{ else }}''{{ end }} +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName +output AZURE_AI_PROJECT_CONNECTION_NAMES string = projectResources.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = projectEndpoint +output AZD_FOUNDRY_ACR_MODE string = '{{ .AcrMode }}' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.arm.json new file mode 100644 index 00000000000..5841aac4426 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.arm.json @@ -0,0 +1,715 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "11013753549236288148" + } + }, + "definitions": { + "deploymentType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "sku": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "capacity": { + "type": "int" + } + } + } + } + }, + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "target": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + } + } + } + }, + "parameters": { + "projectResourceId": { + "type": "string" + }, + "resourceGroupName": { + "type": "string" + }, + "location": { + "type": "string" + }, + "resourceTokenSalt": { + "type": "string", + "defaultValue": "" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "deployments": { + "type": "array", + "items": { + "$ref": "#/definitions/deploymentType" + }, + "defaultValue": [] + }, + "acrMode": { + "type": "string", + "defaultValue": "none", + "allowedValues": [ + "none", + "create", + "reuse-connect", + "already-connected" + ] + }, + "existingAcrResourceId": { + "type": "string", + "defaultValue": "" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, + "acrPullAssigned": { + "type": "bool", + "defaultValue": false + }, + "projectEndpoint": { + "type": "string" + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "defaultValue": [] + }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {} + } + }, + "variables": { + "projectIdParts": "[split(parameters('projectResourceId'), '/')]", + "projectSubscriptionId": "[variables('projectIdParts')[2]]", + "projectResourceGroupName": "[variables('projectIdParts')[4]]", + "accountName": "[variables('projectIdParts')[8]]", + "projectName": "[variables('projectIdParts')[10]]", + "tokenSeed": "[format('{0}{1}{2}', subscription().subscriptionId, parameters('resourceGroupName'), parameters('resourceTokenSalt'))]", + "acrName": "[format('cr{0}', toLower(uniqueString(variables('tokenSeed'))))]", + "createAcr": "[equals(parameters('acrMode'), 'create')]", + "reuseAcr": "[or(equals(parameters('acrMode'), 'reuse-connect'), equals(parameters('acrMode'), 'already-connected'))]", + "createAcrConnection": "[or(equals(parameters('acrMode'), 'create'), equals(parameters('acrMode'), 'reuse-connect'))]", + "effectiveAcrName": "[if(variables('createAcr'), variables('acrName'), if(variables('reuseAcr'), last(split(parameters('existingAcrResourceId'), '/')), ''))]" + }, + "resources": { + "foundryAccount::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "name": "[format('{0}/{1}', variables('accountName'), variables('projectName'))]" + }, + "adjunctResourceGroup": { + "condition": "[variables('createAcr')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2021-04-01", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "name": "[variables('accountName')]" + }, + "newContainerRegistry": { + "condition": "[variables('createAcr')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "container-registry", + "resourceGroup": "[parameters('resourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "mode": { + "value": "create" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "registryName": { + "value": "[variables('acrName')]" + }, + "projectPrincipalId": { + "value": "[reference('foundryAccount::project', '2025-04-01-preview', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "544637247330827061" + } + }, + "parameters": { + "mode": { + "type": "string", + "allowedValues": [ + "create", + "reuse-connect" + ] + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "registryName": { + "type": "string" + }, + "projectPrincipalId": { + "type": "string" + } + }, + "variables": { + "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('registryName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Premium" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "Enabled", + "zoneRedundancy": "Disabled" + } + }, + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" + ] + }, + { + "condition": "[equals(parameters('mode'), 'reuse-connect')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + } + } + ], + "outputs": { + "endpoint": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer)]" + }, + "resourceId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" + } + } + } + }, + "dependsOn": [ + "adjunctResourceGroup", + "foundryAccount::project" + ] + }, + "existingContainerRegistry": { + "condition": "[and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "container-registry", + "subscriptionId": "[split(parameters('existingAcrResourceId'), '/')[2]]", + "resourceGroup": "[split(parameters('existingAcrResourceId'), '/')[4]]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "mode": { + "value": "reuse-connect" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "registryName": { + "value": "[variables('effectiveAcrName')]" + }, + "projectPrincipalId": { + "value": "[reference('foundryAccount::project', '2025-04-01-preview', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "544637247330827061" + } + }, + "parameters": { + "mode": { + "type": "string", + "allowedValues": [ + "create", + "reuse-connect" + ] + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "registryName": { + "type": "string" + }, + "projectPrincipalId": { + "type": "string" + } + }, + "variables": { + "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('registryName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Premium" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "Enabled", + "zoneRedundancy": "Disabled" + } + }, + { + "condition": "[equals(parameters('mode'), 'create')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" + ] + }, + { + "condition": "[equals(parameters('mode'), 'reuse-connect')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + } + } + ], + "outputs": { + "endpoint": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer)]" + }, + "resourceId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" + } + } + } + }, + "dependsOn": [ + "foundryAccount::project" + ] + }, + "projectResources": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-project-resources", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[variables('accountName')]" + }, + "projectName": { + "value": "[variables('projectName')]" + }, + "deployments": { + "value": "[parameters('deployments')]" + }, + "connections": { + "value": "[parameters('connections')]" + }, + "connectionCredentials": { + "value": "[parameters('connectionCredentials')]" + }, + "acrName": { + "value": "[variables('effectiveAcrName')]" + }, + "acrEndpoint": "[if(variables('createAcr'), createObject('value', reference('newContainerRegistry').outputs.endpoint.value), if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), createObject('value', reference('existingContainerRegistry').outputs.endpoint.value), createObject('value', parameters('existingAcrEndpoint'))))]", + "acrResourceId": "[if(variables('createAcr'), createObject('value', reference('newContainerRegistry').outputs.resourceId.value), if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), createObject('value', reference('existingContainerRegistry').outputs.resourceId.value), createObject('value', parameters('existingAcrResourceId'))))]", + "createAcrConnection": { + "value": "[variables('createAcrConnection')]" + }, + "existingAcrConnectionName": { + "value": "[parameters('existingAcrConnectionName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "13015898647040372786" + } + }, + "definitions": { + "deploymentType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "sku": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "capacity": { + "type": "int" + } + } + } + } + }, + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "target": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + } + } + } + }, + "parameters": { + "accountName": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "deployments": { + "type": "array", + "items": { + "$ref": "#/definitions/deploymentType" + }, + "defaultValue": [] + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "defaultValue": [] + }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {} + }, + "acrName": { + "type": "string", + "defaultValue": "" + }, + "acrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "acrResourceId": { + "type": "string", + "defaultValue": "" + }, + "createAcrConnection": { + "type": "bool", + "defaultValue": false + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + } + }, + "resources": { + "foundryAccountPreview::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[parameters('accountName')]" + }, + "modelDeployments": { + "copy": { + "name": "modelDeployments", + "count": "[length(parameters('deployments'))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", + "properties": { + "model": "[parameters('deployments')[copyIndex()].model]" + }, + "sku": "[parameters('deployments')[copyIndex()].sku]" + }, + "foundryAccountPreview": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('accountName')]" + }, + "acrConnection": { + "condition": "[parameters('createAcrConnection')]", + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), format('{0}-conn', parameters('acrName')))]", + "properties": { + "category": "ContainerRegistry", + "target": "[parameters('acrEndpoint')]", + "authType": "ManagedIdentity", + "credentials": { + "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", + "resourceId": "[parameters('acrResourceId')]" + }, + "isSharedToAll": true, + "metadata": { + "ResourceId": "[parameters('acrResourceId')]" + } + }, + "dependsOn": [ + "foundryAccountPreview::project" + ] + }, + "projectConnections": { + "copy": { + "name": "projectConnections", + "count": "[length(parameters('connections'))]" + }, + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connections')[copyIndex()].name)]", + "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(contains(parameters('connectionCredentials'), parameters('connections')[copyIndex()].name), createObject('credentials', parameters('connectionCredentials')[parameters('connections')[copyIndex()].name]), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + } + }, + "outputs": { + "acrConnectionName": { + "type": "string", + "value": "[if(parameters('createAcrConnection'), format('{0}-conn', parameters('acrName')), parameters('existingAcrConnectionName'))]" + }, + "connectionNames": { + "type": "string", + "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + } + } + } + }, + "dependsOn": [ + "existingContainerRegistry", + "newContainerRegistry" + ] + } + }, + "outputs": { + "AZURE_AI_PROJECT_ID": { + "type": "string", + "value": "[parameters('projectResourceId')]" + }, + "AZURE_AI_ACCOUNT_NAME": { + "type": "string", + "value": "[variables('accountName')]" + }, + "AZURE_AI_PROJECT_NAME": { + "type": "string", + "value": "[variables('projectName')]" + }, + "AZURE_OPENAI_ENDPOINT": { + "type": "string", + "value": "[format('https://{0}.openai.azure.com/', variables('accountName'))]" + }, + "FOUNDRY_PROJECT_ENDPOINT": { + "type": "string", + "value": "[parameters('projectEndpoint')]" + }, + "AZURE_FOUNDRY_RESOURCE_GROUP": { + "type": "string", + "value": "[if(variables('createAcr'), parameters('resourceGroupName'), '')]" + }, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": { + "type": "string", + "value": "[if(equals(parameters('acrMode'), 'none'), '', if(variables('createAcr'), reference('newContainerRegistry').outputs.endpoint.value, if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), reference('existingContainerRegistry').outputs.endpoint.value, parameters('existingAcrEndpoint'))))]" + }, + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { + "type": "string", + "value": "[if(equals(parameters('acrMode'), 'none'), '', if(variables('createAcr'), reference('newContainerRegistry').outputs.resourceId.value, if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), reference('existingContainerRegistry').outputs.resourceId.value, parameters('existingAcrResourceId'))))]" + }, + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { + "type": "string", + "value": "[reference('projectResources').outputs.acrConnectionName.value]" + }, + "AZURE_AI_PROJECT_CONNECTION_NAMES": { + "type": "string", + "value": "[reference('projectResources').outputs.connectionNames.value]" + }, + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": { + "type": "string", + "value": "[parameters('projectEndpoint')]" + }, + "AZD_FOUNDRY_ACR_MODE": { + "type": "string", + "value": "[parameters('acrMode')]" + } + } +} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep new file mode 100644 index 00000000000..c984fa9d7e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep @@ -0,0 +1,140 @@ +// Editable infrastructure for an existing Foundry project. The account and +// project are referenced only; scoped modules manage project children and an +// optional adjunct resource group without taking ownership of the project. + +targetScope = 'subscription' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param projectResourceId string +param resourceGroupName string +param location string +param resourceTokenSalt string = '' +param tags object = {} +param deployments deploymentType[] = [] +@allowed([ + 'none' + 'create' + 'reuse-connect' + 'already-connected' +]) +param acrMode string = 'none' +param existingAcrResourceId string = '' +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' +param acrPullAssigned bool = false +param projectEndpoint string +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} + +var projectIdParts = split(projectResourceId, '/') +var projectSubscriptionId = projectIdParts[2] +var projectResourceGroupName = projectIdParts[4] +var accountName = projectIdParts[8] +var projectName = projectIdParts[10] +var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' +var acrName = 'cr${toLower(uniqueString(tokenSeed))}' +var createAcr = acrMode == 'create' +var reuseAcr = acrMode == 'reuse-connect' || acrMode == 'already-connected' +var createAcrConnection = acrMode == 'create' || acrMode == 'reuse-connect' +var effectiveAcrName = createAcr ? acrName : (reuseAcr ? last(split(existingAcrResourceId, '/')) : '') +var effectiveAcrEndpoint = createAcr + ? newContainerRegistry!.outputs.endpoint + : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.endpoint : existingAcrEndpoint) +var effectiveAcrResourceId = createAcr + ? newContainerRegistry!.outputs.resourceId + : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.resourceId : existingAcrResourceId) + +resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createAcr) { + name: resourceGroupName + location: location + tags: tags +} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +module newContainerRegistry 'modules/container-registry.bicep' = if (createAcr) { + name: 'container-registry' + scope: resourceGroup(resourceGroupName) + params: { + mode: 'create' + location: location + tags: tags + registryName: acrName + projectPrincipalId: foundryAccount::project.identity.principalId + } + dependsOn: [adjunctResourceGroup] +} + +module existingContainerRegistry 'modules/container-registry.bicep' = if (acrMode == 'reuse-connect' && !acrPullAssigned) { + name: 'container-registry' + scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) + params: { + mode: 'reuse-connect' + location: location + tags: tags + registryName: effectiveAcrName + projectPrincipalId: foundryAccount::project.identity.principalId + } +} + +module projectResources 'modules/foundry-project.bicep' = { + name: 'foundry-project-resources' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + accountName: accountName + projectName: projectName + deployments: deployments + connections: connections + connectionCredentials: connectionCredentials + acrName: effectiveAcrName + acrEndpoint: effectiveAcrEndpoint + acrResourceId: effectiveAcrResourceId + createAcrConnection: createAcrConnection + existingAcrConnectionName: existingAcrConnectionName + } +} + +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = accountName +output AZURE_AI_PROJECT_NAME string = projectName +output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint +output AZURE_FOUNDRY_RESOURCE_GROUP string = createAcr ? resourceGroupName : '' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrMode == 'none' + ? '' + : effectiveAcrEndpoint +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = acrMode == 'none' + ? '' + : effectiveAcrResourceId +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName +output AZURE_AI_PROJECT_CONNECTION_NAMES string = projectResources.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = projectEndpoint +output AZD_FOUNDRY_ACR_MODE string = acrMode diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json index ab9db833c2e..3a9d7201bc3 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json @@ -5,8 +5,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "1699321523334639873" + "version": "0.46.1.21595", + "templateHash": "6633403746269170516" } }, "definitions": { @@ -346,8 +346,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "8110487961768042532" + "version": "0.46.1.21595", + "templateHash": "9663028600979689998" } }, "definitions": { @@ -736,8 +736,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "18361164219559996781" + "version": "0.46.1.21595", + "templateHash": "10967961645819739964" } }, "parameters": { @@ -830,8 +830,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "11947348622491745192" + "version": "0.46.1.21595", + "templateHash": "10528013059105939935" } }, "parameters": { @@ -915,8 +915,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "11947348622491745192" + "version": "0.46.1.21595", + "templateHash": "10528013059105939935" } }, "parameters": { @@ -1050,8 +1050,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "1414665861706683761" + "version": "0.46.1.21595", + "templateHash": "16746221481092115316" } }, "parameters": { @@ -1221,8 +1221,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "7461045817315422644" + "version": "0.46.1.21595", + "templateHash": "2415286770463307805" } }, "parameters": { @@ -1448,8 +1448,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.45.15.27210", - "templateHash": "14839319862176027437" + "version": "0.46.1.21595", + "templateHash": "13263232618571910387" } }, "definitions": { @@ -1669,6 +1669,10 @@ "type": "string", "value": "[reference('resources').outputs.AZURE_AI_PROJECT_CONNECTION_NAMES.value]" }, + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": { + "type": "string", + "value": "[reference('resources').outputs.FOUNDRY_PROJECT_ENDPOINT.value]" + }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", "value": "[reference('resources').outputs.AZURE_FOUNDRY_NETWORK_MODE.value]" diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep index 2c6d007385a..7d6176054a6 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep @@ -1,8 +1,8 @@ // Provisioning template for a Foundry project service. // // Inputs are derived from the host: azure.ai.project service body in -// azure.yaml by internal/synthesis. Greenfield only (no endpoint:); a -// brownfield path is handled by the provider before synthesis. +// azure.yaml by internal/synthesis. This entry point creates a new Foundry +// account and project; existing projects use the separate editable entry point. // // Subscription-scoped so the resource group is part of the deployment. This // keeps `azd provision --preview` side-effect free: the resource group shows @@ -171,5 +171,6 @@ output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAI output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME output AZURE_AI_PROJECT_CONNECTION_NAMES string = resources.outputs.AZURE_AI_PROJECT_CONNECTION_NAMES +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT output AZURE_FOUNDRY_NETWORK_MODE string = resources.outputs.AZURE_FOUNDRY_NETWORK_MODE output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = resources.outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl new file mode 100644 index 00000000000..4518ded44b1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl @@ -0,0 +1,48 @@ +targetScope = 'resourceGroup' + +param registryName string +param projectPrincipalId string +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) +{{- if eq .AcrMode "create" }} +param location string +param tags object = {} + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: registryName + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + zoneRedundancy: 'Disabled' + } +} +{{- else }} + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: registryName +} +{{- end }} + +resource registryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: registry + name: guid(registry.id, projectPrincipalId, acrPullRoleId) + properties: { + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +output registryName string = registry.name +output endpoint string = {{ if eq .AcrMode "create" }}registry.properties.loginServer{{ else }}''{{ end }} +output resourceId string = registry.id diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep new file mode 100644 index 00000000000..709df975d41 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep @@ -0,0 +1,60 @@ +targetScope = 'resourceGroup' + +@allowed([ + 'create' + 'reuse-connect' +]) +param mode string +param location string +param tags object = {} +param registryName string +param projectPrincipalId string + +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + +resource newRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (mode == 'create') { + name: registryName + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + zoneRedundancy: 'Disabled' + } +} + +resource existingRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = if (mode == 'reuse-connect') { + name: registryName +} + +resource newRegistryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (mode == 'create') { + scope: newRegistry + name: guid(newRegistry.id, projectPrincipalId, acrPullRoleId) + properties: { + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +resource existingRegistryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (mode == 'reuse-connect') { + scope: existingRegistry + name: guid(existingRegistry.id, projectPrincipalId, acrPullRoleId) + properties: { + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +output endpoint string = mode == 'create' ? newRegistry!.properties.loginServer : existingRegistry!.properties.loginServer +output resourceId string = mode == 'create' ? newRegistry!.id : existingRegistry!.id diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep new file mode 100644 index 00000000000..0331938c976 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep @@ -0,0 +1,95 @@ +targetScope = 'resourceGroup' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param accountName string +param projectName string +param deployments deploymentType[] = [] +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} +param acrName string = '' +param acrEndpoint string = '' +param acrResourceId string = '' +param createAcrConnection bool = false +param existingAcrConnectionName string = '' + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: accountName +} + +@batchSize(1) +resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for d in deployments: { + parent: foundryAccount + name: d.name + properties: { + model: d.model + } + sku: d.sku + } +] + +resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (createAcrConnection) { + parent: foundryAccountPreview::project + name: '${acrName}-conn' + properties: { + category: 'ContainerRegistry' + target: acrEndpoint + authType: 'ManagedIdentity' + credentials: { + clientId: foundryAccountPreview::project.identity.principalId + resourceId: acrResourceId + } + isSharedToAll: true + metadata: { + ResourceId: acrResourceId + } + } +} + +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccountPreview::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + contains(connectionCredentials, c.name) ? { credentials: connectionCredentials[c.name] } : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +output acrConnectionName string = createAcrConnection ? acrConnection!.name : existingAcrConnectionName +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/connections.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/connections.tf new file mode 100644 index 00000000000..eec41bd6baf --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/connections.tf @@ -0,0 +1,24 @@ +resource "azapi_resource" "connection" { + for_each = { for c in var.connections : c.name => c } + + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = each.value.name + parent_id = local.normalized_project_id + + body = { + properties = merge( + { + category = each.value.category + target = each.value.target + authType = each.value.authType + }, + each.value.metadata != null ? { metadata = each.value.metadata } : {} + ) + } + + sensitive_body = each.value.credentials != null ? { + properties = { + credentials = each.value.credentials + } + } : null +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf new file mode 100644 index 00000000000..45138d42cae --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf @@ -0,0 +1,32 @@ +data "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.normalized_project_id + response_export_values = ["identity.principalId"] +} + +locals { + existing_acr_name = element(reverse(split("/", var.existing_acr_resource_id)), 0) + project_principal_id = data.azapi_resource.project.output.identity.principalId +} + +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.existing_acr_name}-conn" + parent_id = local.normalized_project_id + + body = { + properties = { + category = "ContainerRegistry" + target = var.existing_acr_endpoint + authType = "ManagedIdentity" + credentials = { + clientId = local.project_principal_id + resourceId = var.existing_acr_resource_id + } + isSharedToAll = true + metadata = { + ResourceId = var.existing_acr_resource_id + } + } + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf new file mode 100644 index 00000000000..f72a118203a --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf @@ -0,0 +1,73 @@ +locals { + resource_token = substr(sha1(join("-", compact([ + var.subscription_id, + var.resource_group_name, + var.location, + var.resource_token_salt, + ]))), 0, 13) + container_registry_name = "cr${local.resource_token}" + acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" +} + +resource "azurerm_resource_group" "adjunct" { + name = var.resource_group_name + location = var.location + tags = merge(var.tags, { "azd-env-name" = var.environment_name }) +} + +data "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.normalized_project_id + response_export_values = ["identity.principalId"] +} + +locals { + project_principal_id = data.azapi_resource.project.output.identity.principalId +} + +resource "azurerm_container_registry" "this" { + name = local.container_registry_name + resource_group_name = azurerm_resource_group.adjunct.name + location = azurerm_resource_group.adjunct.location + tags = var.tags + sku = "Premium" + admin_enabled = false + + identity { + type = "SystemAssigned" + } + + public_network_access_enabled = true + zone_redundancy_enabled = false +} + +resource "azurerm_role_assignment" "foundry_acr_pull" { + scope = azurerm_container_registry.this.id + role_definition_id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" + principal_id = local.project_principal_id + principal_type = "ServicePrincipal" +} + +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.container_registry_name}-conn" + parent_id = local.normalized_project_id + + body = { + properties = { + category = "ContainerRegistry" + target = azurerm_container_registry.this.login_server + authType = "ManagedIdentity" + credentials = { + clientId = local.project_principal_id + resourceId = azurerm_container_registry.this.id + } + isSharedToAll = true + metadata = { + ResourceId = azurerm_container_registry.this.id + } + } + } + + depends_on = [azurerm_role_assignment.foundry_acr_pull] +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf new file mode 100644 index 00000000000..9f8c1bf857d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf @@ -0,0 +1,50 @@ +data "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.normalized_project_id + response_export_values = ["identity.principalId"] +} + +provider "azurerm" { + alias = "existing_acr" + subscription_id = split("/", var.existing_acr_resource_id)[2] + tenant_id = var.tenant_id + features {} +} + +locals { + existing_acr_name = element(reverse(split("/", var.existing_acr_resource_id)), 0) + project_principal_id = data.azapi_resource.project.output.identity.principalId + acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" +} + +resource "azurerm_role_assignment" "foundry_acr_pull" { + provider = azurerm.existing_acr + scope = var.existing_acr_resource_id + role_definition_id = "/subscriptions/${split("/", var.existing_acr_resource_id)[2]}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" + principal_id = local.project_principal_id + principal_type = "ServicePrincipal" +} + +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.existing_acr_name}-conn" + parent_id = local.normalized_project_id + + body = { + properties = { + category = "ContainerRegistry" + target = var.existing_acr_endpoint + authType = "ManagedIdentity" + credentials = { + clientId = local.project_principal_id + resourceId = var.existing_acr_resource_id + } + isSharedToAll = true + metadata = { + ResourceId = var.existing_acr_resource_id + } + } + } + + depends_on = [azurerm_role_assignment.foundry_acr_pull] +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf new file mode 100644 index 00000000000..f800d5bacc8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf @@ -0,0 +1,15 @@ +locals { + project_id_parts = split("/", var.project_resource_id) + project_subscription_id = local.project_id_parts[2] + project_resource_group = local.project_id_parts[4] + foundry_account_name = local.project_id_parts[8] + foundry_project_name = local.project_id_parts[10] + foundry_account_id = join("/", slice(local.project_id_parts, 0, 9)) + normalized_project_id = join("/", local.project_id_parts) + project_endpoint_matches = regexall( + "(?i)^https://([^.]+)\\.services\\.ai\\.azure\\.com/(?:api/)?projects/([^/?#]+)/?$", + var.project_endpoint, + ) + project_endpoint_account = length(local.project_endpoint_matches) == 1 ? local.project_endpoint_matches[0][0] : "" + project_endpoint_project = length(local.project_endpoint_matches) == 1 ? local.project_endpoint_matches[0][1] : "" +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl new file mode 100644 index 00000000000..a5c15b5d775 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl @@ -0,0 +1,54 @@ +output "AZURE_AI_PROJECT_ID" { + value = local.normalized_project_id +} + +output "AZURE_AI_ACCOUNT_NAME" { + value = local.foundry_account_name +} + +output "AZURE_AI_PROJECT_NAME" { + value = local.foundry_project_name +} + +output "AZURE_OPENAI_ENDPOINT" { + value = "https://${local.foundry_account_name}.openai.azure.com/" +} + +output "FOUNDRY_PROJECT_ENDPOINT" { + value = var.project_endpoint + + precondition { + condition = ( + lower(local.project_endpoint_account) == lower(local.foundry_account_name) && + lower(local.project_endpoint_project) == lower(local.foundry_project_name) + ) + error_message = "project_endpoint must identify the same Foundry project as project_resource_id." + } +} + +output "AZURE_AI_PROJECT_CONNECTION_NAMES" { + value = join(",", [for c in azapi_resource.connection : c.name]) +} + +output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT" { + value = var.project_endpoint +} +output "AZURE_FOUNDRY_RESOURCE_GROUP" { + value = {{ if eq .AcrMode "create" }}var.resource_group_name{{ else }}""{{ end }} +} + +output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { + value = {{ if eq .AcrMode "create" }}azurerm_container_registry.this.login_server{{ else if eq .AcrMode "none" }}""{{ else }}var.existing_acr_endpoint{{ end }} +} + +output "AZURE_CONTAINER_REGISTRY_RESOURCE_ID" { + value = {{ if eq .AcrMode "create" }}azurerm_container_registry.this.id{{ else if eq .AcrMode "none" }}""{{ else }}var.existing_acr_resource_id{{ end }} +} + +output "AZURE_AI_PROJECT_ACR_CONNECTION_NAME" { + value = {{ if eq .AcrMode "already-connected" }}var.existing_acr_connection_name{{ else if eq .AcrMode "none" }}""{{ else }}azapi_resource.acr_connection.name{{ end }} +} + +output "AZD_FOUNDRY_ACR_MODE" { + value = "{{ .AcrMode }}" +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf new file mode 100644 index 00000000000..389c3626f0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.3.0, < 2.0.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + azapi = { + source = "Azure/azapi" + version = "~> 2.0" + } + } +} + +provider "azurerm" { + subscription_id = var.subscription_id + tenant_id = var.tenant_id + features {} +} + +provider "azapi" { + subscription_id = local.project_subscription_id + tenant_id = var.tenant_id +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf new file mode 100644 index 00000000000..220faf22591 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf @@ -0,0 +1,99 @@ +variable "subscription_id" { + description = "Subscription where adjunct resources are created." + type = string +} + +variable "tenant_id" { + description = "Microsoft Entra tenant that owns the target subscriptions." + type = string +} + +variable "project_resource_id" { + description = "ARM resource ID of the existing Foundry project." + type = string + + validation { + condition = can(regex( + "(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.CognitiveServices/accounts/[^/]+/projects/[^/]+$", + var.project_resource_id, + )) + error_message = "project_resource_id must be a Foundry project ARM resource ID." + } +} + +variable "project_endpoint" { + description = "Endpoint of the existing Foundry project." + type = string +} + +variable "location" { + description = "Azure region for adjunct resources." + type = string +} + +variable "resource_group_name" { + description = "Resource group to create for adjunct resources such as ACR." + type = string +} + +variable "environment_name" { + description = "azd environment name. Used to tag adjunct resources." + type = string +} + +variable "tags" { + description = "Tags applied to adjunct resources." + type = map(string) + default = {} +} + +variable "resource_token_salt" { + description = "Optional salt to vary adjunct resource names." + type = string + default = "" +} + +variable "deployments" { + description = "Model deployments to provision on the existing Foundry account." + type = list(object({ + name = string + model = object({ + name = string + format = string + version = string + }) + sku = object({ + name = string + capacity = number + }) + })) + default = [] +} + +variable "connections" { + description = "Connections to provision on the existing Foundry project." + type = list(object({ + name = string + category = string + target = string + authType = string + credentials = optional(any) + metadata = optional(map(string)) + })) + default = [] +} + +variable "existing_acr_endpoint" { + type = string + default = "" +} + +variable "existing_acr_resource_id" { + type = string + default = "" +} + +variable "existing_acr_connection_name" { + type = string + default = "" +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/acr.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/container-registry.tf similarity index 100% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/acr.tf rename to cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/container-registry.tf diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl index ca8bafb4134..6b645246f8d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl @@ -31,13 +31,13 @@ output "FOUNDRY_PROJECT_ENDPOINT" { value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" } -output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT" { - value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" -} - output "AZURE_AI_PROJECT_CONNECTION_NAMES" { value = join(",", [for c in azapi_resource.connection : c.name]) } + +output "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT" { + value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" +} {{ if .IncludeAcr }} output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { value = azurerm_container_registry.this.login_server diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go index 3cde22f6a66..66feb2ea6d3 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go @@ -11,14 +11,16 @@ import "embed" // needs a bicep CLI at user runtime. // //go:generate bicep build templates/main.bicep --outfile templates/main.arm.json -//go:generate bicep build templates/brownfield.bicep --outfile templates/brownfield.arm.json +//go:generate bicep build templates/existing-project.bicep --outfile templates/existing-project.arm.json //go:embed templates/main.bicep //go:embed templates/main.arm.json -//go:embed templates/brownfield.bicep -//go:embed templates/brownfield.arm.json +//go:embed templates/existing-project.bicep +//go:embed templates/existing-project-eject.bicep.tmpl +//go:embed templates/existing-project.arm.json //go:embed templates/abbreviations.json //go:embed templates/modules/*.bicep +//go:embed templates/modules/*.bicep.tmpl var templatesFS embed.FS // terraformTemplatesFS holds the on-disk Terraform module emitted by @@ -26,15 +28,19 @@ var templatesFS embed.FS // no compile step (no ARM JSON to regenerate); azd-core's built-in Terraform // provider consumes the .tf files directly at `azd provision`. // -// acr.tf is copied only when an agent uses docker:; outputs.tf is generated +// container-registry.tf is copied only when an agent uses docker:; outputs.tf is generated // from outputs.tf.tmpl (text/template) so the ACR outputs reference the -// registry resources only when acr.tf is present. main.tfvars.json is likewise +// registry resources only when container-registry.tf is present. main.tfvars.json is likewise // generated at eject time. // //go:embed templates/terraform/*.tf //go:embed templates/terraform/outputs.tf.tmpl var terraformTemplatesFS embed.FS +//go:embed templates/terraform-existing-project/*.tf +//go:embed templates/terraform-existing-project/outputs.tf.tmpl +var existingProjectTerraformTemplatesFS embed.FS + // TemplatesFS exposes the embedded provisioning templates. Callers that // only need the ready-to-deploy ARM JSON should prefer ARMTemplate(). func TemplatesFS() embed.FS { return templatesFS } @@ -45,15 +51,15 @@ func TemplatesFS() embed.FS { return templatesFS } // generates main.tfvars.json alongside them. func TerraformTemplatesFS() embed.FS { return terraformTemplatesFS } +// ExistingProjectTerraformTemplatesFS exposes the Terraform module for an existing project. +func ExistingProjectTerraformTemplatesFS() embed.FS { return existingProjectTerraformTemplatesFS } + // ARMTemplate returns the compiled ARM JSON for main.bicep. func ARMTemplate() ([]byte, error) { return templatesFS.ReadFile("templates/main.arm.json") } -// BrownfieldARMTemplate returns the compiled ARM JSON for brownfield.bicep, which -// creates/upserts model deployments on an EXISTING Foundry account (referenced, -// not created). Used by the provider when the project sets endpoint: and declares -// deployments: to add to the existing project. -func BrownfieldARMTemplate() ([]byte, error) { - return templatesFS.ReadFile("templates/brownfield.arm.json") +// ExistingProjectARMTemplate returns the compiled editable existing-project graph. +func ExistingProjectARMTemplate() ([]byte, error) { + return templatesFS.ReadFile("templates/existing-project.arm.json") }