From b62da2b42b907e3beb9b428684c0c6c1b10caa68 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 15:36:40 +0800 Subject: [PATCH 01/16] feat: move Foundry provisioning to projects extension --- cli/azd/extensions/azure.ai.agents/AGENTS.md | 7 + cli/azd/extensions/azure.ai.agents/README.md | 5 +- .../docs/private-networking.md | 4 + .../extensions/azure.ai.agents/extension.yaml | 7 +- .../azure.ai.agents/internal/cmd/listen.go | 82 +- .../internal/cmd/resource_services.go | 35 +- .../internal/cmd/resource_services_test.go | 39 +- .../internal/project/provisioning_provider.go | 2 +- .../extensions/azure.ai.projects/AGENTS.md | 23 +- .../extensions/azure.ai.projects/README.md | 34 + .../extensions/azure.ai.projects/build.ps1 | 4 +- cli/azd/extensions/azure.ai.projects/build.sh | 4 +- .../extensions/azure.ai.projects/ci-build.ps1 | 3 +- .../extensions/azure.ai.projects/cspell.yaml | 4 + .../azure.ai.projects/extension.yaml | 8 +- cli/azd/extensions/azure.ai.projects/go.mod | 22 +- cli/azd/extensions/azure.ai.projects/go.sum | 28 +- .../internal/azure/client_options.go | 36 + .../internal/cmd/ownership_test.go | 97 + .../internal/cmd/project_service_config.go | 147 ++ .../cmd/project_service_config_test.go | 268 +++ .../azure.ai.projects/internal/cmd/root.go | 54 +- .../internal/cmd/service_target.go | 40 +- .../azure.ai.projects/internal/cmd/version.go | 15 +- .../internal/exterrors/codes.go | 39 +- .../internal/exterrors/errors.go | 101 + .../provisioning/contract_parity_test.go | 136 ++ .../foundry_provisioning_provider.go | 6 +- ...ovisioning_provider_brownfield_acr_test.go | 4 +- ...visioning_provider_destroy_confirm_test.go | 4 +- ...y_provisioning_provider_resolveenv_test.go | 4 +- .../foundry_provisioning_provider_test.go | 6 +- .../internal/provisioning}/ondisk_template.go | 4 +- .../provisioning}/ondisk_template_test.go | 4 +- .../internal/provisioning}/preview_helpers.go | 4 +- .../provisioning}/preview_helpers_test.go | 4 +- .../provisioning/provisioning_provider.go | 46 + .../resource_group_location_check.go | 17 +- .../resource_group_location_check_test.go | 2 +- ...urce_group_location_check_validate_test.go | 2 +- .../internal/synthesis/parity_test.go | 92 + .../internal/synthesis/schema_test.go | 206 ++ .../internal/synthesis/synthesizer.go | 828 +++++++++ .../internal/synthesis/synthesizer_test.go | 1381 ++++++++++++++ .../synthesis/templates/abbreviations.json | 4 + .../synthesis/templates/brownfield.arm.json | 280 +++ .../synthesis/templates/brownfield.bicep | 186 ++ .../synthesis/templates/main.arm.json | 1652 +++++++++++++++++ .../internal/synthesis/templates/main.bicep | 170 ++ .../synthesis/templates/modules/acr.bicep | 115 ++ .../templates/modules/connections.bicep | 85 + .../synthesis/templates/modules/network.bicep | 94 + .../modules/private-endpoint-dns.bicep | 165 ++ .../templates/modules/resources.bicep | 326 ++++ .../synthesis/templates/modules/subnet.bicep | 28 + .../synthesis/templates/terraform/acr.tf | 63 + .../synthesis/templates/terraform/main.tf | 140 ++ .../templates/terraform/outputs.tf.tmpl | 39 + .../synthesis/templates/terraform/provider.tf | 23 + .../templates/terraform/variables.tf | 71 + .../internal/synthesis/templates_embed.go | 59 + .../internal/version/version.go | 11 + docs/reference/telemetry-data.md | 4 +- 63 files changed, 7195 insertions(+), 178 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/azure/client_options.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/contract_parity_test.go rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/foundry_provisioning_provider.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/foundry_provisioning_provider_brownfield_acr_test.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/foundry_provisioning_provider_destroy_confirm_test.go (98%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/foundry_provisioning_provider_resolveenv_test.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/foundry_provisioning_provider_test.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/ondisk_template.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/ondisk_template_test.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/preview_helpers.go (98%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/preview_helpers_test.go (99%) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/provisioning_provider.go rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/resource_group_location_check.go (95%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/resource_group_location_check_test.go (99%) rename cli/azd/extensions/{azure.ai.agents/internal/project => azure.ai.projects/internal/provisioning}/resource_group_location_check_validate_test.go (99%) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/acr.tf create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/version/version.go diff --git a/cli/azd/extensions/azure.ai.agents/AGENTS.md b/cli/azd/extensions/azure.ai.agents/AGENTS.md index b03e3847b98..6da80a3b9eb 100644 --- a/cli/azd/extensions/azure.ai.agents/AGENTS.md +++ b/cli/azd/extensions/azure.ai.agents/AGENTS.md @@ -124,6 +124,13 @@ Define new codes in `internal/exterrors/codes.go`. A new extension release ships in two PRs: +### Provider handoff release + +The release that removes `microsoft.foundry` from this extension must +be coordinated with the provider-bearing `azure.ai.projects` release. +Publish both artifacts before updating either registry entry, then +update both entries and the `microsoft.foundry` meta-package together. + ### PR 1 — Version bump Bumps the extension to the new version. Touches only: diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index a5979b86524..32ab964caa1 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -52,7 +52,10 @@ services: Foundry project services can be provisioned as network-secured, VNet-bound accounts by adding a `network:` block to the `host: azure.ai.project` service in -`azure.yaml`. See [Private networking for `host: azure.ai.project`](docs/private-networking.md) +`azure.yaml`. The `azure.ai.projects` extension owns that service and the +`microsoft.foundry` provider; this extension still authors the block during +agent init. See +[Private networking for `host: azure.ai.project`](docs/private-networking.md) for the schema reference, BYO-image requirements, and VNet deployment cheatsheet. diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 8c3893676a4..aba38b38eb2 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -4,6 +4,10 @@ A Foundry project service can be provisioned as a **network-secured (VNet-bound) Do **not** place `network:` on `host: azure.ai.agent`. Agent services describe deployable agents and depend on the project through `uses:`; the project service owns account-level provisioning inputs such as `endpoint:`, `deployments:`, and `network:`. +The `azure.ai.projects` extension owns the project service and the +`microsoft.foundry` provider. `azd ai agent init` continues to author +the block and eject its IaC during the staged ownership migration. + When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. ```yaml diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 9a2436766f9..e4b36e9ec13 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -10,22 +10,19 @@ requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.inspector version: "~1.0.0-beta.1" + - id: azure.ai.projects + version: "~1.0.0-beta.3" language: go capabilities: - custom-commands - lifecycle-events - mcp-server - service-target-provider - - provisioning-provider - - validation-provider - metadata providers: - name: azure.ai.agent type: service-target description: Deploys agents to the Foundry Agent Service - - name: microsoft.foundry - type: provisioning-provider - description: Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory examples: - name: init description: Initialize a new AI agent project. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 098a2df14cd..5a51c36d52a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -35,20 +35,6 @@ func configureExtensionHost(host *azdext.ExtensionHost) { WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider { return project.NewAgentServiceTargetProvider(azdClient) }). - WithProvisioningProvider(project.FoundryProviderName, func() azdext.ProvisioningProvider { - return project.NewFoundryProvisioningProvider(azdClient) - }). - WithValidationCheck(azdext.ValidationCheckRegistration{ - // Provider-agnostic check: runs before provisioning regardless of the - // provisioning provider. This matters because the azure.ai.agents - // extension provisions through its own microsoft.foundry provider, - // which never triggers the Bicep-only "local-preflight" checks. - CheckType: azdext.ValidationCheckTypeProvision, - RuleID: project.ResourceGroupLocationRuleID, - Factory: func() azdext.ValidationCheckProvider { - return project.NewResourceGroupLocationCheck(azdClient) - }, - }). WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { return preprovisionHandler(ctx, azdClient, args) }). @@ -67,8 +53,11 @@ func configureExtensionHost(host *azdext.ExtensionHost) { } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { - deployments, err := collectProjectDeployments(args.Project.Services) - if err != nil { + if err := updateLegacyProjectDeployments( + ctx, + azdClient, + args.Project.Services, + ); err != nil { return err } connections, err := collectConnections(args.Project.Services) @@ -82,7 +71,13 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args if err := populateContainerSettings(ctx, azdClient, svc); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } - if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { + if err := envUpdate( + ctx, + azdClient, + args.Project, + svc, + connections, + ); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } } @@ -167,6 +162,34 @@ func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, e return resp.Environment.Name, nil } +func updateLegacyProjectDeployments( + ctx context.Context, + azdClient *azdext.AzdClient, + services map[string]*azdext.ServiceConfig, +) error { + deployments, err := collectLegacyProjectDeployments(services) + if err != nil { + return err + } + if len(deployments) == 0 { + return nil + } + + envName, err := currentEnvName(ctx, azdClient) + if err != nil { + return fmt.Errorf( + "resolving environment for legacy deployments: %w", + err, + ) + } + return deploymentEnvUpdate( + ctx, + deployments, + azdClient, + envName, + ) +} + // developerRBACOnce ensures CheckDeveloperRBAC runs at most once per extension // process lifetime. Service-level predeploy handlers fire per-service, but the // RBAC pre-flight check is project-scoped and idempotent — running it once is @@ -193,8 +216,11 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az warnDuplicateAgentNames(args.Project) }) - deployments, err := collectProjectDeployments(args.Project.Services) - if err != nil { + if err := updateLegacyProjectDeployments( + ctx, + azdClient, + args.Project.Services, + ); err != nil { return err } connections, err := collectConnections(args.Project.Services) @@ -205,7 +231,13 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az if err := populateContainerSettings(ctx, azdClient, svc); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } - if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { + if err := envUpdate( + ctx, + azdClient, + args.Project, + svc, + connections, + ); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } @@ -463,7 +495,6 @@ func envUpdate( azdClient *azdext.AzdClient, azdProject *azdext.ProjectConfig, svc *azdext.ServiceConfig, - deployments []project.Deployment, connections []project.Connection, ) error { @@ -481,15 +512,6 @@ func envUpdate( return err } - // Deployments and connections are sourced from the sibling - // azure.ai.project and azure.ai.connection services. Resources and tool - // connections stay on the agent service. - if len(deployments) > 0 { - if err := deploymentEnvUpdate(ctx, deployments, azdClient, currentEnvResponse.Environment.Name); err != nil { - return err - } - } - if foundryAgentConfig != nil && len(foundryAgentConfig.Resources) > 0 { if err := resourcesEnvUpdate(ctx, foundryAgentConfig.Resources, azdClient, currentEnvResponse.Environment.Name); err != nil { return err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index bce529919dd..68a27e7538a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -346,36 +346,23 @@ func reserveServiceName(used map[string]string, name, source string) error { return nil } -// collectProjectDeployments gathers the model deployments declared across all -// azure.ai.project services so provisioning handlers can source them from the -// sibling project service instead of the agent service config. Services are -// visited in sorted name order so serialized env-var output stays stable. -// -// Falls back to the deployments bundled on the agent service when no project -// service carries any, so an azure.yaml written before the per-resource split -// still provisions without re-running init. -func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]project.Deployment, error) { - var out []project.Deployment - for _, svc := range sortedServices(services) { - props := project.ServiceConfigProps(svc) - if svc.Host != AiProjectHost || props == nil { - continue - } - var cfg *project.ServiceTargetAgentConfig - if err := project.UnmarshalStruct(props, &cfg); err != nil { - return nil, fmt.Errorf("parsing project service %q config: %w", svc.Name, err) - } - if cfg != nil { - out = append(out, cfg.Deployments...) +// collectLegacyProjectDeployments reads only pre-split agent config. +// A split project disables this compatibility path because projects +// owns that service's runtime projection. +func collectLegacyProjectDeployments( + services map[string]*azdext.ServiceConfig, +) ([]project.Deployment, error) { + for _, svc := range services { + if svc.GetHost() == AiProjectHost { + return nil, nil } } - if len(out) > 0 { - return out, nil - } + legacy, err := collectLegacyAgentConfigs(services) if err != nil { return nil, err } + var out []project.Deployment for _, cfg := range legacy { out = append(out, cfg.Deployments...) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index 0cfd1caacfc..ea7740e2c65 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -88,9 +88,9 @@ func TestReserveServiceName(t *testing.T) { assert.Contains(t, err.Error(), "agent service") } -// TestCollectProjectDeployments verifies deployments are sourced only from -// azure.ai.project services and ignore sibling hosts. -func TestCollectProjectDeployments(t *testing.T) { +func TestCollectLegacyProjectDeploymentsIgnoresSplitProject( + t *testing.T, +) { t.Parallel() dep := project.Deployment{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}} @@ -100,10 +100,9 @@ func TestCollectProjectDeployments(t *testing.T) { "conn": connectionService(t, "conn", project.Connection{Name: "conn"}), } - deployments, err := collectProjectDeployments(services) + deployments, err := collectLegacyProjectDeployments(services) require.NoError(t, err) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) + assert.Empty(t, deployments) } // TestCollectConnections verifies connections are sourced from @@ -170,7 +169,7 @@ func TestCollectHelpers_EmptyAndNilConfigs(t *testing.T) { "nilcfg": {Name: "nilcfg", Host: AiProjectHost}, } - deployments, err := collectProjectDeployments(services) + deployments, err := collectLegacyProjectDeployments(services) require.NoError(t, err) assert.Empty(t, deployments) @@ -200,7 +199,7 @@ func TestCollect_FallbackToBundledAgentConfig(t *testing.T) { svc.Host = AiAgentHost services := map[string]*azdext.ServiceConfig{"my-agent": svc} - deployments, err := collectProjectDeployments(services) + deployments, err := collectLegacyProjectDeployments(services) require.NoError(t, err) require.Len(t, deployments, 1) assert.Equal(t, "gpt-4o", deployments[0].Name) @@ -216,10 +215,9 @@ func TestCollect_FallbackToBundledAgentConfig(t *testing.T) { assert.Equal(t, "tb", toolboxes[0].Name) } -// TestCollectProjectDeployments_SiblingWinsOverBundled verifies the sibling -// azure.ai.project service takes precedence: the fallback to bundled agent -// deployments only applies when no project service carries any. -func TestCollectProjectDeployments_SiblingWinsOverBundled(t *testing.T) { +func TestCollectLegacyProjectDeploymentsSplitDisablesFallback( + t *testing.T, +) { t.Parallel() bundled := &project.ServiceTargetAgentConfig{ @@ -236,10 +234,9 @@ func TestCollectProjectDeployments_SiblingWinsOverBundled(t *testing.T) { ), } - deployments, err := collectProjectDeployments(services) + deployments, err := collectLegacyProjectDeployments(services) require.NoError(t, err) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) + assert.Empty(t, deployments) } // recordingProjectServer captures the AddService and SetServiceConfigValue @@ -429,11 +426,15 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { services[svc.Name] = svc } - // The collectors read the service-level shape back through ServiceConfigProps. - gotDeployments, err := collectProjectDeployments(services) + // Init must write a project shape the owning extension can parse. + var projectCfg project.ServiceTargetAgentConfig + err := project.UnmarshalStruct( + project.ServiceConfigProps(services["ai-project"]), + &projectCfg, + ) require.NoError(t, err) - require.Len(t, gotDeployments, 1) - assert.Equal(t, "gpt-4.1-mini", gotDeployments[0].Name) + require.Len(t, projectCfg.Deployments, 1) + assert.Equal(t, "gpt-4.1-mini", projectCfg.Deployments[0].Name) gotConns, err := collectConnections(services) require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go index 2560af144ad..32f5dc0e402 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go @@ -7,7 +7,7 @@ import "slices" // FoundryProviderName is the value written to `infra.provider` in // azure.yaml by `azd ai agent init` and looked up by azd's provider -// resolver to dispatch provisioning to this extension. +// resolver to dispatch provisioning to azure.ai.projects. const FoundryProviderName = "microsoft.foundry" // BicepProviderName and TerraformProviderName are azd-core's built-in diff --git a/cli/azd/extensions/azure.ai.projects/AGENTS.md b/cli/azd/extensions/azure.ai.projects/AGENTS.md index f0dd2150bcd..ad6a7852aac 100644 --- a/cli/azd/extensions/azure.ai.projects/AGENTS.md +++ b/cli/azd/extensions/azure.ai.projects/AGENTS.md @@ -4,7 +4,13 @@ Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root ## Overview -`azure.ai.projects` is a first-party azd extension under `cli/azd/extensions/azure.ai.projects/`. It runs as a separate Go binary and talks to the azd host over gRPC. +`azure.ai.projects` is a first-party azd extension under +`cli/azd/extensions/azure.ai.projects/`. It runs as a separate Go +binary and talks to the azd host over gRPC. + +It owns the `azure.ai.project` service target and the +`microsoft.foundry` provisioning provider. The provider handles +greenfield and existing-project provisioning, preview, and teardown. It owns the Foundry project endpoint context used by other AI extensions (e.g. `azure.ai.agents`). The `azd ai project` commands persist, resolve, and surface the endpoint through a 5-level cascade: @@ -19,8 +25,14 @@ The resolver also performs a one-time auto-migration from the legacy `extensions Useful places to start: - `internal/cmd/`: Cobra commands, the endpoint resolver, and the config store +- `internal/provisioning/`: the `microsoft.foundry` provider +- `internal/synthesis/`: project config synthesis and embedded IaC - `internal/exterrors/`: structured error factories and extension-specific codes +During the staged ownership migration, `internal/synthesis/` is also +present in `azure.ai.agents` for `azd ai agent init --infra`. Keep the +production files and templates byte-for-byte aligned until init moves. + ## Build and test From `cli/azd/extensions/azure.ai.projects`: @@ -138,6 +150,15 @@ When changing the store: A new extension release ships in two PRs: +### Provider handoff release + +The first release that moves `microsoft.foundry` here must be +coordinated with the matching `azure.ai.agents` release. Publish both +artifacts before updating either registry entry, then update both +entries and the `microsoft.foundry` meta-package together. Old agents +and new projects versions cannot run together because azd rejects +duplicate provider registration. + ### PR 1 — Version bump Bumps the extension to the new version. Touches only: diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 676e8668213..1213bed0f0e 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -1,3 +1,37 @@ # Foundry Projects Manage Microsoft Foundry Project resources from your terminal. (Preview) + +## `azure.yaml` ownership + +This extension owns `host: azure.ai.project` services and the +`microsoft.foundry` provisioning provider. A project service carries +account-level settings such as an existing project endpoint, model +deployments, and private networking. + +```yaml +infra: + provider: microsoft.foundry + +services: + my-project: + host: azure.ai.project + endpoint: https://my-account.services.ai.azure.com/api/projects/my-project + deployments: + - name: gpt-4.1-mini + model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + sku: + name: GlobalStandard + capacity: 50 +``` + +When `endpoint` is omitted, `azd provision` creates a Foundry account +and project. When it is set, provisioning reuses that project and +reconciles the declarations that can be applied to an existing account. + +The `azd ai project set`, `show`, and `unset` commands manage the +default Foundry project endpoint context. They do not currently author +the project service in `azure.yaml`. diff --git a/cli/azd/extensions/azure.ai.projects/build.ps1 b/cli/azd/extensions/azure.ai.projects/build.ps1 index 5ceb60a8bbc..b0482edc650 100644 --- a/cli/azd/extensions/azure.ai.projects/build.ps1 +++ b/cli/azd/extensions/azure.ai.projects/build.ps1 @@ -41,7 +41,7 @@ else { ) } -$APP_PATH = "$env:EXTENSION_ID/internal/cmd" +$VERSION_PATH = "$env:EXTENSION_ID/internal/version" # Loop through platforms and build foreach ($PLATFORM in $PLATFORMS) { @@ -65,7 +65,7 @@ foreach ($PLATFORM in $PLATFORMS) { $env:GOARCH = $ARCH go build ` - -ldflags="-X '$APP_PATH.Version=$env:EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" ` + -ldflags="-X '$VERSION_PATH.Version=$env:EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_PATH.BuildDate=$BUILD_DATE'" ` -o $OUTPUT_NAME if ($LASTEXITCODE -ne 0) { diff --git a/cli/azd/extensions/azure.ai.projects/build.sh b/cli/azd/extensions/azure.ai.projects/build.sh index f1a995ec5e9..79de103803c 100644 --- a/cli/azd/extensions/azure.ai.projects/build.sh +++ b/cli/azd/extensions/azure.ai.projects/build.sh @@ -33,7 +33,7 @@ else ) fi -APP_PATH="$EXTENSION_ID/internal/cmd" +VERSION_PATH="$EXTENSION_ID/internal/version" # Loop through platforms and build for PLATFORM in "${PLATFORMS[@]}"; do @@ -53,7 +53,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do # Set environment variables for Go build GOOS=$OS GOARCH=$ARCH go build \ - -ldflags="-X '$APP_PATH.Version=$EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" \ + -ldflags="-X '$VERSION_PATH.Version=$EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_PATH.BuildDate=$BUILD_DATE'" \ -o "$OUTPUT_NAME" if [ $? -ne 0 ]; then diff --git a/cli/azd/extensions/azure.ai.projects/ci-build.ps1 b/cli/azd/extensions/azure.ai.projects/ci-build.ps1 index 83366b4241e..17025f2b277 100644 --- a/cli/azd/extensions/azure.ai.projects/ci-build.ps1 +++ b/cli/azd/extensions/azure.ai.projects/ci-build.ps1 @@ -29,7 +29,8 @@ if ($CodeCoverageEnabled) { $tagsFlag = "-tags=cfi,cfg,osusergo" -$ldFlag = "-ldflags=-s -w -X azure.ai.projects/internal/cmd.Version=$Version -X azure.ai.projects/internal/cmd.Commit=$SourceVersion -X azure.ai.projects/internal/cmd.BuildDate=$(Get-Date -Format o) " +$versionPath = "azure.ai.projects/internal/version" +$ldFlag = "-ldflags=-s -w -X $versionPath.Version=$Version -X $versionPath.Commit=$SourceVersion -X $versionPath.BuildDate=$(Get-Date -Format o) " if ($IsWindows) { $msg = "Building for Windows" diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index 1093f923dd4..d1bb1ef30cc 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -1,6 +1,10 @@ import: ../../.vscode/cspell.yaml words: + - alnum - exterrors + - foundryproject - idempotently + - ondisk + - purgeable # Contributors - hemarina diff --git a/cli/azd/extensions/azure.ai.projects/extension.yaml b/cli/azd/extensions/azure.ai.projects/extension.yaml index 1ea63c1be97..132bb11ce0d 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -1,7 +1,10 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json capabilities: - custom-commands + - lifecycle-events - service-target-provider + - provisioning-provider + - validation-provider - metadata description: Manage Microsoft Foundry Project resources from your terminal. (Beta) displayName: Foundry Projects (Beta) @@ -12,9 +15,12 @@ providers: - name: azure.ai.project type: service-target description: Owns the azure.ai.project host so azd can walk the Foundry project service in azure.yaml + - name: microsoft.foundry + type: provisioning-provider + description: Provisions Microsoft Foundry projects from azure.yaml tags: - ai - project usage: azd ai project [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.projects/go.mod b/cli/azd/extensions/azure.ai.projects/go.mod index 770b267b855..2c12d1c3ff4 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -3,18 +3,27 @@ module azure.ai.projects go 1.26.4 require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 - github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 + github.com/azure/azure-dev/cli/azd v1.27.1 + github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + go.yaml.in/yaml/v3 v3.0.4 google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect @@ -42,7 +51,6 @@ require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/drone/envsubst v1.0.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gofrs/flock v0.12.1 // indirect @@ -83,20 +91,18 @@ require ( github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.projects/go.sum b/cli/azd/extensions/azure.ai.projects/go.sum index 62a7863d2ab..6e462905c96 100644 --- a/cli/azd/extensions/azure.ai.projects/go.sum +++ b/cli/azd/extensions/azure.ai.projects/go.sum @@ -1,20 +1,28 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 h1:0g4UTtvRA9goC37cmD9ZHdW6CCNJR4cOXBnHz0r4ubM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3/go.mod h1:fEiHi0sbYqbo3shUkIF1SNxm8GyeEJl+Poc/djOvbdE= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 h1:pxphC/uRZKNHNPbZ0duDDgKkefju2F03OkG5xF6byHQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0/go.mod h1:twcwRey+l1znKBL5TEzYiZMtiVkWfM7Pq8a9vY04xYc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= @@ -45,8 +53,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/azure/azure-dev/cli/azd v1.25.0 h1:gb8Ah5ntUcUAKIDBhCdpx8xxDWSAtCLGyck+Y50QZhw= -github.com/azure/azure-dev/cli/azd v1.25.0/go.mod h1:1ZoZZlUbK8FMTZRibM9hEo/UqSaEXA+SFeIKpya4fsY= +github.com/azure/azure-dev/cli/azd v1.27.1 h1:0SrTTBDSG9huHfgt6m89EminAErKtpmOi9gQ+b/T5aw= +github.com/azure/azure-dev/cli/azd v1.27.1/go.mod h1:Ge7QaU9PoJM7i6J0xArDoQCf2tUn6O7OIKkoItxFTA8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -245,6 +253,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -256,11 +266,13 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/cli/azd/extensions/azure.ai.projects/internal/azure/client_options.go b/cli/azd/extensions/azure.ai.projects/internal/azure/client_options.go new file mode 100644 index 00000000000..105f15ba71c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/azure/client_options.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package azure configures Azure SDK clients for this extension. +package azure + +import ( + "fmt" + + "azure.ai.projects/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +// NewArmClientOptions returns the extension's ARM client options. +func NewArmClientOptions() *arm.ClientOptions { + userAgent := fmt.Sprintf( + "azd-ext-azure-ai-projects/%s", + version.Version, + ) + return &arm.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{ + azsdk.MsCorrelationIdHeader, + }, + }, + PerCallPolicies: []policy.Policy{ + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + }, + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go new file mode 100644 index 00000000000..d794129aea0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +type extensionManifest struct { + Capabilities []string `yaml:"capabilities"` + Dependencies []struct { + ID string `yaml:"id"` + Version string `yaml:"version"` + } `yaml:"dependencies"` + Providers []struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + } `yaml:"providers"` +} + +func TestProvisioningOwnershipMetadata(t *testing.T) { + t.Parallel() + + projects := readExtensionManifest(t, "../../extension.yaml") + agents := readExtensionManifest( + t, + "../../../azure.ai.agents/extension.yaml", + ) + + assert.Contains(t, projects.Capabilities, "lifecycle-events") + assert.Contains(t, projects.Capabilities, "provisioning-provider") + assert.Contains(t, projects.Capabilities, "validation-provider") + assert.True(t, manifestHasProvider( + projects, + "microsoft.foundry", + "provisioning-provider", + )) + + assert.NotContains(t, agents.Capabilities, "provisioning-provider") + assert.NotContains(t, agents.Capabilities, "validation-provider") + assert.False(t, manifestHasProvider( + agents, + "microsoft.foundry", + "provisioning-provider", + )) + assert.True(t, manifestHasDependency( + agents, + "azure.ai.projects", + "~1.0.0-beta.3", + )) +} + +func readExtensionManifest( + t *testing.T, + path string, +) extensionManifest { + t.Helper() + + //nolint:gosec // repository-controlled manifest path + data, err := os.ReadFile(path) + require.NoError(t, err) + var manifest extensionManifest + require.NoError(t, yaml.Unmarshal(data, &manifest)) + return manifest +} + +func manifestHasProvider( + manifest extensionManifest, + name string, + providerType string, +) bool { + for _, provider := range manifest.Providers { + if provider.Name == name && provider.Type == providerType { + return true + } + } + return false +} + +func manifestHasDependency( + manifest extensionManifest, + id string, + version string, +) bool { + for _, dependency := range manifest.Dependencies { + if dependency.ID == id && dependency.Version == version { + return true + } + } + return false +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go new file mode 100644 index 00000000000..3a7257ffa20 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "azure.ai.projects/internal/synthesis" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/protobuf/types/known/structpb" +) + +const projectDeploymentsEnvKey = "AI_PROJECT_DEPLOYMENTS" + +type projectServiceConfig struct { + Endpoint string `json:"endpoint,omitempty"` + Deployments []synthesis.Deployment `json:"deployments,omitempty"` +} + +func projectLifecycleHandler( + ctx context.Context, + azdClient *azdext.AzdClient, + args *azdext.ProjectEventArgs, +) error { + if args == nil || args.Project == nil { + return fmt.Errorf("project lifecycle event has no project") + } + + cfg, found, err := loadProjectServiceConfig(args.Project.Services) + if err != nil { + return err + } + if !found { + return nil + } + + current, err := azdClient.Environment().GetCurrent( + ctx, + &azdext.EmptyRequest{}, + ) + if err != nil { + return fmt.Errorf("resolving current azd environment: %w", err) + } + if current.GetEnvironment().GetName() == "" { + return fmt.Errorf("current azd environment has no name") + } + + value, err := encodeProjectDeployments(cfg.Deployments) + if err != nil { + return err + } + if _, err := azdClient.Environment().SetValue( + ctx, + &azdext.SetEnvRequest{ + EnvName: current.GetEnvironment().GetName(), + Key: projectDeploymentsEnvKey, + Value: value, + }, + ); err != nil { + return fmt.Errorf( + "setting %s in azd environment: %w", + projectDeploymentsEnvKey, + err, + ) + } + + return nil +} + +func loadProjectServiceConfig( + services map[string]*azdext.ServiceConfig, +) (*projectServiceConfig, bool, error) { + var names []string + for name, service := range services { + if service.GetHost() == aiProjectHost { + names = append(names, name) + } + } + slices.Sort(names) + + switch len(names) { + case 0: + return nil, false, nil + case 1: + default: + return nil, false, fmt.Errorf( + "multiple services use host %q: %s", + aiProjectHost, + strings.Join(names, ", "), + ) + } + + service := services[names[0]] + props := projectServiceProperties(service) + cfg := &projectServiceConfig{} + if props == nil { + return cfg, true, nil + } + + data, err := json.Marshal(props.AsMap()) + if err != nil { + return nil, false, fmt.Errorf( + "encoding project service %q config: %w", + names[0], + err, + ) + } + if err := json.Unmarshal(data, cfg); err != nil { + return nil, false, fmt.Errorf( + "parsing project service %q config: %w", + names[0], + err, + ) + } + return cfg, true, nil +} + +func projectServiceProperties( + service *azdext.ServiceConfig, +) *structpb.Struct { + if props := service.GetAdditionalProperties(); props != nil && + len(props.GetFields()) > 0 { + return props + } + return service.GetConfig() +} + +func encodeProjectDeployments( + deployments []synthesis.Deployment, +) (string, error) { + if deployments == nil { + deployments = []synthesis.Deployment{} + } + data, err := json.Marshal(deployments) + if err != nil { + return "", fmt.Errorf("encoding project deployments: %w", err) + } + + escaped := strings.ReplaceAll(string(data), "\\", "\\\\") + return strings.ReplaceAll(escaped, "\"", "\\\""), nil +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go new file mode 100644 index 00000000000..0ad2f70af7b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "net" + "sync" + "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" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestLoadProjectServiceConfig(t *testing.T) { + t.Parallel() + + deployment := synthesis.Deployment{ + Name: "gpt-4.1-mini", + Model: synthesis.DeploymentModel{ + Format: "OpenAI", + Name: "gpt-4.1-mini", + Version: "2025-04-14", + }, + Sku: synthesis.DeploymentSku{ + Name: "GlobalStandard", + Capacity: 10, + }, + } + props := mustProjectProperties(t, map[string]any{ + "endpoint": "https://example.services.ai.azure.com/" + + "api/projects/example", + "deployments": []any{ + map[string]any{ + "name": deployment.Name, + "model": map[string]any{ + "format": deployment.Model.Format, + "name": deployment.Model.Name, + "version": deployment.Model.Version, + }, + "sku": map[string]any{ + "name": deployment.Sku.Name, + "capacity": deployment.Sku.Capacity, + }, + }, + }, + }) + + tests := []struct { + name string + service *azdext.ServiceConfig + wantSeen bool + }{ + { + name: "inline properties", + service: &azdext.ServiceConfig{ + Host: aiProjectHost, + AdditionalProperties: props, + }, + wantSeen: true, + }, + { + name: "legacy config", + service: &azdext.ServiceConfig{ + Host: aiProjectHost, + Config: props, + }, + wantSeen: true, + }, + { + name: "unrelated host", + service: &azdext.ServiceConfig{ + Host: "azure.ai.agent", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + cfg, found, err := loadProjectServiceConfig( + map[string]*azdext.ServiceConfig{ + "service": test.service, + }, + ) + require.NoError(t, err) + assert.Equal(t, test.wantSeen, found) + if !found { + return + } + require.Len(t, cfg.Deployments, 1) + assert.Equal(t, deployment, cfg.Deployments[0]) + }) + } +} + +func TestLoadProjectServiceConfigRejectsDuplicates(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "zeta": {Host: aiProjectHost}, + "alpha": {Host: aiProjectHost}, + } + + _, _, err := loadProjectServiceConfig(services) + require.Error(t, err) + assert.Contains(t, err.Error(), "alpha, zeta") +} + +func TestProjectLifecycleHandlerWritesDeployments(t *testing.T) { + t.Parallel() + + envServer := &recordingProjectEnvironmentServer{ + envName: "dev", + } + client := newProjectEnvironmentClient(t, envServer) + props := mustProjectProperties(t, map[string]any{ + "deployments": []any{ + map[string]any{ + "name": "gpt-4.1-mini", + "model": map[string]any{ + "format": "OpenAI", + "name": "gpt-4.1-mini", + "version": "2025-04-14", + }, + "sku": map[string]any{ + "name": "GlobalStandard", + "capacity": 10, + }, + }, + }, + }) + + err := projectLifecycleHandler( + t.Context(), + client, + &azdext.ProjectEventArgs{ + Project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "project": { + Host: aiProjectHost, + AdditionalProperties: props, + }, + }, + }, + }, + ) + require.NoError(t, err) + + envServer.mu.Lock() + defer envServer.mu.Unlock() + assert.Equal(t, "dev", envServer.envNameSet) + assert.Equal(t, projectDeploymentsEnvKey, envServer.key) + assert.Equal( + t, + `[{\"name\":\"gpt-4.1-mini\",`+ + `\"model\":{\"name\":\"gpt-4.1-mini\",`+ + `\"format\":\"OpenAI\",\"version\":\"2025-04-14\"},`+ + `\"sku\":{\"name\":\"GlobalStandard\",`+ + `\"capacity\":10}}]`, + envServer.value, + ) +} + +func TestProjectLifecycleHandlerClearsEmptyDeployments(t *testing.T) { + t.Parallel() + + envServer := &recordingProjectEnvironmentServer{ + envName: "dev", + } + client := newProjectEnvironmentClient(t, envServer) + + err := projectLifecycleHandler( + t.Context(), + client, + &azdext.ProjectEventArgs{ + Project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "project": {Host: aiProjectHost}, + }, + }, + }, + ) + require.NoError(t, err) + + envServer.mu.Lock() + defer envServer.mu.Unlock() + assert.Equal(t, "[]", envServer.value) +} + +func mustProjectProperties( + t *testing.T, + value map[string]any, +) *structpb.Struct { + t.Helper() + + props, err := structpb.NewStruct(value) + require.NoError(t, err) + return props +} + +type recordingProjectEnvironmentServer struct { + azdext.UnimplementedEnvironmentServiceServer + + mu sync.Mutex + envName string + envNameSet string + key string + value string +} + +func (s *recordingProjectEnvironmentServer) GetCurrent( + context.Context, + *azdext.EmptyRequest, +) (*azdext.EnvironmentResponse, error) { + return &azdext.EnvironmentResponse{ + Environment: &azdext.Environment{Name: s.envName}, + }, nil +} + +func (s *recordingProjectEnvironmentServer) SetValue( + _ context.Context, + request *azdext.SetEnvRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.envNameSet = request.EnvName + s.key = request.Key + s.value = request.Value + return &azdext.EmptyResponse{}, nil +} + +func newProjectEnvironmentClient( + t *testing.T, + envServer azdext.EnvironmentServiceServer, +) *azdext.AzdClient { + t.Helper() + + server := grpc.NewServer() + azdext.RegisterEnvironmentServiceServer(server, envServer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + client, err := azdext.NewAzdClient( + azdext.WithAddress(listener.Addr().String()), + ) + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + }) + return client +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index 822e7e60f17..f376368b423 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -4,6 +4,10 @@ package cmd import ( + "context" + + "azure.ai.projects/internal/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -34,13 +38,49 @@ func NewRootCommand() *cobra.Command { return rootCmd } -// configureExtensionHost is the listen callback. It registers the -// azure.ai.project service target so `azd up`/`azd deploy` can walk the project -// service declared in azure.yaml. The project itself is provisioned by the -// built-in microsoft.foundry Bicep provider, so the target is a no-op at deploy. +// configureExtensionHost registers project lifecycle providers. func configureExtensionHost(host *azdext.ExtensionHost) { azdClient := host.Client() - host.WithServiceTarget(aiProjectHost, func() azdext.ServiceTargetProvider { - return newProjectServiceTarget(azdClient) - }) + host. + WithServiceTarget( + aiProjectHost, + func() azdext.ServiceTargetProvider { + return newProjectServiceTarget(azdClient) + }, + ). + WithProvisioningProvider( + provisioning.FoundryProviderName, + func() azdext.ProvisioningProvider { + return provisioning.NewFoundryProvisioningProvider( + azdClient, + ) + }, + ). + WithValidationCheck(azdext.ValidationCheckRegistration{ + CheckType: azdext.ValidationCheckTypeProvision, + RuleID: provisioning.ResourceGroupLocationRuleID, + Factory: func() azdext.ValidationCheckProvider { + return provisioning.NewResourceGroupLocationCheck( + azdClient, + ) + }, + }). + WithProjectEventHandler( + "preprovision", + func( + ctx context.Context, + args *azdext.ProjectEventArgs, + ) error { + return projectLifecycleHandler(ctx, azdClient, args) + }, + ). + WithProjectEventHandler( + "predeploy", + func( + ctx context.Context, + args *azdext.ProjectEventArgs, + ) error { + return projectLifecycleHandler(ctx, azdClient, args) + }, + ) } diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go index 81c89ef386b..1d5941eb809 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go @@ -9,41 +9,38 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -// aiProjectHost is the azure.yaml service host kind owned by this extension. A -// `host: azure.ai.project` service entry represents the Foundry project and carries its -// model `deployments` (and an optional `endpoint:` for reuse). +// aiProjectHost is the azure.yaml host owned by this extension. +// A project service carries model deployments. +// It may also carry an endpoint for an existing project. const aiProjectHost = "azure.ai.project" var _ azdext.ServiceTargetProvider = (*projectServiceTarget)(nil) -// projectServiceTarget owns the azure.ai.project host. The Foundry project, its model -// deployments, the underlying Account, and RBAC are provisioned at `azd provision` by the -// built-in `microsoft.foundry` Bicep provider (or by the user's own infra), so this -// target has no deploy-time work: Package, Publish, and Deploy are no-ops. It exists so -// the azure.ai.projects extension owns the project host (rather than the shared no-op -// shim in the agents extension) and so `azd deploy`/`azd up` can walk a project entry. +// projectServiceTarget owns the azure.ai.project host. +// The microsoft.foundry provider provisions projects, deployments, +// accounts, and RBAC. This target keeps deploy graph ordering but has +// no package, publish, or deploy work. // -// When the project entry sets `endpoint:` (bring-your-own project), provisioning is -// skipped by the provider and azd connects to the existing project; this target still -// has nothing to upsert at deploy. +// When the entry sets `endpoint:`, provisioning reuses that project. +// This target still has nothing to upsert during deploy. type projectServiceTarget struct { azdClient *azdext.AzdClient serviceConfig *azdext.ServiceConfig } -// newProjectServiceTarget creates the azure.ai.project service-target provider. +// newProjectServiceTarget creates the project service target. func newProjectServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { return &projectServiceTarget{azdClient: azdClient} } -// Initialize stores the service configuration; no other setup is required. +// Initialize stores the service configuration. func (p *projectServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { p.serviceConfig = serviceConfig return nil } -// Endpoints returns no endpoints; the project endpoint is surfaced through the azd -// environment (FOUNDRY_PROJECT_ENDPOINT) during provisioning, not here. +// Endpoints returns no endpoints. +// Provisioning publishes the endpoint through the azd environment. func (p *projectServiceTarget) Endpoints( ctx context.Context, serviceConfig *azdext.ServiceConfig, @@ -52,8 +49,8 @@ func (p *projectServiceTarget) Endpoints( return nil, nil } -// GetTargetResource delegates to azd's default resolver and falls back to a minimal -// target so the deploy pipeline can proceed. +// GetTargetResource delegates to azd's resolver. +// It returns a minimal target when the resolver cannot find one. func (p *projectServiceTarget) GetTargetResource( ctx context.Context, subscriptionId string, @@ -90,10 +87,9 @@ func (p *projectServiceTarget) Publish( return &azdext.ServicePublishResult{}, nil } -// Deploy is a no-op; the project and its model deployments are created at provision time -// by the built-in microsoft.foundry Bicep provider (or the user's infra). Removing the -// service from azure.yaml stops azd managing the project but does not delete it; teardown -// runs through `azd down`. +// Deploy is a no-op because resources are provisioned earlier. +// Removing the service stops management without deleting resources. +// Teardown continues to run through `azd down`. func (p *projectServiceTarget) Deploy( ctx context.Context, serviceConfig *azdext.ServiceConfig, diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/version.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/version.go index 76c5c2cd8e0..0de97bb5e62 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/version.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/version.go @@ -4,17 +4,16 @@ package cmd import ( + "azure.ai.projects/internal/version" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) -var ( - // Populated at build time - Version = "dev" // Default value for development builds - Commit = "none" - BuildDate = "unknown" -) - func newVersionCommand(outputFormat *string) *cobra.Command { - return azdext.NewVersionCommand("azure.ai.projects", Version, outputFormat) + return azdext.NewVersionCommand( + "azure.ai.projects", + version.Version, + outputFormat, + ) } 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 58f4399963c..d6a94cf3212 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -3,18 +3,51 @@ package exterrors +const CodeCancelled = "cancelled" + // Error codes commonly used for validation errors. // // These are paired with [Validation] when user input or configuration values // fail validation. const ( - CodeInvalidParameter = "invalid_parameter" + CodeInvalidParameter = "invalid_parameter" + CodeInvalidServiceConfig = "invalid_service_config" + CodeInvalidAzureYaml = "invalid_azure_yaml" + CodeMissingResourceGroup = "missing_resource_group" + CodeDestroyRequiresForce = "destroy_requires_force" + CodeOnDiskBicepCompileFailed = "ondisk_bicep_compile_failed" + CodeOnDiskBicepParseFailed = "ondisk_bicep_parse_failed" + CodeOnDiskParametersInvalid = "ondisk_parameters_invalid" + CodeOnDiskTemplateMissing = "ondisk_template_missing" + CodeArmWhatIfFailed = "arm_what_if_failed" ) // Error codes commonly used for dependency errors. // // These are paired with [Dependency] when a required external value is missing. const ( - CodeMissingProjectEndpoint = "missing_project_endpoint" - CodeAzdClientFailed = "azd_client_failed" + CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeAzdClientFailed = "azd_client_failed" + CodeEnvironmentNotFound = "environment_not_found" + CodeEnvironmentValuesFailed = "environment_values_failed" + CodeMissingAzureSubscription = "missing_azure_subscription_id" + CodeMissingAzureLocation = "missing_azure_location" + CodeProvisioningServiceNotFound = "provisioning_service_not_found" +) + +const ( + //nolint:gosec // error code, not a credential + CodeCredentialCreationFailed = "credential_creation_failed" + CodeTenantLookupFailed = "tenant_lookup_failed" +) + +const ( + OpArmDeploymentCreate = "arm_deployment_create" + OpArmDeploymentGet = "arm_deployment_get" + OpArmDeploymentWhatIf = "arm_deployment_what_if" + OpResourceGroupDelete = "resource_group_delete" + OpCognitiveAccountList = "cognitive_account_list" + OpCognitiveAccountPurge = "cognitive_account_purge" + OpCognitiveDeploymentList = "cognitive_deployment_list" + OpCognitiveDeploymentDelete = "cognitive_deployment_delete" ) diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go index c230dd8ec15..35027f7f4a6 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go @@ -16,7 +16,15 @@ package exterrors import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Validation returns a validation [azdext.LocalError] for user-input or @@ -40,3 +48,96 @@ func Dependency(code, message, suggestion string) error { Suggestion: suggestion, } } + +// Auth returns an authentication or authorization error. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// User returns a user-action error without a suggestion. +func User(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryUser, + } +} + +// Internal returns an unexpected local failure. +func Internal(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryInternal, + } +} + +// ServiceFromAzure classifies an Azure SDK response error. +func ServiceFromAzure(err error, operation string) error { + if responseErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + serviceName := "" + if responseErr.RawResponse != nil && + responseErr.RawResponse.Request != nil { + serviceName = responseErr.RawResponse.Request.Host + } + code := responseErr.ErrorCode + if code == "" { + code = fmt.Sprintf("%d", responseErr.StatusCode) + } + return &azdext.ServiceError{ + Message: fmt.Sprintf( + "%s: %s", + operation, + responseErr.Error(), + ), + ErrorCode: fmt.Sprintf("%s.%s", operation, code), + StatusCode: responseErr.StatusCode, + ServiceName: serviceName, + } + } + if IsCancellation(err) { + return Cancelled(fmt.Sprintf("%s was cancelled", operation)) + } + return Internal( + operation, + fmt.Sprintf("%s: %s", operation, err.Error()), + ) +} + +// IsCancellation reports whether an operation was cancelled. +func IsCancellation(err error) bool { + if errors.Is(err, context.Canceled) { + return true + } + if grpcStatus, ok := status.FromError(err); ok { + return grpcStatus.Code() == codes.Canceled + } + return false +} + +// IsPromptRequired detects a no-prompt host failure. +func IsPromptRequired(err error) bool { + if err == nil { + return false + } + if grpcStatus, ok := status.FromError(err); ok { + return strings.Contains( + strings.ToLower(grpcStatus.Message()), + "prompt required", + ) + } + return strings.Contains( + strings.ToLower(err.Error()), + "prompt required", + ) +} + +// Cancelled returns a structured user cancellation. +func Cancelled(message string) error { + return User(CodeCancelled, message) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/contract_parity_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/contract_parity_test.go new file mode 100644 index 00000000000..cd01dbd8211 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/contract_parity_test.go @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const agentsProjectPath = "../../../azure.ai.agents/internal/project" + +func TestAgentsProvisioningConstantsMatch(t *testing.T) { + t.Parallel() + + agents := readStringConstants( + t, + filepath.Join( + agentsProjectPath, + "provisioning_provider.go", + ), + ) + + assert.Equal(t, FoundryProviderName, agents["FoundryProviderName"]) + assert.Equal(t, FoundryProjectHost, agents["FoundryProjectHost"]) + assert.Equal(t, BicepProviderName, agents["BicepProviderName"]) + assert.Equal( + t, + TerraformProviderName, + agents["TerraformProviderName"], + ) + assert.Equal( + t, + []string{"azure.ai.project"}, + FoundryProjectServiceHosts, + ) + assert.Equal( + t, + []string{"azure.ai.agent", "microsoft.foundry"}, + FoundryLegacyProvisioningHosts, + ) +} + +func TestAgentsProvisioningErrorCodesMatch(t *testing.T) { + t.Parallel() + + projects := readStringConstants( + t, + filepath.Join("..", "exterrors", "codes.go"), + ) + agents := readStringConstants( + t, + filepath.Join( + agentsProjectPath, + "..", + "exterrors", + "codes.go", + ), + ) + + names := []string{ + "CodeArmWhatIfFailed", + "CodeAzdClientFailed", + "CodeCredentialCreationFailed", + "CodeDestroyRequiresForce", + "CodeEnvironmentNotFound", + "CodeEnvironmentValuesFailed", + "CodeInvalidAzureYaml", + "CodeInvalidServiceConfig", + "CodeMissingAzureLocation", + "CodeMissingAzureSubscription", + "CodeMissingResourceGroup", + "CodeOnDiskBicepCompileFailed", + "CodeOnDiskBicepParseFailed", + "CodeOnDiskParametersInvalid", + "CodeOnDiskTemplateMissing", + "CodeProvisioningServiceNotFound", + "CodeTenantLookupFailed", + "OpArmDeploymentCreate", + "OpArmDeploymentGet", + "OpArmDeploymentWhatIf", + "OpCognitiveAccountList", + "OpCognitiveAccountPurge", + "OpCognitiveDeploymentDelete", + "OpCognitiveDeploymentList", + "OpResourceGroupDelete", + } + for _, name := range names { + assert.NotEmpty(t, projects[name], name) + assert.Equal(t, projects[name], agents[name], name) + } +} + +func readStringConstants( + t *testing.T, + path string, +) map[string]string { + t.Helper() + + file, err := parser.ParseFile( + token.NewFileSet(), + path, + nil, + 0, + ) + require.NoError(t, err) + + values := map[string]string{} + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range spec.Names { + if i >= len(spec.Values) { + continue + } + literal, ok := spec.Values[i].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + continue + } + value, err := strconv.Unquote(literal.Value) + require.NoError(t, err) + values[name.Name] = value + } + return true + }) + return values +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index b6f0663007f..6a6db6673dd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" @@ -17,8 +17,8 @@ import ( "strings" "time" - "azureaiagent/internal/exterrors" - "azureaiagent/internal/synthesis" + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_brownfield_acr_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_brownfield_acr_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go index 2ba8a2f0562..d412a8752df 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_brownfield_acr_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" @@ -9,7 +9,7 @@ import ( "strings" "testing" - "azureaiagent/internal/synthesis" + "azure.ai.projects/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_destroy_confirm_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_destroy_confirm_test.go similarity index 98% rename from cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_destroy_confirm_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_destroy_confirm_test.go index 5fd4e369c00..f7e5ac2b0d3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_destroy_confirm_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_destroy_confirm_test.go @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" "net" "testing" - "azureaiagent/internal/exterrors" + "azure.ai.projects/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_resolveenv_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_resolveenv_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go index 23b6926b3cc..de9c5c55dcb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_resolveenv_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" "net" "testing" - "azureaiagent/internal/exterrors" + "azure.ai.projects/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index ee92cf65807..a6039fd3a6d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "encoding/json" @@ -12,8 +12,8 @@ import ( "strings" "testing" - "azureaiagent/internal/exterrors" - "azureaiagent/internal/synthesis" + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/ondisk_template.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/ondisk_template.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go index 75752c44bcf..d291aec028d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/ondisk_template.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" @@ -13,7 +13,7 @@ import ( "os" "path/filepath" - "azureaiagent/internal/exterrors" + "azure.ai.projects/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" "github.com/drone/envsubst" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/ondisk_template_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/ondisk_template_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go index 3abebff29b2..5f566b06531 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/ondisk_template_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" @@ -12,7 +12,7 @@ import ( "sort" "testing" - "azureaiagent/internal/exterrors" + "azure.ai.projects/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/preview_helpers.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/preview_helpers.go similarity index 98% rename from cli/azd/extensions/azure.ai.agents/internal/project/preview_helpers.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/preview_helpers.go index c6174a2fa2b..45422d15b1e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/preview_helpers.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/preview_helpers.go @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "fmt" "strings" - "azureaiagent/internal/exterrors" + "azure.ai.projects/internal/exterrors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/preview_helpers_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/preview_helpers_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/preview_helpers_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/preview_helpers_test.go index 6be1a8c9a88..ae7b241b719 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/preview_helpers_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/preview_helpers_test.go @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "errors" "strings" "testing" - "azureaiagent/internal/exterrors" + "azure.ai.projects/internal/exterrors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/provisioning_provider.go new file mode 100644 index 00000000000..de84c119afc --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/provisioning_provider.go @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import "slices" + +// FoundryProviderName is the value written to `infra.provider` in +// azure.yaml by `azd ai agent init` and looked up by azd's provider +// resolver to dispatch provisioning to this extension. +const FoundryProviderName = "microsoft.foundry" + +// BicepProviderName and TerraformProviderName are azd-core's built-in +// provisioning providers. `azd ai agent init --infra=terraform` stamps +// TerraformProviderName onto azure.yaml so azd-core's Terraform provider +// (not this extension's microsoft.foundry provider) handles provisioning. +const ( + BicepProviderName = "bicep" + TerraformProviderName = "terraform" +) + +// FoundryProjectHost is the `services..host` value whose service body +// owns Foundry account/project provisioning inputs such as endpoint:, deployments:, and network:. +const FoundryProjectHost = "azure.ai.project" + +// FoundryProjectServiceHosts lists the values that the provisioning provider +// treats as Foundry project services. Keep this project-scoped: agent services +// depend on the project service, but do not own account-level provisioning settings. +var FoundryProjectServiceHosts = []string{FoundryProjectHost} + +// FoundryLegacyProvisioningHosts lists pre-split service hosts that can still drive +// provisioning when no azure.ai.project service exists. network: remains unsupported +// on these hosts; this compatibility path is only for existing non-network projects. +var FoundryLegacyProvisioningHosts = []string{"azure.ai.agent", "microsoft.foundry"} + +// FoundryProvisioningServiceHosts lists every service host accepted by the synthesizer. +var FoundryProvisioningServiceHosts = append( + slices.Clone(FoundryProjectServiceHosts), FoundryLegacyProvisioningHosts...) + +// IsFoundryNetworkHost reports whether a host belongs to a Foundry service shape +// where network: would be a likely user mistake. The shipped network contract is +// project-scoped, so callers use this to reject misplaced network: blocks with +// actionable guidance instead of silently ignoring them. +func IsFoundryNetworkHost(host string) bool { + return host == "azure.ai.agent" || host == "microsoft.foundry" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go similarity index 95% rename from cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go index 252418d04fc..92d419d4707 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" @@ -10,29 +10,22 @@ import ( "path/filepath" "strings" - "azureaiagent/internal/pkg/azure" + "azure.ai.projects/internal/azure" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -// ResourceGroupLocationRuleID is the stable identifier for the resource-group -// location-mismatch provision validation check. It doubles as both the -// registration RuleID and the result DiagnosticId. -// -// The identifier is prefixed with the owning extension id (azure.ai.agents) -// because the provision validation rule namespace is global — core enforces -// uniqueness on check_type + rule_id across every installed extension. Namespacing -// keeps the rule unambiguously mappable back to this extension for future -// features such as suppressions and rule sets. +// ResourceGroupLocationRuleID is the stable validation identifier. +// It keeps its original prefix so existing consumers do not break. const ResourceGroupLocationRuleID = "azure.ai.agents.resource_group_location_mismatch" // ResourceGroupLocationCheck is a provider-agnostic "provision" validation check // (azdext.ValidationCheckTypeProvision) that detects an immutable resource-group // region conflict before provisioning starts. It is registered under the // provision check type — rather than the Bicep-only "local-preflight" type — -// because the azure.ai.agents extension provisions through its own +// because the azure.ai.projects extension provisions through its own // microsoft.foundry provider, which never triggers local-preflight checks. // // The azure.ai.agents extension writes a stable, salted resource group name to diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_test.go index 5e3f14756d6..bea2900de48 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "testing" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go rename to cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go index 2047d9d12fd..46da39a2b0f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check_validate_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package provisioning import ( "context" 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 new file mode 100644 index 00000000000..94c9ec3b987 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const agentsSynthesisPath = "../../../azure.ai.agents/internal/synthesis" + +func TestAgentsSynthesisCopyMatches(t *testing.T) { + t.Parallel() + + canonical := readSynthesisFiles(t, ".") + agents := readSynthesisFiles(t, agentsSynthesisPath) + + assert.Equal(t, canonical, agents, + "agents synthesis must match the projects-owned copy") +} + +func TestExtensionsUseSameAzdSdk(t *testing.T) { + t.Parallel() + + projectsVersion := readAzdSdkVersion(t, "../../go.mod") + agentsVersion := readAzdSdkVersion( + t, + filepath.Join(agentsSynthesisPath, "..", "..", "go.mod"), + ) + + assert.Equal(t, projectsVersion, agentsVersion) +} + +func readSynthesisFiles(t *testing.T, root string) map[string][]byte { + t.Helper() + + files := map[string][]byte{} + err := filepath.WalkDir( + root, + func(path string, entry fs.DirEntry, walkErr error) error { + require.NoError(t, walkErr) + if entry.IsDir() { + return nil + } + + rel, err := filepath.Rel(root, path) + require.NoError(t, err) + if !isParityFile(rel) { + return nil + } + + //nolint:gosec // repository-controlled parity path + data, err := os.ReadFile(path) + require.NoError(t, err) + files[filepath.ToSlash(rel)] = data + return nil + }, + ) + require.NoError(t, err) + return files +} + +func isParityFile(path string) bool { + path = filepath.ToSlash(path) + if strings.HasPrefix(path, "templates/") { + return true + } + return strings.HasSuffix(path, ".go") && + !strings.HasSuffix(path, "_test.go") +} + +func readAzdSdkVersion(t *testing.T, path string) string { + t.Helper() + + //nolint:gosec // repository-controlled module path + data, err := os.ReadFile(path) + require.NoError(t, err) + match := regexp.MustCompile( + `(?m)^\s*github\.com/azure/azure-dev/cli/azd\s+(\S+)`, + ).FindSubmatch(data) + require.Len(t, match, 2) + return string(bytes.TrimSpace(match[1])) +} 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 new file mode 100644 index 00000000000..cf52f4536a9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// schemaPath is the editor-tooling JSON schema for the Foundry project service body, +// resolved from this package directory. +const schemaPath = "../../../azure.ai.projects/schemas/azure.ai.project.json" + +// TestSchema_NetworkStructuralInvariants guards the network surface of the +// hand-maintained JSON schema against drift from the synthesizer's contract: +// peSubnet is mandatory, the old mode/byo/managed shape is gone, and every +// subnet requires an explicit vnet + name. +func TestSchema_NetworkStructuralInvariants(t *testing.T) { + raw, err := os.ReadFile(schemaPath) + if errors.Is(err, os.ErrNotExist) { + t.Skipf("project schema not found at %s; skipping cross-extension schema invariant test", schemaPath) + } + require.NoError(t, err) + + var doc struct { + Properties struct { + Network struct { + Required []string `json:"required"` + AllOf []json.RawMessage `json:"allOf"` + Properties map[string]json.RawMessage `json:"properties"` + } `json:"network"` + } `json:"properties"` + Definitions struct { + Subnet struct { + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` + } `json:"Subnet"` + } `json:"definitions"` + } + require.NoError(t, json.Unmarshal(raw, &doc), "schema must be valid JSON") + + net := doc.Properties.Network + assert.Contains(t, net.Required, "peSubnet", + "network must require peSubnet (no public data-plane fallback)") + assert.Contains(t, net.Properties, "agentSubnet", "network must expose agentSubnet") + assert.Contains(t, net.Properties, "isolationMode", "network must expose isolationMode") + assert.Contains(t, net.Properties, "peSubnet", "network must expose peSubnet") + + assertNetworkRejectsAgentSubnetWithIsolationMode(t, net.AllOf) + + // The retired mode-enum shape must not reappear. + assert.NotContains(t, net.Properties, "mode", "network.mode was removed") + assert.NotContains(t, net.Properties, "byo", "network.byo was removed") + assert.NotContains(t, net.Properties, "managed", "network.managed was removed") + + sub := doc.Definitions.Subnet + assert.ElementsMatch(t, []string{"vnet", "name"}, sub.Required, + "a subnet must require exactly vnet + name") + assert.Contains(t, sub.Properties, "prefix", "subnet must expose prefix (create vs reference)") +} + +func assertNetworkRejectsAgentSubnetWithIsolationMode(t *testing.T, allOf []json.RawMessage) { + t.Helper() + + for _, rule := range allOf { + var candidate struct { + Not struct { + Required []string `json:"required"` + } `json:"not"` + } + require.NoError(t, json.Unmarshal(rule, &candidate), "network allOf rule must be valid JSON") + if slices.Contains(candidate.Not.Required, "agentSubnet") && + slices.Contains(candidate.Not.Required, "isolationMode") { + return + } + } + + assert.Fail(t, "network schema must reject agentSubnet and isolationMode together") +} + +// TestARMTemplate_MatchesBicepBuild fails if templates/main.arm.json is stale +// relative to main.bicep. AGENTS guidance forbids hand-editing the ARM JSON; +// this catches a forgotten `bicep build`. Skipped when the bicep CLI is not on +// PATH (e.g. minimal CI images) so it never produces a phantom failure. +func TestARMTemplate_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, "main.arm.json")) + require.NoError(t, err) + + out := filepath.Join(t.TempDir(), "main.arm.json") + //nolint:gosec // bicep comes from PATH or the Azure CLI + cmd := exec.CommandContext(t.Context(), bicep, "build", + filepath.Join(templatesDir, "main.bicep"), "--outfile", out) + var stderr bytes.Buffer + cmd.Stderr = &stderr + require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) + + //nolint:gosec // test-owned temporary output path + rebuilt, err := os.ReadFile(out) + require.NoError(t, err) + + committedNormalized := normalizeArmTemplate(t, committed) + rebuiltNormalized := normalizeArmTemplate(t, rebuilt) + + assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), + "templates/main.arm.json is stale; regenerate with `bicep build main.bicep "+ + "--outfile main.arm.json` from the templates directory") +} + +// TestBrownfieldARMTemplate_MatchesBicepBuild is the brownfield.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) { + 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")) + require.NoError(t, err) + + out := filepath.Join(t.TempDir(), "brownfield.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) + var stderr bytes.Buffer + cmd.Stderr = &stderr + require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) + + //nolint:gosec // test-owned temporary output path + rebuilt, err := os.ReadFile(out) + require.NoError(t, err) + + committedNormalized := normalizeArmTemplate(t, committed) + 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") +} + +// normalizeArmTemplate returns a stable JSON representation of an ARM template +// for drift comparison. Bicep generator metadata includes the local Bicep CLI +// version/hash and can differ between developer machines and CI images without +// changing the template semantics. +func normalizeArmTemplate(t *testing.T, raw []byte) []byte { + t.Helper() + + var doc any + require.NoError(t, json.Unmarshal(raw, &doc)) + stripBicepGeneratorMetadata(doc) + + normalized, err := json.Marshal(doc) + require.NoError(t, err) + return normalized +} + +// stripBicepGeneratorMetadata recursively removes Bicep's generator metadata +// from a decoded ARM template. Bicep emits this metadata for the top-level +// template and nested module templates. +func stripBicepGeneratorMetadata(value any) { + switch v := value.(type) { + case map[string]any: + delete(v, "_generator") + for _, child := range v { + stripBicepGeneratorMetadata(child) + } + case []any: + for _, child := range v { + stripBicepGeneratorMetadata(child) + } + } +} + +// lookupBicep returns a usable bicep binary path, preferring PATH and falling +// back to the az-bundled location. +func lookupBicep() string { + if p, err := exec.LookPath("bicep"); err == nil { + return p + } + if home, err := os.UserHomeDir(); err == nil { + for _, name := range []string{"bicep", "bicep.exe"} { + azBicep := filepath.Join(home, ".azure", "bin", name) + if _, err := os.Stat(azBicep); err == nil { + return azBicep + } + } + } + return "" +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go new file mode 100644 index 00000000000..4e7c9071042 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -0,0 +1,828 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package synthesis turns the body of a Foundry service in azure.yaml +// into the inputs needed to compile an ARM template in memory: +// +// - the embedded main.bicep + modules tree, ready to be staged on disk +// for the bicep compiler +// - a Parameters map of the values the template's params consume +// +// Greenfield only: if the service has an endpoint: field, ErrEndpointBrownfield +// is returned so callers can short-circuit the provision path. +package synthesis + +import ( + "errors" + "fmt" + "maps" + "net" + "os" + "regexp" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "go.yaml.in/yaml/v3" +) + +// Sentinel errors returned by Synthesize. +var ( + // ErrEndpointBrownfield indicates the service points at an existing + // Foundry project via endpoint:. The provider should skip ARM + // provisioning and connect to the endpoint directly. + ErrEndpointBrownfield = errors.New("synthesis: service has endpoint: (brownfield)") + + // ErrServiceNotFound indicates the requested service does not exist + // in azure.yaml or its host: value is not in AcceptedHosts. + ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") +) + +// Input is the synthesizer's view of azure.yaml. +type Input struct { + // RawAzureYAML is the full bytes of azure.yaml. + RawAzureYAML []byte + + // ServiceName is the key under services: to synthesize for + // (e.g. "my-project"). + ServiceName string + + // AcceptedHosts lists the values of `services..host` the + // caller treats as a Foundry service. If empty, the service's host + // value is not checked (only existence and endpoint: are). + AcceptedHosts []string + + // Env maps azd environment variable names to values. Used to resolve + // ${VAR} references in network fields (subnet vnet ids, dns.subscription). + // When a referenced variable is absent here, the synthesizer falls back + // to the process environment before failing. May be nil. + Env map[string]string + + // PreserveVarRefs keeps ${VAR} references verbatim instead of resolving + // them. Used by the eject path, where the synthesized main.parameters.json + // must stay environment-portable: the on-disk provision flow resolves + // ${VAR} from the azd environment at provision time. When false (the + // provision path), ${VAR} is resolved here and a missing variable fails. + PreserveVarRefs bool + + // ProjectRoot is the directory holding azure.yaml. When set, $ref file + // includes in the service entry (and its deployment items) are resolved + // against it before synthesis, so refs become the actual content rather + // than zero-valued params. Empty disables resolution. + ProjectRoot string +} + +// Result bundles the bicep sources and the parameter values derived +// from the service body. Callers stage Templates on disk, compile +// main.bicep, and pass Parameters when invoking the resulting ARM +// deployment. +type Result struct { + // Parameters maps bicep param names to plain Go values. Callers wrap + // these in ARM's {"value": ...} envelope when serializing. + Parameters map[string]any + + // NetworkMode is "none", "byo", or "managed" — derived from the + // network: block (or its absence). Exposed for telemetry. + NetworkMode string +} + +// Deployment mirrors the deploymentType in main.bicep. +type Deployment struct { + Name string `yaml:"name" json:"name"` + Model DeploymentModel `yaml:"model" json:"model"` + Sku DeploymentSku `yaml:"sku" json:"sku"` +} + +// DeploymentModel mirrors the model field of deploymentType. +type DeploymentModel struct { + Name string `yaml:"name" json:"name"` + Format string `yaml:"format" json:"format"` + Version string `yaml:"version" json:"version"` +} + +// DeploymentSku mirrors the sku field of deploymentType. +type DeploymentSku struct { + Name string `yaml:"name" json:"name"` + Capacity int `yaml:"capacity" json:"capacity"` +} + +// Connection mirrors the connectionType in modules/connections.bicep: the +// synthesized shape of a host: azure.ai.connection service, where the service +// key becomes Name. Credentials and Metadata pass through as-is so any auth +// type (ApiKey, CustomKeys, OAuth2, identity tokens, ...) can be expressed. +type Connection struct { + Name string `yaml:"name" json:"name"` + Category string `yaml:"category" json:"category"` + Target string `yaml:"target" json:"target"` + AuthType string `yaml:"authType" json:"authType"` + Credentials map[string]any `yaml:"credentials,omitempty" json:"credentials,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` +} + +// connectionService is the subset of a host: azure.ai.connection service body +// the synthesizer reads. The service key (not a body field) is the connection +// name; see collectConnections. +type connectionService struct { + Host string `yaml:"host"` + Category string `yaml:"category,omitempty"` + Target string `yaml:"target,omitempty"` + AuthType string `yaml:"authType,omitempty"` + Credentials map[string]any `yaml:"credentials,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty"` +} + +// aiConnectionHost is the host: value that marks a service as a Foundry +// connection. Matches the azure.ai.connection service-target provider name. +const aiConnectionHost = "azure.ai.connection" + +// codeConfigBlock marks an agent as a code (ZIP) deploy. Its presence is the +// signal; the keys are camelCase because the unified azure.ai.agent service +// entry is serialized from the agent definition's JSON tags. +type codeConfigBlock struct { + Runtime string `yaml:"runtime,omitempty"` + EntryPoint string `yaml:"entryPoint,omitempty"` +} + +// agentBlock is the subset of an agent entry we inspect — both the legacy inline +// agents[] shape and the unified azure.ai.agent service shape. +type agentBlock struct { + Name string `yaml:"name,omitempty"` + Kind string `yaml:"kind,omitempty"` + Image string `yaml:"image,omitempty"` + CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` +} + +// serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. +type serviceBlock struct { + Host string `yaml:"host"` + Kind string `yaml:"kind,omitempty"` + Image string `yaml:"image,omitempty"` + CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` + Agents []agentBlock `yaml:"agents,omitempty"` +} + +// projectService is the subset of a host: azure.ai.project service body the synthesizer reads. +// Unknown fields are intentionally ignored: they are reconciled in deploy-time service targets. +type projectService struct { + Host string `yaml:"host"` + Endpoint string `yaml:"endpoint,omitempty"` + Deployments []Deployment `yaml:"deployments,omitempty"` + Agents []agentBlock `yaml:"agents,omitempty"` + Network *networkBlock `yaml:"network,omitempty"` +} + +// networkBlock mirrors the network: sub-tree on the service body. +// +// The block models two orthogonal axes: +// +// - Egress (agent runtime network): agentSubnet present injects the agent into +// that customer subnet; agentSubnet absent uses the Microsoft-managed +// network. isolationMode tunes the managed network's outbound posture and is +// valid only when agentSubnet is absent. +// - Ingress (account data plane): peSubnet is required and always yields an +// account private endpoint, so a network-bound account is never public. +type networkBlock struct { + AgentSubnet *subnetSpec `yaml:"agentSubnet,omitempty"` + IsolationMode string `yaml:"isolationMode,omitempty"` + PESubnet *subnetSpec `yaml:"peSubnet,omitempty"` + DNS *dnsBlock `yaml:"dns,omitempty"` +} + +// subnetSpec is a self-contained subnet descriptor: vnet + name identify the +// subnet, and the optional prefix toggles create-vs-reference. +// +// vnet + name -> reference the existing subnet +// vnet + name + prefix -> create the subnet with that CIDR +type subnetSpec struct { + VNet string `yaml:"vnet,omitempty"` + Name string `yaml:"name,omitempty"` + Prefix string `yaml:"prefix,omitempty"` +} + +// dnsBlock mirrors network.dns (private DNS zone references). +type dnsBlock struct { + ResourceGroup string `yaml:"resourceGroup,omitempty"` + Subscription string `yaml:"subscription,omitempty"` +} + +// projectFile is the root of azure.yaml as we care about it: only services. +type projectFile struct { + Services map[string]yaml.Node `yaml:"services"` +} + +// Synthesize derives the parameter values needed by main.bicep from one +// Foundry project service in azure.yaml. +func Synthesize(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) + } + + node, ok := root.Services[in.ServiceName] + if !ok { + return nil, ErrServiceNotFound + } + + // Resolve $ref file includes (service-entry-level and per-deployment) so the + // decoded service body carries the referenced content, not raw $ref objects. + if in.ProjectRoot != "" { + var err error + node, err = resolveServiceRefs(node, in.ProjectRoot, in.ServiceName) + if err != nil { + return nil, err + } + } + + var svc projectService + if err := node.Decode(&svc); err != nil { + return nil, fmt.Errorf("decode service %q: %w", in.ServiceName, err) + } + + if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { + return nil, ErrServiceNotFound + } + if strings.TrimSpace(svc.Endpoint) != "" { + return nil, ErrEndpointBrownfield + } + + includeAcr := deriveIncludeAcr(root.Services, svc) + + deployments := svc.Deployments + if deployments == nil { + deployments = []Deployment{} + } + + connections, err := collectConnections(root.Services, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + + netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + + params := map[string]any{ + "deployments": deployments, + "includeAcr": includeAcr, + "connections": connections, + } + maps.Copy(params, netParams) + + return &Result{ + Parameters: params, + NetworkMode: netMode, + }, nil +} + +// BrownfieldDeployments returns the model deployments declared on a brownfield +// (endpoint:) Foundry project service. Synthesize short-circuits with +// ErrEndpointBrownfield before reading deployments:, so the provider uses this +// to learn which model deployments to create on the existing account. Returns +// nil (not an error) when the service declares no deployments. +func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) { + if len(raw) == 0 { + return nil, errors.New("synthesis: raw azure.yaml is empty") + } + if serviceName == "" { + return nil, errors.New("synthesis: serviceName is empty") + } + + var root projectFile + if err := yaml.Unmarshal(raw, &root); err != nil { + return nil, fmt.Errorf("parse azure.yaml: %w", err) + } + + node, ok := root.Services[serviceName] + if !ok { + return nil, ErrServiceNotFound + } + + var svc projectService + if err := node.Decode(&svc); err != nil { + return nil, fmt.Errorf("decode service %q: %w", serviceName, err) + } + + return svc.Deployments, nil +} + +// BrownfieldConnections returns the host: azure.ai.connection services declared +// in azure.yaml, for a brownfield (endpoint:) project. Synthesize short-circuits +// with ErrEndpointBrownfield before collecting connections, so the provider uses +// this to create the same connections on the existing account. ${VAR} is +// resolved from env since brownfield provisions immediately; Foundry ${{...}} +// expressions pass through. Returns an empty slice (not an error) when none +// are declared. +func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, error) { + if len(raw) == 0 { + return nil, errors.New("synthesis: raw azure.yaml is empty") + } + + var root projectFile + if err := yaml.Unmarshal(raw, &root); err != nil { + return nil, fmt.Errorf("parse azure.yaml: %w", err) + } + + return collectConnections(root.Services, env, true) +} + +// resolveServiceRefs expands $ref file includes in one service entry. It decodes +// the node to the map shape foundry.ResolveFileRefs expects, resolves refs +// against projectRoot, and re-encodes to a yaml.Node so the rest of Synthesize +// decodes resolved content instead of raw {"$ref": ...} objects. +func resolveServiceRefs(node yaml.Node, projectRoot, serviceName string) (yaml.Node, error) { + var raw map[string]any + if err := node.Decode(&raw); err != nil { + // Not a mapping (unexpected for a service entry); leave it untouched. + return node, nil + } + resolved, err := foundry.ResolveFileRefs(raw, projectRoot) + if err != nil { + return node, fmt.Errorf("resolve $ref includes for service %q: %w", serviceName, err) + } + var out yaml.Node + if err := out.Encode(resolved); err != nil { + return node, fmt.Errorf("re-encode service %q after $ref resolution: %w", serviceName, err) + } + return out, nil +} + +// deriveIncludeAcr reports whether provisioning should create an ACR. An ACR is +// needed when any agent is a hosted, build-from-source agent: azd builds its +// image and pushes it to the registry. Agents live on sibling azure.ai.agent +// services in the split shape, or under agents[] in the legacy inline shape; +// both are scanned. The decision mirrors the deploy-time contract: +// +// - codeConfiguration present -> code (ZIP) deploy, no ACR +// - image present -> pre-built image (BYO registry), no ACR +// - otherwise (hosted) -> container build from source, needs ACR +// +// Keying on image/codeConfiguration rather than the optional docker: block is +// deliberate: docker: is not in the agent schema and is dropped by omitempty +// when remoteBuild is false, so it is not a reliable build signal. +func deriveIncludeAcr(services map[string]yaml.Node, svc projectService) bool { + if slices.ContainsFunc(svc.Agents, agentNeedsAcr) { + return true + } + + for _, node := range services { + var service serviceBlock + if err := node.Decode(&service); err != nil { + continue + } + if service.Host != "azure.ai.agent" { + continue + } + if agentNeedsAcr(agentBlock{ + Kind: service.Kind, + Image: service.Image, + CodeConfiguration: service.CodeConfiguration, + }) { + return true + } + } + return false +} + +// agentNeedsAcr reports whether a single agent entry builds a container image +// from source (and therefore requires an ACR). Pre-built images and code/ZIP +// deploys do not; any other hosted agent does. +func agentNeedsAcr(a agentBlock) bool { + if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { + return false + } + // "hosted" is the only container kind; an empty kind defaults to hosted for + // back-compat. Other explicit kinds (prompt, workflow) do not build. + // NOTE: if a future non-container kind can omit kind:, replace this + // default-to-hosted with an explicit allowlist so it does not trigger ACR. + kind := strings.TrimSpace(a.Kind) + return kind == "" || strings.EqualFold(kind, "hosted") +} + +// collectConnections scans all services for host: azure.ai.connection entries +// (the service key is the connection name) and returns them sorted by name so +// the synthesized parameter is deterministic regardless of YAML map order. +// +// ${VAR} in target/credentials/metadata is expanded from env when resolve is +// true (provision path) and kept verbatim when false (eject path); Foundry +// ${{...}} expressions are always preserved, mirroring synthesizeNetwork. +func collectConnections( + services map[string]yaml.Node, + env map[string]string, + resolve bool, +) ([]Connection, error) { + connections := []Connection{} + + for name, node := range services { + var host struct { + Host string `yaml:"host"` + } + if err := node.Decode(&host); err != nil || host.Host != aiConnectionHost { + continue + } + var svc connectionService + if err := node.Decode(&svc); err != nil { + return nil, fmt.Errorf("services.%s: decode connection: %w", name, err) + } + + target, err := maybeExpand(svc.Target, env, resolve) + if err != nil { + return nil, fmt.Errorf("services.%s.target: %w", name, err) + } + + credentials, err := expandCredentials(svc.Credentials, env, resolve) + if err != nil { + return nil, fmt.Errorf("services.%s.credentials: %w", name, err) + } + + metadata, err := expandMetadata(svc.Metadata, env, resolve) + if err != nil { + return nil, fmt.Errorf("services.%s.metadata: %w", name, err) + } + + connections = append(connections, Connection{ + Name: name, + Category: svc.Category, + Target: target, + AuthType: svc.AuthType, + Credentials: credentials, + Metadata: metadata, + }) + } + + slices.SortFunc(connections, func(a, b Connection) int { + return strings.Compare(a.Name, b.Name) + }) + return connections, nil +} + +// maybeExpand expands ${VAR} references in s when resolve is true, preserving +// Foundry ${{...}} expressions; when resolve is false it returns s unchanged so +// the eject path keeps references verbatim. +func maybeExpand(s string, env map[string]string, resolve bool) (string, error) { + if !resolve || s == "" { + return s, nil + } + return foundry.ExpandEnv(s, func(name string) string { + if v, ok := env[name]; ok { + return v + } + v, _ := os.LookupEnv(name) + return v + }) +} + +// expandCredentials deep-copies a credentials map, expanding ${VAR} in every +// string leaf (recursing into nested maps like CustomKeys' keys:). Non-string +// leaves are copied as-is. A nil map returns nil so the connection omits +// credentials entirely (e.g. None / identity auth). +func expandCredentials( + creds map[string]any, + env map[string]string, + resolve bool, +) (map[string]any, error) { + if creds == nil { + return nil, nil + } + out := make(map[string]any, len(creds)) + for k, v := range creds { + expanded, err := expandValue(v, env, resolve) + if err != nil { + return nil, err + } + out[k] = expanded + } + return out, nil +} + +// expandValue recursively expands ${VAR} in string values, map values, and +// slice elements, leaving other types untouched. +func expandValue(v any, env map[string]string, resolve bool) (any, error) { + switch val := v.(type) { + case string: + return maybeExpand(val, env, resolve) + case map[string]any: + out := make(map[string]any, len(val)) + for k, inner := range val { + expanded, err := expandValue(inner, env, resolve) + if err != nil { + return nil, err + } + out[k] = expanded + } + return out, nil + case []any: + out := make([]any, len(val)) + for i, inner := range val { + expanded, err := expandValue(inner, env, resolve) + if err != nil { + return nil, err + } + out[i] = expanded + } + return out, nil + default: + return v, nil + } +} + +// expandMetadata deep-copies a metadata map, expanding ${VAR} in each value. +// A nil map returns nil so the connection omits metadata entirely. +func expandMetadata( + metadata map[string]string, + env map[string]string, + resolve bool, +) (map[string]string, error) { + if metadata == nil { + return nil, nil + } + out := make(map[string]string, len(metadata)) + for k, v := range metadata { + expanded, err := maybeExpand(v, env, resolve) + if err != nil { + return nil, err + } + out[k] = expanded + } + return out, nil +} + +// Network mode values surfaced for telemetry and emitted as bicep params. +const ( + NetworkModeNone = "none" + NetworkModeByo = "byo" + NetworkModeManaged = "managed" +) + +// Default subnet names used when a subnet descriptor is omitted. +const ( + defaultAgentSubnetName = "agent-subnet" + defaultPESubnetName = "pe-subnet" +) + +// vnetIDPattern matches a Microsoft.Network/virtualNetworks ARM resource id. +var vnetIDPattern = regexp.MustCompile( + `(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Network/virtualNetworks/[^/]+$`, +) + +// guidPattern matches a bare GUID. +var guidPattern = regexp.MustCompile( + `(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, +) + +// rgNamePattern matches a valid Azure resource group name. +var rgNamePattern = regexp.MustCompile(`^[-\w._()]{1,90}$`) + +// varRefPattern matches a ${VAR} reference. +var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +// synthesizeNetwork validates the network: block and returns the bicep +// parameter set plus the telemetry mode. When net is nil the returned +// params disable network isolation and the output is byte-identical to the +// pre-network behavior. +// +// When resolve is true, ${VAR} references in byo.vnet.id / dns.subscription +// are expanded from env (provision path) and an unresolved variable fails. +// When resolve is false (eject path), ${VAR} references are kept verbatim so +// the synthesized parameters file stays environment-portable; the format +// checks that cannot run against an unexpanded placeholder are skipped. +func synthesizeNetwork( + net *networkBlock, + svcName string, + env map[string]string, + resolve bool, +) (map[string]any, string, error) { + // Public account: every network param defaults off. + params := map[string]any{ + "enableNetworkIsolation": false, + "useManagedEgress": false, + "vnetId": "", + "agentSubnetName": defaultAgentSubnetName, + "agentSubnetPrefix": "", + "createAgentSubnet": false, + "peSubnetName": defaultPESubnetName, + "peSubnetPrefix": "", + "createPESubnet": false, + "managedIsolationMode": "", + "dnsZonesResourceGroup": "", + "dnsZonesSubscription": "", + } + if net == nil { + return params, NetworkModeNone, nil + } + + fp := func(suffix string) string { + return fmt.Sprintf("services.%s.network%s", svcName, suffix) + } + + // Ingress: a network-bound account always gets an account private endpoint, + // so peSubnet is mandatory. There is no public data-plane fallback. + if net.PESubnet == nil { + return nil, "", fmt.Errorf("%s: private networking requires peSubnet", fp("")) + } + + // Egress: agentSubnet present injects the agent into the customer subnet; + // absent uses the Microsoft-managed network. + useManagedEgress := net.AgentSubnet == nil + + // isolationMode governs the Microsoft-managed network only. + isoMode := strings.TrimSpace(net.IsolationMode) + if isoMode != "" { + if !useManagedEgress { + return nil, "", fmt.Errorf( + "%s.isolationMode: only valid for managed egress (omit agentSubnet)", fp("")) + } + if isoMode != "AllowInternetOutbound" && isoMode != "AllowOnlyApprovedOutbound" { + return nil, "", fmt.Errorf( + "%s.isolationMode: %q is not one of AllowInternetOutbound, AllowOnlyApprovedOutbound", + fp(""), isoMode) + } + } + + // Ingress subnet (account private endpoint). + peVnet, peName, pePrefix, createPE, err := resolveSubnet(net.PESubnet, fp(".peSubnet"), env, resolve) + if err != nil { + return nil, "", err + } + vnetID := peVnet + + // Egress subnet (byo only). v1 keeps both subnets in one VNet so a single + // vnetId drives injection, the PE, and DNS linking. + if !useManagedEgress { + agentVnet, agentName, agentPrefix, createAgent, aerr := resolveSubnet( + net.AgentSubnet, fp(".agentSubnet"), env, resolve) + if aerr != nil { + return nil, "", aerr + } + if !sameVNet(agentVnet, peVnet) { + return nil, "", fmt.Errorf( + "%s: agentSubnet.vnet and peSubnet.vnet must reference the same virtual network", fp("")) + } + // The agent and PE subnets share one VNet, so their names must differ. + // Identical names would point the account private endpoint at the + // Microsoft.App/environments-delegated agent subnet (PEs cannot live in a + // delegated subnet), surfacing as a confusing deploy-time failure. + if strings.EqualFold(agentName, peName) { + return nil, "", fmt.Errorf( + "%s: agentSubnet.name and peSubnet.name must differ (both subnets share one VNet)", fp("")) + } + params["agentSubnetName"] = agentName + params["agentSubnetPrefix"] = agentPrefix + params["createAgentSubnet"] = createAgent + vnetID = agentVnet + } + + params["enableNetworkIsolation"] = true + params["useManagedEgress"] = useManagedEgress + params["vnetId"] = vnetID + params["peSubnetName"] = peName + params["peSubnetPrefix"] = pePrefix + params["createPESubnet"] = createPE + params["managedIsolationMode"] = isoMode + + if net.DNS != nil { + if rg := strings.TrimSpace(net.DNS.ResourceGroup); rg != "" { + if !rgNamePattern.MatchString(rg) { + return nil, "", fmt.Errorf("%s.dns.resourceGroup: %q is not a valid resource group name", fp(""), rg) + } + params["dnsZonesResourceGroup"] = rg + } + if sub := strings.TrimSpace(net.DNS.Subscription); sub != "" { + if resolve { + resolved, err := resolveVars(sub, env) + if err != nil { + return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) + } + sub = resolved + } + // Normalize to a bare GUID only when concrete; an unexpanded ${VAR} + // (eject path) is normalized at provision time. + if containsVarRef(sub) { + params["dnsZonesSubscription"] = sub + } else { + guid, err := normalizeSubscription(sub) + if err != nil { + return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) + } + params["dnsZonesSubscription"] = guid + } + } + } + + mode := NetworkModeByo + if useManagedEgress { + mode = NetworkModeManaged + } + return params, mode, nil +} + +// resolveSubnet validates a subnet descriptor and returns the VNet id, subnet +// name, prefix, and whether azd should create the subnet. +// +// vnet + name -> reference existing subnet (create=false) +// vnet + name + prefix -> create subnet with that CIDR (create=true) +// +// vnet and name are required; ${VAR} references in vnet are expanded when +// resolve is true and validated as a Microsoft.Network/virtualNetworks id only +// when fully concrete. +func resolveSubnet( + s *subnetSpec, fieldPath string, env map[string]string, resolve bool, +) (vnetID, name, prefix string, create bool, err error) { + if s == nil { + return "", "", "", false, fmt.Errorf("%s: required", fieldPath) + } + vnetID = strings.TrimSpace(s.VNet) + name = strings.TrimSpace(s.Name) + prefix = strings.TrimSpace(s.Prefix) + + if vnetID == "" { + return "", "", "", false, fmt.Errorf("%s.vnet: required", fieldPath) + } + if name == "" { + return "", "", "", false, fmt.Errorf("%s.name: required", fieldPath) + } + if resolve { + resolved, rerr := resolveVars(vnetID, env) + if rerr != nil { + return "", "", "", false, fmt.Errorf("%s.vnet: %w", fieldPath, rerr) + } + vnetID = resolved + } + // Validate the ARM id shape only when fully concrete; an unexpanded ${VAR} + // (eject path) is validated at provision time. + if !containsVarRef(vnetID) && !vnetIDPattern.MatchString(vnetID) { + return "", "", "", false, fmt.Errorf( + "%s.vnet: %q is not a well-formed Microsoft.Network/virtualNetworks id", fieldPath, vnetID) + } + if prefix != "" { + if _, _, perr := net.ParseCIDR(prefix); perr != nil { + return "", "", "", false, fmt.Errorf("%s.prefix: %q is not a valid CIDR", fieldPath, prefix) + } + create = true + } + return vnetID, name, prefix, create, nil +} + +// sameVNet reports whether two VNet references point at the same VNet. Concrete +// ids compare case-insensitively (ARM ids are case-insensitive); unresolved +// ${VAR} references compare verbatim. +func sameVNet(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if containsVarRef(a) || containsVarRef(b) { + return a == b + } + return strings.EqualFold(a, b) +} + +// containsVarRef reports whether s still contains a ${VAR} reference. +func containsVarRef(s string) bool { + return varRefPattern.MatchString(s) +} + +// resolveVars expands ${VAR} references in s using env first, then the +// process environment. An unresolved reference is an error naming the +// variable. +func resolveVars(s string, env map[string]string) (string, error) { + var unresolved string + out := varRefPattern.ReplaceAllStringFunc(s, func(match string) string { + name := varRefPattern.FindStringSubmatch(match)[1] + if v, ok := env[name]; ok { + return v + } + if v, ok := os.LookupEnv(name); ok { + return v + } + if unresolved == "" { + unresolved = name + } + return match + }) + if unresolved != "" { + return "", fmt.Errorf("unresolved environment variable ${%s}", unresolved) + } + return out, nil +} + +// normalizeSubscription accepts a bare GUID or a /subscriptions/[/...] +// path and returns the bare GUID. +func normalizeSubscription(s string) (string, error) { + s = strings.TrimSpace(s) + if guidPattern.MatchString(s) { + return s, nil + } + if strings.HasPrefix(strings.ToLower(s), "/subscriptions/") { + parts := strings.Split(strings.Trim(s, "/"), "/") + if len(parts) >= 2 && guidPattern.MatchString(parts[1]) { + return parts[1], nil + } + } + return "", fmt.Errorf("%q is not a subscription GUID or /subscriptions/ id", s) +} 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 new file mode 100644 index 00000000000..d72300a5f80 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go @@ -0,0 +1,1381 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSynthesize(t *testing.T) { + tests := []struct { + name string + yaml string + serviceName string + + wantErr error + wantDeployLen int + wantIncludeAcr bool + // wantDeployName0, if non-empty, asserts the name of the first deployment. + wantDeployName0 string + // wantConnectionNames, if non-nil, asserts the exact names (sorted) of + // the synthesized connections. + wantConnectionNames []string + }{ + { + name: "greenfield hosted agent with docker", + yaml: ` +name: my-foundry-agent +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + sku: + capacity: 10 + name: GlobalStandard + agents: + - name: my-agent + kind: hosted + project: src/my-agent + docker: + path: Dockerfile + remoteBuild: true +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: true, + wantDeployName0: "gpt-4.1-mini", + }, + { + name: "split project with sibling docker agent => ACR on", + yaml: ` +name: my-foundry-agent +services: + my-agent: + host: azure.ai.agent + project: src/my-agent + uses: + - my-project + docker: + path: Dockerfile + remoteBuild: true + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + sku: + capacity: 10 + name: GlobalStandard +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: true, + wantDeployName0: "gpt-4.1-mini", + }, + { + name: "split project with sibling docker agent and image => no ACR", + yaml: ` +services: + my-agent: + host: azure.ai.agent + project: src/my-agent + uses: + - my-project + image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + path: Dockerfile + remoteBuild: true + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, + { + name: "legacy inline docker agent with image => no ACR", + yaml: ` +services: + my-project: + host: azure.ai.project + agents: + - name: my-agent + kind: hosted + image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + path: Dockerfile +`, + serviceName: "my-project", + wantDeployLen: 0, + wantIncludeAcr: false, + }, + { + name: "greenfield hosted agent runtime-only (no docker) => ACR on", + yaml: ` +name: my-foundry-agent +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + sku: + capacity: 10 + name: GlobalStandard + agents: + - name: my-agent + kind: hosted + project: src/my-agent + runtime: + stack: python + version: "3.12" +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: true, + }, + { + // Schema-conformant hand-authored shape (see schemas/examples/simple.azure.yaml): + // hosted agent built from source with no docker:/image:/codeConfiguration:. + name: "schema-conformant hosted agent, no docker => ACR on", + yaml: ` +services: + assistant: + host: azure.ai.agent + project: ./agents/assistant + kind: hosted + name: assistant + uses: + - ai-project + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "ai-project", + wantDeployLen: 1, + wantIncludeAcr: true, + }, + { + name: "sibling hosted agent, kind omitted defaults hosted => ACR on", + yaml: ` +services: + assistant: + host: azure.ai.agent + project: ./agents/assistant + name: assistant + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "ai-project", + wantDeployLen: 1, + wantIncludeAcr: true, + }, + { + name: "sibling hosted agent with codeConfiguration => no ACR", + yaml: ` +services: + assistant: + host: azure.ai.agent + project: ./agents/assistant + kind: hosted + name: assistant + codeConfiguration: + runtime: python_3_13 + entryPoint: app.py + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "ai-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, + { + name: "sibling hosted agent with image, no docker => no ACR", + yaml: ` +services: + assistant: + host: azure.ai.agent + project: ./agents/assistant + kind: hosted + name: assistant + image: myprivacr.azurecr.io/agents/assistant:v1 + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "ai-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, + { + name: "inline hosted agent with codeConfiguration => no ACR", + yaml: ` +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} + agents: + - name: my-agent + kind: hosted + project: src/my-agent + codeConfiguration: + runtime: dotnet_10 + entryPoint: MyAgent.dll +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, + { + name: "prompt-only agent (no project/runtime/docker)", + yaml: ` +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + sku: + capacity: 10 + name: GlobalStandard + agents: + - name: triage-agent + kind: prompt + instructions: route the user +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, + { + name: "mixed: one runtime agent and one docker agent => ACR on", + yaml: ` +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1 + model: + format: OpenAI + name: gpt-4.1 + version: "2025-04-14" + sku: + capacity: 50 + name: GlobalStandard + agents: + - name: support-agent + kind: hosted + project: src/support-agent + runtime: {stack: python, version: "3.12"} + - name: research-agent + kind: hosted + project: src/research-agent + docker: {path: Dockerfile, remoteBuild: true} +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: true, + }, + { + name: "no deployments declared => empty array, not nil", + yaml: ` +services: + my-project: + host: azure.ai.project + agents: + - name: prompt-agent + kind: prompt + instructions: hi +`, + serviceName: "my-project", + wantDeployLen: 0, + wantIncludeAcr: false, + }, + { + name: "ignores inline connections/toolboxes/skills on the project (deploy-time concerns)", + yaml: ` +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} + connections: + - name: github-mcp-conn + category: CustomKeys + target: https://api.githubcopilot.com/mcp + authType: CustomKeys + toolboxes: + - name: t1 + tools: [{type: web_search}] + skills: + - name: s1 + instructions: hi + routines: + - name: r1 + agent: prompt-agent + trigger: {type: schedule, cron: "0 8 * * *"} + agents: + - name: prompt-agent + kind: prompt + instructions: hi +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: false, + wantConnectionNames: []string{}, + }, + { + name: "collects sibling azure.ai.connection services (sorted by name)", + yaml: ` +services: + my-project: + host: azure.ai.project + search-conn: + host: azure.ai.connection + uses: [my-project] + category: CognitiveSearch + target: https://my-search.search.windows.net + authType: ApiKey + credentials: + key: static-key + bing-conn: + host: azure.ai.connection + uses: [my-project] + category: ApiKey + target: https://api.bing.microsoft.com + authType: ApiKey +`, + serviceName: "my-project", + wantDeployLen: 0, + wantIncludeAcr: false, + wantConnectionNames: []string{"bing-conn", "search-conn"}, + }, + { + name: "no connections yields empty slice", + yaml: ` +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "my-project", + wantDeployLen: 1, + wantConnectionNames: []string{}, + }, + { + name: "brownfield: endpoint set => ErrEndpointBrownfield", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "my-project", + wantErr: ErrEndpointBrownfield, + }, + { + name: "brownfield: endpoint + network => network ignored, still ErrEndpointBrownfield", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + network: + peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} +`, + serviceName: "my-project", + wantErr: ErrEndpointBrownfield, + }, + { + name: "blank endpoint is treated as greenfield", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: " " +`, + serviceName: "my-project", + }, + { + name: "service not found", + yaml: ` +services: + my-project: + host: azure.ai.project +`, + serviceName: "nope", + wantErr: ErrServiceNotFound, + }, + { + name: "wrong host treated as not found", + yaml: ` +services: + webapp: + host: containerapp + project: src/web +`, + serviceName: "webapp", + wantErr: ErrServiceNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: []byte(tt.yaml), + ServiceName: tt.serviceName, + AcceptedHosts: []string{"azure.ai.project"}, + }) + + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr), "got %v, want %v", err, tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, res) + + deployments, ok := res.Parameters["deployments"].([]Deployment) + require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) + assert.Len(t, deployments, tt.wantDeployLen) + if tt.wantDeployName0 != "" { + require.NotEmpty(t, deployments) + assert.Equal(t, tt.wantDeployName0, deployments[0].Name) + } + + includeAcr, ok := res.Parameters["includeAcr"].(bool) + require.True(t, ok, "includeAcr param should be bool") + assert.Equal(t, tt.wantIncludeAcr, includeAcr) + + connections, ok := res.Parameters["connections"].([]Connection) + require.True(t, ok, "connections param should be []Connection, got %T", res.Parameters["connections"]) + if tt.wantConnectionNames != nil { + gotNames := make([]string, len(connections)) + for i, c := range connections { + gotNames[i] = c.Name + } + assert.Equal(t, tt.wantConnectionNames, gotNames) + } + }) + } +} + +// TestSynthesize_Connections covers the ${VAR} resolve-vs-preserve behavior for +// connection target and credential values, mirroring the network path. +func TestSynthesize_Connections(t *testing.T) { + const yaml = ` +services: + my-project: + host: azure.ai.project + mcp-conn: + host: azure.ai.connection + uses: [my-project] + category: RemoteTool + target: ${MCP_URL} + authType: CustomKeys + credentials: + keys: + x-api-key: ${MCP_KEY} + metadata: + owner: ${MCP_OWNER} +` + env := map[string]string{ + "MCP_URL": "https://mcp.example.com/mcp", + "MCP_KEY": "secret-value", + "MCP_OWNER": "team-ai", + } + + getConn := func(t *testing.T, res *Result) Connection { + t.Helper() + conns, ok := res.Parameters["connections"].([]Connection) + require.True(t, ok) + require.Len(t, conns, 1) + return conns[0] + } + + t.Run("provision path resolves ${VAR}", func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: env, + }) + require.NoError(t, err) + + c := getConn(t, res) + assert.Equal(t, "https://mcp.example.com/mcp", c.Target) + keys, ok := c.Credentials["keys"].(map[string]any) + require.True(t, ok, "keys should be a nested map, got %T", c.Credentials["keys"]) + assert.Equal(t, "secret-value", keys["x-api-key"]) + assert.Equal(t, "team-ai", c.Metadata["owner"]) + }) + + t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: env, + PreserveVarRefs: true, + }) + require.NoError(t, err) + + c := getConn(t, res) + assert.Equal(t, "${MCP_URL}", c.Target) + keys, ok := c.Credentials["keys"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "${MCP_KEY}", keys["x-api-key"]) + assert.Equal(t, "${MCP_OWNER}", c.Metadata["owner"]) + }) + + t.Run("Foundry ${{...}} expressions survive provision-path expansion", func(t *testing.T) { + const serverSideYAML = ` +services: + my-project: + host: azure.ai.project + mcp-conn: + host: azure.ai.connection + uses: [my-project] + category: RemoteTool + target: https://mcp.example.com/mcp + authType: CustomKeys + credentials: + keys: + x-api-key: ${{connections.other.credentials.key}} +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(serverSideYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: env, + }) + require.NoError(t, err) + + c := getConn(t, res) + keys := c.Credentials["keys"].(map[string]any) + assert.Equal(t, "${{connections.other.credentials.key}}", keys["x-api-key"]) + }) + + t.Run("missing ${VAR} on provision path resolves to empty (matches deploy-time ExpandEnv)", func(t *testing.T) { + // foundry.ExpandEnv (drone/envsubst) treats an unset variable as empty + // rather than an error, matching the deploy-time azure.ai.connection + // service target's resolveConnectionEnv. A missing secret therefore + // yields an empty value, not a synthesis failure. + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{}, // nothing set + }) + require.NoError(t, err) + + c := getConn(t, res) + assert.Equal(t, "", c.Target) + keys := c.Credentials["keys"].(map[string]any) + assert.Equal(t, "", keys["x-api-key"]) + }) +} + +// TestBrownfieldConnections verifies connection services are collected for a +// brownfield (endpoint:) project, with ${VAR} resolved (brownfield provisions +// so references must be concrete) and Foundry ${{...}} preserved. +func TestBrownfieldConnections(t *testing.T) { + const yaml = ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + search-conn: + host: azure.ai.connection + uses: [my-project] + category: CognitiveSearch + target: https://my-search.search.windows.net + authType: ApiKey + credentials: + key: ${SEARCH_API_KEY} + bing-conn: + host: azure.ai.connection + uses: [my-project] + category: ApiKey + target: https://api.bing.microsoft.com + authType: ApiKey +` + + t.Run("collects and resolves connections (sorted)", func(t *testing.T) { + conns, err := BrownfieldConnections([]byte(yaml), map[string]string{"SEARCH_API_KEY": "secret"}) + require.NoError(t, err) + require.Len(t, conns, 2) + assert.Equal(t, "bing-conn", conns[0].Name) + assert.Equal(t, "search-conn", conns[1].Name) + assert.Equal(t, "CognitiveSearch", conns[1].Category) + assert.Equal(t, "secret", conns[1].Credentials["key"]) + }) + + t.Run("no connection services yields empty slice", func(t *testing.T) { + const noConns = ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 +` + conns, err := BrownfieldConnections([]byte(noConns), nil) + require.NoError(t, err) + assert.Empty(t, conns) + }) + + t.Run("empty raw errors", func(t *testing.T) { + _, err := BrownfieldConnections(nil, nil) + require.Error(t, err) + }) +} + +func TestBrownfieldDeployments(t *testing.T) { + tests := []struct { + name string + yaml string + serviceName string + + wantErr error + wantLen int + wantName0 string + wantVersion string + }{ + { + name: "endpoint set with deployments returns them", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + deployments: + - name: gpt-4.1-mini-new + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "my-project", + wantLen: 1, + wantName0: "gpt-4.1-mini-new", + wantVersion: "2025-04-14", + }, + { + name: "endpoint set, multiple deployments", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + deployments: + - name: gpt-4.1 + model: {format: OpenAI, name: gpt-4.1, version: "2025-04-14"} + sku: {capacity: 50, name: GlobalStandard} + - name: text-embedding-3-large + model: {format: OpenAI, name: text-embedding-3-large, version: "1"} + sku: {capacity: 120, name: Standard} +`, + serviceName: "my-project", + wantLen: 2, + wantName0: "gpt-4.1", + }, + { + name: "endpoint set, no deployments => nil", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 +`, + serviceName: "my-project", + wantLen: 0, + }, + { + name: "service not found", + yaml: ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 +`, + serviceName: "nope", + wantErr: ErrServiceNotFound, + }, + { + name: "empty service name", + yaml: "services: {}", + serviceName: "", + wantErr: nil, // returns a non-typed error; asserted below + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BrownfieldDeployments([]byte(tt.yaml), tt.serviceName) + + if tt.serviceName == "" { + require.Error(t, err) + return + } + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr), "got %v, want %v", err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Len(t, got, tt.wantLen) + if tt.wantName0 != "" { + require.NotEmpty(t, got) + assert.Equal(t, tt.wantName0, got[0].Name) + } + if tt.wantVersion != "" { + require.NotEmpty(t, got) + assert.Equal(t, tt.wantVersion, got[0].Model.Version) + } + }) + } +} + +func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { + _, err := BrownfieldDeployments(nil, "my-project") + require.Error(t, err) +} + +func TestSynthesize_NetworkPreserveVarRefs(t *testing.T) { + // Eject path: ${VAR} references must pass through verbatim (and skip the + // format checks that cannot run on an unexpanded placeholder), so the + // ejected main.parameters.json stays environment-portable. + yaml := ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} + dns: + resourceGroup: rg-dns + subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + PreserveVarRefs: true, + }) + require.NoError(t, err, "unset ${VAR} must not fail on the eject path") + require.NotNil(t, res) + assert.Equal(t, "${AZURE_VNET_ID}", res.Parameters["vnetId"]) + assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", res.Parameters["dnsZonesSubscription"]) + assert.Equal(t, "rg-dns", res.Parameters["dnsZonesResourceGroup"]) +} + +func TestSynthesize_NetworkPreserveVarRefs_StillValidatesConcrete(t *testing.T) { + // PreserveVarRefs only skips checks for unexpanded placeholders; a + // concrete-but-malformed value still fails on the eject path. + yaml := ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: not-an-arm-id, name: pe-subnet} +` + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + PreserveVarRefs: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a well-formed") +} + +func TestSynthesize_ResolvesDeploymentRef(t *testing.T) { + // A deployment item authored as a $ref must be loaded so synthesis sees the + // real deployment, not a zero-valued {"$ref": ...} placeholder. + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "deployments"), 0750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "deployments", "gpt-4o.yaml"), []byte( + "name: gpt-4o\nmodel:\n name: gpt-4o\n format: OpenAI\n version: \"2024-08-06\"\nsku:\n name: Standard\n capacity: 10\n"), + 0600)) + + yaml := ` +services: + my-project: + host: azure.ai.project + deployments: + - $ref: ./deployments/gpt-4o.yaml +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + require.NoError(t, err) + deployments, ok := res.Parameters["deployments"].([]Deployment) + require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) + assert.Equal(t, "gpt-4o", deployments[0].Model.Name) + assert.Equal(t, 10, deployments[0].Sku.Capacity) +} + +func TestSynthesize_InputValidation(t *testing.T) { + tests := []struct { + name string + in Input + want string + }{ + { + name: "empty yaml", + in: Input{ServiceName: "x"}, + want: "RawAzureYAML is empty", + }, + { + name: "empty service name", + in: Input{RawAzureYAML: []byte("services:\n x:\n host: azure.ai.project\n")}, + want: "ServiceName is empty", + }, + { + name: "malformed yaml", + in: Input{ + RawAzureYAML: []byte("services: [this is not a map"), + ServiceName: "x", + }, + want: "parse azure.yaml", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Synthesize(tt.in) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +func TestTemplatesFS_Embedded(t *testing.T) { + fs := TemplatesFS() + + wantFiles := []string{ + "templates/main.bicep", + "templates/main.arm.json", + "templates/abbreviations.json", + "templates/modules/acr.bicep", + "templates/modules/connections.bicep", + "templates/modules/network.bicep", + "templates/modules/subnet.bicep", + "templates/modules/private-endpoint-dns.bicep", + } + for _, p := range wantFiles { + t.Run(p, func(t *testing.T) { + data, err := fs.ReadFile(p) + require.NoError(t, err) + assert.NotEmpty(t, data, "%s should not be empty", p) + }) + } +} + +func TestTerraformTemplatesFS_Embedded(t *testing.T) { + fs := TerraformTemplatesFS() + + wantFiles := []string{ + "templates/terraform/provider.tf", + "templates/terraform/variables.tf", + "templates/terraform/main.tf", + "templates/terraform/acr.tf", + "templates/terraform/outputs.tf.tmpl", + } + for _, p := range wantFiles { + t.Run(p, func(t *testing.T) { + data, err := fs.ReadFile(p) + require.NoError(t, err) + assert.NotEmpty(t, data, "%s should not be empty", p) + }) + } + + // 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). + for _, p := range []string{ + "templates/terraform/outputs.tf", + "templates/terraform/main.tfvars.json", + } { + _, err := fs.ReadFile(p) + assert.Error(t, err, "%s must not be embedded; it is generated at eject time", p) + } +} + +// TestTerraformModule_DerivesNamesWhenEmpty guards the regression where unset +// AZURE_AI_PROJECT_NAME / AZURE_RESOURCE_GROUP substituted to "" in +// main.tfvars.json and failed at plan time (foundry_project_name validation / +// "name cannot be blank" on the resource group). The fix: main.tf derives both +// names from environment_name when the corresponding var is empty. This asserts +// the embedded templates still carry those fallbacks so they cannot regress. +func TestTerraformModule_DerivesNamesWhenEmpty(t *testing.T) { + fs := TerraformTemplatesFS() + + vars, err := fs.ReadFile("templates/terraform/variables.tf") + require.NoError(t, err) + // Empty must be accepted by the variable validation (not a hard 3-32 regex). + assert.Contains(t, string(vars), `var.foundry_project_name == ""`, + "variables.tf must allow an empty foundry_project_name (empty => derive from env)") + + main, err := fs.ReadFile("templates/terraform/main.tf") + require.NoError(t, err) + // main.tf must compute an effective project name with an env-name fallback. + assert.Contains(t, string(main), "derived_project_name", + "main.tf must derive a project name when foundry_project_name is empty") + assert.Contains(t, string(main), "local.foundry_project_name", + "the project resource must use the derived local, not the raw variable") + // main.tf must compute an effective resource group name with a fallback. + assert.Contains(t, string(main), "local.resource_group_name", + "the resource group must use the derived local, not the raw variable") + assert.Contains(t, string(main), `"rg-${local.derived_rg_suffix}"`, + "main.tf must derive an rg-{env} name when resource_group_name is empty") +} + +func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { + data, err := ARMTemplate() + require.NoError(t, err) + require.NotEmpty(t, data) + + var arm map[string]any + require.NoError(t, json.Unmarshal(data, &arm), "ARM template must be valid JSON") + + // Sanity-check the ARM document is what we expect. + assert.Contains(t, arm, "$schema") + assert.Contains(t, arm, "resources") + assert.Contains(t, arm, "parameters") + + // The template is subscription-scoped so `azd provision --preview` can run + // what-if without creating the resource group first. + schema, _ := arm["$schema"].(string) + assert.Contains(t, schema, "subscriptionDeploymentTemplate", + "main.bicep must target subscription scope") + + // resourceGroupName is the parameter that drives the resource group the + // template creates; the provider supplies it at provision time. + params, ok := arm["parameters"].(map[string]any) + require.True(t, ok, "parameters must be an object") + assert.Contains(t, params, "resourceGroupName") + + // connections carries the synthesized host: azure.ai.connection services so + // the connections module can create them at provision time. + assert.Contains(t, params, "connections", "connections param must be declared in the ARM template") + + // Network isolation parameters must exist so the synthesizer's network + // param set is accepted by ARM (extra params would fail the deployment). + for _, p := range []string{ + "enableNetworkIsolation", "useManagedEgress", "vnetId", + "agentSubnetName", "agentSubnetPrefix", "createAgentSubnet", + "peSubnetName", "peSubnetPrefix", "createPESubnet", + "managedIsolationMode", "dnsZonesResourceGroup", "dnsZonesSubscription", + } { + assert.Contains(t, params, p, "network param %q must be declared in the ARM template", p) + } + + // The old mode-enum param must be gone; egress is driven by useManagedEgress. + assert.NotContains(t, params, "networkMode", + "networkMode param was replaced by useManagedEgress") + + // Secure-by-default lock: the account data plane must be private whenever + // network isolation is on. The compiled template must gate public access on + // enableNetworkIsolation (not on egress mode), so a network-bound account is + // never left public. This is the regression guard for the data-plane fix. + text := string(data) + wantDisable := `"disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]"` + wantPublic := `"publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]"` + assert.Contains(t, text, wantDisable, + "public data-plane access must be disabled for every network-isolated account") + assert.Contains(t, text, wantPublic, + "account publicNetworkAccess must follow disablePublicDataPlaneAccess") + + // Egress injection shape: byo injects into the customer subnet + // (useMicrosoftManagedNetwork=false), managed uses the Microsoft-managed + // network (useMicrosoftManagedNetwork=true). Both branches must survive + // compilation so the account gets the right networkInjections per mode. + assert.Contains(t, text, "'useMicrosoftManagedNetwork', false()", + "byo egress must inject the agent subnet (useMicrosoftManagedNetwork=false)") + assert.Contains(t, text, "'useMicrosoftManagedNetwork', true()", + "managed egress must use the Microsoft-managed network (useMicrosoftManagedNetwork=true)") + assert.Contains(t, text, `"networkInjections": "[variables('agentNetworkInjections')]"`, + "account must carry the computed networkInjections") + + // isolationMode must be wired to the V2 managed network child resource + // (regression guard: it was previously a no-op echoed only to output). + assert.Contains(t, text, `"type": "Microsoft.CognitiveServices/accounts/managedNetworks"`, + "managed isolationMode must provision a managedNetworks child resource") + assert.Contains(t, text, `"isolationMode": "[parameters('managedIsolationMode')]"`, + "managedNetworks isolationMode must come from the managedIsolationMode param") +} + +func TestSynthesize_Network(t *testing.T) { + t.Setenv("AZURE_VNET_ID", + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/"+ + "providers/Microsoft.Network/virtualNetworks/my-vnet") + + const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/my-vnet" + + tests := []struct { + name string + yaml string + wantMode string + check func(t *testing.T, p map[string]any) + }{ + { + name: "no network block => public account, isolation off", + yaml: ` +services: + my-project: + host: azure.ai.project + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} +`, + wantMode: NetworkModeNone, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, false, p["enableNetworkIsolation"]) + assert.Equal(t, false, p["useManagedEgress"]) + }, + }, + { + name: "byo egress (agentSubnet present) with explicit subnets => create both", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + agentSubnet: {vnet: ` + validVNet + `, name: agent-subnet, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} + dns: + resourceGroup: rg-private-dns + subscription: 22222222-2222-2222-2222-222222222222 +`, + wantMode: NetworkModeByo, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["enableNetworkIsolation"]) + assert.Equal(t, false, p["useManagedEgress"]) + assert.Equal(t, validVNet, p["vnetId"]) + assert.Equal(t, "agent-subnet", p["agentSubnetName"]) + assert.Equal(t, "192.168.0.0/24", p["agentSubnetPrefix"]) + assert.Equal(t, true, p["createAgentSubnet"]) + assert.Equal(t, true, p["createPESubnet"]) + assert.Equal(t, "rg-private-dns", p["dnsZonesResourceGroup"]) + assert.Equal(t, "22222222-2222-2222-2222-222222222222", p["dnsZonesSubscription"]) + }, + }, + { + name: "subnet without prefix => reference (create=false)", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + agentSubnet: {vnet: ` + validVNet + `, name: existing-agent} + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} +`, + wantMode: NetworkModeByo, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, "existing-agent", p["agentSubnetName"]) + assert.Equal(t, false, p["createAgentSubnet"]) + assert.Equal(t, "pe-subnet", p["peSubnetName"]) + assert.Equal(t, true, p["createPESubnet"]) + }, + }, + { + name: "subnet vnet from ${VAR}", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Contains(t, p["vnetId"], "/virtualNetworks/my-vnet") + }, + }, + { + name: "managed egress (agentSubnet absent) with isolation", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + isolationMode: AllowOnlyApprovedOutbound + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["enableNetworkIsolation"]) + assert.Equal(t, true, p["useManagedEgress"]) + assert.Equal(t, false, p["createAgentSubnet"]) + assert.Equal(t, "AllowOnlyApprovedOutbound", p["managedIsolationMode"]) + }, + }, + { + name: "dns subscription normalized from /subscriptions/", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} + dns: + resourceGroup: rg-dns + subscription: /subscriptions/33333333-3333-3333-3333-333333333333 +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, "33333333-3333-3333-3333-333333333333", p["dnsZonesSubscription"]) + }, + }, + { + name: "managed egress, isolationMode unset => empty managedIsolationMode", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["useManagedEgress"]) + assert.Equal(t, "", p["managedIsolationMode"]) + assert.Equal(t, true, p["createPESubnet"]) + }, + }, + { + name: "managed egress, AllowInternetOutbound with referenced peSubnet", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + isolationMode: AllowInternetOutbound + peSubnet: {vnet: ` + validVNet + `, name: existing-pe} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["useManagedEgress"]) + assert.Equal(t, "AllowInternetOutbound", p["managedIsolationMode"]) + assert.Equal(t, "existing-pe", p["peSubnetName"]) + assert.Equal(t, false, p["createPESubnet"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: []byte(tt.yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, tt.wantMode, res.NetworkMode) + if tt.check != nil { + tt.check(t, res.Parameters) + } + }) + } +} + +func TestSynthesize_NetworkValidationErrors(t *testing.T) { + const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/my-vnet" + const validVNet2 = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/other-vnet" + + tests := []struct { + name string + yaml string + wantSub string + }{ + { + name: "network present but peSubnet missing", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + isolationMode: AllowInternetOutbound +`, + wantSub: "private networking requires peSubnet", + }, + { + name: "isolationMode with agentSubnet present", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + isolationMode: AllowInternetOutbound + agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: 192.168.1.0/24} +`, + wantSub: "only valid for managed egress", + }, + { + name: "agentSubnet and peSubnet in different vnets", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet2 + `, name: pe, prefix: 192.168.1.0/24} +`, + wantSub: "same virtual network", + }, + { + name: "agentSubnet and peSubnet share a name in one vnet", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + agentSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.1.0/24} +`, + wantSub: "agentSubnet.name and peSubnet.name must differ", + }, + { + name: "subnet missing vnet", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {name: pe} +`, + wantSub: "peSubnet.vnet: required", + }, + { + name: "subnet missing name", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: ` + validVNet + `} +`, + wantSub: "peSubnet.name: required", + }, + { + name: "malformed vnet id", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: not-an-arm-id, name: pe} +`, + wantSub: "not a well-formed", + }, + { + name: "subnet invalid cidr", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: not-a-cidr} +`, + wantSub: "not a valid CIDR", + }, + { + name: "unresolved var", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: "${DEFINITELY_NOT_SET_VAR_XYZ}", name: pe} +`, + wantSub: "unresolved environment variable", + }, + { + name: "bad managed isolation mode", + yaml: ` +services: + my-project: + host: azure.ai.project + network: + isolationMode: Wide + peSubnet: {vnet: ` + validVNet + `, name: pe} +`, + wantSub: "isolationMode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Synthesize(Input{ + RawAzureYAML: []byte(tt.yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantSub) + // Errors carry the service-scoped field path. + assert.Contains(t, err.Error(), "services.my-project.network") + }) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json new file mode 100644 index 00000000000..8e6ed84df9f --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json @@ -0,0 +1,4 @@ +{ + "cognitiveServicesAccounts": "cog-", + "containerRegistryRegistries": "cr" +} 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 new file mode 100644 index 00000000000..942b2761a89 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json @@ -0,0 +1,280 @@ +{ + "$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": "13884339769371824597" + } + }, + "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "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)." + } + } + }, + "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" + } + }, + "foundryAcrPull": { + "condition": "[parameters('includeAcr')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + }, + "dependsOn": [ + "foundryAccountPreview::project", + "registry" + ] + }, + "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + } + }, + "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 new file mode 100644 index 00000000000..fd93a5cddea --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep @@ -0,0 +1,186 @@ +// 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 + credentials: object? + 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 = [] + +// 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' +) + +// Grant the existing project's managed identity AcrPull on the new registry so +// the hosted agent can pull images using the project identity. +resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (includeAcr) { + name: guid(registry.id, foundryAccountPreview::project.id, acrPullRoleId) + scope: registry + properties: { + principalId: foundryAccountPreview::project.identity.principalId + principalType: 'ServicePrincipal' + 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 + }, + c.?credentials != null ? { credentials: c.?credentials } : {}, + 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/main.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json new file mode 100644 index 00000000000..61f627f1fda --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json @@ -0,0 +1,1652 @@ +{ + "$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.44.1.10279", + "templateHash": "11064647930674610643" + } + }, + "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region for all resources." + } + }, + "resourceGroupName": { + "type": "string", + "minLength": 1, + "maxLength": 90, + "metadata": { + "description": "Name of the resource group to create and deploy resources into." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Tags applied to all resources." + } + }, + "resourceTokenSalt": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional salt to vary resource names across re-provisions." + } + }, + "foundryProjectName": { + "type": "string", + "minLength": 3, + "maxLength": 32, + "metadata": { + "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." + } + }, + "deployments": { + "$ref": "#/definitions/deploymentsType", + "defaultValue": [], + "metadata": { + "description": "Model deployments to provision on the Foundry account." + } + }, + "includeAcr": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Include an Azure Container Registry. Set true when any agent uses docker:." + } + }, + "connections": { + "$ref": "#/definitions/connectionsType", + "defaultValue": [], + "metadata": { + "description": "Foundry project connections to create (host: azure.ai.connection services)." + } + }, + "principalId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail." + } + }, + "principalType": { + "type": "string", + "defaultValue": "User", + "metadata": { + "description": "Principal type used in the developer role assignment." + } + }, + "enableNetworkIsolation": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Master switch: when true the account is VNet-bound (private)." + } + }, + "useManagedEgress": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "ARM id of the existing customer VNet (byo mode)." + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "Agent (delegated) subnet name." + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Agent subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createAgentSubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the agent subnet; when false, reference it." + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "Private-endpoint subnet name." + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createPESubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the PE subnet; when false, reference it." + } + }, + "managedIsolationMode": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Managed-network isolation mode (managed mode)." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } + } + }, + "resources": { + "resourceGroup": { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2021-04-01", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]" + }, + "resources": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-resources", + "resourceGroup": "[parameters('resourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "resourceTokenSalt": { + "value": "[parameters('resourceTokenSalt')]" + }, + "foundryProjectName": { + "value": "[parameters('foundryProjectName')]" + }, + "deployments": { + "value": "[parameters('deployments')]" + }, + "includeAcr": { + "value": "[parameters('includeAcr')]" + }, + "connections": { + "value": "[parameters('connections')]" + }, + "principalId": { + "value": "[parameters('principalId')]" + }, + "principalType": { + "value": "[parameters('principalType')]" + }, + "enableNetworkIsolation": { + "value": "[parameters('enableNetworkIsolation')]" + }, + "useManagedEgress": { + "value": "[parameters('useManagedEgress')]" + }, + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "createAgentSubnet": { + "value": "[parameters('createAgentSubnet')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + }, + "createPESubnet": { + "value": "[parameters('createPESubnet')]" + }, + "managedIsolationMode": { + "value": "[parameters('managedIsolationMode')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" + } + }, + "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.44.1.10279", + "templateHash": "8482090354551809818" + } + }, + "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Azure region for all resources." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Tags applied to all resources." + } + }, + "resourceTokenSalt": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional salt to vary resource names across re-provisions." + } + }, + "foundryProjectName": { + "type": "string", + "minLength": 3, + "maxLength": 32, + "metadata": { + "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." + } + }, + "deployments": { + "$ref": "#/definitions/deploymentsType", + "defaultValue": [], + "metadata": { + "description": "Model deployments to provision on the Foundry account." + } + }, + "includeAcr": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Include an Azure Container Registry. Set true when any agent uses docker:." + } + }, + "connections": { + "$ref": "#/definitions/connectionsType", + "defaultValue": [], + "metadata": { + "description": "Foundry project connections to create (host: azure.ai.connection services)." + } + }, + "principalId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail." + } + }, + "principalType": { + "type": "string", + "defaultValue": "User", + "metadata": { + "description": "Principal type used in the developer role assignment." + } + }, + "enableNetworkIsolation": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Master switch: when true the account is VNet-bound (private)." + } + }, + "useManagedEgress": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "ARM id of the existing customer VNet (byo mode)." + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "Agent (delegated) subnet name." + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Agent subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createAgentSubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the agent subnet; when false, reference it." + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "Private-endpoint subnet name." + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createPESubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the PE subnet; when false, reference it." + } + }, + "managedIsolationMode": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } + } + }, + "variables": { + "$fxv#0": { + "cognitiveServicesAccounts": "cog-", + "containerRegistryRegistries": "cr" + }, + "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", + "abbrs": "[variables('$fxv#0')]", + "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", + "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", + "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", + "disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]", + "cognitiveServicesUserRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", + "agentSubnetArmId": "[format('{0}/subnets/{1}', parameters('vnetId'), parameters('agentSubnetName'))]", + "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]" + }, + "resources": { + "foundryAccount::modelDeployments": { + "copy": { + "name": "foundryAccount::modelDeployments", + "count": "[length(parameters('deployments'))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('deployments')[copyIndex()].name)]", + "properties": { + "model": "[parameters('deployments')[copyIndex()].model]" + }, + "sku": "[parameters('deployments')[copyIndex()].sku]", + "dependsOn": [ + "foundryAccount" + ] + }, + "foundryAccount::project": { + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "description": "[format('{0} Project', parameters('foundryProjectName'))]", + "displayName": "[parameters('foundryProjectName')]" + }, + "dependsOn": [ + "foundryAccount", + "foundryAccount::modelDeployments" + ] + }, + "foundryAccount": { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[variables('foundryAccountName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "S0" + }, + "kind": "AIServices", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "allowProjectManagement": true, + "customSubDomainName": "[variables('foundryAccountName')]", + "publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]", + "disableLocalAuth": true, + "networkAcls": { + "defaultAction": "[if(variables('disablePublicDataPlaneAccess'), 'Deny', 'Allow')]", + "bypass": "[if(variables('disablePublicDataPlaneAccess'), 'AzureServices', null())]", + "virtualNetworkRules": [], + "ipRules": [] + }, + "networkInjections": "[variables('agentNetworkInjections')]" + }, + "dependsOn": [ + "network" + ] + }, + "foundryManagedNetwork": { + "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/{1}', variables('foundryAccountName'), 'default')]", + "properties": { + "managedNetwork": { + "isolationMode": "[parameters('managedIsolationMode')]" + } + }, + "dependsOn": [ + "foundryAccount" + ] + }, + "developerCognitiveServicesUser": { + "condition": "[not(empty(parameters('principalId')))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName')), parameters('principalId'), variables('cognitiveServicesUserRoleId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "[parameters('principalType')]", + "roleDefinitionId": "[variables('cognitiveServicesUserRoleId')]" + }, + "dependsOn": [ + "foundryAccount::project" + ] + }, + "network": { + "condition": "[parameters('enableNetworkIsolation')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-network", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "createAgentSubnet": { + "value": "[parameters('createAgentSubnet')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + }, + "createPESubnet": { + "value": "[parameters('createPESubnet')]" + } + }, + "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": "11653429655583605398" + } + }, + "parameters": { + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the existing customer VNet." + } + }, + "agentSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the agent (delegated) subnet." + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "CIDR for the agent subnet. Empty derives a /24 from the VNet space." + } + }, + "createAgentSubnet": { + "type": "bool", + "metadata": { + "description": "When true, create the agent subnet; when false, reference it." + } + }, + "peSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the private-endpoint subnet." + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space." + } + }, + "createPESubnet": { + "type": "bool", + "metadata": { + "description": "When true, create the PE subnet; when false, reference it." + } + } + }, + "variables": { + "vnetParts": "[split(parameters('vnetId'), '/')]", + "vnetSubscriptionId": "[variables('vnetParts')[2]]", + "vnetResourceGroupName": "[variables('vnetParts')[4]]", + "vnetName": "[last(variables('vnetParts'))]" + }, + "resources": [ + { + "condition": "[parameters('createAgentSubnet')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", + "subscriptionId": "[variables('vnetSubscriptionId')]", + "resourceGroup": "[variables('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[variables('vnetName')]" + }, + "subnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", + "delegations": { + "value": [ + { + "name": "Microsoft.App/environments", + "properties": { + "serviceName": "Microsoft.App/environments" + } + } + ] + } + }, + "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": "9706203844896299767" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network the subnet belongs to." + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet to create." + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "CIDR for the subnet." + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Subnet delegations (e.g. Microsoft.App/environments for the agent subnet)." + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + } + }, + { + "condition": "[parameters('createPESubnet')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", + "subscriptionId": "[variables('vnetSubscriptionId')]", + "resourceGroup": "[variables('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[variables('vnetName')]" + }, + "subnetName": { + "value": "[parameters('peSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", + "delegations": { + "value": [] + } + }, + "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": "9706203844896299767" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network the subnet belongs to." + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet to create." + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "CIDR for the subnet." + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Subnet delegations (e.g. Microsoft.App/environments for the agent subnet)." + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName'))))]" + ] + } + ], + "outputs": { + "vnetId": { + "type": "string", + "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName'))]" + }, + "vnetName": { + "type": "string", + "value": "[variables('vnetName')]" + }, + "vnetSubscriptionId": { + "type": "string", + "value": "[variables('vnetSubscriptionId')]" + }, + "vnetResourceGroupName": { + "type": "string", + "value": "[variables('vnetResourceGroupName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), parameters('agentSubnetName'))]" + }, + "peSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), parameters('peSubnetName'))]" + }, + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + } + } + } + } + }, + "acr": { + "condition": "[parameters('includeAcr')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "acr", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "name": { + "value": "[format('{0}{1}', variables('abbrs').containerRegistryRegistries, variables('resourceToken'))]" + }, + "foundryAccountName": { + "value": "[variables('foundryAccountName')]" + }, + "foundryProjectName": { + "value": "[parameters('foundryProjectName')]" + }, + "foundryProjectPrincipalId": { + "value": "[reference('foundryAccount::project', '2025-06-01', 'full').identity.principalId]" + }, + "enableNetworkIsolation": { + "value": "[parameters('enableNetworkIsolation')]" + } + }, + "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": "2948233716175969636" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Tags applied to all resources." + } + }, + "name": { + "type": "string", + "minLength": 5, + "maxLength": 50, + "metadata": { + "description": "Registry name. 5-50 alphanumeric chars." + } + }, + "foundryAccountName": { + "type": "string", + "metadata": { + "description": "Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection." + } + }, + "foundryProjectName": { + "type": "string", + "metadata": { + "description": "Name of the existing Foundry project receiving the ACR connection." + } + }, + "foundryProjectPrincipalId": { + "type": "string", + "metadata": { + "description": "Principal id of the Foundry project managed identity; receives AcrPull and is the connection credential identity." + } + }, + "enableNetworkIsolation": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, the registry disables public network access to stay inside the isolation boundary." + } + } + }, + "variables": { + "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), format('{0}-conn', parameters('name')))]", + "properties": { + "category": "ContainerRegistry", + "target": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]", + "authType": "ManagedIdentity", + "credentials": { + "clientId": "[parameters('foundryProjectPrincipalId')]", + "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + }, + "isSharedToAll": true, + "metadata": { + "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + } + }, + "dependsOn": [ + "[extensionResourceId(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId')))]", + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + ] + }, + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Premium" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "[if(parameters('enableNetworkIsolation'), 'Disabled', 'Enabled')]", + "zoneRedundancy": "Disabled" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('foundryProjectPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + ] + } + ], + "outputs": { + "loginServer": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]" + }, + "resourceId": { + "type": "string", + "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + }, + "connectionName": { + "type": "string", + "value": "[format('{0}-conn', parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "foundryAccount", + "foundryAccount::project" + ] + }, + "privateEndpointDns": { + "condition": "[parameters('enableNetworkIsolation')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-private-endpoint-dns", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aiAccountName": { + "value": "[variables('foundryAccountName')]" + }, + "vnetId": { + "value": "[reference('network').outputs.vnetId.value]" + }, + "peSubnetId": { + "value": "[reference('network').outputs.peSubnetId.value]" + }, + "suffix": { + "value": "[variables('resourceToken')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" + } + }, + "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": "17066190331540845310" + } + }, + "parameters": { + "aiAccountName": { + "type": "string", + "metadata": { + "description": "Name of the Foundry (AIServices) account to bind the private endpoint to." + } + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the customer VNet." + } + }, + "peSubnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the private-endpoint subnet." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource/link names." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } + } + }, + "variables": { + "aiServicesDnsZoneName": "privatelink.services.ai.azure.com", + "openAiDnsZoneName": "privatelink.openai.azure.com", + "cognitiveServicesDnsZoneName": "privatelink.cognitiveservices.azure.com", + "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", + "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", + "aiServicesZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", + "openAiZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", + "cognitiveServicesZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]" + }, + "resources": [ + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", + "location": "[resourceGroup().location]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', parameters('aiAccountName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aiAccountName'))]", + "groupIds": [ + "account" + ] + } + } + ] + } + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('aiServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('openAiDnsZoneName')]", + "location": "global" + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('cognitiveServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" + ] + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiAccountName')), format('{0}-dns-group', parameters('aiAccountName')))]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('aiServicesZoneId')]" + } + }, + { + "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('openAiZoneId')]" + } + }, + { + "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('cognitiveServicesZoneId')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + } + ] + } + }, + "dependsOn": [ + "foundryAccount", + "network" + ] + }, + "projectConnections": { + "condition": "[not(empty(parameters('connections')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-connections", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "foundryAccountName": { + "value": "[variables('foundryAccountName')]" + }, + "foundryProjectName": { + "value": "[parameters('foundryProjectName')]" + }, + "connections": { + "value": "[parameters('connections')]" + } + }, + "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.44.1.10279", + "templateHash": "2215109488049914988" + } + }, + "definitions": { + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Connection name. The resource name and the key a toolbox tool references via connection: ." + } + }, + "category": { + "type": "string", + "metadata": { + "description": "Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys." + } + }, + "target": { + "type": "string", + "metadata": { + "description": "Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL." + } + }, + "authType": { + "type": "string", + "metadata": { + "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." + } + }, + "credentials": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." + } + }, + "metadata": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional metadata key-value pairs." + } + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } + }, + "connectionsType": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "metadata": { + "description": "Shape of a list of connections." + } + } + }, + "parameters": { + "foundryAccountName": { + "type": "string", + "metadata": { + "description": "Name of the existing Foundry CognitiveServices account that hosts the project." + } + }, + "foundryProjectName": { + "type": "string", + "metadata": { + "description": "Name of the existing Foundry project the connections are created on." + } + }, + "connections": { + "$ref": "#/definitions/connectionsType", + "defaultValue": [], + "metadata": { + "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." + } + } + }, + "resources": { + "foundryAccount::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('foundryAccountName'), parameters('foundryProjectName'))]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('foundryAccountName')]" + }, + "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('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", + "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + } + }, + "outputs": { + "connectionNames": { + "type": "string", + "metadata": { + "description": "Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding." + }, + "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + } + } + } + }, + "dependsOn": [ + "foundryAccount", + "foundryAccount::project" + ] + } + }, + "outputs": { + "AZURE_AI_PROJECT_ID": { + "type": "string", + "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]" + }, + "AZURE_AI_ACCOUNT_NAME": { + "type": "string", + "value": "[variables('foundryAccountName')]" + }, + "AZURE_AI_PROJECT_NAME": { + "type": "string", + "value": "[parameters('foundryProjectName')]" + }, + "AZURE_OPENAI_ENDPOINT": { + "type": "string", + "value": "[format('https://{0}.openai.azure.com/', variables('foundryAccountName'))]" + }, + "FOUNDRY_PROJECT_ENDPOINT": { + "type": "string", + "value": "[format('https://{0}.services.ai.azure.com/api/projects/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]" + }, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": { + "type": "string", + "value": "[if(parameters('includeAcr'), reference('acr').outputs.loginServer.value, '')]" + }, + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { + "type": "string", + "value": "[if(parameters('includeAcr'), reference('acr').outputs.resourceId.value, '')]" + }, + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { + "type": "string", + "value": "[if(parameters('includeAcr'), reference('acr').outputs.connectionName.value, '')]" + }, + "AZURE_AI_PROJECT_CONNECTION_NAMES": { + "type": "string", + "value": "[if(empty(parameters('connections')), '', reference('projectConnections').outputs.connectionNames.value)]" + }, + "AZURE_FOUNDRY_NETWORK_MODE": { + "type": "string", + "value": "[if(not(parameters('enableNetworkIsolation')), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" + }, + "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { + "type": "string", + "value": "[if(variables('useManagedNetwork'), parameters('managedIsolationMode'), '')]" + } + } + } + }, + "dependsOn": [ + "resourceGroup" + ] + } + }, + "outputs": { + "AZURE_RESOURCE_GROUP": { + "type": "string", + "value": "[parameters('resourceGroupName')]" + }, + "AZURE_AI_PROJECT_ID": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_AI_PROJECT_ID.value]" + }, + "AZURE_AI_ACCOUNT_NAME": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_AI_ACCOUNT_NAME.value]" + }, + "AZURE_AI_PROJECT_NAME": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_AI_PROJECT_NAME.value]" + }, + "AZURE_OPENAI_ENDPOINT": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_OPENAI_ENDPOINT.value]" + }, + "FOUNDRY_PROJECT_ENDPOINT": { + "type": "string", + "value": "[reference('resources').outputs.FOUNDRY_PROJECT_ENDPOINT.value]" + }, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT.value]" + }, + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID.value]" + }, + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME.value]" + }, + "AZURE_AI_PROJECT_CONNECTION_NAMES": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_AI_PROJECT_CONNECTION_NAMES.value]" + }, + "AZURE_FOUNDRY_NETWORK_MODE": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_FOUNDRY_NETWORK_MODE.value]" + }, + "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE.value]" + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..0478f864e0d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep @@ -0,0 +1,170 @@ +// 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. +// +// Subscription-scoped so the resource group is part of the deployment. This +// keeps `azd provision --preview` side-effect free: the resource group shows +// up as a previewed Create instead of being created up front to satisfy a +// resource-group-scoped what-if. + +targetScope = 'subscription' + +// User-defined types + +@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 + credentials: object? + metadata: object? +} + +// Parameters + +@description('Azure region for all resources.') +param location string + +@description('Name of the resource group to create and deploy resources into.') +@minLength(1) +@maxLength(90) +param resourceGroupName string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Optional salt to vary resource names across re-provisions.') +param resourceTokenSalt string = '' + +@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') +@minLength(3) +@maxLength(32) +param foundryProjectName string + +@description('Model deployments to provision on the Foundry account.') +param deployments deploymentsType = [] + +@description('Include an Azure Container Registry. Set true when any agent uses docker:.') +param includeAcr bool = false + +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] + +@description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') +param principalId string = '' + +@description('Principal type used in the developer role assignment.') +param principalType string = 'User' + +// Network isolation parameters (see modules/resources.bicep for semantics). +// All default off so an absent network: block yields a public account. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode).') +param managedIsolationMode string = '' + +@description('Resource group holding existing private DNS zones. Empty creates new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +// Resources + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +module resources 'modules/resources.bicep' = { + name: 'foundry-resources' + scope: resourceGroup + params: { + location: location + tags: tags + resourceTokenSalt: resourceTokenSalt + foundryProjectName: foundryProjectName + deployments: deployments + includeAcr: includeAcr + connections: connections + principalId: principalId + principalType: principalType + enableNetworkIsolation: enableNetworkIsolation + useManagedEgress: useManagedEgress + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + managedIsolationMode: managedIsolationMode + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +// Outputs + +output AZURE_RESOURCE_GROUP string = resourceGroup.name +output AZURE_AI_PROJECT_ID string = resources.outputs.AZURE_AI_PROJECT_ID +output AZURE_AI_ACCOUNT_NAME string = resources.outputs.AZURE_AI_ACCOUNT_NAME +output AZURE_AI_PROJECT_NAME string = resources.outputs.AZURE_AI_PROJECT_NAME +output AZURE_OPENAI_ENDPOINT string = resources.outputs.AZURE_OPENAI_ENDPOINT +output FOUNDRY_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +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_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/acr.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep new file mode 100644 index 00000000000..cf9c12543ea --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep @@ -0,0 +1,115 @@ +// Azure Container Registry for hosted agents that use docker:. +// Wires the registry as a connection on the Foundry project so the +// project's managed identity can pull images. +// +// Premium SKU is intentional: Foundry recommends Premium so the registry +// can support content trust and geo-replication if the user enables them +// post-provision. + +// Parameters + +@description('Azure region.') +param location string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Registry name. 5-50 alphanumeric chars.') +@minLength(5) +@maxLength(50) +param name string + +@description('Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection.') +param foundryAccountName string + +@description('Name of the existing Foundry project receiving the ACR connection.') +param foundryProjectName string + +@description('Principal id of the Foundry project managed identity; receives AcrPull and is the connection credential identity.') +param foundryProjectPrincipalId string + +@description('When true, the registry disables public network access to stay inside the isolation boundary.') +param enableNetworkIsolation bool = false + +// Variables + +// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + +// Resources + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: name + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + // Disable public access when network isolation is enabled so the registry + // stays inside the VNet boundary. Docker-backed agents in isolated projects + // must pull via the private endpoint; public access would leave a dependency + // outside the isolation perimeter and can break pulls in locked-down egress. + publicNetworkAccess: enableNetworkIsolation ? 'Disabled' : 'Enabled' + zoneRedundancy: 'Disabled' + } +} + +// Grant the Foundry project's managed identity AcrPull on this registry so the +// hosted agent can pull images using the project identity. +resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, foundryProjectPrincipalId, acrPullRoleId) + scope: registry + properties: { + principalId: foundryProjectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +// Existing parent references so the connection can be nested under the +// project. Pinned to 2025-04-01-preview: GA 2025-06-01 fails to resolve the +// projects/connections ContainerRegistry sub-resource (MissingApiVersionParameter). +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: foundryAccountName + + resource project 'projects' existing = { + name: foundryProjectName + + // Project-scoped connection so Foundry can resolve the registry by name. + resource acrConnection 'connections' = { + name: '${name}-conn' + properties: { + category: 'ContainerRegistry' + target: registry.properties.loginServer + authType: 'ManagedIdentity' + // RegistryIdentity auth requires both the identity client id (the + // project principal) and the registry resource id. + credentials: { + clientId: foundryProjectPrincipalId + resourceId: registry.id + } + isSharedToAll: true + metadata: { + ResourceId: registry.id + } + } + dependsOn: [ + foundryAcrPull + ] + } + } +} + +// Outputs + +output loginServer string = registry.properties.loginServer +output resourceId string = registry.id +output connectionName string = foundryAccount::project::acrConnection.name diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep new file mode 100644 index 00000000000..3435248945d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep @@ -0,0 +1,85 @@ +// Foundry project connections declared as host: azure.ai.connection services +// in azure.yaml. Creates one Microsoft.CognitiveServices/accounts/projects/connections +// resource per entry. +// +// This is the provision-time equivalent of the deploy-time azure.ai.connection +// service target, but it supports every auth type (the service target only +// upserts none/api-key/custom-keys at deploy). credentials and metadata are +// passed through untouched so any category/authType can be expressed. +// +// Pinned to 2025-04-01-preview via a separate existing account reference: GA +// 2025-06-01 fails to resolve the projects/connections sub-resource +// (MissingApiVersionParameter), the same reason acr.bicep does this. + +// User-defined types + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + @description('Connection name. The resource name and the key a toolbox tool references via connection: .') + name: string + + @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') + category: string + + @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') + target: string + + @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') + authType: string + + @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') + credentials: object? + + @description('Optional metadata key-value pairs.') + metadata: object? +} + +@description('Shape of a list of connections.') +type connectionsType = connectionType[] + +// Parameters + +@description('Name of the existing Foundry CognitiveServices account that hosts the project.') +param foundryAccountName string + +@description('Name of the existing Foundry project the connections are created on.') +param foundryProjectName string + +@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') +param connections connectionsType = [] + +// Resources + +// Existing parent references so each connection nests under the project. +// Pinned to 2025-04-01-preview (see file header). +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: foundryAccountName + + resource project 'projects' existing = { + name: foundryProjectName + } +} + +// One connection per entry. Optional properties (credentials / metadata) are +// only emitted when supplied so None / identity-token connections don't send an +// empty credentials object. +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccount::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + c.?credentials != null ? { credentials: c.?credentials } : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +// Outputs + +@description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep new file mode 100644 index 00000000000..68f414e6da5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep @@ -0,0 +1,94 @@ +// Virtual network wiring for a network-secured (VNet-injected) Foundry account. +// +// Bring-your-own VNet only (network.mode: byo). The VNet must already exist; +// v1 references it by the ARM id supplied in azure.yaml. Each subnet follows +// the tri-state rule from the synthesizer: +// +// create=true, prefix set -> create the subnet with that prefix +// create=true, prefix empty -> create the subnet with a derived /24 prefix +// create=false -> reference an existing subnet as-is +// +// All subnet ids are deterministic ('/subnets/'), so outputs are +// valid whether the subnet was created here or already existed. + +targetScope = 'resourceGroup' + +@description('ARM resource id of the existing customer VNet.') +param vnetId string + +@description('Name of the agent (delegated) subnet.') +param agentSubnetName string + +@description('CIDR for the agent subnet. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool + +@description('Name of the private-endpoint subnet.') +param peSubnetName string + +@description('CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool + +// The VNet may live in a different resource group than the deployment RG. +var vnetParts = split(vnetId, '/') +var vnetSubscriptionId = vnetParts[2] +var vnetResourceGroupName = vnetParts[4] +var vnetName = last(vnetParts) + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: vnetName + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) +} + +var vnetAddressSpace = vnet.properties.addressSpace.addressPrefixes[0] +var agentPrefix = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix +var pePrefix = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix + +// Create the agent subnet, delegated to Microsoft.App/environments so the +// hosted agent's container app environment can be injected into it. +module agentSubnet 'subnet.bicep' = if (createAgentSubnet) { + name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: agentSubnetName + addressPrefix: agentPrefix + delegations: [ + { + name: 'Microsoft.App/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } +} + +// Create the private-endpoint subnet. Depends on the agent subnet so the two +// subnet PUTs against the same VNet do not race (ARM serializes subnet writes). +module peSubnet 'subnet.bicep' = if (createPESubnet) { + name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: peSubnetName + addressPrefix: pePrefix + delegations: [] + } + dependsOn: [ + agentSubnet + ] +} + +output vnetId string = vnet.id +output vnetName string = vnetName +output vnetSubscriptionId string = vnetSubscriptionId +output vnetResourceGroupName string = vnetResourceGroupName +output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' +output peSubnetId string = '${vnet.id}/subnets/${peSubnetName}' +output peSubnetName string = peSubnetName diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep new file mode 100644 index 00000000000..a8af939b859 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep @@ -0,0 +1,165 @@ +// Account private endpoint + the three AI private DNS zones for a +// network-secured Foundry account. Dependent stores stay platform-managed, so +// only the account itself gets a private endpoint here (no Search / Storage / +// Cosmos endpoints). +// +// DNS zones are created and linked to the VNet by default. When +// dnsZonesResourceGroup is set, the zones are referenced from that resource +// group (in dnsZonesSubscription, defaulting to this subscription) instead of +// being created. + +targetScope = 'resourceGroup' + +@description('Name of the Foundry (AIServices) account to bind the private endpoint to.') +param aiAccountName string + +@description('ARM resource id of the customer VNet.') +param vnetId string + +@description('ARM resource id of the private-endpoint subnet.') +param peSubnetId string + +@description('Suffix for unique resource/link names.') +param suffix string + +@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +var aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' +var openAiDnsZoneName = 'privatelink.openai.azure.com' +var cognitiveServicesDnsZoneName = 'privatelink.cognitiveservices.azure.com' + +var useExistingZones = !empty(dnsZonesResourceGroup) +var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription + +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiAccountName + scope: resourceGroup() +} + +// Account private endpoint in the PE subnet, targeting the 'account' group. +resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${aiAccountName}-private-endpoint' + location: resourceGroup().location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: '${aiAccountName}-private-link-service-connection' + properties: { + privateLinkServiceId: aiAccount.id + groupIds: [ + 'account' + ] + } + } + ] + } +} + +// ---- Private DNS zones: create-and-link, or reference existing ---- + +resource aiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: aiServicesDnsZoneName + location: 'global' +} +resource existingAiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: aiServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var aiServicesZoneId = useExistingZones ? existingAiServicesZone.id : aiServicesZone.id + +resource openAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: openAiDnsZoneName + location: 'global' +} +resource existingOpenAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: openAiDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var openAiZoneId = useExistingZones ? existingOpenAiZone.id : openAiZone.id + +resource cognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: cognitiveServicesDnsZoneName + location: 'global' +} +resource existingCognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: cognitiveServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var cognitiveServicesZoneId = useExistingZones ? existingCognitiveServicesZone.id : cognitiveServicesZone.id + +// ---- VNet links (only when we create the zones) ---- + +resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: aiServicesZone + name: 'aiServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: openAiZone + name: 'aiServicesOpenAI-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: cognitiveServicesZone + name: 'aiServicesCognitiveServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} + +// ---- DNS zone group binds the three zones to the account endpoint ---- + +resource aiAccountDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: aiAccountPrivateEndpoint + name: '${aiAccountName}-dns-group' + properties: { + privateDnsZoneConfigs: [ + { + name: '${aiAccountName}-dns-aiserv-config' + properties: { + privateDnsZoneId: aiServicesZoneId + } + } + { + name: '${aiAccountName}-dns-openai-config' + properties: { + privateDnsZoneId: openAiZoneId + } + } + { + name: '${aiAccountName}-dns-cogserv-config' + properties: { + privateDnsZoneId: cognitiveServicesZoneId + } + } + ] + } + dependsOn: [ + aiServicesLink + openAiLink + cognitiveServicesLink + ] +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep new file mode 100644 index 00000000000..c28b5caaf9e --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep @@ -0,0 +1,326 @@ +// Resource-group-scoped resources for a microsoft.foundry service: the +// Foundry (AIServices) account, its project, model deployments, the optional +// container registry, and the developer role assignment. +// +// Deployed by main.bicep into a resource group it creates at subscription +// scope. Kept as a separate module so main.bicep can target the subscription +// (and thus create the resource group) while these resources stay RG-scoped. + +targetScope = 'resourceGroup' + +// User-defined types + +@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 + credentials: object? + metadata: object? +} + +// Parameters + +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Optional salt to vary resource names across re-provisions.') +param resourceTokenSalt string = '' + +@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') +@minLength(3) +@maxLength(32) +param foundryProjectName string + +@description('Model deployments to provision on the Foundry account.') +param deployments deploymentsType = [] + +@description('Include an Azure Container Registry. Set true when any agent uses docker:.') +param includeAcr bool = false + +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] + +@description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') +param principalId string = '' + +@description('Principal type used in the developer role assignment.') +param principalType string = 'User' + +// Network isolation parameters. All default off so an absent network: block in +// azure.yaml yields a public account identical to the pre-network template. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound.') +param managedIsolationMode string = '' + +@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +// Variables + +var resourceToken = empty(resourceTokenSalt) + ? uniqueString(subscription().id, resourceGroup().id, location) + : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) + +var abbrs = loadJsonContent('../abbreviations.json') + +var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' + +// Egress: byo injects the agent into a customer subnet; managed uses the +// Microsoft-managed network. Ingress: an account private endpoint is always +// provisioned when isolation is on, so the data plane is never left public. +var useByoNetwork = enableNetworkIsolation && !useManagedEgress +var useManagedNetwork = enableNetworkIsolation && useManagedEgress +var disablePublicDataPlaneAccess = enableNetworkIsolation + +// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles +var cognitiveServicesUserRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'a97b65f3-24c7-4388-baec-2e87135dc908' +) + +// Resources + +// Customer VNet wiring: reference the VNet and create or reference the agent +// (byo egress only) + private-endpoint subnets. Runs whenever isolation is on +// because the account private endpoint is always provisioned. +module network 'network.bicep' = if (enableNetworkIsolation) { + name: 'foundry-network' + params: { + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + } +} + +// networkInjections wires the account into the agent subnet (byo) or the +// Microsoft-managed network (managed). Null when isolation is off. +// +// subnetArmId is built as a concrete string from the (concrete) vnetId param +// rather than network!.outputs.agentSubnetId. The account and the network +// module deploy in the same template, so an inter-module reference() here is +// unresolved at the CognitiveServices RP preflight, which then fails to convert +// networkInjections to its typed contract (ARM what-if does not catch this). +// The deterministic id avoids the unresolved reference; an explicit dependsOn +// on the network module preserves ordering (the subnet must exist first). +var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' +var agentNetworkInjections = useByoNetwork + ? [ + { + scenario: 'agent' + subnetArmId: agentSubnetArmId + useMicrosoftManagedNetwork: false + } + ] + : (useManagedNetwork + ? [ + { + scenario: 'agent' + useMicrosoftManagedNetwork: true + } + ] + : null) + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { + name: foundryAccountName + location: location + tags: tags + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: foundryAccountName + publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' + disableLocalAuth: true + networkAcls: { + defaultAction: disablePublicDataPlaneAccess ? 'Deny' : 'Allow' + bypass: disablePublicDataPlaneAccess ? 'AzureServices' : null + virtualNetworkRules: [] + ipRules: [] + } + networkInjections: agentNetworkInjections + } + + // The account injects into the agent subnet via a deterministic id (above), + // so Bicep cannot infer the dependency on the network module that creates + // that subnet. Declare it explicitly so the subnet exists before injection. + dependsOn: useByoNetwork ? [network] : [] + + // Sequential model deployment creation; ARM throttles concurrent + // deployments on the same account. + @batchSize(1) + resource modelDeployments 'deployments' = [ + for d in deployments: { + name: d.name + properties: { + model: d.model + } + sku: d.sku + } + ] + + resource project 'projects' = { + name: foundryProjectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: '${foundryProjectName} Project' + displayName: foundryProjectName + } + // Explicit dependsOn ensures all model deployments complete before + // the project is created; the project does not reference them so + // there is no implicit dependency Bicep can infer. + dependsOn: [ + modelDeployments + ] + } +} + +// Managed-network isolation (managed egress only). Applies the chosen outbound +// isolation mode to the Microsoft-managed VNet that hosts the agent runtime. +// Only deployed when an explicit isolationMode is requested; otherwise the +// platform default applies. Note: AllowOnlyApprovedOutbound additionally +// requires approved outbound rules for the agent to reach dependent resources; +// for the platform-managed stores used here those are managed by the platform. +resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = + if (useManagedNetwork && !empty(managedIsolationMode)) { + parent: foundryAccount + name: 'default' + properties: { + managedNetwork: { + isolationMode: managedIsolationMode + } + } + } + +module acr 'acr.bicep' = if (includeAcr) { + name: 'acr' + params: { + location: location + tags: tags + name: '${abbrs.containerRegistryRegistries}${resourceToken}' + foundryAccountName: foundryAccount.name + foundryProjectName: foundryAccount::project.name + foundryProjectPrincipalId: foundryAccount::project.identity.principalId + enableNetworkIsolation: enableNetworkIsolation + } +} + +// Account private endpoint + AI private DNS zones. The account is always given a +// private endpoint when isolation is on (byo or managed egress); dependent +// stores stay platform-managed, so only the account gets an endpoint. +module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { + name: 'foundry-private-endpoint-dns' + params: { + aiAccountName: foundryAccount.name + vnetId: network!.outputs.vnetId + peSubnetId: network!.outputs.peSubnetId + suffix: resourceToken + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as +// host: azure.ai.connection services. Created at provision time so a toolbox +// that references a connection by name resolves it at deploy. Depends on the +// project via foundryAccount.name / project.name so ordering is correct. +module projectConnections 'connections.bicep' = if (!empty(connections)) { + name: 'foundry-connections' + params: { + foundryAccountName: foundryAccount.name + foundryProjectName: foundryAccount::project.name + connections: connections + } +} + +// Grant the developer Cognitive Services User on the project so they can call +// the Foundry data-plane (chat/completions, agents API) from their machine. +resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(principalId)) { + name: guid(foundryAccount::project.id, principalId, cognitiveServicesUserRoleId) + scope: foundryAccount::project + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: cognitiveServicesUserRoleId + } +} + +// Outputs + +output AZURE_AI_PROJECT_ID string = foundryAccount::project.id +output AZURE_AI_ACCOUNT_NAME string = foundryAccount.name +output AZURE_AI_PROJECT_NAME string = foundryAccount::project.name +output AZURE_OPENAI_ENDPOINT string = 'https://${foundryAccount.name}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.services.ai.azure.com/api/projects/${foundryAccount::project.name}' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames +output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') +output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep new file mode 100644 index 00000000000..27abadcb813 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep @@ -0,0 +1,28 @@ +// Single subnet on an existing VNet. Kept as its own module so the parent can +// place subnets in the VNet's resource group (which may differ from the +// deployment RG) and serialize subnet writes via module dependsOn. + +targetScope = 'resourceGroup' + +@description('Name of the virtual network the subnet belongs to.') +param vnetName string + +@description('Name of the subnet to create.') +param subnetName string + +@description('CIDR for the subnet.') +param addressPrefix string + +@description('Subnet delegations (e.g. Microsoft.App/environments for the agent subnet).') +param delegations array = [] + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + name: '${vnetName}/${subnetName}' + properties: { + addressPrefix: addressPrefix + delegations: delegations + } +} + +output subnetId string = subnet.id +output subnetName string = subnetName 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/acr.tf new file mode 100644 index 00000000000..fc5f3242e0c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/acr.tf @@ -0,0 +1,63 @@ +# Azure Container Registry for hosted agents that use docker, wired as a +# connection on the Foundry project so the project identity can pull images. +# Premium SKU is required for Foundry registry connections. + +locals { + container_registry_name = "cr${local.resource_token}" + + # https://learn.microsoft.com/azure/role-based-access-control/built-in-roles + acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" + + project_principal_id = try(azapi_resource.project.output.identity.principalId, "") +} + +resource "azurerm_container_registry" "this" { + name = local.container_registry_name + resource_group_name = azurerm_resource_group.this.name + location = azurerm_resource_group.this.location + tags = var.tags + sku = "Premium" + admin_enabled = false + + identity { + type = "SystemAssigned" + } + + public_network_access_enabled = true + zone_redundancy_enabled = false +} + +# Grants the Foundry project identity AcrPull so hosted agents can pull images. +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" +} + +# Project connection so Foundry can resolve the registry by name. +# Pinned to 2025-04-01-preview: GA 2025-06-01 cannot resolve the +# projects/connections ContainerRegistry sub-resource (MissingApiVersionParameter). +resource "azapi_resource" "acr_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.container_registry_name}-conn" + parent_id = azapi_resource.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/main.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf new file mode 100644 index 00000000000..84a9b3b9bc3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf @@ -0,0 +1,140 @@ +# Foundry (AIServices) account, its project, model deployments, and the +# developer role assignment. + +locals { + # Resource group name. Falls back to rg-{environment_name} when not provided. + resource_group_name = ( + var.resource_group_name != "" ? var.resource_group_name : "rg-${local.derived_rg_suffix}" + ) + derived_rg_suffix = trimsuffix( + substr(trim(coalesce(var.environment_name, "env"), "-."), 0, 87), + "-", + ) + + # Token to keep globally-unique resource names stable across re-provisions. + resource_token = substr(sha1(join("-", compact([ + var.subscription_id, + local.resource_group_name, + var.location, + var.resource_token_salt, + ]))), 0, 13) + + foundry_account_name = "cog-${local.resource_token}" + + # Project name. Falls back to a sanitized environment_name (3-32 chars) when + # not provided. + foundry_project_name = ( + var.foundry_project_name != "" ? var.foundry_project_name : local.derived_project_name + ) + + sanitized_env_name = trim( + replace(lower(coalesce(var.environment_name, "")), "/[^a-z0-9-]/", "-"), + "-", + ) + # substr errors when length exceeds the string, so only truncate when needed. + capped_env_name = ( + length(local.sanitized_env_name) > 32 + ? substr(local.sanitized_env_name, 0, 32) + : local.sanitized_env_name + ) + derived_project_name = ( + length(local.capped_env_name) >= 3 + ? local.capped_env_name + : (local.capped_env_name == "" ? "foundryproject" : "${local.capped_env_name}prj") + ) + + # https://learn.microsoft.com/azure/role-based-access-control/built-in-roles + cognitive_services_user_role_id = "a97b65f3-24c7-4388-baec-2e87135dc908" +} + +resource "azurerm_resource_group" "this" { + name = local.resource_group_name + location = var.location + tags = var.tags +} + +# azapi is used so allowProjectManagement can be set (not exposed by +# azurerm_cognitive_account). +resource "azapi_resource" "foundry_account" { + type = "Microsoft.CognitiveServices/accounts@2025-06-01" + name = local.foundry_account_name + location = azurerm_resource_group.this.location + parent_id = azurerm_resource_group.this.id + tags = var.tags + + identity { + type = "SystemAssigned" + } + + body = { + kind = "AIServices" + sku = { + name = "S0" + } + properties = { + allowProjectManagement = true + customSubDomainName = local.foundry_account_name + publicNetworkAccess = "Enabled" + disableLocalAuth = true + networkAcls = { + defaultAction = "Allow" + virtualNetworkRules = [] + ipRules = [] + } + } + } +} + +# Created one at a time; ARM throttles concurrent deployments on one account. +resource "azurerm_cognitive_deployment" "model" { + for_each = { for d in var.deployments : d.name => d } + + name = each.value.name + cognitive_account_id = azapi_resource.foundry_account.id + + model { + format = each.value.model.format + name = each.value.model.name + version = each.value.model.version + } + + sku { + name = each.value.sku.name + capacity = each.value.sku.capacity + } +} + +# Created after all model deployments complete. +resource "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-06-01" + name = local.foundry_project_name + location = azurerm_resource_group.this.location + parent_id = azapi_resource.foundry_account.id + + identity { + type = "SystemAssigned" + } + + body = { + properties = { + description = "${local.foundry_project_name} Project" + displayName = local.foundry_project_name + } + } + + response_export_values = ["identity.principalId"] + + depends_on = [azurerm_cognitive_deployment.model] +} + +# Grants the developer Cognitive Services User on the project to call the +# Foundry data-plane (chat/completions, agents API). Skipped when principal_id +# is empty. +resource "azurerm_role_assignment" "developer_cognitive_services_user" { + count = var.principal_id == "" ? 0 : 1 + + scope = azapi_resource.project.id + role_definition_id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_services_user_role_id}" + principal_id = var.principal_id + principal_type = var.principal_type +} 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 new file mode 100644 index 00000000000..fa6bcc7635e --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl @@ -0,0 +1,39 @@ +# Outputs consumed by azd. Each value is written into the azd environment, so +# the names must stay stable. + +output "AZURE_RESOURCE_GROUP" { + value = azurerm_resource_group.this.name +} + +output "AZURE_AI_PROJECT_ID" { + value = azapi_resource.project.id +} + +output "AZURE_AI_ACCOUNT_NAME" { + value = azapi_resource.foundry_account.name +} + +output "AZURE_AI_PROJECT_NAME" { + value = azapi_resource.project.name +} + +output "AZURE_OPENAI_ENDPOINT" { + value = "https://${azapi_resource.foundry_account.name}.openai.azure.com/" +} + +output "FOUNDRY_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 +} + +output "AZURE_CONTAINER_REGISTRY_RESOURCE_ID" { + value = azurerm_container_registry.this.id +} + +output "AZURE_AI_PROJECT_ACR_CONNECTION_NAME" { + value = azapi_resource.acr_connection.name +} +{{- end }} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf new file mode 100644 index 00000000000..32359a3ae4d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf @@ -0,0 +1,23 @@ +terraform { + required_version = ">= 1.1.7, < 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 + features {} +} + +provider "azapi" { + subscription_id = var.subscription_id +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf new file mode 100644 index 00000000000..cab146a2bcf --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf @@ -0,0 +1,71 @@ +variable "subscription_id" { + description = "Azure subscription id." + type = string +} + +variable "location" { + description = "Azure region for all resources." + type = string +} + +variable "resource_group_name" { + description = "Name of the resource group to create and deploy resources into." + type = string +} + +variable "environment_name" { + description = "azd environment name. Used to tag resources and to derive default names." + type = string +} + +variable "tags" { + description = "Tags applied to all resources." + type = map(string) + default = {} +} + +variable "resource_token_salt" { + description = "Optional salt to vary resource names across re-provisions." + type = string + default = "" +} + +variable "foundry_project_name" { + description = "Foundry project name (3-32 alphanumeric/hyphen chars). When empty, derived from environment_name." + type = string + default = "" + + validation { + condition = var.foundry_project_name == "" || can(regex("^[a-zA-Z0-9-]{3,32}$", var.foundry_project_name)) + error_message = "foundry_project_name must be empty or 3-32 alphanumeric/hyphen characters." + } +} + +variable "deployments" { + description = "Model deployments to provision on the Foundry account." + type = list(object({ + name = string + model = object({ + name = string + format = string + version = string + }) + sku = object({ + name = string + capacity = number + }) + })) + default = [] +} + +variable "principal_id" { + description = "Object id of the developer running azd. When empty, the developer role assignment is skipped." + type = string + default = "" +} + +variable "principal_type" { + description = "Principal type used in the developer role assignment." + type = string + default = "User" +} 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 new file mode 100644 index 00000000000..3cde22f6a66 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import "embed" + +// Re-generate main.arm.json from main.bicep. Run via `go generate ./...` +// from the extension root after editing any *.bicep file under templates/. +// The compiled ARM JSON is embedded so the provisioning provider never +// 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:embed templates/main.bicep +//go:embed templates/main.arm.json +//go:embed templates/brownfield.bicep +//go:embed templates/brownfield.arm.json +//go:embed templates/abbreviations.json +//go:embed templates/modules/*.bicep +var templatesFS embed.FS + +// terraformTemplatesFS holds the on-disk Terraform module emitted by +// `azd ai agent init --infra=terraform`. Unlike the Bicep templates there is +// 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 +// 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 +// generated at eject time. +// +//go:embed templates/terraform/*.tf +//go:embed templates/terraform/outputs.tf.tmpl +var terraformTemplatesFS 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 } + +// TerraformTemplatesFS exposes the embedded Terraform module (the *.tf files +// and outputs.tf.tmpl under templates/terraform/). The eject step copies the +// static .tf files to ./infra/, renders outputs.tf from outputs.tf.tmpl, and +// generates main.tfvars.json alongside them. +func TerraformTemplatesFS() embed.FS { return terraformTemplatesFS } + +// 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") +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/version/version.go b/cli/azd/extensions/azure.ai.projects/internal/version/version.go new file mode 100644 index 00000000000..e977db7a4da --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/version/version.go @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package version holds extension build metadata. +package version + +var ( + Version = "dev" + Commit = "none" + BuildDate = "unknown" +) diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 3ce70fc5f4c..6a3cd0b0201 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -395,7 +395,9 @@ Emitted on `azd provision` / `azd up` to measure adoption and safety of `infra.l
Foundry Private Networking -Emitted at provision start by the `microsoft.foundry` provisioning provider (the `azure.ai.agents` extension) to measure secured-agent adoption and the BYO-vs-managed split. +Emitted at provision start by the `microsoft.foundry` provisioning provider +in the `azure.ai.projects` extension to measure secured-agent adoption and +the BYO-vs-managed split. | Field Key | Type | Description | |-----------|------|-------------| From 2ca8c31d38d8a8383f6270fcb4ad1f7fe8a63dc1 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 16:22:59 +0800 Subject: [PATCH 02/16] fix: prepare Foundry extension handoff release --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 4 ++++ cli/azd/extensions/azure.ai.agents/extension.yaml | 2 +- cli/azd/extensions/azure.ai.agents/version.txt | 2 +- cli/azd/extensions/azure.ai.projects/CHANGELOG.md | 10 ++++++++++ cli/azd/extensions/azure.ai.projects/extension.yaml | 2 +- cli/azd/extensions/azure.ai.projects/version.txt | 2 +- 6 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 941dd97a5f9..dda43f87438 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,6 +4,10 @@ ### Other Changes +- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) Foundry + provisioning ownership moved to `azure.ai.projects`. This extension + now depends on `azure.ai.projects` for `microsoft.foundry` provisioning + while retaining agent init and pre-split manifest compatibility. - [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path. ## 1.0.0-beta.5 (2026-07-09) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index e4b36e9ec13..132eae69565 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Beta) description: Ship agents with Microsoft Foundry from your terminal. (Beta) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 1.0.0-beta.5 +version: 1.0.0-beta.6 requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index ae27bfcaecc..6c3924b6842 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -1.0.0-beta.5 +1.0.0-beta.6 diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 61c9b1c39ac..61c826bbdae 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,5 +1,15 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Features Added + +- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) The + `azure.ai.projects` extension now owns the `microsoft.foundry` + provisioning provider and its project validation checks. It reads + `azure.ai.project` model deployments before provisioning and deploys, + while retaining compatibility with pre-split Foundry manifests. + ## 1.0.0-beta.2 (2026-07-09) ### Other Changes diff --git a/cli/azd/extensions/azure.ai.projects/extension.yaml b/cli/azd/extensions/azure.ai.projects/extension.yaml index 132bb11ce0d..ea35b78894a 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -22,5 +22,5 @@ tags: - ai - project usage: azd ai project [options] -version: 1.0.0-beta.2 +version: 1.0.0-beta.3 requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.projects/version.txt b/cli/azd/extensions/azure.ai.projects/version.txt index 7e0b23109f3..b0a2ffd0f5f 100644 --- a/cli/azd/extensions/azure.ai.projects/version.txt +++ b/cli/azd/extensions/azure.ai.projects/version.txt @@ -1 +1 @@ -1.0.0-beta.2 +1.0.0-beta.3 From 9bcc5f3b5013da40875e9a4bf81f24629bdb11d5 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 18:38:42 +0800 Subject: [PATCH 03/16] fix: install projects dependency in agents CI --- .github/workflows/test-ext-azure-ai-agents.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/test-ext-azure-ai-agents.yml b/.github/workflows/test-ext-azure-ai-agents.yml index b2d924d763e..53d230d8a65 100644 --- a/.github/workflows/test-ext-azure-ai-agents.yml +++ b/.github/workflows/test-ext-azure-ai-agents.yml @@ -5,6 +5,7 @@ on: paths: - "cli/azd/extensions/azure.ai.agents/**" - "cli/azd/extensions/azure.ai.inspector/**" + - "cli/azd/extensions/azure.ai.projects/**" - "cli/azd/test/functional/ai_agents/**" - ".github/workflows/test-ext-azure-ai-agents.yml" branches: [main] @@ -55,6 +56,16 @@ jobs: azd x publish azd extension install azure.ai.inspector --source local --force --no-prompt + - name: Build & install projects dependency from source + working-directory: cli/azd/extensions/azure.ai.projects + run: | + pwsh -File ci-build.ps1 -OutputFileName azure-ai-projects-linux-amd64 -Version "$(cat version.txt | tr -d '\r\n')" + mkdir -p bin + mv azure-ai-projects-linux-amd64 bin/ + azd x pack + azd x publish + azd extension install azure.ai.projects --source local --force --no-prompt + - name: Build extension (ci-build.ps1) working-directory: cli/azd/extensions/azure.ai.agents run: | From 3271d2b39f0fc94106093c818201d01e405d5881 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 19:01:56 +0800 Subject: [PATCH 04/16] fix: resolve and secure Foundry provisioning --- .../extensions/azure.ai.agents/CHANGELOG.md | 3 + .../internal/cmd/init_infra_test.go | 11 +- .../internal/synthesis/synthesizer.go | 145 +++++++++---- .../internal/synthesis/synthesizer_test.go | 31 ++- .../synthesis/templates/brownfield.arm.json | 54 +---- .../synthesis/templates/brownfield.bicep | 24 +-- .../synthesis/templates/main.arm.json | 190 +++--------------- .../internal/synthesis/templates/main.bicep | 18 +- .../templates/modules/connections.bicep | 35 +--- .../templates/modules/resources.bicep | 23 +-- .../extensions/azure.ai.projects/CHANGELOG.md | 3 + .../internal/cmd/project_service_config.go | 21 +- .../cmd/project_service_config_test.go | 42 +++- .../foundry_provisioning_provider.go | 77 ++++++- ...ovisioning_provider_brownfield_acr_test.go | 22 +- .../internal/synthesis/synthesizer.go | 145 +++++++++---- .../internal/synthesis/synthesizer_test.go | 140 ++++++++++++- .../synthesis/templates/brownfield.arm.json | 54 +---- .../synthesis/templates/brownfield.bicep | 24 +-- .../synthesis/templates/main.arm.json | 190 +++--------------- .../internal/synthesis/templates/main.bicep | 18 +- .../templates/modules/connections.bicep | 35 +--- .../templates/modules/resources.bicep | 23 +-- 23 files changed, 644 insertions(+), 684 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index dda43f87438..c11b057274d 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -8,6 +8,9 @@ provisioning ownership moved to `azure.ai.projects`. This extension now depends on `azure.ai.projects` for `microsoft.foundry` provisioning while retaining agent init and pre-split manifest compatibility. + Upgrade with `azure.ai.projects` 1.0.0-beta.3 or later. The paired + extensions must be upgraded together to avoid duplicate + `microsoft.foundry` provider registration. - [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path. ## 1.0.0-beta.5 (2026-07-09) 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 ab1aa5fb992..3fefe5bd30a 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 @@ -355,8 +355,15 @@ services: require.NoError(t, json.Unmarshal(raw, &doc)) require.Contains(t, doc.Parameters, "connections") - conns, ok := doc.Parameters["connections"].Value.([]any) - require.True(t, ok, "connections should be an array, got %T", doc.Parameters["connections"].Value) + connectionsJSON, ok := doc.Parameters["connections"].Value.(string) + require.True( + t, + ok, + "connections should be a JSON string, got %T", + doc.Parameters["connections"].Value, + ) + var conns []any + require.NoError(t, json.Unmarshal([]byte(connectionsJSON), &conns)) require.Len(t, conns, 1) conn, ok := conns[0].(map[string]any) 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 4e7c9071042..8cff6d807f4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -13,6 +13,7 @@ package synthesis import ( + "encoding/json" "errors" "fmt" "maps" @@ -225,24 +226,9 @@ func Synthesize(in Input) (*Result, error) { return nil, fmt.Errorf("parse azure.yaml: %w", err) } - node, ok := root.Services[in.ServiceName] - if !ok { - return nil, ErrServiceNotFound - } - - // Resolve $ref file includes (service-entry-level and per-deployment) so the - // decoded service body carries the referenced content, not raw $ref objects. - if in.ProjectRoot != "" { - var err error - node, err = resolveServiceRefs(node, in.ProjectRoot, in.ServiceName) - if err != nil { - return nil, err - } - } - - var svc projectService - if err := node.Decode(&svc); err != nil { - return nil, fmt.Errorf("decode service %q: %w", in.ServiceName, 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) { @@ -252,14 +238,23 @@ func Synthesize(in Input) (*Result, error) { return nil, ErrEndpointBrownfield } - includeAcr := deriveIncludeAcr(root.Services, svc) + includeAcr := deriveIncludeAcr(root.Services, svc, in.ProjectRoot) deployments := svc.Deployments if deployments == nil { deployments = []Deployment{} } - connections, err := collectConnections(root.Services, in.Env, !in.PreserveVarRefs) + connections, err := collectConnections( + root.Services, + in.Env, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } + encodedConnections, err := EncodeConnections(connections) if err != nil { return nil, err } @@ -272,7 +267,7 @@ func Synthesize(in Input) (*Result, error) { params := map[string]any{ "deployments": deployments, "includeAcr": includeAcr, - "connections": connections, + "connections": encodedConnections, } maps.Copy(params, netParams) @@ -282,12 +277,28 @@ func Synthesize(in Input) (*Result, error) { }, nil } +// EncodeConnections serializes connection definitions for a secure ARM parameter. +func EncodeConnections(connections []Connection) (string, error) { + if connections == nil { + connections = []Connection{} + } + data, err := json.Marshal(connections) + if err != nil { + return "", fmt.Errorf("encode connections: %w", err) + } + return string(data), nil +} + // BrownfieldDeployments returns the model deployments declared on a brownfield // (endpoint:) Foundry project service. Synthesize short-circuits with // ErrEndpointBrownfield before reading deployments:, so the provider uses this // to learn which model deployments to create on the existing account. Returns // nil (not an error) when the service declares no deployments. -func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) { +func BrownfieldDeployments( + raw []byte, + serviceName string, + projectRoot string, +) ([]Deployment, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") } @@ -300,14 +311,9 @@ func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) return nil, fmt.Errorf("parse azure.yaml: %w", err) } - node, ok := root.Services[serviceName] - if !ok { - return nil, ErrServiceNotFound - } - - var svc projectService - if err := node.Decode(&svc); err != nil { - return nil, fmt.Errorf("decode service %q: %w", serviceName, err) + svc, err := loadProjectService(root.Services, serviceName, projectRoot) + if err != nil { + return nil, err } return svc.Deployments, nil @@ -320,7 +326,11 @@ func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) // resolved from env since brownfield provisions immediately; Foundry ${{...}} // expressions pass through. Returns an empty slice (not an error) when none // are declared. -func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, error) { +func BrownfieldConnections( + raw []byte, + env map[string]string, + projectRoot string, +) ([]Connection, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") } @@ -330,7 +340,55 @@ func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, err return nil, fmt.Errorf("parse azure.yaml: %w", err) } - return collectConnections(root.Services, env, true) + return collectConnections(root.Services, env, true, projectRoot) +} + +// ProjectEndpoint returns the endpoint configured on a Foundry project service. +// It resolves $ref includes before decoding the service body. +func ProjectEndpoint( + raw []byte, + serviceName string, + projectRoot string, +) (string, error) { + if len(raw) == 0 { + return "", errors.New("synthesis: raw azure.yaml is empty") + } + + var root projectFile + if err := yaml.Unmarshal(raw, &root); err != nil { + return "", fmt.Errorf("parse azure.yaml: %w", err) + } + + svc, err := loadProjectService(root.Services, serviceName, projectRoot) + if err != nil { + return "", err + } + return strings.TrimSpace(svc.Endpoint), nil +} + +// loadProjectService decodes a service after resolving any local $ref includes. +func loadProjectService( + services map[string]yaml.Node, + serviceName string, + projectRoot string, +) (projectService, error) { + node, ok := services[serviceName] + if !ok { + return projectService{}, ErrServiceNotFound + } + if projectRoot != "" { + var err error + node, err = resolveServiceRefs(node, projectRoot, serviceName) + if err != nil { + return projectService{}, err + } + } + + var svc projectService + if err := node.Decode(&svc); err != nil { + return projectService{}, fmt.Errorf("decode service %q: %w", serviceName, err) + } + return svc, nil } // resolveServiceRefs expands $ref file includes in one service entry. It decodes @@ -367,12 +425,23 @@ func resolveServiceRefs(node yaml.Node, projectRoot, serviceName string) (yaml.N // Keying on image/codeConfiguration rather than the optional docker: block is // deliberate: docker: is not in the agent schema and is dropped by omitempty // when remoteBuild is false, so it is not a reliable build signal. -func deriveIncludeAcr(services map[string]yaml.Node, svc projectService) bool { +func deriveIncludeAcr( + services map[string]yaml.Node, + svc projectService, + projectRoot string, +) bool { if slices.ContainsFunc(svc.Agents, agentNeedsAcr) { return true } - for _, node := range services { + for name, node := range services { + if projectRoot != "" { + var err error + node, err = resolveServiceRefs(node, projectRoot, name) + if err != nil { + continue + } + } var service serviceBlock if err := node.Decode(&service); err != nil { continue @@ -417,10 +486,18 @@ func collectConnections( services map[string]yaml.Node, env map[string]string, resolve bool, + projectRoot string, ) ([]Connection, error) { connections := []Connection{} for name, node := range services { + if projectRoot != "" { + var err error + node, err = resolveServiceRefs(node, projectRoot, name) + if err != nil { + return nil, err + } + } var host struct { Host string `yaml:"host"` } 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 d72300a5f80..a4dae0da8cd 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 @@ -500,8 +500,7 @@ services: require.True(t, ok, "includeAcr param should be bool") assert.Equal(t, tt.wantIncludeAcr, includeAcr) - connections, ok := res.Parameters["connections"].([]Connection) - require.True(t, ok, "connections param should be []Connection, got %T", res.Parameters["connections"]) + connections := resultConnections(t, res) if tt.wantConnectionNames != nil { gotNames := make([]string, len(connections)) for i, c := range connections { @@ -540,8 +539,7 @@ services: getConn := func(t *testing.T, res *Result) Connection { t.Helper() - conns, ok := res.Parameters["connections"].([]Connection) - require.True(t, ok) + conns := resultConnections(t, res) require.Len(t, conns, 1) return conns[0] } @@ -629,6 +627,17 @@ services: }) } +func resultConnections(t *testing.T, result *Result) []Connection { + t.Helper() + + raw, ok := result.Parameters["connections"].(string) + require.True(t, ok, "connections param should be a JSON string") + + var connections []Connection + require.NoError(t, json.Unmarshal([]byte(raw), &connections)) + return connections +} + // TestBrownfieldConnections verifies connection services are collected for a // brownfield (endpoint:) project, with ${VAR} resolved (brownfield provisions // so references must be concrete) and Foundry ${{...}} preserved. @@ -655,7 +664,11 @@ services: ` t.Run("collects and resolves connections (sorted)", func(t *testing.T) { - conns, err := BrownfieldConnections([]byte(yaml), map[string]string{"SEARCH_API_KEY": "secret"}) + conns, err := BrownfieldConnections( + []byte(yaml), + map[string]string{"SEARCH_API_KEY": "secret"}, + "", + ) require.NoError(t, err) require.Len(t, conns, 2) assert.Equal(t, "bing-conn", conns[0].Name) @@ -671,13 +684,13 @@ services: host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 ` - conns, err := BrownfieldConnections([]byte(noConns), nil) + conns, err := BrownfieldConnections([]byte(noConns), nil, "") require.NoError(t, err) assert.Empty(t, conns) }) t.Run("empty raw errors", func(t *testing.T) { - _, err := BrownfieldConnections(nil, nil) + _, err := BrownfieldConnections(nil, nil, "") require.Error(t, err) }) } @@ -761,7 +774,7 @@ services: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := BrownfieldDeployments([]byte(tt.yaml), tt.serviceName) + got, err := BrownfieldDeployments([]byte(tt.yaml), tt.serviceName, "") if tt.serviceName == "" { require.Error(t, err) @@ -788,7 +801,7 @@ services: } func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { - _, err := BrownfieldDeployments(nil, "my-project") + _, err := BrownfieldDeployments(nil, "my-project", "") require.Error(t, err) } 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 index 942b2761a89..e32aa1aab90 100644 --- 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13884339769371824597" + "templateHash": "12070387023310168485" } }, "definitions": { @@ -54,43 +54,6 @@ "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" - }, - "credentials": { - "type": "object", - "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } } }, "parameters": { @@ -145,14 +108,15 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." + "description": "JSON-encoded Foundry project connections to create." } } }, "variables": { + "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" }, "resources": { @@ -251,12 +215,12 @@ "projectConnections": { "copy": { "name": "projectConnections", - "count": "[length(parameters('connections'))]" + "count": "[length(variables('connectionList'))]" }, "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), variables('connectionList')[copyIndex()].name)]", + "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" } }, "outputs": { @@ -274,7 +238,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + "value": "[join(map(variables('connectionList'), 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 index fd93a5cddea..39303b9a311 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep @@ -25,19 +25,6 @@ type deploymentType = { } } -@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 - credentials: object? - metadata: object? -} - // Parameters @description('Name of the existing Foundry (AIServices) account.') @@ -63,8 +50,11 @@ 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('JSON-encoded Foundry project connections to create.') +@secure() +param connections string = '' + +var connectionList = json(empty(connections) ? '[]' : connections) // Resources @@ -163,7 +153,7 @@ resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connection // 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: { + for c in connectionList: { parent: foundryAccountPreview::project name: c.name properties: union( @@ -183,4 +173,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne 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), ',') +output AZURE_AI_PROJECT_CONNECTION_NAMES string = join(map(connectionList, c => c.name), ',') 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 61f627f1fda..1fb9ed8edec 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11064647930674610643" + "templateHash": "17959860464748483608" } }, "definitions": { @@ -54,43 +54,6 @@ "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" - }, - "credentials": { - "type": "object", - "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } } }, "parameters": { @@ -145,10 +108,10 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Foundry project connections to create (host: azure.ai.connection services)." + "description": "JSON-encoded Foundry project connections to create." } }, "principalId": { @@ -341,7 +304,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8482090354551809818" + "templateHash": "354603187727008413" } }, "definitions": { @@ -389,43 +352,6 @@ "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" - }, - "credentials": { - "type": "object", - "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } } }, "parameters": { @@ -473,10 +399,10 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Foundry project connections to create (host: azure.ai.connection services)." + "description": "JSON-encoded Foundry project connections to create." } }, "principalId": { @@ -585,6 +511,7 @@ }, "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", + "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", @@ -1396,7 +1323,7 @@ ] }, "projectConnections": { - "condition": "[not(empty(parameters('connections')))]", + "condition": "[not(empty(variables('connectionList')))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-connections", @@ -1418,70 +1345,12 @@ }, "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.44.1.10279", - "templateHash": "2215109488049914988" - } - }, - "definitions": { - "connectionType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Connection name. The resource name and the key a toolbox tool references via connection: ." - } - }, - "category": { - "type": "string", - "metadata": { - "description": "Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys." - } - }, - "target": { - "type": "string", - "metadata": { - "description": "Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL." - } - }, - "authType": { - "type": "string", - "metadata": { - "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." - } - }, - "credentials": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." - } - }, - "metadata": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional metadata key-value pairs." - } - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } - }, - "connectionsType": { - "type": "array", - "items": { - "$ref": "#/definitions/connectionType" - }, - "metadata": { - "description": "Shape of a list of connections." - } + "templateHash": "3592032988386574245" } }, "parameters": { @@ -1498,44 +1367,35 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." + "description": "JSON-encoded connections to create on the Foundry project." } } }, - "resources": { - "foundryAccount::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('foundryAccountName'), parameters('foundryProjectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('foundryAccountName')]" - }, - "projectConnections": { + "variables": { + "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]" + }, + "resources": [ + { "copy": { "name": "projectConnections", - "count": "[length(parameters('connections'))]" + "count": "[length(variables('connectionList'))]" }, "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), variables('connectionList')[copyIndex()].name)]", + "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" } - }, + ], "outputs": { "connectionNames": { "type": "string", "metadata": { "description": "Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding." }, - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + "value": "[join(map(variables('connectionList'), lambda('c', lambdaVariables('c').name)), ',')]" } } } @@ -1581,7 +1441,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[if(empty(parameters('connections')), '', reference('projectConnections').outputs.connectionNames.value)]" + "value": "[if(empty(variables('connectionList')), '', reference('projectConnections').outputs.connectionNames.value)]" }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", 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 0478f864e0d..74d22636d37 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 @@ -30,19 +30,6 @@ type deploymentType = { } } -@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 - credentials: object? - metadata: object? -} - // Parameters @description('Azure region for all resources.') @@ -70,8 +57,9 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('Foundry project connections to create (host: azure.ai.connection services).') -param connections connectionsType = [] +@description('JSON-encoded Foundry project connections to create.') +@secure() +param connections string = '' @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep index 3435248945d..7de87513eff 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep @@ -13,30 +13,6 @@ // User-defined types -@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') -type connectionType = { - @description('Connection name. The resource name and the key a toolbox tool references via connection: .') - name: string - - @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') - category: string - - @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') - target: string - - @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') - authType: string - - @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') - credentials: object? - - @description('Optional metadata key-value pairs.') - metadata: object? -} - -@description('Shape of a list of connections.') -type connectionsType = connectionType[] - // Parameters @description('Name of the existing Foundry CognitiveServices account that hosts the project.') @@ -45,8 +21,11 @@ param foundryAccountName string @description('Name of the existing Foundry project the connections are created on.') param foundryProjectName string -@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') -param connections connectionsType = [] +@description('JSON-encoded connections to create on the Foundry project.') +@secure() +param connections string = '' + +var connectionList = json(empty(connections) ? '[]' : connections) // Resources @@ -64,7 +43,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview // only emitted when supplied so None / identity-token connections don't send an // empty credentials object. resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connections: { + for c in connectionList: { parent: foundryAccount::project name: c.name properties: union( @@ -82,4 +61,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne // Outputs @description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') -output connectionNames string = join(map(connections, c => c.name), ',') +output connectionNames string = join(map(connectionList, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep index c28b5caaf9e..1c3cddf7d5d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep @@ -27,19 +27,6 @@ type deploymentType = { } } -@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 - credentials: object? - metadata: object? -} - // Parameters @description('Azure region for all resources.') @@ -62,8 +49,9 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('Foundry project connections to create (host: azure.ai.connection services).') -param connections connectionsType = [] +@description('JSON-encoded Foundry project connections to create.') +@secure() +param connections string = '' @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -117,6 +105,7 @@ var resourceToken = empty(resourceTokenSalt) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') +var connectionList = json(empty(connections) ? '[]' : connections) var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' @@ -290,7 +279,7 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat // host: azure.ai.connection services. Created at provision time so a toolbox // that references a connection by name resolves it at deploy. Depends on the // project via foundryAccount.name / project.name so ordering is correct. -module projectConnections 'connections.bicep' = if (!empty(connections)) { +module projectConnections 'connections.bicep' = if (!empty(connectionList)) { name: 'foundry-connections' params: { foundryAccountName: foundryAccount.name @@ -321,6 +310,6 @@ output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.service output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connectionList) ? '' : projectConnections!.outputs.connectionNames output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 61c826bbdae..bbcf778b26f 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -9,6 +9,9 @@ provisioning provider and its project validation checks. It reads `azure.ai.project` model deployments before provisioning and deploys, while retaining compatibility with pre-split Foundry manifests. + Upgrade with `azure.ai.agents` 1.0.0-beta.6 or later. The paired extensions + must be upgraded together to avoid duplicate `microsoft.foundry` provider + registration. ## 1.0.0-beta.2 (2026-07-09) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go index 3a7257ffa20..a488b698d5d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go @@ -13,6 +13,7 @@ import ( "azure.ai.projects/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" "google.golang.org/protobuf/types/known/structpb" ) @@ -32,7 +33,10 @@ func projectLifecycleHandler( return fmt.Errorf("project lifecycle event has no project") } - cfg, found, err := loadProjectServiceConfig(args.Project.Services) + cfg, found, err := loadProjectServiceConfig( + args.Project.Services, + args.Project.Path, + ) if err != nil { return err } @@ -75,6 +79,7 @@ func projectLifecycleHandler( func loadProjectServiceConfig( services map[string]*azdext.ServiceConfig, + projectRoot string, ) (*projectServiceConfig, bool, error) { var names []string for name, service := range services { @@ -103,7 +108,19 @@ func loadProjectServiceConfig( return cfg, true, nil } - data, err := json.Marshal(props.AsMap()) + values := props.AsMap() + if projectRoot != "" { + resolved, err := foundry.ResolveFileRefs(values, projectRoot) + if err != nil { + return nil, false, fmt.Errorf( + "resolve project service %q $ref includes: %w", + names[0], + err, + ) + } + values = resolved + } + data, err := json.Marshal(values) if err != nil { return nil, false, fmt.Errorf( "encoding project service %q config: %w", diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go index 0ad2f70af7b..292a414be93 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go @@ -6,6 +6,8 @@ package cmd import ( "context" "net" + "os" + "path/filepath" "sync" "testing" @@ -89,6 +91,7 @@ func TestLoadProjectServiceConfig(t *testing.T) { map[string]*azdext.ServiceConfig{ "service": test.service, }, + "", ) require.NoError(t, err) assert.Equal(t, test.wantSeen, found) @@ -109,7 +112,7 @@ func TestLoadProjectServiceConfigRejectsDuplicates(t *testing.T) { "alpha": {Host: aiProjectHost}, } - _, _, err := loadProjectServiceConfig(services) + _, _, err := loadProjectServiceConfig(services, "") require.Error(t, err) assert.Contains(t, err.Error(), "alpha, zeta") } @@ -195,6 +198,43 @@ func TestProjectLifecycleHandlerClearsEmptyDeployments(t *testing.T) { assert.Equal(t, "[]", envServer.value) } +func TestProjectLifecycleHandlerResolvesDeploymentRefs(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "deployment.yaml"), + []byte("name: gpt-4o\nmodel: {name: gpt-4o, format: OpenAI, version: '2024-08-06'}\n"+ + "sku: {name: Standard, capacity: 10}\n"), + 0600, + )) + + envServer := &recordingProjectEnvironmentServer{envName: "dev"} + client := newProjectEnvironmentClient(t, envServer) + err := projectLifecycleHandler( + t.Context(), + client, + &azdext.ProjectEventArgs{ + Project: &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "project": { + Host: aiProjectHost, + AdditionalProperties: mustProjectProperties(t, map[string]any{ + "deployments": []any{ + map[string]any{"$ref": "./deployment.yaml"}, + }, + }), + }, + }, + }, + }, + ) + require.NoError(t, err) + + envServer.mu.Lock() + defer envServer.mu.Unlock() + assert.Contains(t, envServer.value, `\"name\":\"gpt-4o\"`) +} + func mustProjectProperties( t *testing.T, value map[string]any, 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 6a6db6673dd..abdc04a3d39 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 @@ -150,7 +150,21 @@ func (p *FoundryProvisioningProvider) Initialize( "skipping synthesizer", filepath.Join(projectPath, onDiskInfraDir)) // endpoint: (brownfield) reuse skips provisioning even on the on-disk // path; connect to the existing project instead of compiling Bicep. - if endpoint := foundryServiceEndpoint(rawYAML, svcName); endpoint != "" { + if endpoint, err := synthesis.ProjectEndpoint( + rawYAML, + svcName, + projectPath, + ); 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", + ) + } else if endpoint != "" { warnNetworkIgnoredInBrownfield(rawYAML, svcName) p.brownfieldEndpoint = endpoint if err := p.captureBrownfieldDeployments(ctx, rawYAML, svcName); err != nil { @@ -173,7 +187,23 @@ func (p *FoundryProvisioningProvider) Initialize( // endpoint: reuse — connect to the existing project, skip provisioning. // network: has no effect in brownfield mode; warn if both are present. warnNetworkIgnoredInBrownfield(rawYAML, svcName) - p.brownfieldEndpoint = foundryServiceEndpoint(rawYAML, svcName) + endpoint, endpointErr := synthesis.ProjectEndpoint( + rawYAML, + svcName, + projectPath, + ) + if endpointErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "read endpoint for Foundry project service %q: %s", + svcName, + endpointErr, + ), + "check the endpoint field under your azure.ai.project service", + ) + } + p.brownfieldEndpoint = endpoint if err := p.captureBrownfieldDeployments(ctx, rawYAML, svcName); err != nil { return err } @@ -702,7 +732,11 @@ func (p *FoundryProvisioningProvider) Deploy( func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( ctx context.Context, rawYAML []byte, svcName string, ) error { - deployments, err := synthesis.BrownfieldDeployments(rawYAML, svcName) + deployments, err := synthesis.BrownfieldDeployments( + rawYAML, + svcName, + p.projectPath, + ) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, @@ -712,7 +746,11 @@ func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( } p.brownfieldDeployments = deployments - connections, err := synthesis.BrownfieldConnections(rawYAML, p.networkEnvMap(ctx)) + connections, err := synthesis.BrownfieldConnections( + rawYAML, + p.networkEnvMap(ctx), + p.projectPath, + ) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, @@ -765,11 +803,15 @@ func (p *FoundryProvisioningProvider) deployBrownfield( 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: p.brownfieldParams(ctx, account, rg, createACR), + Parameters: params, Mode: new(armresources.DeploymentModeIncremental), }, Tags: map[string]*string{ @@ -836,11 +878,18 @@ func brownfieldReconcileMessage(hasDeployments, createACR, hasConnections bool) // 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 { +) (map[string]any, error) { + connections, err := synthesis.EncodeConnections(p.brownfieldConnections) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("encode brownfield connections: %s", err), + ) + } params := map[string]any{ "accountName": map[string]any{"value": account}, "deployments": map[string]any{"value": p.brownfieldDeployments}, - "connections": map[string]any{"value": p.brownfieldConnections}, + "connections": map[string]any{"secureValue": connections}, // 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 "/" @@ -857,7 +906,7 @@ func (p *FoundryProvisioningProvider) brownfieldParams( params["location"] = map[string]any{"value": loc} } } - return params + return params, nil } // previewBrownfield runs a resource-group-scoped what-if on brownfield.arm.json @@ -886,6 +935,10 @@ func (p *FoundryProvisioningProvider) previewBrownfield( 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 { @@ -895,7 +948,7 @@ func (p *FoundryProvisioningProvider) previewBrownfield( whatIf := armresources.DeploymentWhatIf{ Properties: &armresources.DeploymentWhatIfProperties{ Template: tmpl, - Parameters: p.brownfieldParams(ctx, account, rg, createACR), + Parameters: params, Mode: new(armresources.DeploymentModeIncremental), }, } @@ -1697,7 +1750,11 @@ func (p *FoundryProvisioningProvider) armParameters() map[string]any { return out } for k, v := range p.synthResult.Parameters { - out[k] = map[string]any{"value": v} + if k == "connections" { + out[k] = map[string]any{"secureValue": v} + } else { + out[k] = map[string]any{"value": v} + } } return out } 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 index d412a8752df..976dc48070d 100644 --- 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 @@ -184,11 +184,12 @@ func TestBrownfieldParams(t *testing.T) { brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", brownfieldDeployments: deployments, } - params := p.brownfieldParams(t.Context(), "acct", "rg", false) + 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(nil)}, params["connections"]) + assert.Equal(t, map[string]any{"secureValue": "[]"}, params["connections"]) assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) assert.NotContains(t, params, "includeAcr") assert.NotContains(t, params, "acrName") @@ -202,9 +203,14 @@ func TestBrownfieldParams(t *testing.T) { brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", brownfieldConnections: conns, } - params := p.brownfieldParams(t.Context(), "acct", "rg", false) - - assert.Equal(t, map[string]any{"value": conns}, params["connections"]) + params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) + require.NoError(t, err) + + assert.Equal( + t, + map[string]any{"secureValue": `[{"name":"search-conn","category":"CognitiveSearch","target":"","authType":""}]`}, + params["connections"], + ) // Connections are project-scoped, so projectName must be supplied even // without ACR. assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) @@ -218,7 +224,8 @@ func TestBrownfieldParams(t *testing.T) { brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", azdClient: newKVEnvClient(t, map[string]string{"AZURE_LOCATION": "westus2"}), } - params := p.brownfieldParams(t.Context(), "acct", "rg", true) + 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"]) @@ -235,7 +242,8 @@ func TestBrownfieldParams(t *testing.T) { brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", azdClient: newKVEnvClient(t, map[string]string{}), } - params := p.brownfieldParams(t.Context(), "acct", "rg", true) + params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) + require.NoError(t, err) assert.Contains(t, params, "includeAcr") assert.NotContains(t, params, "location") 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 4e7c9071042..8cff6d807f4 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -13,6 +13,7 @@ package synthesis import ( + "encoding/json" "errors" "fmt" "maps" @@ -225,24 +226,9 @@ func Synthesize(in Input) (*Result, error) { return nil, fmt.Errorf("parse azure.yaml: %w", err) } - node, ok := root.Services[in.ServiceName] - if !ok { - return nil, ErrServiceNotFound - } - - // Resolve $ref file includes (service-entry-level and per-deployment) so the - // decoded service body carries the referenced content, not raw $ref objects. - if in.ProjectRoot != "" { - var err error - node, err = resolveServiceRefs(node, in.ProjectRoot, in.ServiceName) - if err != nil { - return nil, err - } - } - - var svc projectService - if err := node.Decode(&svc); err != nil { - return nil, fmt.Errorf("decode service %q: %w", in.ServiceName, 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) { @@ -252,14 +238,23 @@ func Synthesize(in Input) (*Result, error) { return nil, ErrEndpointBrownfield } - includeAcr := deriveIncludeAcr(root.Services, svc) + includeAcr := deriveIncludeAcr(root.Services, svc, in.ProjectRoot) deployments := svc.Deployments if deployments == nil { deployments = []Deployment{} } - connections, err := collectConnections(root.Services, in.Env, !in.PreserveVarRefs) + connections, err := collectConnections( + root.Services, + in.Env, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } + encodedConnections, err := EncodeConnections(connections) if err != nil { return nil, err } @@ -272,7 +267,7 @@ func Synthesize(in Input) (*Result, error) { params := map[string]any{ "deployments": deployments, "includeAcr": includeAcr, - "connections": connections, + "connections": encodedConnections, } maps.Copy(params, netParams) @@ -282,12 +277,28 @@ func Synthesize(in Input) (*Result, error) { }, nil } +// EncodeConnections serializes connection definitions for a secure ARM parameter. +func EncodeConnections(connections []Connection) (string, error) { + if connections == nil { + connections = []Connection{} + } + data, err := json.Marshal(connections) + if err != nil { + return "", fmt.Errorf("encode connections: %w", err) + } + return string(data), nil +} + // BrownfieldDeployments returns the model deployments declared on a brownfield // (endpoint:) Foundry project service. Synthesize short-circuits with // ErrEndpointBrownfield before reading deployments:, so the provider uses this // to learn which model deployments to create on the existing account. Returns // nil (not an error) when the service declares no deployments. -func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) { +func BrownfieldDeployments( + raw []byte, + serviceName string, + projectRoot string, +) ([]Deployment, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") } @@ -300,14 +311,9 @@ func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) return nil, fmt.Errorf("parse azure.yaml: %w", err) } - node, ok := root.Services[serviceName] - if !ok { - return nil, ErrServiceNotFound - } - - var svc projectService - if err := node.Decode(&svc); err != nil { - return nil, fmt.Errorf("decode service %q: %w", serviceName, err) + svc, err := loadProjectService(root.Services, serviceName, projectRoot) + if err != nil { + return nil, err } return svc.Deployments, nil @@ -320,7 +326,11 @@ func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) // resolved from env since brownfield provisions immediately; Foundry ${{...}} // expressions pass through. Returns an empty slice (not an error) when none // are declared. -func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, error) { +func BrownfieldConnections( + raw []byte, + env map[string]string, + projectRoot string, +) ([]Connection, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") } @@ -330,7 +340,55 @@ func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, err return nil, fmt.Errorf("parse azure.yaml: %w", err) } - return collectConnections(root.Services, env, true) + return collectConnections(root.Services, env, true, projectRoot) +} + +// ProjectEndpoint returns the endpoint configured on a Foundry project service. +// It resolves $ref includes before decoding the service body. +func ProjectEndpoint( + raw []byte, + serviceName string, + projectRoot string, +) (string, error) { + if len(raw) == 0 { + return "", errors.New("synthesis: raw azure.yaml is empty") + } + + var root projectFile + if err := yaml.Unmarshal(raw, &root); err != nil { + return "", fmt.Errorf("parse azure.yaml: %w", err) + } + + svc, err := loadProjectService(root.Services, serviceName, projectRoot) + if err != nil { + return "", err + } + return strings.TrimSpace(svc.Endpoint), nil +} + +// loadProjectService decodes a service after resolving any local $ref includes. +func loadProjectService( + services map[string]yaml.Node, + serviceName string, + projectRoot string, +) (projectService, error) { + node, ok := services[serviceName] + if !ok { + return projectService{}, ErrServiceNotFound + } + if projectRoot != "" { + var err error + node, err = resolveServiceRefs(node, projectRoot, serviceName) + if err != nil { + return projectService{}, err + } + } + + var svc projectService + if err := node.Decode(&svc); err != nil { + return projectService{}, fmt.Errorf("decode service %q: %w", serviceName, err) + } + return svc, nil } // resolveServiceRefs expands $ref file includes in one service entry. It decodes @@ -367,12 +425,23 @@ func resolveServiceRefs(node yaml.Node, projectRoot, serviceName string) (yaml.N // Keying on image/codeConfiguration rather than the optional docker: block is // deliberate: docker: is not in the agent schema and is dropped by omitempty // when remoteBuild is false, so it is not a reliable build signal. -func deriveIncludeAcr(services map[string]yaml.Node, svc projectService) bool { +func deriveIncludeAcr( + services map[string]yaml.Node, + svc projectService, + projectRoot string, +) bool { if slices.ContainsFunc(svc.Agents, agentNeedsAcr) { return true } - for _, node := range services { + for name, node := range services { + if projectRoot != "" { + var err error + node, err = resolveServiceRefs(node, projectRoot, name) + if err != nil { + continue + } + } var service serviceBlock if err := node.Decode(&service); err != nil { continue @@ -417,10 +486,18 @@ func collectConnections( services map[string]yaml.Node, env map[string]string, resolve bool, + projectRoot string, ) ([]Connection, error) { connections := []Connection{} for name, node := range services { + if projectRoot != "" { + var err error + node, err = resolveServiceRefs(node, projectRoot, name) + if err != nil { + return nil, err + } + } var host struct { Host string `yaml:"host"` } 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 d72300a5f80..cffa05e424b 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 @@ -500,8 +500,7 @@ services: require.True(t, ok, "includeAcr param should be bool") assert.Equal(t, tt.wantIncludeAcr, includeAcr) - connections, ok := res.Parameters["connections"].([]Connection) - require.True(t, ok, "connections param should be []Connection, got %T", res.Parameters["connections"]) + connections := resultConnections(t, res) if tt.wantConnectionNames != nil { gotNames := make([]string, len(connections)) for i, c := range connections { @@ -540,8 +539,7 @@ services: getConn := func(t *testing.T, res *Result) Connection { t.Helper() - conns, ok := res.Parameters["connections"].([]Connection) - require.True(t, ok) + conns := resultConnections(t, res) require.Len(t, conns, 1) return conns[0] } @@ -655,7 +653,11 @@ services: ` t.Run("collects and resolves connections (sorted)", func(t *testing.T) { - conns, err := BrownfieldConnections([]byte(yaml), map[string]string{"SEARCH_API_KEY": "secret"}) + conns, err := BrownfieldConnections( + []byte(yaml), + map[string]string{"SEARCH_API_KEY": "secret"}, + "", + ) require.NoError(t, err) require.Len(t, conns, 2) assert.Equal(t, "bing-conn", conns[0].Name) @@ -671,13 +673,13 @@ services: host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 ` - conns, err := BrownfieldConnections([]byte(noConns), nil) + conns, err := BrownfieldConnections([]byte(noConns), nil, "") require.NoError(t, err) assert.Empty(t, conns) }) t.Run("empty raw errors", func(t *testing.T) { - _, err := BrownfieldConnections(nil, nil) + _, err := BrownfieldConnections(nil, nil, "") require.Error(t, err) }) } @@ -761,7 +763,7 @@ services: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := BrownfieldDeployments([]byte(tt.yaml), tt.serviceName) + got, err := BrownfieldDeployments([]byte(tt.yaml), tt.serviceName, "") if tt.serviceName == "" { require.Error(t, err) @@ -788,7 +790,7 @@ services: } func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { - _, err := BrownfieldDeployments(nil, "my-project") + _, err := BrownfieldDeployments(nil, "my-project", "") require.Error(t, err) } @@ -870,6 +872,107 @@ services: assert.Equal(t, 10, deployments[0].Sku.Capacity) } +func TestSynthesize_ResolvesSiblingServiceRefs(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte("category: CognitiveSearch\ntarget: https://search.example\n"+ + "authType: ApiKey\ncredentials:\n key: ${SEARCH_KEY}\n"), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "agent.yaml"), + []byte("kind: hosted\nimage: example.azurecr.io/agent:latest\n"), + 0600, + )) + + yaml := ` +services: + project: + host: azure.ai.project + connection: + host: azure.ai.connection + $ref: ./connection.yaml + agent: + host: azure.ai.agent + $ref: ./agent.yaml +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{"SEARCH_KEY": "secret"}, + ProjectRoot: root, + }) + require.NoError(t, err) + assert.Equal(t, false, res.Parameters["includeAcr"]) + + connections := resultConnections(t, res) + require.Len(t, connections, 1) + assert.Equal(t, "CognitiveSearch", connections[0].Category) + assert.Equal(t, "https://search.example", connections[0].Target) + assert.Equal(t, "secret", connections[0].Credentials["key"]) +} + +func resultConnections(t *testing.T, result *Result) []Connection { + t.Helper() + + raw, ok := result.Parameters["connections"].(string) + require.True(t, ok, "connections param should be a JSON string") + + var connections []Connection + require.NoError(t, json.Unmarshal([]byte(raw), &connections)) + return connections +} + +func TestBrownfieldServiceResolversResolveRefs(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "project.yaml"), + []byte("endpoint: https://example.services.ai.azure.com/api/projects/existing\n"+ + "deployments:\n - $ref: ./deployment.yaml\n"), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "deployment.yaml"), + []byte("name: gpt-4o\nmodel: {name: gpt-4o, format: OpenAI, version: '2024-08-06'}\n"+ + "sku: {name: Standard, capacity: 10}\n"), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte("category: CognitiveSearch\ntarget: https://search.example\nauthType: None\n"), + 0600, + )) + + yaml := ` +services: + project: + host: azure.ai.project + $ref: ./project.yaml + connection: + host: azure.ai.connection + $ref: ./connection.yaml +` + endpoint, err := ProjectEndpoint([]byte(yaml), "project", root) + require.NoError(t, err) + assert.Equal( + t, + "https://example.services.ai.azure.com/api/projects/existing", + endpoint, + ) + + deployments, err := BrownfieldDeployments([]byte(yaml), "project", root) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) + + connections, err := BrownfieldConnections([]byte(yaml), nil, root) + require.NoError(t, err) + require.Len(t, connections, 1) + assert.Equal(t, "CognitiveSearch", connections[0].Category) +} + func TestSynthesize_InputValidation(t *testing.T) { tests := []struct { name string @@ -1010,9 +1113,11 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { require.True(t, ok, "parameters must be an object") assert.Contains(t, params, "resourceGroupName") - // connections carries the synthesized host: azure.ai.connection services so - // the connections module can create them at provision time. + // connections carries credentials and must be a secure ARM parameter. assert.Contains(t, params, "connections", "connections param must be declared in the ARM template") + connections, ok := params["connections"].(map[string]any) + require.True(t, ok, "connections param must be an object") + assert.Equal(t, "securestring", connections["type"]) // Network isolation parameters must exist so the synthesizer's network // param set is accepted by ARM (extra params would fail the deployment). @@ -1060,6 +1165,19 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { "managedNetworks isolationMode must come from the managedIsolationMode param") } +func TestBrownfieldARMTemplate_UsesSecureConnections(t *testing.T) { + data, err := BrownfieldARMTemplate() + require.NoError(t, err) + + var arm map[string]any + require.NoError(t, json.Unmarshal(data, &arm)) + params, ok := arm["parameters"].(map[string]any) + 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, "securestring", connections["type"]) +} + func TestSynthesize_Network(t *testing.T) { t.Setenv("AZURE_VNET_ID", "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/"+ 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 index 942b2761a89..e32aa1aab90 100644 --- 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13884339769371824597" + "templateHash": "12070387023310168485" } }, "definitions": { @@ -54,43 +54,6 @@ "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" - }, - "credentials": { - "type": "object", - "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } } }, "parameters": { @@ -145,14 +108,15 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." + "description": "JSON-encoded Foundry project connections to create." } } }, "variables": { + "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" }, "resources": { @@ -251,12 +215,12 @@ "projectConnections": { "copy": { "name": "projectConnections", - "count": "[length(parameters('connections'))]" + "count": "[length(variables('connectionList'))]" }, "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), variables('connectionList')[copyIndex()].name)]", + "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" } }, "outputs": { @@ -274,7 +238,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + "value": "[join(map(variables('connectionList'), 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 index fd93a5cddea..39303b9a311 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep @@ -25,19 +25,6 @@ type deploymentType = { } } -@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 - credentials: object? - metadata: object? -} - // Parameters @description('Name of the existing Foundry (AIServices) account.') @@ -63,8 +50,11 @@ 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('JSON-encoded Foundry project connections to create.') +@secure() +param connections string = '' + +var connectionList = json(empty(connections) ? '[]' : connections) // Resources @@ -163,7 +153,7 @@ resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connection // 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: { + for c in connectionList: { parent: foundryAccountPreview::project name: c.name properties: union( @@ -183,4 +173,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne 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), ',') +output AZURE_AI_PROJECT_CONNECTION_NAMES string = join(map(connectionList, c => c.name), ',') 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 61f627f1fda..1fb9ed8edec 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11064647930674610643" + "templateHash": "17959860464748483608" } }, "definitions": { @@ -54,43 +54,6 @@ "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" - }, - "credentials": { - "type": "object", - "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } } }, "parameters": { @@ -145,10 +108,10 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Foundry project connections to create (host: azure.ai.connection services)." + "description": "JSON-encoded Foundry project connections to create." } }, "principalId": { @@ -341,7 +304,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8482090354551809818" + "templateHash": "354603187727008413" } }, "definitions": { @@ -389,43 +352,6 @@ "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" - }, - "credentials": { - "type": "object", - "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } } }, "parameters": { @@ -473,10 +399,10 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Foundry project connections to create (host: azure.ai.connection services)." + "description": "JSON-encoded Foundry project connections to create." } }, "principalId": { @@ -585,6 +511,7 @@ }, "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", + "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", @@ -1396,7 +1323,7 @@ ] }, "projectConnections": { - "condition": "[not(empty(parameters('connections')))]", + "condition": "[not(empty(variables('connectionList')))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-connections", @@ -1418,70 +1345,12 @@ }, "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.44.1.10279", - "templateHash": "2215109488049914988" - } - }, - "definitions": { - "connectionType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Connection name. The resource name and the key a toolbox tool references via connection: ." - } - }, - "category": { - "type": "string", - "metadata": { - "description": "Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys." - } - }, - "target": { - "type": "string", - "metadata": { - "description": "Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL." - } - }, - "authType": { - "type": "string", - "metadata": { - "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." - } - }, - "credentials": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." - } - }, - "metadata": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional metadata key-value pairs." - } - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } - }, - "connectionsType": { - "type": "array", - "items": { - "$ref": "#/definitions/connectionType" - }, - "metadata": { - "description": "Shape of a list of connections." - } + "templateHash": "3592032988386574245" } }, "parameters": { @@ -1498,44 +1367,35 @@ } }, "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], + "type": "securestring", + "defaultValue": "", "metadata": { - "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." + "description": "JSON-encoded connections to create on the Foundry project." } } }, - "resources": { - "foundryAccount::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('foundryAccountName'), parameters('foundryProjectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('foundryAccountName')]" - }, - "projectConnections": { + "variables": { + "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]" + }, + "resources": [ + { "copy": { "name": "projectConnections", - "count": "[length(parameters('connections'))]" + "count": "[length(variables('connectionList'))]" }, "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), variables('connectionList')[copyIndex()].name)]", + "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" } - }, + ], "outputs": { "connectionNames": { "type": "string", "metadata": { "description": "Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding." }, - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" + "value": "[join(map(variables('connectionList'), lambda('c', lambdaVariables('c').name)), ',')]" } } } @@ -1581,7 +1441,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[if(empty(parameters('connections')), '', reference('projectConnections').outputs.connectionNames.value)]" + "value": "[if(empty(variables('connectionList')), '', reference('projectConnections').outputs.connectionNames.value)]" }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", 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 0478f864e0d..74d22636d37 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 @@ -30,19 +30,6 @@ type deploymentType = { } } -@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 - credentials: object? - metadata: object? -} - // Parameters @description('Azure region for all resources.') @@ -70,8 +57,9 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('Foundry project connections to create (host: azure.ai.connection services).') -param connections connectionsType = [] +@description('JSON-encoded Foundry project connections to create.') +@secure() +param connections string = '' @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep index 3435248945d..7de87513eff 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep @@ -13,30 +13,6 @@ // User-defined types -@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') -type connectionType = { - @description('Connection name. The resource name and the key a toolbox tool references via connection: .') - name: string - - @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') - category: string - - @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') - target: string - - @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') - authType: string - - @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') - credentials: object? - - @description('Optional metadata key-value pairs.') - metadata: object? -} - -@description('Shape of a list of connections.') -type connectionsType = connectionType[] - // Parameters @description('Name of the existing Foundry CognitiveServices account that hosts the project.') @@ -45,8 +21,11 @@ param foundryAccountName string @description('Name of the existing Foundry project the connections are created on.') param foundryProjectName string -@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') -param connections connectionsType = [] +@description('JSON-encoded connections to create on the Foundry project.') +@secure() +param connections string = '' + +var connectionList = json(empty(connections) ? '[]' : connections) // Resources @@ -64,7 +43,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview // only emitted when supplied so None / identity-token connections don't send an // empty credentials object. resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connections: { + for c in connectionList: { parent: foundryAccount::project name: c.name properties: union( @@ -82,4 +61,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne // Outputs @description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') -output connectionNames string = join(map(connections, c => c.name), ',') +output connectionNames string = join(map(connectionList, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep index c28b5caaf9e..1c3cddf7d5d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep @@ -27,19 +27,6 @@ type deploymentType = { } } -@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 - credentials: object? - metadata: object? -} - // Parameters @description('Azure region for all resources.') @@ -62,8 +49,9 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('Foundry project connections to create (host: azure.ai.connection services).') -param connections connectionsType = [] +@description('JSON-encoded Foundry project connections to create.') +@secure() +param connections string = '' @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -117,6 +105,7 @@ var resourceToken = empty(resourceTokenSalt) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') +var connectionList = json(empty(connections) ? '[]' : connections) var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' @@ -290,7 +279,7 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat // host: azure.ai.connection services. Created at provision time so a toolbox // that references a connection by name resolves it at deploy. Depends on the // project via foundryAccount.name / project.name so ordering is correct. -module projectConnections 'connections.bicep' = if (!empty(connections)) { +module projectConnections 'connections.bicep' = if (!empty(connectionList)) { name: 'foundry-connections' params: { foundryAccountName: foundryAccount.name @@ -321,6 +310,6 @@ output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.service output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connectionList) ? '' : projectConnections!.outputs.connectionNames output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' From 22536950257b2a7da48f470f4fdef86a2767fb76 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 23:05:27 +0800 Subject: [PATCH 05/16] fix: use valid ARM connection parameters --- .../foundry_provisioning_provider.go | 8 ++------ ...rovisioning_provider_brownfield_acr_test.go | 4 ++-- .../foundry_provisioning_provider_test.go | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) 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 abdc04a3d39..ae7f3952eba 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 @@ -889,7 +889,7 @@ func (p *FoundryProvisioningProvider) brownfieldParams( params := map[string]any{ "accountName": map[string]any{"value": account}, "deployments": map[string]any{"value": p.brownfieldDeployments}, - "connections": map[string]any{"secureValue": connections}, + "connections": map[string]any{"value": connections}, // 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 "/" @@ -1750,11 +1750,7 @@ func (p *FoundryProvisioningProvider) armParameters() map[string]any { return out } for k, v := range p.synthResult.Parameters { - if k == "connections" { - out[k] = map[string]any{"secureValue": v} - } else { - out[k] = map[string]any{"value": v} - } + out[k] = map[string]any{"value": v} } return out } 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 index 976dc48070d..0fb93ac804e 100644 --- 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 @@ -189,7 +189,7 @@ func TestBrownfieldParams(t *testing.T) { 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{"secureValue": "[]"}, params["connections"]) + assert.Equal(t, map[string]any{"value": "[]"}, params["connections"]) assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) assert.NotContains(t, params, "includeAcr") assert.NotContains(t, params, "acrName") @@ -208,7 +208,7 @@ func TestBrownfieldParams(t *testing.T) { assert.Equal( t, - map[string]any{"secureValue": `[{"name":"search-conn","category":"CognitiveSearch","target":"","authType":""}]`}, + map[string]any{"value": `[{"name":"search-conn","category":"CognitiveSearch","target":"","authType":""}]`}, params["connections"], ) // Connections are project-scoped, so projectName must be supplied even 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 a6039fd3a6d..6d572aa5c41 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 @@ -597,6 +597,24 @@ func TestArmParameters_NilSafeOnMissingSynthResult(t *testing.T) { "synthesizer-derived parameters should be absent when synthResult is nil") } +func TestArmParameters_UseValueEnvelopeForSecureConnections(t *testing.T) { + p := &FoundryProvisioningProvider{ + synthResult: &synthesis.Result{ + Parameters: map[string]any{ + "connections": `[{"name":"search-conn"}]`, + }, + }, + } + + out := p.armParameters() + + assert.Equal( + t, + map[string]any{"value": `[{"name":"search-conn"}]`}, + out["connections"], + ) +} + func TestDestroy_RefusesWithoutForceWhenNonInteractive(t *testing.T) { // Destroy must NEVER silently delete (or, worse, silently leak) // resources. Without --force the provider prompts for confirmation, but From c2ba868aeebb1017f4c7369ad9086138f43e5e4d Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 23:09:31 +0800 Subject: [PATCH 06/16] fix: normalize PR Markdown wrapping --- cli/azd/extensions/azure.ai.agents/AGENTS.md | 5 +---- .../extensions/azure.ai.agents/CHANGELOG.md | 8 +------- cli/azd/extensions/azure.ai.agents/README.md | 9 +-------- .../docs/private-networking.md | 4 +--- .../extensions/azure.ai.projects/AGENTS.md | 19 ++++--------------- .../extensions/azure.ai.projects/CHANGELOG.md | 9 +-------- .../extensions/azure.ai.projects/README.md | 13 +++---------- docs/reference/telemetry-data.md | 4 +--- 8 files changed, 13 insertions(+), 58 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/AGENTS.md b/cli/azd/extensions/azure.ai.agents/AGENTS.md index 6da80a3b9eb..16a7e065acf 100644 --- a/cli/azd/extensions/azure.ai.agents/AGENTS.md +++ b/cli/azd/extensions/azure.ai.agents/AGENTS.md @@ -126,10 +126,7 @@ A new extension release ships in two PRs: ### Provider handoff release -The release that removes `microsoft.foundry` from this extension must -be coordinated with the provider-bearing `azure.ai.projects` release. -Publish both artifacts before updating either registry entry, then -update both entries and the `microsoft.foundry` meta-package together. +The release that removes `microsoft.foundry` from this extension must be coordinated with the provider-bearing `azure.ai.projects` release. Publish both artifacts before updating either registry entry, then update both entries and the `microsoft.foundry` meta-package together. ### PR 1 — Version bump diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index c11b057274d..ec4da24cf28 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,13 +4,7 @@ ### Other Changes -- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) Foundry - provisioning ownership moved to `azure.ai.projects`. This extension - now depends on `azure.ai.projects` for `microsoft.foundry` provisioning - while retaining agent init and pre-split manifest compatibility. - Upgrade with `azure.ai.projects` 1.0.0-beta.3 or later. The paired - extensions must be upgraded together to avoid duplicate - `microsoft.foundry` provider registration. +- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) Foundry provisioning ownership moved to `azure.ai.projects`. This extension now depends on `azure.ai.projects` for `microsoft.foundry` provisioning while retaining agent init and pre-split manifest compatibility. Upgrade with `azure.ai.projects` 1.0.0-beta.3 or later. The paired extensions must be upgraded together to avoid duplicate `microsoft.foundry` provider registration. - [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path. ## 1.0.0-beta.5 (2026-07-09) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 32ab964caa1..6335045ba68 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -50,14 +50,7 @@ services: ## Private networking for `host: azure.ai.project` -Foundry project services can be provisioned as network-secured, VNet-bound -accounts by adding a `network:` block to the `host: azure.ai.project` service in -`azure.yaml`. The `azure.ai.projects` extension owns that service and the -`microsoft.foundry` provider; this extension still authors the block during -agent init. See -[Private networking for `host: azure.ai.project`](docs/private-networking.md) -for the schema reference, BYO-image requirements, and VNet deployment -cheatsheet. +Foundry project services can be provisioned as network-secured, VNet-bound accounts by adding a `network:` block to the `host: azure.ai.project` service in `azure.yaml`. The `azure.ai.projects` extension owns that service and the `microsoft.foundry` provider; this extension still authors the block during agent init. See [Private networking for `host: azure.ai.project`](docs/private-networking.md) for the schema reference, BYO-image requirements, and VNet deployment cheatsheet. ## Local Development diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index aba38b38eb2..3f92ad53d0f 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -4,9 +4,7 @@ A Foundry project service can be provisioned as a **network-secured (VNet-bound) Do **not** place `network:` on `host: azure.ai.agent`. Agent services describe deployable agents and depend on the project through `uses:`; the project service owns account-level provisioning inputs such as `endpoint:`, `deployments:`, and `network:`. -The `azure.ai.projects` extension owns the project service and the -`microsoft.foundry` provider. `azd ai agent init` continues to author -the block and eject its IaC during the staged ownership migration. +The `azure.ai.projects` extension owns the project service and the `microsoft.foundry` provider. `azd ai agent init` continues to author the block and eject its IaC during the staged ownership migration. When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. diff --git a/cli/azd/extensions/azure.ai.projects/AGENTS.md b/cli/azd/extensions/azure.ai.projects/AGENTS.md index ad6a7852aac..2d0da27d1c8 100644 --- a/cli/azd/extensions/azure.ai.projects/AGENTS.md +++ b/cli/azd/extensions/azure.ai.projects/AGENTS.md @@ -4,13 +4,9 @@ Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root ## Overview -`azure.ai.projects` is a first-party azd extension under -`cli/azd/extensions/azure.ai.projects/`. It runs as a separate Go -binary and talks to the azd host over gRPC. +`azure.ai.projects` is a first-party azd extension under `cli/azd/extensions/azure.ai.projects/`. It runs as a separate Go binary and talks to the azd host over gRPC. -It owns the `azure.ai.project` service target and the -`microsoft.foundry` provisioning provider. The provider handles -greenfield and existing-project provisioning, preview, and teardown. +It owns the `azure.ai.project` service target and the `microsoft.foundry` provisioning provider. The provider handles greenfield and existing-project provisioning, preview, and teardown. It owns the Foundry project endpoint context used by other AI extensions (e.g. `azure.ai.agents`). The `azd ai project` commands persist, resolve, and surface the endpoint through a 5-level cascade: @@ -29,9 +25,7 @@ Useful places to start: - `internal/synthesis/`: project config synthesis and embedded IaC - `internal/exterrors/`: structured error factories and extension-specific codes -During the staged ownership migration, `internal/synthesis/` is also -present in `azure.ai.agents` for `azd ai agent init --infra`. Keep the -production files and templates byte-for-byte aligned until init moves. +During the staged ownership migration, `internal/synthesis/` is also present in `azure.ai.agents` for `azd ai agent init --infra`. Keep the production files and templates byte-for-byte aligned until init moves. ## Build and test @@ -152,12 +146,7 @@ A new extension release ships in two PRs: ### Provider handoff release -The first release that moves `microsoft.foundry` here must be -coordinated with the matching `azure.ai.agents` release. Publish both -artifacts before updating either registry entry, then update both -entries and the `microsoft.foundry` meta-package together. Old agents -and new projects versions cannot run together because azd rejects -duplicate provider registration. +The first release that moves `microsoft.foundry` here must be coordinated with the matching `azure.ai.agents` release. Publish both artifacts before updating either registry entry, then update both entries and the `microsoft.foundry` meta-package together. Old agents and new projects versions cannot run together because azd rejects duplicate provider registration. ### PR 1 — Version bump diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index bbcf778b26f..d4bc839f952 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -4,14 +4,7 @@ ### Features Added -- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) The - `azure.ai.projects` extension now owns the `microsoft.foundry` - provisioning provider and its project validation checks. It reads - `azure.ai.project` model deployments before provisioning and deploys, - while retaining compatibility with pre-split Foundry manifests. - Upgrade with `azure.ai.agents` 1.0.0-beta.6 or later. The paired extensions - must be upgraded together to avoid duplicate `microsoft.foundry` provider - registration. +- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) The `azure.ai.projects` extension now owns the `microsoft.foundry` provisioning provider and its project validation checks. It reads `azure.ai.project` model deployments before provisioning and deploys, while retaining compatibility with pre-split Foundry manifests. Upgrade with `azure.ai.agents` 1.0.0-beta.6 or later. The paired extensions must be upgraded together to avoid duplicate `microsoft.foundry` provider registration. ## 1.0.0-beta.2 (2026-07-09) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 1213bed0f0e..9cfcbba854f 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -4,10 +4,7 @@ Manage Microsoft Foundry Project resources from your terminal. (Preview) ## `azure.yaml` ownership -This extension owns `host: azure.ai.project` services and the -`microsoft.foundry` provisioning provider. A project service carries -account-level settings such as an existing project endpoint, model -deployments, and private networking. +This extension owns `host: azure.ai.project` services and the `microsoft.foundry` provisioning provider. A project service carries account-level settings such as an existing project endpoint, model deployments, and private networking. ```yaml infra: @@ -28,10 +25,6 @@ services: capacity: 50 ``` -When `endpoint` is omitted, `azd provision` creates a Foundry account -and project. When it is set, provisioning reuses that project and -reconciles the declarations that can be applied to an existing account. +When `endpoint` is omitted, `azd provision` creates a Foundry account and project. When it is set, provisioning reuses that project and reconciles the declarations that can be applied to an existing account. -The `azd ai project set`, `show`, and `unset` commands manage the -default Foundry project endpoint context. They do not currently author -the project service in `azure.yaml`. +The `azd ai project set`, `show`, and `unset` commands manage the default Foundry project endpoint context. They do not currently author the project service in `azure.yaml`. diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 6a3cd0b0201..12cd7dbdc0d 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -395,9 +395,7 @@ Emitted on `azd provision` / `azd up` to measure adoption and safety of `infra.l
Foundry Private Networking -Emitted at provision start by the `microsoft.foundry` provisioning provider -in the `azure.ai.projects` extension to measure secured-agent adoption and -the BYO-vs-managed split. +Emitted at provision start by the `microsoft.foundry` provisioning provider in the `azure.ai.projects` extension to measure secured-agent adoption and the BYO-vs-managed split. | Field Key | Type | Description | |-----------|------|-------------| From aff97c7b3dbf6d2bd9338522f5211a96fa9258a0 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 23:18:17 +0800 Subject: [PATCH 07/16] ci: run projects tests in agents workflow --- .github/workflows/test-ext-azure-ai-agents.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test-ext-azure-ai-agents.yml b/.github/workflows/test-ext-azure-ai-agents.yml index 53d230d8a65..d54bcb02773 100644 --- a/.github/workflows/test-ext-azure-ai-agents.yml +++ b/.github/workflows/test-ext-azure-ai-agents.yml @@ -66,6 +66,10 @@ jobs: azd x publish azd extension install azure.ai.projects --source local --force --no-prompt + - name: Run projects unit tests + working-directory: cli/azd/extensions/azure.ai.projects + run: go test ./... -count=1 + - name: Build extension (ci-build.ps1) working-directory: cli/azd/extensions/azure.ai.agents run: | From 7ec2ce6d7b93cce5a1b09eb28262d483efe521ff Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 09:57:30 +0800 Subject: [PATCH 08/16] fix: defer Foundry extension release metadata --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 1 - cli/azd/extensions/azure.ai.agents/extension.yaml | 2 +- cli/azd/extensions/azure.ai.agents/version.txt | 2 +- cli/azd/extensions/azure.ai.projects/CHANGELOG.md | 6 ------ cli/azd/extensions/azure.ai.projects/extension.yaml | 2 +- cli/azd/extensions/azure.ai.projects/version.txt | 2 +- 6 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index ec4da24cf28..941dd97a5f9 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,7 +4,6 @@ ### Other Changes -- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) Foundry provisioning ownership moved to `azure.ai.projects`. This extension now depends on `azure.ai.projects` for `microsoft.foundry` provisioning while retaining agent init and pre-split manifest compatibility. Upgrade with `azure.ai.projects` 1.0.0-beta.3 or later. The paired extensions must be upgraded together to avoid duplicate `microsoft.foundry` provider registration. - [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path. ## 1.0.0-beta.5 (2026-07-09) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 132eae69565..e4b36e9ec13 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Beta) description: Ship agents with Microsoft Foundry from your terminal. (Beta) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 1.0.0-beta.6 +version: 1.0.0-beta.5 requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index 6c3924b6842..ae27bfcaecc 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -1.0.0-beta.6 +1.0.0-beta.5 diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index d4bc839f952..61c9b1c39ac 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.3 (Unreleased) - -### Features Added - -- [[#9085]](https://github.com/Azure/azure-dev/issues/9085) The `azure.ai.projects` extension now owns the `microsoft.foundry` provisioning provider and its project validation checks. It reads `azure.ai.project` model deployments before provisioning and deploys, while retaining compatibility with pre-split Foundry manifests. Upgrade with `azure.ai.agents` 1.0.0-beta.6 or later. The paired extensions must be upgraded together to avoid duplicate `microsoft.foundry` provider registration. - ## 1.0.0-beta.2 (2026-07-09) ### Other Changes diff --git a/cli/azd/extensions/azure.ai.projects/extension.yaml b/cli/azd/extensions/azure.ai.projects/extension.yaml index ea35b78894a..132bb11ce0d 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -22,5 +22,5 @@ tags: - ai - project usage: azd ai project [options] -version: 1.0.0-beta.3 +version: 1.0.0-beta.2 requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.projects/version.txt b/cli/azd/extensions/azure.ai.projects/version.txt index b0a2ffd0f5f..7e0b23109f3 100644 --- a/cli/azd/extensions/azure.ai.projects/version.txt +++ b/cli/azd/extensions/azure.ai.projects/version.txt @@ -1 +1 @@ -1.0.0-beta.3 +1.0.0-beta.2 From ff80d3c977477dcc22caae953d037d4d3323742f Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 10:04:38 +0800 Subject: [PATCH 09/16] fix: document existing Foundry project ID --- cli/azd/extensions/azure.ai.projects/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 9cfcbba854f..9a03c2eb064 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -27,4 +27,12 @@ services: When `endpoint` is omitted, `azd provision` creates a Foundry account and project. When it is set, provisioning reuses that project and reconciles the declarations that can be applied to an existing account. +To reconcile deployments, connections, or a pending container registry on an existing project, set the project's full ARM resource ID in the active azd environment: + +```sh +azd env set AZURE_AI_PROJECT_ID "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" +``` + +`azd ai agent init` sets this value when initialized against an existing project. An endpoint-only service with no resources to reconcile does not require it. + The `azd ai project set`, `show`, and `unset` commands manage the default Foundry project endpoint context. They do not currently author the project service in `azure.yaml`. From 06dd90312b7849d803b7a0550681321b4fe62463 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 10:29:08 +0800 Subject: [PATCH 10/16] fix: validate isolated Foundry image provisioning --- .../workflows/test-ext-azure-ai-agents.yml | 2 +- .../internal/synthesis/synthesizer.go | 5 +++++ .../internal/synthesis/synthesizer_test.go | 22 +++++++++++++++++++ .../internal/synthesis/synthesizer.go | 5 +++++ .../internal/synthesis/synthesizer_test.go | 22 +++++++++++++++++++ 5 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-ext-azure-ai-agents.yml b/.github/workflows/test-ext-azure-ai-agents.yml index d54bcb02773..6cbe59990fb 100644 --- a/.github/workflows/test-ext-azure-ai-agents.yml +++ b/.github/workflows/test-ext-azure-ai-agents.yml @@ -86,7 +86,7 @@ jobs: run: | azd x pack azd x publish - azd extension install azure.ai.agents --source local --force --no-prompt + azd extension install azure.ai.agents --source local --force --no-dependencies --no-prompt - name: Run Tier 0 tests (offline, production extension) working-directory: cli/azd 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 8cff6d807f4..f9a1004ac9e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -263,6 +263,11 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } + if includeAcr && netMode != NetworkModeNone { + return nil, errors.New( + "synthesis: private networking does not support an auto-created Azure Container Registry; specify an image instead", + ) + } params := map[string]any{ "deployments": deployments, 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 a4dae0da8cd..9903e2008e7 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 @@ -1250,6 +1250,28 @@ services: } } +func TestSynthesize_RejectsAutoCreatedAcrWithPrivateNetworking(t *testing.T) { + const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/my-vnet" + const yaml = ` +services: + assistant: + host: azure.ai.agent + kind: hosted + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} +` + + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + }) + require.ErrorContains(t, err, "does not support an auto-created Azure Container Registry") +} + func TestSynthesize_NetworkValidationErrors(t *testing.T) { const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + "providers/Microsoft.Network/virtualNetworks/my-vnet" 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 8cff6d807f4..f9a1004ac9e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -263,6 +263,11 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } + if includeAcr && netMode != NetworkModeNone { + return nil, errors.New( + "synthesis: private networking does not support an auto-created Azure Container Registry; specify an image instead", + ) + } params := map[string]any{ "deployments": deployments, 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 cffa05e424b..66ed2614671 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 @@ -1355,6 +1355,28 @@ services: } } +func TestSynthesize_RejectsAutoCreatedAcrWithPrivateNetworking(t *testing.T) { + const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/my-vnet" + const yaml = ` +services: + assistant: + host: azure.ai.agent + kind: hosted + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} +` + + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + }) + require.ErrorContains(t, err, "does not support an auto-created Azure Container Registry") +} + func TestSynthesize_NetworkValidationErrors(t *testing.T) { const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + "providers/Microsoft.Network/virtualNetworks/my-vnet" From f7abe46a689fdc8305fff8a3a906437d5c27bddb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:43:46 +0000 Subject: [PATCH 11/16] fix: resolve merge conflict in init_infra_test.go Co-authored-by: huimiu <107838226+huimiu@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_infra_test.go | 6 ------ 1 file changed, 6 deletions(-) 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 ad410cdcbfb..e2d65324e61 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 @@ -364,7 +364,6 @@ services: require.NoError(t, json.Unmarshal(raw, &doc)) require.Contains(t, doc.Parameters, "connections") -<<<<<<< HEAD connectionsJSON, ok := doc.Parameters["connections"].Value.(string) require.True( t, @@ -374,12 +373,7 @@ services: ) var conns []any require.NoError(t, json.Unmarshal([]byte(connectionsJSON), &conns)) - require.Len(t, conns, 1) -======= - conns, ok := doc.Parameters["connections"].Value.([]any) - require.True(t, ok, "connections should be an array, got %T", doc.Parameters["connections"].Value) require.Len(t, conns, 2) ->>>>>>> origin/main conn, ok := conns[1].(map[string]any) require.True(t, ok, "connection entry should be an object, got %T", conns[0]) From e64f94bd9436fa687202cf6bda6125c9949bbb08 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 14:57:12 +0800 Subject: [PATCH 12/16] fix: restore typed Foundry connection parameters --- .../internal/cmd/init_infra_test.go | 11 +- .../internal/synthesis/synthesizer.go | 20 +- .../internal/synthesis/synthesizer_test.go | 7 +- .../synthesis/templates/brownfield.arm.json | 54 ++++- .../synthesis/templates/brownfield.bicep | 24 ++- .../synthesis/templates/main.arm.json | 190 +++++++++++++++--- .../internal/synthesis/templates/main.bicep | 18 +- .../templates/modules/connections.bicep | 35 +++- .../templates/modules/resources.bicep | 24 ++- .../foundry_provisioning_provider.go | 9 +- ...ovisioning_provider_brownfield_acr_test.go | 8 +- .../internal/synthesis/synthesizer.go | 20 +- .../internal/synthesis/synthesizer_test.go | 16 +- .../synthesis/templates/brownfield.arm.json | 54 ++++- .../synthesis/templates/brownfield.bicep | 24 ++- .../synthesis/templates/main.arm.json | 190 +++++++++++++++--- .../internal/synthesis/templates/main.bicep | 18 +- .../templates/modules/connections.bicep | 35 +++- .../templates/modules/resources.bicep | 24 ++- .../templates/terraform/connections.tf | 34 ++++ .../templates/terraform/outputs.tf.tmpl | 4 + .../templates/terraform/variables.tf | 13 ++ 22 files changed, 641 insertions(+), 191 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/connections.tf 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 e2d65324e61..bcf9240c3ca 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 @@ -364,15 +364,8 @@ services: require.NoError(t, json.Unmarshal(raw, &doc)) require.Contains(t, doc.Parameters, "connections") - connectionsJSON, ok := doc.Parameters["connections"].Value.(string) - require.True( - t, - ok, - "connections should be a JSON string, got %T", - doc.Parameters["connections"].Value, - ) - var conns []any - require.NoError(t, json.Unmarshal([]byte(connectionsJSON), &conns)) + conns, ok := doc.Parameters["connections"].Value.([]any) + require.True(t, ok, "connections should be an array, got %T", doc.Parameters["connections"].Value) require.Len(t, conns, 2) conn, ok := conns[1].(map[string]any) 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 f9a1004ac9e..0f293cfc619 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -13,7 +13,6 @@ package synthesis import ( - "encoding/json" "errors" "fmt" "maps" @@ -254,11 +253,6 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } - encodedConnections, err := EncodeConnections(connections) - if err != nil { - return nil, err - } - netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) if err != nil { return nil, err @@ -272,7 +266,7 @@ func Synthesize(in Input) (*Result, error) { params := map[string]any{ "deployments": deployments, "includeAcr": includeAcr, - "connections": encodedConnections, + "connections": connections, } maps.Copy(params, netParams) @@ -282,18 +276,6 @@ func Synthesize(in Input) (*Result, error) { }, nil } -// EncodeConnections serializes connection definitions for a secure ARM parameter. -func EncodeConnections(connections []Connection) (string, error) { - if connections == nil { - connections = []Connection{} - } - data, err := json.Marshal(connections) - if err != nil { - return "", fmt.Errorf("encode connections: %w", err) - } - return string(data), nil -} - // BrownfieldDeployments returns the model deployments declared on a brownfield // (endpoint:) Foundry project service. Synthesize short-circuits with // ErrEndpointBrownfield before reading deployments:, so the provider uses this 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 18afcf38d90..75a36f13b55 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 @@ -630,11 +630,8 @@ services: func resultConnections(t *testing.T, result *Result) []Connection { t.Helper() - raw, ok := result.Parameters["connections"].(string) - require.True(t, ok, "connections param should be a JSON string") - - var connections []Connection - require.NoError(t, json.Unmarshal([]byte(raw), &connections)) + connections, ok := result.Parameters["connections"].([]Connection) + require.True(t, ok, "connections param should be []Connection") return connections } 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 index e32aa1aab90..942b2761a89 100644 --- 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "12070387023310168485" + "templateHash": "13884339769371824597" } }, "definitions": { @@ -54,6 +54,43 @@ "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } } }, "parameters": { @@ -108,15 +145,14 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded Foundry project connections to create." + "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." } } }, "variables": { - "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" }, "resources": { @@ -215,12 +251,12 @@ "projectConnections": { "copy": { "name": "projectConnections", - "count": "[length(variables('connectionList'))]" + "count": "[length(parameters('connections'))]" }, "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), variables('connectionList')[copyIndex()].name)]", - "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" + "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" } }, "outputs": { @@ -238,7 +274,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[join(map(variables('connectionList'), lambda('c', lambdaVariables('c').name)), ',')]" + "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 index 39303b9a311..fd93a5cddea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep @@ -25,6 +25,19 @@ type deploymentType = { } } +@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 + credentials: object? + metadata: object? +} + // Parameters @description('Name of the existing Foundry (AIServices) account.') @@ -50,11 +63,8 @@ param includeAcr bool = false @description('Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true.') param acrName string = '' -@description('JSON-encoded Foundry project connections to create.') -@secure() -param connections string = '' - -var connectionList = json(empty(connections) ? '[]' : connections) +@description('Foundry project connections to create on the existing project (host: azure.ai.connection services).') +param connections connectionsType = [] // Resources @@ -153,7 +163,7 @@ resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connection // 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 connectionList: { + for c in connections: { parent: foundryAccountPreview::project name: c.name properties: union( @@ -173,4 +183,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne 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(connectionList, c => c.name), ',') +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/main.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json index 1fb9ed8edec..61f627f1fda 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "17959860464748483608" + "templateHash": "11064647930674610643" } }, "definitions": { @@ -54,6 +54,43 @@ "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } } }, "parameters": { @@ -108,10 +145,10 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded Foundry project connections to create." + "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, "principalId": { @@ -304,7 +341,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "354603187727008413" + "templateHash": "8482090354551809818" } }, "definitions": { @@ -352,6 +389,43 @@ "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } } }, "parameters": { @@ -399,10 +473,10 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded Foundry project connections to create." + "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, "principalId": { @@ -511,7 +585,6 @@ }, "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", - "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", @@ -1323,7 +1396,7 @@ ] }, "projectConnections": { - "condition": "[not(empty(variables('connectionList')))]", + "condition": "[not(empty(parameters('connections')))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-connections", @@ -1345,12 +1418,70 @@ }, "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.44.1.10279", - "templateHash": "3592032988386574245" + "templateHash": "2215109488049914988" + } + }, + "definitions": { + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Connection name. The resource name and the key a toolbox tool references via connection: ." + } + }, + "category": { + "type": "string", + "metadata": { + "description": "Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys." + } + }, + "target": { + "type": "string", + "metadata": { + "description": "Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL." + } + }, + "authType": { + "type": "string", + "metadata": { + "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." + } + }, + "credentials": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." + } + }, + "metadata": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional metadata key-value pairs." + } + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } + }, + "connectionsType": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "metadata": { + "description": "Shape of a list of connections." + } } }, "parameters": { @@ -1367,35 +1498,44 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded connections to create on the Foundry project." + "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." } } }, - "variables": { - "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]" - }, - "resources": [ - { + "resources": { + "foundryAccount::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('foundryAccountName'), parameters('foundryProjectName'))]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('foundryAccountName')]" + }, + "projectConnections": { "copy": { "name": "projectConnections", - "count": "[length(variables('connectionList'))]" + "count": "[length(parameters('connections'))]" }, "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), variables('connectionList')[copyIndex()].name)]", - "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", + "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" } - ], + }, "outputs": { "connectionNames": { "type": "string", "metadata": { "description": "Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding." }, - "value": "[join(map(variables('connectionList'), lambda('c', lambdaVariables('c').name)), ',')]" + "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" } } } @@ -1441,7 +1581,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[if(empty(variables('connectionList')), '', reference('projectConnections').outputs.connectionNames.value)]" + "value": "[if(empty(parameters('connections')), '', reference('projectConnections').outputs.connectionNames.value)]" }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", 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 74d22636d37..0478f864e0d 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 @@ -30,6 +30,19 @@ type deploymentType = { } } +@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 + credentials: object? + metadata: object? +} + // Parameters @description('Azure region for all resources.') @@ -57,9 +70,8 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('JSON-encoded Foundry project connections to create.') -@secure() -param connections string = '' +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep index 7de87513eff..d38f4f599d4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep @@ -11,8 +11,6 @@ // 2025-06-01 fails to resolve the projects/connections sub-resource // (MissingApiVersionParameter), the same reason acr.bicep does this. -// User-defined types - // Parameters @description('Name of the existing Foundry CognitiveServices account that hosts the project.') @@ -21,11 +19,32 @@ param foundryAccountName string @description('Name of the existing Foundry project the connections are created on.') param foundryProjectName string -@description('JSON-encoded connections to create on the Foundry project.') -@secure() -param connections string = '' +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + @description('Connection name. The resource name and the key a toolbox tool references via connection: .') + name: string + + @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') + category: string + + @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') + target: string + + @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') + authType: string + + @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') + credentials: object? + + @description('Optional metadata key-value pairs.') + metadata: object? +} + +@description('Shape of a list of connections.') +type connectionsType = connectionType[] -var connectionList = json(empty(connections) ? '[]' : connections) +@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') +param connections connectionsType = [] // Resources @@ -43,7 +62,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview // only emitted when supplied so None / identity-token connections don't send an // empty credentials object. resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connectionList: { + for c in connections: { parent: foundryAccount::project name: c.name properties: union( @@ -61,4 +80,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne // Outputs @description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') -output connectionNames string = join(map(connectionList, c => c.name), ',') +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep index 1c3cddf7d5d..03f5a4269be 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep @@ -27,6 +27,19 @@ type deploymentType = { } } +@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 + credentials: object? + metadata: object? +} + // Parameters @description('Azure region for all resources.') @@ -49,9 +62,8 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('JSON-encoded Foundry project connections to create.') -@secure() -param connections string = '' +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -105,8 +117,6 @@ var resourceToken = empty(resourceTokenSalt) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') -var connectionList = json(empty(connections) ? '[]' : connections) - var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' // Egress: byo injects the agent into a customer subnet; managed uses the @@ -279,7 +289,7 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat // host: azure.ai.connection services. Created at provision time so a toolbox // that references a connection by name resolves it at deploy. Depends on the // project via foundryAccount.name / project.name so ordering is correct. -module projectConnections 'connections.bicep' = if (!empty(connectionList)) { +module projectConnections 'connections.bicep' = if (!empty(connections)) { name: 'foundry-connections' params: { foundryAccountName: foundryAccount.name @@ -310,6 +320,6 @@ output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.service output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connectionList) ? '' : projectConnections!.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' 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 ae7f3952eba..f844e074ea6 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 @@ -879,12 +879,9 @@ func brownfieldReconcileMessage(hasDeployments, createACR, hasConnections bool) func (p *FoundryProvisioningProvider) brownfieldParams( ctx context.Context, account, rg string, createACR bool, ) (map[string]any, error) { - connections, err := synthesis.EncodeConnections(p.brownfieldConnections) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("encode brownfield connections: %s", err), - ) + connections := p.brownfieldConnections + if connections == nil { + connections = []synthesis.Connection{} } params := map[string]any{ "accountName": map[string]any{"value": account}, 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 index 0fb93ac804e..8b93608c3e3 100644 --- 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 @@ -189,7 +189,7 @@ func TestBrownfieldParams(t *testing.T) { 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": "[]"}, params["connections"]) + assert.Equal(t, map[string]any{"value": []synthesis.Connection{}}, params["connections"]) assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) assert.NotContains(t, params, "includeAcr") assert.NotContains(t, params, "acrName") @@ -206,11 +206,7 @@ func TestBrownfieldParams(t *testing.T) { params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) require.NoError(t, err) - assert.Equal( - t, - map[string]any{"value": `[{"name":"search-conn","category":"CognitiveSearch","target":"","authType":""}]`}, - params["connections"], - ) + assert.Equal(t, map[string]any{"value": conns}, params["connections"]) // Connections are project-scoped, so projectName must be supplied even // without ACR. assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) 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 f9a1004ac9e..0f293cfc619 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -13,7 +13,6 @@ package synthesis import ( - "encoding/json" "errors" "fmt" "maps" @@ -254,11 +253,6 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } - encodedConnections, err := EncodeConnections(connections) - if err != nil { - return nil, err - } - netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) if err != nil { return nil, err @@ -272,7 +266,7 @@ func Synthesize(in Input) (*Result, error) { params := map[string]any{ "deployments": deployments, "includeAcr": includeAcr, - "connections": encodedConnections, + "connections": connections, } maps.Copy(params, netParams) @@ -282,18 +276,6 @@ func Synthesize(in Input) (*Result, error) { }, nil } -// EncodeConnections serializes connection definitions for a secure ARM parameter. -func EncodeConnections(connections []Connection) (string, error) { - if connections == nil { - connections = []Connection{} - } - data, err := json.Marshal(connections) - if err != nil { - return "", fmt.Errorf("encode connections: %w", err) - } - return string(data), nil -} - // BrownfieldDeployments returns the model deployments declared on a brownfield // (endpoint:) Foundry project service. Synthesize short-circuits with // ErrEndpointBrownfield before reading deployments:, so the provider uses this 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 66ed2614671..f0065e80988 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 @@ -917,11 +917,8 @@ services: func resultConnections(t *testing.T, result *Result) []Connection { t.Helper() - raw, ok := result.Parameters["connections"].(string) - require.True(t, ok, "connections param should be a JSON string") - - var connections []Connection - require.NoError(t, json.Unmarshal([]byte(raw), &connections)) + connections, ok := result.Parameters["connections"].([]Connection) + require.True(t, ok, "connections param should be []Connection") return connections } @@ -1113,11 +1110,12 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { require.True(t, ok, "parameters must be an object") assert.Contains(t, params, "resourceGroupName") - // connections carries credentials and must be a secure ARM parameter. + // connections must remain an array so ejected templates preserve the + // connection object shape. assert.Contains(t, params, "connections", "connections param must be declared in the ARM template") connections, ok := params["connections"].(map[string]any) require.True(t, ok, "connections param must be an object") - assert.Equal(t, "securestring", connections["type"]) + assert.Equal(t, "#/definitions/connectionsType", connections["$ref"]) // Network isolation parameters must exist so the synthesizer's network // param set is accepted by ARM (extra params would fail the deployment). @@ -1165,7 +1163,7 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { "managedNetworks isolationMode must come from the managedIsolationMode param") } -func TestBrownfieldARMTemplate_UsesSecureConnections(t *testing.T) { +func TestBrownfieldARMTemplate_UsesConnectionArray(t *testing.T) { data, err := BrownfieldARMTemplate() require.NoError(t, err) @@ -1175,7 +1173,7 @@ func TestBrownfieldARMTemplate_UsesSecureConnections(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, "securestring", connections["type"]) + assert.Equal(t, "#/definitions/connectionsType", connections["$ref"]) } 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 index e32aa1aab90..942b2761a89 100644 --- 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "12070387023310168485" + "templateHash": "13884339769371824597" } }, "definitions": { @@ -54,6 +54,43 @@ "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } } }, "parameters": { @@ -108,15 +145,14 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded Foundry project connections to create." + "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." } } }, "variables": { - "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" }, "resources": { @@ -215,12 +251,12 @@ "projectConnections": { "copy": { "name": "projectConnections", - "count": "[length(variables('connectionList'))]" + "count": "[length(parameters('connections'))]" }, "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), variables('connectionList')[copyIndex()].name)]", - "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" + "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" } }, "outputs": { @@ -238,7 +274,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[join(map(variables('connectionList'), lambda('c', lambdaVariables('c').name)), ',')]" + "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 index 39303b9a311..fd93a5cddea 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep @@ -25,6 +25,19 @@ type deploymentType = { } } +@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 + credentials: object? + metadata: object? +} + // Parameters @description('Name of the existing Foundry (AIServices) account.') @@ -50,11 +63,8 @@ param includeAcr bool = false @description('Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true.') param acrName string = '' -@description('JSON-encoded Foundry project connections to create.') -@secure() -param connections string = '' - -var connectionList = json(empty(connections) ? '[]' : connections) +@description('Foundry project connections to create on the existing project (host: azure.ai.connection services).') +param connections connectionsType = [] // Resources @@ -153,7 +163,7 @@ resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connection // 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 connectionList: { + for c in connections: { parent: foundryAccountPreview::project name: c.name properties: union( @@ -173,4 +183,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne 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(connectionList, c => c.name), ',') +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/main.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json index 1fb9ed8edec..61f627f1fda 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "17959860464748483608" + "templateHash": "11064647930674610643" } }, "definitions": { @@ -54,6 +54,43 @@ "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } } }, "parameters": { @@ -108,10 +145,10 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded Foundry project connections to create." + "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, "principalId": { @@ -304,7 +341,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "354603187727008413" + "templateHash": "8482090354551809818" } }, "definitions": { @@ -352,6 +389,43 @@ "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" + }, + "credentials": { + "type": "object", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } } }, "parameters": { @@ -399,10 +473,10 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded Foundry project connections to create." + "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, "principalId": { @@ -511,7 +585,6 @@ }, "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", - "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]", "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", @@ -1323,7 +1396,7 @@ ] }, "projectConnections": { - "condition": "[not(empty(variables('connectionList')))]", + "condition": "[not(empty(parameters('connections')))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-connections", @@ -1345,12 +1418,70 @@ }, "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.44.1.10279", - "templateHash": "3592032988386574245" + "templateHash": "2215109488049914988" + } + }, + "definitions": { + "connectionType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Connection name. The resource name and the key a toolbox tool references via connection: ." + } + }, + "category": { + "type": "string", + "metadata": { + "description": "Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys." + } + }, + "target": { + "type": "string", + "metadata": { + "description": "Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL." + } + }, + "authType": { + "type": "string", + "metadata": { + "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." + } + }, + "credentials": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." + } + }, + "metadata": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional metadata key-value pairs." + } + } + }, + "metadata": { + "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." + } + }, + "connectionsType": { + "type": "array", + "items": { + "$ref": "#/definitions/connectionType" + }, + "metadata": { + "description": "Shape of a list of connections." + } } }, "parameters": { @@ -1367,35 +1498,44 @@ } }, "connections": { - "type": "securestring", - "defaultValue": "", + "$ref": "#/definitions/connectionsType", + "defaultValue": [], "metadata": { - "description": "JSON-encoded connections to create on the Foundry project." + "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." } } }, - "variables": { - "connectionList": "[json(if(empty(parameters('connections')), '[]', parameters('connections')))]" - }, - "resources": [ - { + "resources": { + "foundryAccount::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('foundryAccountName'), parameters('foundryProjectName'))]" + }, + "foundryAccount": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('foundryAccountName')]" + }, + "projectConnections": { "copy": { "name": "projectConnections", - "count": "[length(variables('connectionList'))]" + "count": "[length(parameters('connections'))]" }, "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), variables('connectionList')[copyIndex()].name)]", - "properties": "[union(createObject('category', variables('connectionList')[copyIndex()].category, 'target', variables('connectionList')[copyIndex()].target, 'authType', variables('connectionList')[copyIndex()].authType), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(variables('connectionList')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(variables('connectionList')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(variables('connectionList')[copyIndex()], 'metadata')), createObject()))]" + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", + "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" } - ], + }, "outputs": { "connectionNames": { "type": "string", "metadata": { "description": "Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding." }, - "value": "[join(map(variables('connectionList'), lambda('c', lambdaVariables('c').name)), ',')]" + "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" } } } @@ -1441,7 +1581,7 @@ }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", - "value": "[if(empty(variables('connectionList')), '', reference('projectConnections').outputs.connectionNames.value)]" + "value": "[if(empty(parameters('connections')), '', reference('projectConnections').outputs.connectionNames.value)]" }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", 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 74d22636d37..0478f864e0d 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 @@ -30,6 +30,19 @@ type deploymentType = { } } +@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 + credentials: object? + metadata: object? +} + // Parameters @description('Azure region for all resources.') @@ -57,9 +70,8 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('JSON-encoded Foundry project connections to create.') -@secure() -param connections string = '' +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep index 7de87513eff..d38f4f599d4 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep @@ -11,8 +11,6 @@ // 2025-06-01 fails to resolve the projects/connections sub-resource // (MissingApiVersionParameter), the same reason acr.bicep does this. -// User-defined types - // Parameters @description('Name of the existing Foundry CognitiveServices account that hosts the project.') @@ -21,11 +19,32 @@ param foundryAccountName string @description('Name of the existing Foundry project the connections are created on.') param foundryProjectName string -@description('JSON-encoded connections to create on the Foundry project.') -@secure() -param connections string = '' +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + @description('Connection name. The resource name and the key a toolbox tool references via connection: .') + name: string + + @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') + category: string + + @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') + target: string + + @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') + authType: string + + @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') + credentials: object? + + @description('Optional metadata key-value pairs.') + metadata: object? +} + +@description('Shape of a list of connections.') +type connectionsType = connectionType[] -var connectionList = json(empty(connections) ? '[]' : connections) +@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') +param connections connectionsType = [] // Resources @@ -43,7 +62,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview // only emitted when supplied so None / identity-token connections don't send an // empty credentials object. resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connectionList: { + for c in connections: { parent: foundryAccount::project name: c.name properties: union( @@ -61,4 +80,4 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne // Outputs @description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') -output connectionNames string = join(map(connectionList, c => c.name), ',') +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep index 1c3cddf7d5d..03f5a4269be 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep @@ -27,6 +27,19 @@ type deploymentType = { } } +@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 + credentials: object? + metadata: object? +} + // Parameters @description('Azure region for all resources.') @@ -49,9 +62,8 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false -@description('JSON-encoded Foundry project connections to create.') -@secure() -param connections string = '' +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -105,8 +117,6 @@ var resourceToken = empty(resourceTokenSalt) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') -var connectionList = json(empty(connections) ? '[]' : connections) - var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' // Egress: byo injects the agent into a customer subnet; managed uses the @@ -279,7 +289,7 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat // host: azure.ai.connection services. Created at provision time so a toolbox // that references a connection by name resolves it at deploy. Depends on the // project via foundryAccount.name / project.name so ordering is correct. -module projectConnections 'connections.bicep' = if (!empty(connectionList)) { +module projectConnections 'connections.bicep' = if (!empty(connections)) { name: 'foundry-connections' params: { foundryAccountName: foundryAccount.name @@ -310,6 +320,6 @@ output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.service output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connectionList) ? '' : projectConnections!.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/connections.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/connections.tf new file mode 100644 index 00000000000..4e881d335e1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/connections.tf @@ -0,0 +1,34 @@ +# Foundry project connections declared as host: azure.ai.connection services +# in azure.yaml. One azapi_resource per entry. +# +# Provision-time equivalent of the deploy-time azure.ai.connection service +# target, but supports every auth type (the service target only upserts +# none/api-key/custom-keys). credentials/metadata pass through untouched. +# +# Pinned to 2025-04-01-preview: GA 2025-06-01 cannot resolve the +# projects/connections sub-resource (MissingApiVersionParameter), same as +# acr.tf's acr_connection. +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 = azapi_resource.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/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl index fa6bcc7635e..4e5c180fffc 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 @@ -24,6 +24,10 @@ output "AZURE_OPENAI_ENDPOINT" { 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_CONNECTION_NAMES" { + value = join(",", [for c in azapi_resource.connection : c.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/terraform/variables.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf index cab146a2bcf..3f9e0b55f21 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf @@ -58,6 +58,19 @@ variable "deployments" { default = [] } +variable "connections" { + description = "Foundry project connections to create (host: azure.ai.connection services)." + type = list(object({ + name = string + category = string + target = string + authType = string + credentials = optional(any) + metadata = optional(map(string)) + })) + default = [] +} + variable "principal_id" { description = "Object id of the developer running azd. When empty, the developer role assignment is skipped." type = string From 6bc82b89a409e55454ad48dccddd2050758d5f25 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 15:12:27 +0800 Subject: [PATCH 13/16] fix: remove order-only template changes --- .../templates/modules/connections.bicep | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep index d38f4f599d4..3435248945d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep @@ -11,13 +11,7 @@ // 2025-06-01 fails to resolve the projects/connections sub-resource // (MissingApiVersionParameter), the same reason acr.bicep does this. -// Parameters - -@description('Name of the existing Foundry CognitiveServices account that hosts the project.') -param foundryAccountName string - -@description('Name of the existing Foundry project the connections are created on.') -param foundryProjectName string +// User-defined types @description('Shape of one Foundry project connection (a host: azure.ai.connection service).') type connectionType = { @@ -43,6 +37,14 @@ type connectionType = { @description('Shape of a list of connections.') type connectionsType = connectionType[] +// Parameters + +@description('Name of the existing Foundry CognitiveServices account that hosts the project.') +param foundryAccountName string + +@description('Name of the existing Foundry project the connections are created on.') +param foundryProjectName string + @description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') param connections connectionsType = [] From b7bd6cab9afdbee489bbe539186e86fae89667b6 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 15:13:38 +0800 Subject: [PATCH 14/16] fix: remove order-only template changes --- .../internal/synthesis/templates/modules/resources.bicep | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep index 03f5a4269be..c28b5caaf9e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep @@ -117,6 +117,7 @@ var resourceToken = empty(resourceTokenSalt) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') + var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' // Egress: byo injects the agent into a customer subnet; managed uses the From f5aac0a1310b97c5361a03d251642cf4a5441be0 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 15 Jul 2026 18:41:26 +0800 Subject: [PATCH 15/16] fix: harden Foundry provisioning templates --- .../internal/cmd/init_infra.go | 22 +++- .../internal/cmd/init_infra_test.go | 20 ++-- .../internal/synthesis/synthesizer.go | 44 +++++++- .../internal/synthesis/synthesizer_test.go | 10 +- .../synthesis/templates/brownfield.arm.json | 102 ++++++++++++++---- .../synthesis/templates/brownfield.bicep | 22 ++-- .../synthesis/templates/main.arm.json | 69 ++++++++---- .../internal/synthesis/templates/main.bicep | 6 +- .../modules/acr-pull-role-assignment.bicep | 24 +++++ .../templates/modules/connections.bicep | 22 ++-- .../synthesis/templates/modules/network.bicep | 1 + .../modules/private-endpoint-dns.bicep | 5 +- .../templates/modules/resources.bicep | 7 +- .../synthesis/templates/terraform/provider.tf | 2 +- .../foundry_provisioning_provider.go | 14 +-- ...ovisioning_provider_brownfield_acr_test.go | 27 ++++- .../internal/synthesis/parity_test.go | 1 + .../internal/synthesis/synthesizer.go | 44 +++++++- .../internal/synthesis/synthesizer_test.go | 30 +++++- .../synthesis/templates/brownfield.arm.json | 102 ++++++++++++++---- .../synthesis/templates/brownfield.bicep | 22 ++-- .../synthesis/templates/main.arm.json | 69 ++++++++---- .../internal/synthesis/templates/main.bicep | 6 +- .../modules/acr-pull-role-assignment.bicep | 24 +++++ .../templates/modules/connections.bicep | 38 +++---- .../synthesis/templates/modules/network.bicep | 1 + .../modules/private-endpoint-dns.bicep | 5 +- .../templates/modules/resources.bicep | 8 +- .../synthesis/templates/terraform/provider.tf | 2 +- 29 files changed, 578 insertions(+), 171 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep 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 5ebf4fb6597..42055aa6ae5 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 @@ -614,9 +614,27 @@ func writeTfvarsFile(infraDir string, params map[string]any) (ejectArtifact, err if v, ok := params["deployments"]; ok { doc["deployments"] = v } - if v, ok := params["connections"]; ok { - doc["connections"] = 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"]), + ) + } + connectionCredentials, 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, + connectionCredentials, + ) data, err := json.MarshalIndent(doc, "", " ") if err != nil { 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 bcf9240c3ca..7ec57ddd899 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 @@ -318,9 +318,8 @@ services: func TestEjectInfra_EjectsConnectionServices(t *testing.T) { // See TestEjectInfra_HappyPath_WritesExpectedFiles for why this is not parallel. - // A host: azure.ai.connection service must be synthesized into the - // connections param, its module copied into the ejected tree, and any - // ${VAR} in credentials kept verbatim (environment-portable). + // Connection metadata and credentials are ejected separately. + // Bicep keeps credential values in a secure object parameter. dir := t.TempDir() mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project services: @@ -374,17 +373,20 @@ services: assert.Equal(t, "CognitiveSearch", conn["category"]) assert.Equal(t, "ApiKey", conn["authType"]) - // ${VAR} in credentials must be preserved verbatim on the eject path. - creds, ok := conn["credentials"].(map[string]any) - require.True(t, ok, "credentials should be an object, got %T", conn["credentials"]) - assert.Equal(t, "${SEARCH_API_KEY}", creds["key"]) + assert.NotContains(t, conn, "credentials") // Nested CustomKeys credentials must remain an object so Terraform's // optional(any) value can preserve mixed connection credential shapes. mcpConn, ok := conns[0].(map[string]any) require.True(t, ok, "connection entry should be an object, got %T", conns[0]) assert.Equal(t, "mcp-conn", mcpConn["name"]) - mcpCreds := mcpConn["credentials"].(map[string]any) + assert.NotContains(t, mcpConn, "credentials") + + secureCreds, ok := doc.Parameters["connectionCredentials"].Value.(map[string]any) + require.True(t, ok, "connectionCredentials should be an object") + searchCreds := secureCreds["search-conn"].(map[string]any) + assert.Equal(t, "${SEARCH_API_KEY}", searchCreds["key"]) + mcpCreds := secureCreds["mcp-conn"].(map[string]any) keys := mcpCreds["keys"].(map[string]any) assert.Equal(t, "${MCP_KEY}", keys["x-api-key"]) } @@ -742,6 +744,7 @@ func TestEjectInfra_Terraform_TfvarsShape(t *testing.T) { conns, ok := doc["connections"].([]any) require.True(t, ok, "connections should be an array, got %T", doc["connections"]) assert.Empty(t, conns) + assert.NotContains(t, doc, "connectionCredentials") } func TestEjectInfra_Terraform_EjectsConnectionServices(t *testing.T) { @@ -793,6 +796,7 @@ services: creds, ok := conn["credentials"].(map[string]any) require.True(t, ok, "credentials should be an object, got %T", conn["credentials"]) assert.Equal(t, "${SEARCH_API_KEY}", creds["key"]) + assert.NotContains(t, doc, "connectionCredentials") // outputs.tf always carries the connection-names output, unconditional on // includeAcr (unlike the ACR outputs). 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 0f293cfc619..688911a9737 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -119,6 +119,42 @@ type Connection struct { Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` } +// SplitConnectionCredentials separates credential values. +// ARM carries them in a secure object parameter. +func SplitConnectionCredentials( + connections []Connection, +) ([]Connection, map[string]map[string]any) { + if connections == nil { + connections = []Connection{} + } + + withoutCredentials := slices.Clone(connections) + credentials := map[string]map[string]any{} + for i := range withoutCredentials { + if len(withoutCredentials[i].Credentials) > 0 { + credentials[withoutCredentials[i].Name] = withoutCredentials[i].Credentials + withoutCredentials[i].Credentials = nil + } + } + + return withoutCredentials, credentials +} + +// JoinConnectionCredentials restores credentials for Terraform. +// Terraform isolates them with sensitive_body. +func JoinConnectionCredentials( + connections []Connection, + credentials map[string]map[string]any, +) []Connection { + joined := slices.Clone(connections) + for i := range joined { + if value, ok := credentials[joined[i].Name]; ok { + joined[i].Credentials = value + } + } + return joined +} + // connectionService is the subset of a host: azure.ai.connection service body // the synthesizer reads. The service key (not a body field) is the connection // name; see collectConnections. @@ -253,6 +289,7 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } + connections, connectionCredentials := SplitConnectionCredentials(connections) netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) if err != nil { return nil, err @@ -264,9 +301,10 @@ func Synthesize(in Input) (*Result, error) { } params := map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - "connections": connections, + "deployments": deployments, + "includeAcr": includeAcr, + "connections": connections, + "connectionCredentials": connectionCredentials, } maps.Copy(params, netParams) 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 75a36f13b55..1ce676d260a 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 @@ -559,6 +559,11 @@ services: require.True(t, ok, "keys should be a nested map, got %T", c.Credentials["keys"]) assert.Equal(t, "secret-value", keys["x-api-key"]) assert.Equal(t, "team-ai", c.Metadata["owner"]) + + publicConnections := res.Parameters["connections"].([]Connection) + assert.Nil(t, publicConnections[0].Credentials) + secureCredentials := res.Parameters["connectionCredentials"].(map[string]map[string]any) + assert.Equal(t, "secret-value", secureCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) }) t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { @@ -632,7 +637,9 @@ func resultConnections(t *testing.T, result *Result) []Connection { connections, ok := result.Parameters["connections"].([]Connection) require.True(t, ok, "connections param should be []Connection") - return connections + credentials, ok := result.Parameters["connectionCredentials"].(map[string]map[string]any) + require.True(t, ok, "connectionCredentials param should be a credential map") + return JoinConnectionCredentials(connections, credentials) } // TestBrownfieldConnections verifies connection services are collected for a @@ -922,6 +929,7 @@ func TestTemplatesFS_Embedded(t *testing.T) { "templates/main.arm.json", "templates/abbreviations.json", "templates/modules/acr.bicep", + "templates/modules/acr-pull-role-assignment.bicep", "templates/modules/connections.bicep", "templates/modules/network.bicep", "templates/modules/subnet.bicep", 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 index 942b2761a89..77cda60d3cf 100644 --- 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13884339769371824597" + "templateHash": "5428399781259274778" } }, "definitions": { @@ -79,10 +79,6 @@ "authType": { "type": "string" }, - "credentials": { - "type": "object", - "nullable": true - }, "metadata": { "type": "object", "nullable": true @@ -150,6 +146,13 @@ "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": { @@ -208,22 +211,6 @@ "zoneRedundancy": "Disabled" } }, - "foundryAcrPull": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "foundryAccountPreview::project", - "registry" - ] - }, "acrConnection": { "condition": "[parameters('includeAcr')]", "type": "Microsoft.CognitiveServices/accounts/projects/connections", @@ -256,7 +243,78 @@ "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "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": { 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 index fd93a5cddea..16c41c23f4d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep @@ -34,7 +34,6 @@ type connectionType = { category: string target: string authType: string - credentials: object? metadata: object? } @@ -66,6 +65,10 @@ 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 = { @@ -124,14 +127,13 @@ var acrPullRoleId = subscriptionResourceId( '7f951dda-4ed3-4680-a7ca-43fe172d538d' ) -// Grant the existing project's managed identity AcrPull on the new registry so -// the hosted agent can pull images using the project identity. -resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (includeAcr) { - name: guid(registry.id, foundryAccountPreview::project.id, acrPullRoleId) - scope: registry - properties: { +// 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 - principalType: 'ServicePrincipal' roleDefinitionId: acrPullRoleId } } @@ -172,7 +174,9 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne target: c.target authType: c.authType }, - c.?credentials != null ? { credentials: c.?credentials } : {}, + contains(connectionCredentials, c.name) + ? { credentials: connectionCredentials[c.name] } + : {}, c.?metadata != null ? { metadata: c.?metadata } : {} ) } 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 61f627f1fda..31f7af1ec23 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11064647930674610643" + "templateHash": "10316642581792918930" } }, "definitions": { @@ -79,10 +79,6 @@ "authType": { "type": "string" }, - "credentials": { - "type": "object", - "nullable": true - }, "metadata": { "type": "object", "nullable": true @@ -151,6 +147,13 @@ "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { + "description": "Credentials keyed by Foundry project connection name." + } + }, "principalId": { "type": "string", "defaultValue": "", @@ -290,6 +293,9 @@ "connections": { "value": "[parameters('connections')]" }, + "connectionCredentials": { + "value": "[parameters('connectionCredentials')]" + }, "principalId": { "value": "[parameters('principalId')]" }, @@ -341,7 +347,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8482090354551809818" + "templateHash": "9277725728848811858" } }, "definitions": { @@ -414,10 +420,6 @@ "authType": { "type": "string" }, - "credentials": { - "type": "object", - "nullable": true - }, "metadata": { "type": "object", "nullable": true @@ -479,6 +481,13 @@ "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { + "description": "Credentials keyed by Foundry project connection name." + } + }, "principalId": { "type": "string", "defaultValue": "", @@ -728,7 +737,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11653429655583605398" + "templateHash": "9033097500563316958" } }, "parameters": { @@ -974,6 +983,10 @@ "type": "string", "value": "[variables('vnetName')]" }, + "vnetLocation": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01', 'full').location]" + }, "vnetSubscriptionId": { "type": "string", "value": "[variables('vnetSubscriptionId')]" @@ -1183,6 +1196,9 @@ "aiAccountName": { "value": "[variables('foundryAccountName')]" }, + "location": { + "value": "[reference('network').outputs.vnetLocation.value]" + }, "vnetId": { "value": "[reference('network').outputs.vnetId.value]" }, @@ -1206,7 +1222,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "17066190331540845310" + "templateHash": "17191653749134434458" } }, "parameters": { @@ -1216,6 +1232,12 @@ "description": "Name of the Foundry (AIServices) account to bind the private endpoint to." } }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region of the customer VNet." + } + }, "vnetId": { "type": "string", "metadata": { @@ -1264,7 +1286,7 @@ "type": "Microsoft.Network/privateEndpoints", "apiVersion": "2024-05-01", "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", - "location": "[resourceGroup().location]", + "location": "[parameters('location')]", "properties": { "subnet": { "id": "[parameters('peSubnetId')]" @@ -1414,6 +1436,9 @@ }, "connections": { "value": "[parameters('connections')]" + }, + "connectionCredentials": { + "value": "[parameters('connectionCredentials')]" } }, "template": { @@ -1424,7 +1449,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "2215109488049914988" + "templateHash": "487447368010536336" } }, "definitions": { @@ -1455,13 +1480,6 @@ "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." } }, - "credentials": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." - } - }, "metadata": { "type": "object", "nullable": true, @@ -1503,6 +1521,13 @@ "metadata": { "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." } + }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { + "description": "Credentials keyed by Foundry project connection name." + } } }, "resources": { @@ -1526,7 +1551,7 @@ "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "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": { 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 0478f864e0d..1cbd1c389f0 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 @@ -39,7 +39,6 @@ type connectionType = { category: string target: string authType: string - credentials: object? metadata: object? } @@ -73,6 +72,10 @@ param includeAcr bool = false @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -137,6 +140,7 @@ module resources 'modules/resources.bicep' = { deployments: deployments includeAcr: includeAcr connections: connections + connectionCredentials: connectionCredentials principalId: principalId principalType: principalType enableNetworkIsolation: enableNetworkIsolation diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep new file mode 100644 index 00000000000..f54a2d5a706 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep @@ -0,0 +1,24 @@ +targetScope = 'resourceGroup' + +@description('Name of the Azure Container Registry.') +param registryName string + +@description('Principal receiving AcrPull on the registry.') +param principalId string + +@description('AcrPull role definition resource ID.') +param roleDefinitionId string + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: registryName +} + +resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, principalId, roleDefinitionId) + scope: registry + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: roleDefinitionId + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep index 3435248945d..08fe0a1c204 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/connections.bicep @@ -2,10 +2,9 @@ // in azure.yaml. Creates one Microsoft.CognitiveServices/accounts/projects/connections // resource per entry. // -// This is the provision-time equivalent of the deploy-time azure.ai.connection -// service target, but it supports every auth type (the service target only -// upserts none/api-key/custom-keys at deploy). credentials and metadata are -// passed through untouched so any category/authType can be expressed. +// Provision-time equivalent of the deploy-time connection target. +// Supports every auth type. Metadata passes through. +// Credentials arrive in a separate secure parameter. // // Pinned to 2025-04-01-preview via a separate existing account reference: GA // 2025-06-01 fails to resolve the projects/connections sub-resource @@ -27,9 +26,6 @@ type connectionType = { @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') authType: string - @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') - credentials: object? - @description('Optional metadata key-value pairs.') metadata: object? } @@ -48,6 +44,10 @@ param foundryProjectName string @description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') param connections connectionsType = [] +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + // Resources // Existing parent references so each connection nests under the project. @@ -60,9 +60,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview } } -// One connection per entry. Optional properties (credentials / metadata) are -// only emitted when supplied so None / identity-token connections don't send an -// empty credentials object. +// Optional credentials and metadata are emitted only when supplied. resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ for c in connections: { parent: foundryAccount::project @@ -73,7 +71,9 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne target: c.target authType: c.authType }, - c.?credentials != null ? { credentials: c.?credentials } : {}, + contains(connectionCredentials, c.name) + ? { credentials: connectionCredentials[c.name] } + : {}, c.?metadata != null ? { metadata: c.?metadata } : {} ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep index 68f414e6da5..e9ec3e7fc34 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep @@ -87,6 +87,7 @@ module peSubnet 'subnet.bicep' = if (createPESubnet) { output vnetId string = vnet.id output vnetName string = vnetName +output vnetLocation string = vnet.location output vnetSubscriptionId string = vnetSubscriptionId output vnetResourceGroupName string = vnetResourceGroupName output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep index a8af939b859..1167d0ad5f4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep @@ -13,6 +13,9 @@ targetScope = 'resourceGroup' @description('Name of the Foundry (AIServices) account to bind the private endpoint to.') param aiAccountName string +@description('Azure region of the customer VNet.') +param location string + @description('ARM resource id of the customer VNet.') param vnetId string @@ -43,7 +46,7 @@ resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = // Account private endpoint in the PE subnet, targeting the 'account' group. resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { name: '${aiAccountName}-private-endpoint' - location: resourceGroup().location + location: location properties: { subnet: { id: peSubnetId diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep index c28b5caaf9e..efc8671b98a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep @@ -36,7 +36,6 @@ type connectionType = { category: string target: string authType: string - credentials: object? metadata: object? } @@ -65,6 +64,10 @@ param includeAcr bool = false @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -278,6 +281,7 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat name: 'foundry-private-endpoint-dns' params: { aiAccountName: foundryAccount.name + location: network!.outputs.vnetLocation vnetId: network!.outputs.vnetId peSubnetId: network!.outputs.peSubnetId suffix: resourceToken @@ -296,6 +300,7 @@ module projectConnections 'connections.bicep' = if (!empty(connections)) { foundryAccountName: foundryAccount.name foundryProjectName: foundryAccount::project.name connections: connections + connectionCredentials: connectionCredentials } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/provider.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/provider.tf index 32359a3ae4d..6d70aae0bc7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/provider.tf +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/provider.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.1.7, < 2.0.0" + required_version = ">= 1.3.0, < 2.0.0" required_providers { azurerm = { 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 f844e074ea6..0e4aadc7fa1 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 @@ -879,14 +879,14 @@ func brownfieldReconcileMessage(hasDeployments, createACR, hasConnections bool) func (p *FoundryProvisioningProvider) brownfieldParams( ctx context.Context, account, rg string, createACR bool, ) (map[string]any, error) { - connections := p.brownfieldConnections - if connections == nil { - connections = []synthesis.Connection{} - } + 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}, + "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 "/" 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 index 8b93608c3e3..877d900557d 100644 --- 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 @@ -190,6 +190,11 @@ func TestBrownfieldParams(t *testing.T) { 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") @@ -197,7 +202,11 @@ func TestBrownfieldParams(t *testing.T) { t.Run("connections without ACR carry connections and set projectName", func(t *testing.T) { t.Parallel() - conns := []synthesis.Connection{{Name: "search-conn", Category: "CognitiveSearch"}} + 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", @@ -206,7 +215,21 @@ func TestBrownfieldParams(t *testing.T) { params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) require.NoError(t, err) - assert.Equal(t, map[string]any{"value": conns}, params["connections"]) + 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"]) 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 94c9ec3b987..ec1a7b29052 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,6 +61,7 @@ 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/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index 0f293cfc619..688911a9737 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -119,6 +119,42 @@ type Connection struct { Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` } +// SplitConnectionCredentials separates credential values. +// ARM carries them in a secure object parameter. +func SplitConnectionCredentials( + connections []Connection, +) ([]Connection, map[string]map[string]any) { + if connections == nil { + connections = []Connection{} + } + + withoutCredentials := slices.Clone(connections) + credentials := map[string]map[string]any{} + for i := range withoutCredentials { + if len(withoutCredentials[i].Credentials) > 0 { + credentials[withoutCredentials[i].Name] = withoutCredentials[i].Credentials + withoutCredentials[i].Credentials = nil + } + } + + return withoutCredentials, credentials +} + +// JoinConnectionCredentials restores credentials for Terraform. +// Terraform isolates them with sensitive_body. +func JoinConnectionCredentials( + connections []Connection, + credentials map[string]map[string]any, +) []Connection { + joined := slices.Clone(connections) + for i := range joined { + if value, ok := credentials[joined[i].Name]; ok { + joined[i].Credentials = value + } + } + return joined +} + // connectionService is the subset of a host: azure.ai.connection service body // the synthesizer reads. The service key (not a body field) is the connection // name; see collectConnections. @@ -253,6 +289,7 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } + connections, connectionCredentials := SplitConnectionCredentials(connections) netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) if err != nil { return nil, err @@ -264,9 +301,10 @@ func Synthesize(in Input) (*Result, error) { } params := map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - "connections": connections, + "deployments": deployments, + "includeAcr": includeAcr, + "connections": connections, + "connectionCredentials": connectionCredentials, } maps.Copy(params, netParams) 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 f0065e80988..ebd272c0bf2 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 @@ -559,6 +559,11 @@ services: require.True(t, ok, "keys should be a nested map, got %T", c.Credentials["keys"]) assert.Equal(t, "secret-value", keys["x-api-key"]) assert.Equal(t, "team-ai", c.Metadata["owner"]) + + publicConnections := res.Parameters["connections"].([]Connection) + assert.Nil(t, publicConnections[0].Credentials) + secureCredentials := res.Parameters["connectionCredentials"].(map[string]map[string]any) + assert.Equal(t, "secret-value", secureCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) }) t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { @@ -919,7 +924,9 @@ func resultConnections(t *testing.T, result *Result) []Connection { connections, ok := result.Parameters["connections"].([]Connection) require.True(t, ok, "connections param should be []Connection") - return connections + credentials, ok := result.Parameters["connectionCredentials"].(map[string]map[string]any) + require.True(t, ok, "connectionCredentials param should be a credential map") + return JoinConnectionCredentials(connections, credentials) } func TestBrownfieldServiceResolversResolveRefs(t *testing.T) { @@ -1012,6 +1019,7 @@ func TestTemplatesFS_Embedded(t *testing.T) { "templates/main.arm.json", "templates/abbreviations.json", "templates/modules/acr.bicep", + "templates/modules/acr-pull-role-assignment.bicep", "templates/modules/connections.bicep", "templates/modules/network.bicep", "templates/modules/subnet.bicep", @@ -1034,6 +1042,7 @@ func TestTerraformTemplatesFS_Embedded(t *testing.T) { "templates/terraform/variables.tf", "templates/terraform/main.tf", "templates/terraform/acr.tf", + "templates/terraform/connections.tf", "templates/terraform/outputs.tf.tmpl", } for _, p := range wantFiles { @@ -1083,6 +1092,11 @@ func TestTerraformModule_DerivesNamesWhenEmpty(t *testing.T) { "the resource group must use the derived local, not the raw variable") assert.Contains(t, string(main), `"rg-${local.derived_rg_suffix}"`, "main.tf must derive an rg-{env} name when resource_group_name is empty") + + provider, err := fs.ReadFile("templates/terraform/provider.tf") + require.NoError(t, err) + assert.Contains(t, string(provider), `required_version = ">= 1.3.0`, + "provider.tf must require Terraform 1.3 for optional object attributes") } func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { @@ -1116,6 +1130,9 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { connections, ok := params["connections"].(map[string]any) require.True(t, ok, "connections param must be an object") assert.Equal(t, "#/definitions/connectionsType", connections["$ref"]) + credentials, ok := params["connectionCredentials"].(map[string]any) + require.True(t, ok, "connectionCredentials param must be an object") + assert.Equal(t, "secureObject", credentials["type"]) // Network isolation parameters must exist so the synthesizer's network // param set is accepted by ARM (extra params would fail the deployment). @@ -1161,9 +1178,12 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { "managed isolationMode must provision a managedNetworks child resource") assert.Contains(t, text, `"isolationMode": "[parameters('managedIsolationMode')]"`, "managedNetworks isolationMode must come from the managedIsolationMode param") + assert.Contains(t, text, + `"value": "[reference('network').outputs.vnetLocation.value]"`, + "private endpoint location must come from the customer VNet") } -func TestBrownfieldARMTemplate_UsesConnectionArray(t *testing.T) { +func TestBrownfieldARMTemplate_SecuresConnectionCredentials(t *testing.T) { data, err := BrownfieldARMTemplate() require.NoError(t, err) @@ -1174,6 +1194,12 @@ func TestBrownfieldARMTemplate_UsesConnectionArray(t *testing.T) { connections, ok := params["connections"].(map[string]any) require.True(t, ok, "connections param must be an object") assert.Equal(t, "#/definitions/connectionsType", connections["$ref"]) + 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 index 942b2761a89..77cda60d3cf 100644 --- 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13884339769371824597" + "templateHash": "5428399781259274778" } }, "definitions": { @@ -79,10 +79,6 @@ "authType": { "type": "string" }, - "credentials": { - "type": "object", - "nullable": true - }, "metadata": { "type": "object", "nullable": true @@ -150,6 +146,13 @@ "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": { @@ -208,22 +211,6 @@ "zoneRedundancy": "Disabled" } }, - "foundryAcrPull": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "foundryAccountPreview::project", - "registry" - ] - }, "acrConnection": { "condition": "[parameters('includeAcr')]", "type": "Microsoft.CognitiveServices/accounts/projects/connections", @@ -256,7 +243,78 @@ "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(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "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": { 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 index fd93a5cddea..16c41c23f4d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep @@ -34,7 +34,6 @@ type connectionType = { category: string target: string authType: string - credentials: object? metadata: object? } @@ -66,6 +65,10 @@ 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 = { @@ -124,14 +127,13 @@ var acrPullRoleId = subscriptionResourceId( '7f951dda-4ed3-4680-a7ca-43fe172d538d' ) -// Grant the existing project's managed identity AcrPull on the new registry so -// the hosted agent can pull images using the project identity. -resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (includeAcr) { - name: guid(registry.id, foundryAccountPreview::project.id, acrPullRoleId) - scope: registry - properties: { +// 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 - principalType: 'ServicePrincipal' roleDefinitionId: acrPullRoleId } } @@ -172,7 +174,9 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne target: c.target authType: c.authType }, - c.?credentials != null ? { credentials: c.?credentials } : {}, + contains(connectionCredentials, c.name) + ? { credentials: connectionCredentials[c.name] } + : {}, c.?metadata != null ? { metadata: c.?metadata } : {} ) } 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 61f627f1fda..31f7af1ec23 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 @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11064647930674610643" + "templateHash": "10316642581792918930" } }, "definitions": { @@ -79,10 +79,6 @@ "authType": { "type": "string" }, - "credentials": { - "type": "object", - "nullable": true - }, "metadata": { "type": "object", "nullable": true @@ -151,6 +147,13 @@ "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { + "description": "Credentials keyed by Foundry project connection name." + } + }, "principalId": { "type": "string", "defaultValue": "", @@ -290,6 +293,9 @@ "connections": { "value": "[parameters('connections')]" }, + "connectionCredentials": { + "value": "[parameters('connectionCredentials')]" + }, "principalId": { "value": "[parameters('principalId')]" }, @@ -341,7 +347,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8482090354551809818" + "templateHash": "9277725728848811858" } }, "definitions": { @@ -414,10 +420,6 @@ "authType": { "type": "string" }, - "credentials": { - "type": "object", - "nullable": true - }, "metadata": { "type": "object", "nullable": true @@ -479,6 +481,13 @@ "description": "Foundry project connections to create (host: azure.ai.connection services)." } }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { + "description": "Credentials keyed by Foundry project connection name." + } + }, "principalId": { "type": "string", "defaultValue": "", @@ -728,7 +737,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11653429655583605398" + "templateHash": "9033097500563316958" } }, "parameters": { @@ -974,6 +983,10 @@ "type": "string", "value": "[variables('vnetName')]" }, + "vnetLocation": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01', 'full').location]" + }, "vnetSubscriptionId": { "type": "string", "value": "[variables('vnetSubscriptionId')]" @@ -1183,6 +1196,9 @@ "aiAccountName": { "value": "[variables('foundryAccountName')]" }, + "location": { + "value": "[reference('network').outputs.vnetLocation.value]" + }, "vnetId": { "value": "[reference('network').outputs.vnetId.value]" }, @@ -1206,7 +1222,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "17066190331540845310" + "templateHash": "17191653749134434458" } }, "parameters": { @@ -1216,6 +1232,12 @@ "description": "Name of the Foundry (AIServices) account to bind the private endpoint to." } }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region of the customer VNet." + } + }, "vnetId": { "type": "string", "metadata": { @@ -1264,7 +1286,7 @@ "type": "Microsoft.Network/privateEndpoints", "apiVersion": "2024-05-01", "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", - "location": "[resourceGroup().location]", + "location": "[parameters('location')]", "properties": { "subnet": { "id": "[parameters('peSubnetId')]" @@ -1414,6 +1436,9 @@ }, "connections": { "value": "[parameters('connections')]" + }, + "connectionCredentials": { + "value": "[parameters('connectionCredentials')]" } }, "template": { @@ -1424,7 +1449,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "2215109488049914988" + "templateHash": "487447368010536336" } }, "definitions": { @@ -1455,13 +1480,6 @@ "description": "Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ..." } }, - "credentials": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Auth credentials. Structure depends on authType. Omit for None / identity types." - } - }, "metadata": { "type": "object", "nullable": true, @@ -1503,6 +1521,13 @@ "metadata": { "description": "Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service." } + }, + "connectionCredentials": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { + "description": "Credentials keyed by Foundry project connection name." + } } }, "resources": { @@ -1526,7 +1551,7 @@ "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'credentials'), null())), createObject('credentials', tryGet(parameters('connections')[copyIndex()], 'credentials')), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" + "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": { 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 0478f864e0d..1cbd1c389f0 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 @@ -39,7 +39,6 @@ type connectionType = { category: string target: string authType: string - credentials: object? metadata: object? } @@ -73,6 +72,10 @@ param includeAcr bool = false @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -137,6 +140,7 @@ module resources 'modules/resources.bicep' = { deployments: deployments includeAcr: includeAcr connections: connections + connectionCredentials: connectionCredentials principalId: principalId principalType: principalType enableNetworkIsolation: enableNetworkIsolation diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep new file mode 100644 index 00000000000..f54a2d5a706 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep @@ -0,0 +1,24 @@ +targetScope = 'resourceGroup' + +@description('Name of the Azure Container Registry.') +param registryName string + +@description('Principal receiving AcrPull on the registry.') +param principalId string + +@description('AcrPull role definition resource ID.') +param roleDefinitionId string + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: registryName +} + +resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, principalId, roleDefinitionId) + scope: registry + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: roleDefinitionId + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep index d38f4f599d4..08fe0a1c204 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/connections.bicep @@ -2,22 +2,15 @@ // in azure.yaml. Creates one Microsoft.CognitiveServices/accounts/projects/connections // resource per entry. // -// This is the provision-time equivalent of the deploy-time azure.ai.connection -// service target, but it supports every auth type (the service target only -// upserts none/api-key/custom-keys at deploy). credentials and metadata are -// passed through untouched so any category/authType can be expressed. +// Provision-time equivalent of the deploy-time connection target. +// Supports every auth type. Metadata passes through. +// Credentials arrive in a separate secure parameter. // // Pinned to 2025-04-01-preview via a separate existing account reference: GA // 2025-06-01 fails to resolve the projects/connections sub-resource // (MissingApiVersionParameter), the same reason acr.bicep does this. -// Parameters - -@description('Name of the existing Foundry CognitiveServices account that hosts the project.') -param foundryAccountName string - -@description('Name of the existing Foundry project the connections are created on.') -param foundryProjectName string +// User-defined types @description('Shape of one Foundry project connection (a host: azure.ai.connection service).') type connectionType = { @@ -33,9 +26,6 @@ type connectionType = { @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') authType: string - @description('Auth credentials. Structure depends on authType. Omit for None / identity types.') - credentials: object? - @description('Optional metadata key-value pairs.') metadata: object? } @@ -43,9 +33,21 @@ type connectionType = { @description('Shape of a list of connections.') type connectionsType = connectionType[] +// Parameters + +@description('Name of the existing Foundry CognitiveServices account that hosts the project.') +param foundryAccountName string + +@description('Name of the existing Foundry project the connections are created on.') +param foundryProjectName string + @description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') param connections connectionsType = [] +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + // Resources // Existing parent references so each connection nests under the project. @@ -58,9 +60,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview } } -// One connection per entry. Optional properties (credentials / metadata) are -// only emitted when supplied so None / identity-token connections don't send an -// empty credentials object. +// Optional credentials and metadata are emitted only when supplied. resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ for c in connections: { parent: foundryAccount::project @@ -71,7 +71,9 @@ resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/conne target: c.target authType: c.authType }, - c.?credentials != null ? { credentials: c.?credentials } : {}, + contains(connectionCredentials, c.name) + ? { credentials: connectionCredentials[c.name] } + : {}, c.?metadata != null ? { metadata: c.?metadata } : {} ) } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep index 68f414e6da5..e9ec3e7fc34 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep @@ -87,6 +87,7 @@ module peSubnet 'subnet.bicep' = if (createPESubnet) { output vnetId string = vnet.id output vnetName string = vnetName +output vnetLocation string = vnet.location output vnetSubscriptionId string = vnetSubscriptionId output vnetResourceGroupName string = vnetResourceGroupName output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep index a8af939b859..1167d0ad5f4 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep @@ -13,6 +13,9 @@ targetScope = 'resourceGroup' @description('Name of the Foundry (AIServices) account to bind the private endpoint to.') param aiAccountName string +@description('Azure region of the customer VNet.') +param location string + @description('ARM resource id of the customer VNet.') param vnetId string @@ -43,7 +46,7 @@ resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = // Account private endpoint in the PE subnet, targeting the 'account' group. resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { name: '${aiAccountName}-private-endpoint' - location: resourceGroup().location + location: location properties: { subnet: { id: peSubnetId diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep index 03f5a4269be..efc8671b98a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep @@ -36,7 +36,6 @@ type connectionType = { category: string target: string authType: string - credentials: object? metadata: object? } @@ -65,6 +64,10 @@ param includeAcr bool = false @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -117,6 +120,7 @@ var resourceToken = empty(resourceTokenSalt) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') + var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' // Egress: byo injects the agent into a customer subnet; managed uses the @@ -277,6 +281,7 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat name: 'foundry-private-endpoint-dns' params: { aiAccountName: foundryAccount.name + location: network!.outputs.vnetLocation vnetId: network!.outputs.vnetId peSubnetId: network!.outputs.peSubnetId suffix: resourceToken @@ -295,6 +300,7 @@ module projectConnections 'connections.bicep' = if (!empty(connections)) { foundryAccountName: foundryAccount.name foundryProjectName: foundryAccount::project.name connections: connections + connectionCredentials: connectionCredentials } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf index 32359a3ae4d..6d70aae0bc7 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.1.7, < 2.0.0" + required_version = ">= 1.3.0, < 2.0.0" required_providers { azurerm = { From 89c5aa490d27b463d2b5c46136b95098b8b2a9cf Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 16 Jul 2026 18:16:40 +0800 Subject: [PATCH 16/16] fix: update Foundry telemetry extension name --- docs/reference/telemetry-data.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 41f6856d83c..6315dc3ff78 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -395,7 +395,7 @@ Emitted on `azd provision` / `azd up` to measure adoption and safety of `infra.l
Foundry Private Networking -Emitted at provision start by the `microsoft.foundry` provisioning provider in the `azure.ai.projects` extension to measure secured-agent adoption and the BYO-vs-managed split. +Emitted at provision start by the `microsoft.foundry` provisioning provider (the `azure.ai.projects` extension) to measure secured-agent adoption and the BYO-vs-managed split. | Field Key | Type | Description | |-----------|------|-------------|