diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index ab7e09f0495..6a4c250c19f 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -165,11 +165,11 @@ Details: ## Customize infrastructure -Use `azd ai agent init --infra` to generate editable Foundry Bicep or Terraform. Existing project infrastructure is preserved as a separate layer. See [Customize Foundry infrastructure with `--infra`](docs/infrastructure-eject.md) for migration behavior, file-conflict rules, resource-group ownership, layer dependencies, and limitations. +Use `azd ai project init --infra` to generate editable Foundry Bicep or Terraform. Existing project infrastructure is preserved as a separate layer. `azd ai agent init --infra` delegates to the Projects extension for compatibility. See [Customize Foundry infrastructure with `--infra`](docs/infrastructure-eject.md) for migration behavior, file-conflict rules, resource-group ownership, layer dependencies, and limitations. ## 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; agent init delegates project setup to it. 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/infrastructure-eject.md b/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md index 37f78bf7146..a1d329f435a 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md +++ b/cli/azd/extensions/azure.ai.agents/docs/infrastructure-eject.md @@ -1,17 +1,21 @@ -# Customize Foundry infrastructure with `--infra` +# Customize Foundry infrastructure with `azd ai project init --infra` -`azd ai agent init --infra` generates editable infrastructure-as-code for the -`host: azure.ai.project` service in `azure.yaml`. +`azd ai project init --infra` generates editable infrastructure-as-code for the +`host: azure.ai.project` service in `azure.yaml`. The Projects extension owns +this project service and the generated Foundry infrastructure. ```bash # Generate Bicep (default) -azd ai agent init --infra +azd ai project init --infra # Choose the format explicitly -azd ai agent init --infra=bicep -azd ai agent init --infra=terraform +azd ai project init --infra=bicep +azd ai project init --infra=terraform ``` +For compatibility, `azd ai agent init --infra` delegates to +`azd ai project init --infra`. + Use eject when you need to customize resources beyond the service-level configuration in `azure.yaml`. Future `azd provision` runs use the generated files. 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 b1c1f6780b6..48bf0d005f7 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -4,7 +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` delegates project setup to that extension; use `azd ai project init --infra` to eject its IaC. 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. @@ -93,7 +93,7 @@ azd env set AZURE_DNS_SUBSCRIPTION_ID "" - **BYO container image required.** Secured agents should use a pre-built image. The image belongs to the `azure.ai.agent` service; the VNet configuration belongs to the `azure.ai.project` service. The developer owns the registry's SKU, private endpoint, DNS, and firewall. - **Brownfield (`endpoint:`) ignores `network:`.** When `endpoint:` is set on the project service, the account's network posture is fixed by whoever created it; azd warns and does not reconcile `network:`. - **One default-DNS account per VNet.** Without a `dns:` block azd links the three `privatelink.*` AI zones to your VNet, and a VNet may hold only one link per namespace. A second account (or a brownfield hub that pre-links the zones) must use `dns:` reference mode to bind the private endpoint without re-linking. -- **Terraform IaC is not supported for private networking (v1).** Bicep-only today; `azd ai agent init --infra=terraform` is refused when `network:` is declared. Eject Bicep instead. +- **Terraform IaC is not supported for private networking (v1).** Bicep-only today; `azd ai project init --infra=terraform` is refused when `network:` is declared. Eject Bicep instead. ## Scenario 1 — Managed egress: private account, agent on Microsoft's network @@ -178,7 +178,7 @@ The synthesized template covers the common private-networking shapes. When you n ```bash # Scaffold first, declare network: on the azure.ai.project service, then eject: -azd ai agent init --infra +azd ai project init --infra ``` Eject reads `network:` from the `host: azure.ai.project` service and writes the full Bicep tree. For a Foundry-only project, it writes `infra/main.bicep`, `infra/modules/{resources,network,subnet,private-endpoint-dns,acr}.bicep`, and `infra/main.parameters.json`. If the project already has infrastructure, azd preserves it and adds a separate `infra/foundry` layer with the same file layout and its own `AZURE_FOUNDRY_RESOURCE_GROUP`. `${VAR}` placeholders are preserved in the generated parameters file and resolved from the azd environment at provision time. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_project.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_project.go new file mode 100644 index 00000000000..2b2a8c98d4e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_project.go @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/project" + "azureaiagent/internal/version" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const delegatedProjectSchemaVersion = 2 + +const delegatedProjectSource = "azure.ai.agents/init" + +type delegatedProjectTarget struct { + ResourceID string `json:"resourceId,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Name string `json:"name,omitempty"` +} + +type delegatedProjectInfra struct { + EjectProvider string `json:"ejectProvider,omitempty"` +} + +type delegatedProjectRequirements struct { + AllowedLocations []string `json:"allowedLocations,omitempty"` +} + +type delegatedProjectDeployment struct { + Name string `json:"name"` + Model delegatedProjectDeploymentModel `json:"model"` + SKU delegatedProjectDeploymentSKU `json:"sku"` +} + +type delegatedProjectDeploymentModel struct { + Format string `json:"format"` + Name string `json:"name"` + Version string `json:"version"` +} + +type delegatedProjectDeploymentSKU struct { + Name string `json:"name"` + Capacity int `json:"capacity"` +} + +type delegatedProjectConstraints struct { + Requirements delegatedProjectRequirements + RequiredCapabilities []string + AllowedLocations []string + ExcludedModelNames []string +} + +func delegatedProjectConstraintsForLocation( + location string, +) delegatedProjectConstraints { + location = strings.TrimSpace(location) + constraints := delegatedProjectConstraints{ + RequiredCapabilities: []string{agentsV2ModelCapability}, + } + if location != "" { + constraints.Requirements.AllowedLocations = []string{location} + constraints.AllowedLocations = []string{location} + } + return constraints +} + +func delegatedProjectConstraintsForContext( + azureContext *azdext.AzureContext, +) delegatedProjectConstraints { + if azureContext == nil || azureContext.Scope == nil { + return delegatedProjectConstraintsForLocation("") + } + return delegatedProjectConstraintsForLocation(azureContext.Scope.Location) +} + +type delegatedProjectInitRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion"` + Project delegatedProjectTarget `json:"project"` + Infra delegatedProjectInfra `json:"infra"` + Requirements delegatedProjectRequirements `json:"requirements"` + ResolveAzureContext bool `json:"resolveAzureContext"` + Force bool `json:"force"` + ReplaceDeployments bool `json:"replaceDeployments,omitempty"` + Deployments []delegatedProjectDeployment `json:"deployments,omitempty"` +} + +type delegatedProjectModel struct { + Name string `json:"name"` + DeploymentName string `json:"deploymentName,omitempty"` + Format string `json:"format,omitempty"` + Version string `json:"version,omitempty"` + SKU string `json:"sku,omitempty"` + Capacity int `json:"capacity,omitempty"` + AllowedLocations []string `json:"allowedLocations,omitempty"` + RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` + ExcludedModelNames []string `json:"excludedModelNames,omitempty"` +} + +type delegatedProjectDeploymentRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion"` + Model delegatedProjectModel `json:"model"` + SetAsDefault bool `json:"setAsDefault"` + Force bool `json:"force"` +} + +func delegateFoundryProjectResources( + ctx context.Context, + client *azdext.AzdClient, + projectName string, + projectResourceID string, + projectEndpoint string, + deployments []project.Deployment, + constraints ...delegatedProjectConstraints, +) (string, error) { + var delegation delegatedProjectConstraints + if len(constraints) > 0 { + delegation = constraints[0] + } + serviceName, err := delegateFoundryProjectInit( + ctx, client, projectName, projectResourceID, projectEndpoint, + delegation.Requirements, + ) + if err != nil { + return "", err + } + + for i := range deployments { + deployment := deployments[i] + if deployment.Sku.Capacity < 0 || + deployment.Sku.Capacity > math.MaxInt32 { + return "", fmt.Errorf( + "deployment %q capacity %d is outside the delegated range", + deployment.Name, deployment.Sku.Capacity, + ) + } + modelName := deployment.Model.Name + if deployment.Model.Format != "" { + modelName = deployment.Model.Format + "/" + modelName + } + requiredCapabilities := slices.Clone(delegation.RequiredCapabilities) + if len(requiredCapabilities) == 0 { + requiredCapabilities = []string{agentsV2ModelCapability} + } + allowedLocations := slices.Clone(delegation.AllowedLocations) + if len(allowedLocations) == 0 { + allowedLocations = slices.Clone( + delegation.Requirements.AllowedLocations, + ) + } + request := delegatedProjectDeploymentRequest{ + SchemaVersion: delegatedProjectSchemaVersion, + Source: delegatedProjectSource, + SourceVersion: version.Version, + Model: delegatedProjectModel{ + Name: modelName, + DeploymentName: deployment.Name, + Format: deployment.Model.Format, + Version: deployment.Model.Version, + SKU: deployment.Sku.Name, + Capacity: deployment.Sku.Capacity, + RequiredCapabilities: requiredCapabilities, + AllowedLocations: allowedLocations, + ExcludedModelNames: slices.Clone(delegation.ExcludedModelNames), + }, + SetAsDefault: i == 0, + Force: true, + } + if err := runDelegatedProjectCommand( + ctx, client, []string{"ai", "project", "deployment", "add", "--no-prompt"}, + request, + ); err != nil { + return "", fmt.Errorf( + "delegate Foundry deployment %q: %w", deployment.Name, err, + ) + } + } + return serviceName, nil +} + +func delegateFoundryProjectInit( + ctx context.Context, + client *azdext.AzdClient, + projectName string, + projectResourceID string, + projectEndpoint string, + requirements ...delegatedProjectRequirements, +) (string, error) { + projectResourceID = strings.TrimSpace(projectResourceID) + projectEndpoint = strings.TrimSpace(projectEndpoint) + if projectResourceID != "" { + projectEndpoint = "" + } + initRequest := newDelegatedProjectInitRequest( + projectName, projectResourceID, projectEndpoint, + ) + if len(requirements) > 0 { + initRequest.Requirements = requirements[0] + } + if err := runDelegatedProjectCommand( + ctx, client, []string{"ai", "project", "init", "--no-prompt"}, + initRequest, + ); err != nil { + return "", fmt.Errorf("delegate Foundry project initialization: %w", err) + } + + serviceName, err := delegatedProjectServiceName(ctx, client) + if err != nil { + return "", err + } + return serviceName, nil +} + +func delegateFoundryProjectDeployments( + ctx context.Context, + client *azdext.AzdClient, + projectName string, + projectResourceID string, + projectEndpoint string, + deployments []project.Deployment, +) error { + request := newDelegatedProjectInitRequest( + projectName, projectResourceID, projectEndpoint, + ) + request.ReplaceDeployments = true + request.Deployments = make([]delegatedProjectDeployment, len(deployments)) + for i, deployment := range deployments { + request.Deployments[i] = delegatedProjectDeployment{ + Name: deployment.Name, + Model: delegatedProjectDeploymentModel{ + Format: deployment.Model.Format, + Name: deployment.Model.Name, + Version: deployment.Model.Version, + }, + SKU: delegatedProjectDeploymentSKU{ + Name: deployment.Sku.Name, + Capacity: deployment.Sku.Capacity, + }, + } + } + if err := runDelegatedProjectCommand( + ctx, client, []string{"ai", "project", "init", "--no-prompt"}, + request, + ); err != nil { + return fmt.Errorf("delegate Foundry deployment declarations: %w", err) + } + return nil +} + +func newDelegatedProjectInitRequest( + projectName string, + projectResourceID string, + projectEndpoint string, +) delegatedProjectInitRequest { + projectResourceID = strings.TrimSpace(projectResourceID) + projectEndpoint = strings.TrimSpace(projectEndpoint) + if projectResourceID != "" { + projectEndpoint = "" + } + return delegatedProjectInitRequest{ + SchemaVersion: delegatedProjectSchemaVersion, + Source: delegatedProjectSource, + SourceVersion: version.Version, + Project: delegatedProjectTarget{ + ResourceID: projectResourceID, + Endpoint: projectEndpoint, + Name: strings.TrimSpace(projectName), + }, + ResolveAzureContext: true, + Force: true, + } +} + +func delegateFoundryInfra( + ctx context.Context, + provider string, +) error { + client, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("create azd client for Foundry infrastructure delegation: %w", err) + } + defer client.Close() + return delegateFoundryInfraWithClient(ctx, client, provider) +} + +func delegateFoundryInfraWithClient( + ctx context.Context, + client *azdext.AzdClient, + provider string, +) error { + request := delegatedProjectInitRequest{ + SchemaVersion: delegatedProjectSchemaVersion, + Source: delegatedProjectSource, + SourceVersion: version.Version, + Infra: delegatedProjectInfra{EjectProvider: provider}, + ResolveAzureContext: true, + Force: true, + } + if err := runDelegatedProjectCommand( + ctx, client, []string{"ai", "project", "init", "--no-prompt"}, + request, + ); err != nil { + return fmt.Errorf("delegate Foundry infrastructure ejection: %w", err) + } + return nil +} + +func delegateFoundryInfraAfterInit( + ctx context.Context, + client *azdext.AzdClient, + provider string, +) error { + if provider == "" { + return nil + } + projectRoot, err := azdext.GetProjectDir() + if errors.Is(err, azdext.ErrProjectNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("resolve azd project directory after init: %w", err) + } + hasFoundry, err := hasFoundryServiceForEject(projectRoot) + if err != nil { + return err + } + if !hasFoundry { + return nil + } + return delegateFoundryInfraWithClient(ctx, client, provider) +} + +func delegatedProjectServiceName( + ctx context.Context, + client *azdext.AzdClient, +) (string, error) { + response, err := client.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return "", fmt.Errorf("read delegated Foundry project service: %w", err) + } + var names []string + for name, service := range response.GetProject().GetServices() { + if service.GetHost() == AiProjectHost { + names = append(names, name) + } + } + slices.Sort(names) + if len(names) == 0 { + return "", fmt.Errorf( + "delegated Foundry project initialization did not create an %s service", + AiProjectHost, + ) + } + return names[0], nil +} + +func projectResourceIDHint(project *FoundryProjectInfo) string { + if project == nil { + return "" + } + return project.ResourceId +} + +func runDelegatedProjectCommand( + ctx context.Context, + client *azdext.AzdClient, + args []string, + request any, +) (err error) { + projectRoot, err := azdext.GetProjectDir() + if errors.Is(err, azdext.ErrProjectNotFound) { + projectRoot, err = filepath.Abs(".") + } + if err != nil { + return fmt.Errorf("resolve azd project directory: %w", err) + } + data, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode delegated request: %w", err) + } + file, err := os.CreateTemp(projectRoot, ".azd-agents-project-*.json") + if err != nil { + return fmt.Errorf("create delegated request file: %w", err) + } + requestPath := file.Name() + defer func() { + if removeErr := os.Remove(requestPath); removeErr != nil && + !errors.Is(removeErr, os.ErrNotExist) { + if err == nil { + err = fmt.Errorf("remove delegated request file: %w", removeErr) + } else { + err = fmt.Errorf( + "%w; remove delegated request file: %w", + err, + removeErr, + ) + } + } + }() + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return fmt.Errorf("secure delegated request file: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return fmt.Errorf("write delegated request file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close delegated request file: %w", err) + } + + commandArgs := append(slices.Clone(args), "--request-file", requestPath) + _, err = client.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ + Workflow: &azdext.Workflow{ + Name: "agents-project-delegation", + Steps: []*azdext.WorkflowStep{{ + Command: &azdext.WorkflowCommand{Args: commandArgs}, + }}, + }, + }) + if err != nil { + return fmt.Errorf("run delegated project command: %w", err) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index a424e4e5f9e..4b9f6620800 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1277,7 +1277,9 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if err := validateStandaloneEjectArgs(cmd, args); err != nil { return err } - return ejectInfra(gate.projectRoot, infraProvider) + return delegateFoundryInfra( + azdext.WithAccessToken(cmd.Context()), infraProvider, + ) } } @@ -1501,7 +1503,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, ); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return delegateFoundryInfraAfterInit(ctx, azdClient, infraProvider) } } } @@ -1550,7 +1552,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return delegateFoundryInfraAfterInit(ctx, azdClient, infraProvider) } } } @@ -1595,7 +1597,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } return err } - return ejectInfraAfterInit(infraProvider) + return delegateFoundryInfraAfterInit(ctx, azdClient, infraProvider) } return missingAgentServiceError(flags.manifestPointer) } @@ -1860,7 +1862,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // wrote azure.yaml, chain the eject step. Skip silently when init // didn't produce a foundry-bearing azure.yaml (cancelled or // non-foundry flow) to avoid a confusing "nothing to eject" error. - return ejectInfraAfterInit(infraProvider) + return delegateFoundryInfraAfterInit(ctx, azdClient, infraProvider) }, } @@ -3330,7 +3332,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // Each Foundry resource is written as its own azure.yaml service entry, so // the deployments, connections, and toolboxes move out of the agent config - // into sibling azure.ai.project/connection/toolbox services emitted below. + // into sibling project/connection/toolbox services handled below. // The agent keeps its container, resources, tool connections, and startup // command. The provisioning handlers re-source the moved data from the // sibling services. @@ -3408,8 +3410,10 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + projectResourceIDHint(a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), resourceDeployments, resourceConnections, resourceToolboxes, + delegatedProjectConstraintsForContext(a.azureContext), ); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 598e84f7e83..621cca24050 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -157,7 +157,7 @@ func foundryProjectName(content []byte) string { } // foundryDeploymentEntry holds a parsed deployment along with the service key -// it was declared in, so the azure.yaml can be updated after verification. +// it was declared in, so Projects can update it after verification. type foundryDeploymentEntry struct { ServiceName string Deployment project.Deployment @@ -672,48 +672,6 @@ func promptAlternativeDeployment( }, true, nil } -// updateAzureYamlDeployments writes the filtered deployment list back to the -// azure.yaml project service. Deployments the user chose to "use existing" or -// "skip" are excluded, leaving only those that need provisioning. -func updateAzureYamlDeployments( - ctx context.Context, - azdClient *azdext.AzdClient, - serviceName string, - deployments []project.Deployment, -) error { - // Convert deployments to a structpb-compatible value. - depSlice := make([]any, 0, len(deployments)) - for _, d := range deployments { - depSlice = append(depSlice, map[string]any{ - "name": d.Name, - "model": map[string]any{ - "format": d.Model.Format, - "name": d.Model.Name, - "version": d.Model.Version, - }, - "sku": map[string]any{ - "name": d.Sku.Name, - "capacity": d.Sku.Capacity, - }, - }) - } - - val, err := structpb.NewValue(depSlice) - if err != nil { - return fmt.Errorf("encoding deployments for service %q: %w", serviceName, err) - } - - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: serviceName, - Path: "deployments", - Value: val, - }); err != nil { - return fmt.Errorf("updating deployments in azure.yaml for service %q: %w", serviceName, err) - } - - return nil -} - type adoptedAgentNameResolver func(context.Context, string) (string, error) func adoptedAgentNameConflictSuggestion() string { @@ -987,11 +945,17 @@ func runInitFromAzureYaml( return err } - // When an existing project was selected, stamp its endpoint onto the - // azure.ai.project service so the provisioning provider recognizes the - // brownfield signal and reuses the project instead of creating a new one. + // Let azure.ai.projects reconcile the selected project + // and its endpoint. + // Agents must not mutate the project-owned service directly. if result.FoundryProject != nil { - if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { + if _, err := delegateFoundryProjectInit( + ctx, + azdClient, + result.FoundryProject.ProjectName, + result.FoundryProject.ResourceId, + result.FoundryProject.Endpoint(), + ); err != nil { return err } if err := confirmAdoptedAgentNameConflicts( @@ -1024,22 +988,24 @@ func runInitFromAzureYaml( // Update the azure.yaml if deployments were modified. if deploymentsModified { - // Group kept deployments by their originating service name. - byService := make(map[string][]project.Deployment) - for _, entry := range deploymentEntries { - // Initialize to empty — ensures services with all removed get an empty list. - if _, ok := byService[entry.ServiceName]; !ok { - byService[entry.ServiceName] = nil - } + if result.FoundryProject == nil { + return fmt.Errorf( + "cannot delegate deployment declarations without a Foundry project", + ) } - for _, kept := range keptEntries { - byService[kept.ServiceName] = append(byService[kept.ServiceName], kept.Deployment) + keptDeployments := make([]project.Deployment, len(keptEntries)) + for i, kept := range keptEntries { + keptDeployments[i] = kept.Deployment } - - for svcName, deps := range byService { - if err := updateAzureYamlDeployments(ctx, azdClient, svcName, deps); err != nil { - return err - } + if err := delegateFoundryProjectDeployments( + ctx, + azdClient, + result.FoundryProject.ProjectName, + projectResourceIDHint(result.FoundryProject), + result.FoundryProject.Endpoint(), + keptDeployments, + ); err != nil { + return err } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index 54f659132ff..bf9f1528756 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -937,85 +937,3 @@ func TestUpdateAdoptedAgentNames_UnchangedNamesAreNotWritten(t *testing.T) { defer server.mu.Unlock() require.Empty(t, server.configValues) } - -// TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint -// writes the endpoint to the existing azure.ai.project service via -// SetServiceConfigValue when a valid project is provided. -func TestStampProjectEndpoint_WritesEndpoint(t *testing.T) { - t.Parallel() - - server := &recordingProjectServer{ - existing: map[string]*azdext.ServiceConfig{ - "ai-project": {Name: "ai-project", Host: AiProjectHost}, - }, - } - client := newProjectRecorderClient(t, server) - - selectedProject := &FoundryProjectInfo{ - AccountName: "myaccount", - ProjectName: "myproject", - } - - err := stampProjectEndpoint(t.Context(), client, selectedProject) - require.NoError(t, err) - - server.mu.Lock() - defer server.mu.Unlock() - - // The recording server captures SetServiceConfigValue calls in uses map - // for "uses" path, but for "endpoint" we check the raw call was made by - // verifying through the actual project state. Since recordingProjectServer - // returns success, we verify the function didn't error and the endpoint - // would have been written. For a deeper assertion, check the call was made - // with the correct service name and value by inspecting configValues. - require.Equal(t, "ai-project", server.configValues["endpoint"].serviceName) - require.Equal(t, - "https://myaccount.services.ai.azure.com/api/projects/myproject", - server.configValues["endpoint"].value, - ) -} - -// TestStampProjectEndpoint_NilProject verifies stampProjectEndpoint is a no-op -// when the selected project is nil (user chose "Create new"). -func TestStampProjectEndpoint_NilProject(t *testing.T) { - t.Parallel() - - server := &recordingProjectServer{ - existing: map[string]*azdext.ServiceConfig{ - "ai-project": {Name: "ai-project", Host: AiProjectHost}, - }, - } - client := newProjectRecorderClient(t, server) - - err := stampProjectEndpoint(t.Context(), client, nil) - require.NoError(t, err) - - server.mu.Lock() - defer server.mu.Unlock() - require.Empty(t, server.configValues, "no SetServiceConfigValue calls expected for nil project") -} - -// TestStampProjectEndpoint_NoExistingService verifies stampProjectEndpoint is a -// no-op when no azure.ai.project service exists in the project yet. -func TestStampProjectEndpoint_NoExistingService(t *testing.T) { - t.Parallel() - - server := &recordingProjectServer{ - existing: map[string]*azdext.ServiceConfig{ - "my-agent": {Name: "my-agent", Host: AiAgentHost}, - }, - } - client := newProjectRecorderClient(t, server) - - selectedProject := &FoundryProjectInfo{ - AccountName: "myaccount", - ProjectName: "myproject", - } - - err := stampProjectEndpoint(t.Context(), client, selectedProject) - require.NoError(t, err) - - server.mu.Lock() - defer server.mu.Unlock() - require.Empty(t, server.configValues, "no SetServiceConfigValue calls expected when no project service exists") -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 4dbf273c034..9f4ed4ae0f6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -41,8 +41,8 @@ type FoundryProjectInfo struct { // Endpoint returns the Foundry project data-plane endpoint derived from the // account and project names, or "" when the project is nil or either name is -// missing. The endpoint is the brownfield signal written onto the -// azure.ai.project service so provision connects to the existing project. +// missing. The endpoint is forwarded to Projects as the brownfield +// signal so provision connects to the existing project. func (p *FoundryProjectInfo) Endpoint() string { if p == nil || p.AccountName == "" || p.ProjectName == "" { return "" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index a37a34a1beb..57ad434e948 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -823,8 +823,8 @@ func (a *InitFromCodeAction) addToProject( agentConfig.StartupCommand = startupCmd } - // Move the model deployments out of the agent config into a sibling - // azure.ai.project service, emitted after the agent service below. + // Move the model deployments out of the agent config into the + // project-owned azure.ai.project service. resourceDeployments := agentConfig.Deployments agentConfig.Deployments = nil @@ -883,14 +883,16 @@ func (a *InitFromCodeAction) addToProject( return err } - // Emit the sibling azure.ai.project service carrying the model deployments - // and wire the agent's uses: to it. A selected existing project contributes + // Delegate the project service carrying model deployments and wire + // the agent's uses: to it. A selected existing project contributes // its endpoint so provision reuses it instead of creating a new project. if err := emitResourceServices( ctx, a.azdClient, agentServiceName, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + projectResourceIDHint(a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), resourceDeployments, nil, nil, + delegatedProjectConstraintsForContext(a.azureContext), ); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go index a274088f705..c5aefc0f8c0 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 @@ -1501,7 +1501,7 @@ services: assert.NoDirExists(t, filepath.Join(nestedDir, "infra")) } -func TestInitInfra_StandaloneEjectResolvesParentProject(t *testing.T) { +func TestInitInfra_StandaloneEjectDelegatesToProjects(t *testing.T) { t.Setenv("AZD_EXEC_PROJECT_DIR", "") projectRoot := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "azure.yaml"), []byte(`name: test @@ -1512,6 +1512,9 @@ services: nestedDir := filepath.Join(projectRoot, "src", "agent") require.NoError(t, os.MkdirAll(nestedDir, 0750)) t.Chdir(nestedDir) + server := &recordingProjectServer{} + _, serverAddress := newProjectRecorderClientWithAddress(t, server) + t.Setenv("AZD_SERVER", serverAddress) cmd := newInitCommand(&azdext.ExtensionContext{}) cmd.SetArgs([]string{"--infra"}) @@ -1524,7 +1527,12 @@ services: }) require.NoError(t, execErr) - assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.bicep")) + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.delegatedRequests, 1) + infra := server.delegatedRequests[0]["infra"].(map[string]any) + assert.Equal(t, "bicep", infra["ejectProvider"]) + assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) assert.NoDirExists(t, filepath.Join(nestedDir, "infra")) } 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 83b5c8ae13b..4b4762f5187 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 @@ -23,11 +23,11 @@ import ( // host: azure.ai.. The owning extension registers a service-target // provider for the host so `azd up`/`provision`/`deploy` can walk the service. const ( - // AiProjectHost owns the Foundry project and its model deployments. + // AiProjectHost identifies the project-owned Foundry service. AiProjectHost = "azure.ai.project" - // AiConnectionHost owns a single Foundry project connection. + // AiConnectionHost identifies a single Foundry project connection. AiConnectionHost = "azure.ai.connection" - // AiToolboxHost owns a single Foundry toolbox (toolset). + // AiToolboxHost identifies a single Foundry toolbox (toolset). AiToolboxHost = "azure.ai.toolbox" // aiProjectServiceName is the stable azure.yaml service key used for the @@ -37,28 +37,27 @@ const ( aiProjectServiceName = "ai-project" ) -// emitResourceServices writes the Foundry resource sibling services that the -// agent depends on (one azure.ai.project carrying the model deployments, one -// azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and -// wires the agent service's uses: list to them for ordering. Each resource is -// its own azure.yaml service entry so a different extension can own each host. +// emitResourceServices delegates the project-owned service to +// azure.ai.projects, writes the agent's connection and toolbox siblings, +// and wires the agent service's uses: list to them for ordering. // // projectEndpoint, when non-empty, is written as endpoint: on the project // service to mark an existing (brownfield) Foundry project so provision // connects to it instead of creating a new one. It is empty for new projects. // -// projectName, when known, is the Foundry project name used to derive the -// project service key (so azure.yaml reads like the real project). It falls back -// to aiProjectServiceName when unknown or colliding. See resolveProjectServiceKey. +// projectName, when known, is forwarded to azure.ai.projects so it +// can derive the project service key. func emitResourceServices( ctx context.Context, azdClient *azdext.AzdClient, agentServiceName string, projectName string, + projectResourceID string, projectEndpoint string, deployments []project.Deployment, connections []project.Connection, toolboxes []project.Toolbox, + constraints ...delegatedProjectConstraints, ) error { var agentUses []string @@ -67,9 +66,7 @@ func emitResourceServices( // of silently overwriting each other -- AddService overwrites by name. // Seed it with the agent service name, which the caller adds before this // runs, plus the project's existing non-project services, so a resource - // colliding with the agent or a hand-authored service is caught too. The - // existing azure.ai.project service is intentionally left out: it is reused - // by resolveProjectServiceKey to keep repeated inits idempotent. + // colliding with the agent or a hand-authored service is caught too. usedNames := map[string]string{} if agentServiceName != "" { usedNames[agentServiceName] = "agent service" @@ -83,23 +80,17 @@ func emitResourceServices( } } - // One project service owns the model deployments and represents the single - // Foundry project the agent targets. It is always emitted -- even with no - // deployments (e.g. "Skip model configuration") -- so every agent has one - // project sibling that connections and toolboxes can depend on to enforce - // provisioning order. A non-empty endpoint marks an existing project. - projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{ - Endpoint: projectEndpoint, - Deployments: deployments, - }) + // Projects owns the project service and managed deployments. + // Delegate that work before emitting agent-owned connection and + // toolbox services. + projectServiceName, err := delegateFoundryProjectResources( + ctx, azdClient, projectName, projectResourceID, projectEndpoint, deployments, + constraints..., + ) if err != nil { - return fmt.Errorf("marshaling project service config: %w", err) - } - projectServiceName := resolveProjectServiceKey(ctx, azdClient, projectName, agentServiceName) - if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { return err } - if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { + if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { return err } agentUses = append(agentUses, projectServiceName) @@ -164,61 +155,10 @@ func emitResourceServices( return nil } -// resolveProjectServiceKey picks the azure.yaml service key for the single -// azure.ai.project service. Precedence: -// -// 1. Reuse an existing azure.ai.project service key when one is already in the -// project. This keeps repeated inits idempotent (azd's extension API has no -// remove-service call, so a changed key would leave a second project service -// behind, which the provisioning provider rejects). -// 2. Otherwise derive the key from the Foundry project name when it is known and -// does not collide with the agent service name, so azure.yaml reads like the -// real project. -// 3. Otherwise fall back to the stable "ai-project" default. -// -// The key is not load-bearing: the provider and collectors find the project -// service by host (azure.ai.project), and the generated uses: edges reference -// whatever key this returns. -func resolveProjectServiceKey( - ctx context.Context, - azdClient *azdext.AzdClient, - projectName string, - agentServiceName string, -) string { - if existing := existingProjectServiceKey(ctx, azdClient); existing != "" { - return existing - } - if key := sanitizeServiceName(projectName); key != "" && key != agentServiceName { - return key - } - return aiProjectServiceName -} - -// existingProjectServiceKey returns the key of the azure.ai.project service -// already present in the project, or "" when none exists or the project cannot be -// read. When more than one is present (should not happen) the lexicographically -// first key is returned so the choice is deterministic. -func existingProjectServiceKey(ctx context.Context, azdClient *azdext.AzdClient) string { - resp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) - if err != nil || resp.GetProject() == nil { - return "" - } - var keys []string - for name, svc := range resp.GetProject().GetServices() { - if svc.GetHost() == AiProjectHost { - keys = append(keys, name) - } - } - if len(keys) == 0 { - return "" - } - slices.Sort(keys) - return keys[0] -} - -// projectNameHint returns the Foundry project name to derive the project service -// key from: the selected existing project's name, else the AZURE_AI_PROJECT_NAME -// azd environment value when concretely set (not a ${...} placeholder), else "". +// projectNameHint returns the Foundry project name to forward to +// the Projects extension: the selected existing project's name, else +// AZURE_AI_PROJECT_NAME when concretely set (not a ${...} placeholder), +// else "". func projectNameHint( ctx context.Context, azdClient *azdext.AzdClient, @@ -235,35 +175,6 @@ func projectNameHint( return v } -// stampProjectEndpoint writes the selected project's endpoint onto the existing -// azure.ai.project service in azure.yaml. This is a no-op when the project is -// nil, has no endpoint, or when no ai-project service exists yet. -func stampProjectEndpoint(ctx context.Context, azdClient *azdext.AzdClient, selectedProject *FoundryProjectInfo) error { - if selectedProject == nil { - return nil - } - endpoint := selectedProject.Endpoint() - if endpoint == "" { - return nil - } - projectSvcKey := existingProjectServiceKey(ctx, azdClient) - if projectSvcKey == "" { - return nil - } - endpointVal, err := structpb.NewValue(endpoint) - if err != nil { - return fmt.Errorf("encoding project endpoint: %w", err) - } - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: projectSvcKey, - Path: "endpoint", - Value: endpointVal, - }); err != nil { - return fmt.Errorf("writing project endpoint to azure.yaml: %w", err) - } - return nil -} - // addResourceService adds a single Foundry resource service to azure.yaml with // its keys composed at the service level (inline, via AdditionalProperties, the // same shape the agent service uses) and optionally wires its uses: list. The 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 e01c46c4801..e5b56543d7c 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 @@ -5,9 +5,14 @@ package cmd import ( "context" + "encoding/json" + "fmt" + "maps" "net" "os" "path/filepath" + "slices" + "strings" "sync" "testing" @@ -426,6 +431,8 @@ type recordingProjectServer struct { // simulate a service that already carries an env section (raw, // on-disk templates). rawEnv map[string]map[string]any + delegatedRequestPaths []string + delegatedRequests []map[string]any unsetPaths []string setEnvironmentErr error unsetServiceConfigErr error @@ -456,6 +463,169 @@ func (s *recordingProjectServer) AddService( return &azdext.EmptyResponse{}, nil } +type recordingProjectWorkflowServer struct { + azdext.UnimplementedWorkflowServiceServer + project *recordingProjectServer +} + +func (s *recordingProjectWorkflowServer) Run( + _ context.Context, + req *azdext.RunWorkflowRequest, +) (*azdext.EmptyResponse, error) { + workflow := req.GetWorkflow() + if workflow == nil || len(workflow.GetSteps()) != 1 || + workflow.GetSteps()[0].GetCommand() == nil { + return nil, fmt.Errorf("delegated workflow request is invalid") + } + args := workflow.GetSteps()[0].GetCommand().GetArgs() + requestIndex := slices.Index(args, "--request-file") + if requestIndex < 0 || requestIndex+1 >= len(args) { + return nil, fmt.Errorf("delegated request file is missing") + } + requestPath := args[requestIndex+1] + data, err := os.ReadFile(requestPath) + if err != nil { + return nil, fmt.Errorf("read delegated request: %w", err) + } + var request struct { + Project struct { + Endpoint string `json:"endpoint"` + Name string `json:"name"` + ResourceID string `json:"resourceId"` + } `json:"project"` + ReplaceDeployments bool `json:"replaceDeployments"` + Model struct { + Name string `json:"name"` + DeploymentName string `json:"deploymentName"` + Format string `json:"format"` + Version string `json:"version"` + SKU string `json:"sku"` + Capacity int `json:"capacity"` + } `json:"model"` + } + if err := json.Unmarshal(data, &request); err != nil { + return nil, fmt.Errorf("decode delegated request: %w", err) + } + var rawRequest map[string]any + if err := json.Unmarshal(data, &rawRequest); err != nil { + return nil, fmt.Errorf("decode raw delegated request: %w", err) + } + + s.project.mu.Lock() + defer s.project.mu.Unlock() + s.project.delegatedRequestPaths = append( + s.project.delegatedRequestPaths, requestPath, + ) + s.project.delegatedRequests = append( + s.project.delegatedRequests, rawRequest, + ) + if len(args) >= 4 && args[0] == "ai" && args[1] == "project" && + args[2] == "init" { + name := delegatedProjectServiceNameForTest( + request.Project.Name, s.project.existing, + ) + body := map[string]any{} + if request.Project.Endpoint != "" { + body["endpoint"] = request.Project.Endpoint + } + if request.ReplaceDeployments { + deployments := rawRequest["deployments"] + if deployments == nil { + deployments = []any{} + } + body["deployments"] = deployments + } + properties, err := structpb.NewStruct(body) + if err != nil { + return nil, err + } + if s.project.existing == nil { + s.project.existing = map[string]*azdext.ServiceConfig{} + } + s.project.existing[name] = &azdext.ServiceConfig{ + Name: name, + Host: AiProjectHost, + AdditionalProperties: properties, + } + return &azdext.EmptyResponse{}, nil + } + + if len(args) >= 5 && args[0] == "ai" && args[1] == "project" && + args[2] == "deployment" && args[3] == "add" { + var projectName string + for name, service := range s.project.existing { + if service.GetHost() == AiProjectHost { + projectName = name + break + } + } + if projectName == "" { + return nil, fmt.Errorf("delegated project service is missing") + } + service := s.project.existing[projectName] + body := service.GetAdditionalProperties().AsMap() + deployments, _ := body["deployments"].([]any) + modelName := request.Model.Name + if slash := strings.IndexByte(modelName, '/'); slash >= 0 { + modelName = modelName[slash+1:] + } + deployments = append(deployments, map[string]any{ + "name": firstNonEmptyTest(request.Model.DeploymentName, modelName), + "model": map[string]any{ + "name": modelName, + "format": request.Model.Format, + "version": request.Model.Version, + }, + "sku": map[string]any{ + "name": request.Model.SKU, + "capacity": request.Model.Capacity, + }, + }) + body["deployments"] = deployments + service.AdditionalProperties, err = structpb.NewStruct(body) + if err != nil { + return nil, err + } + return &azdext.EmptyResponse{}, nil + } + return nil, fmt.Errorf("unexpected delegated command %q", args) +} + +func delegatedProjectServiceNameForTest( + projectName string, + services map[string]*azdext.ServiceConfig, +) string { + for name, service := range services { + if service.GetHost() == AiProjectHost { + return name + } + } + base := strings.ToLower(strings.TrimSpace(projectName)) + base = strings.NewReplacer(" ", "-", "_", "-", "/", "-").Replace(base) + base = strings.Trim(base, "-") + if base == "" { + base = aiProjectServiceName + } + if _, exists := services[base]; !exists { + return base + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if _, exists := services[candidate]; !exists { + return candidate + } + } +} + +func firstNonEmptyTest(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func (s *recordingProjectServer) GetServiceConfigValue( _ context.Context, req *azdext.GetServiceConfigValueRequest, ) (*azdext.GetServiceConfigValueResponse, error) { @@ -545,10 +715,24 @@ func newProjectRecorderClient( t *testing.T, server azdext.ProjectServiceServer, ) *azdext.AzdClient { + client, _ := newProjectRecorderClientWithAddress(t, server) + return client +} + +func newProjectRecorderClientWithAddress( + t *testing.T, + server azdext.ProjectServiceServer, +) (*azdext.AzdClient, string) { t.Helper() grpcServer := grpc.NewServer() azdext.RegisterProjectServiceServer(grpcServer, server) + if projectServer, ok := server.(*recordingProjectServer); ok { + azdext.RegisterWorkflowServiceServer( + grpcServer, + &recordingProjectWorkflowServer{project: projectServer}, + ) + } listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -574,29 +758,196 @@ func newProjectRecorderClient( require.NoError(t, err) t.Cleanup(func() { client.Close() }) - return client + return client, listener.Addr().String() +} + +func TestDelegateFoundryProjectResourcesUsesResourceID(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + name, err := delegateFoundryProjectResources( + t.Context(), + client, + "my project", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/account/projects/project", + "https://account.services.ai.azure.com/api/projects/project", + nil, + ) + require.NoError(t, err) + assert.Equal(t, "my-project", name) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.delegatedRequests, 1) + projectBody := server.delegatedRequests[0]["project"].(map[string]any) + assert.Equal(t, + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/account/projects/project", + projectBody["resourceId"], + ) + assert.NotContains(t, projectBody, "endpoint") + require.Len(t, server.delegatedRequestPaths, 1) + _, err = os.Stat(server.delegatedRequestPaths[0]) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestDelegateFoundryProjectInitUsesEndpoint(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + name, err := delegateFoundryProjectInit( + t.Context(), + client, + "my project", + "", + "https://account.services.ai.azure.com/api/projects/project", + ) + require.NoError(t, err) + assert.Equal(t, "my-project", name) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.delegatedRequests, 1) + projectBody := server.delegatedRequests[0]["project"].(map[string]any) + assert.Equal(t, + "https://account.services.ai.azure.com/api/projects/project", + projectBody["endpoint"], + ) + assert.NotContains(t, projectBody, "resourceId") +} + +func TestDelegateFoundryProjectDeploymentsReplacesDeclarations( + t *testing.T, +) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + deployments := []project.Deployment{{ + Name: "chat", + Model: project.DeploymentModel{ + Format: "OpenAI", + Name: "gpt-4.1", + Version: "2025-04-14", + }, + Sku: project.DeploymentSku{ + Name: "GlobalStandard", + Capacity: 10, + }, + }} + + require.NoError(t, delegateFoundryProjectDeployments( + t.Context(), client, "my project", "", "", deployments, + )) + require.NoError(t, delegateFoundryProjectDeployments( + t.Context(), client, "my project", "", "", nil, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.delegatedRequests, 2) + assert.True(t, server.delegatedRequests[0]["replaceDeployments"].(bool)) + declarations := server.delegatedRequests[0]["deployments"].([]any) + require.Len(t, declarations, 1) + declaration := declarations[0].(map[string]any) + assert.Equal(t, "chat", declaration["name"]) + assert.Empty(t, server.delegatedRequests[1]["deployments"]) + for _, path := range server.delegatedRequestPaths { + _, err := os.Stat(path) + assert.ErrorIs(t, err, os.ErrNotExist) + } } -// TestEmitResourceServices_AlwaysEmitsProjectService verifies the ai-project -// service is written even when the agent has no deployments, connections, or -// toolboxes, and that the agent's uses: is wired to it. The project service is -// emitted unconditionally as the stable provisioning-order anchor every agent -// references rather than being gated on a Foundry resource being present. +func TestDelegateFoundryProjectResourcesPreservesDeploymentTuple( + t *testing.T, +) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + deployments := []project.Deployment{{ + Name: "chat", + Model: project.DeploymentModel{ + Format: "OpenAI", + Name: "gpt-4.1", + Version: "2025-04-14", + }, + Sku: project.DeploymentSku{ + Name: "GlobalStandard", + Capacity: 10, + }, + }} + + _, err := delegateFoundryProjectResources( + t.Context(), client, "my project", "", "", deployments, + ) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.delegatedRequests, 2) + model := server.delegatedRequests[1]["model"].(map[string]any) + assert.Equal(t, "OpenAI/gpt-4.1", model["name"]) + assert.Equal(t, "chat", model["deploymentName"]) + assert.Equal(t, "OpenAI", model["format"]) + assert.Equal(t, "2025-04-14", model["version"]) + assert.Equal(t, "GlobalStandard", model["sku"]) + assert.Equal(t, float64(10), model["capacity"]) + for _, path := range server.delegatedRequestPaths { + _, err := os.Stat(path) + assert.ErrorIs(t, err, os.ErrNotExist) + } +} + +func TestDelegateFoundryProjectResourcesForwardsConstraints(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + deployments := []project.Deployment{{ + Name: "chat", + Model: project.DeploymentModel{Name: "gpt-4.1"}, + }} + constraints := delegatedProjectConstraints{ + Requirements: delegatedProjectRequirements{ + AllowedLocations: []string{"eastus"}, + }, + RequiredCapabilities: []string{"agentsV2"}, + AllowedLocations: []string{"eastus"}, + ExcludedModelNames: []string{"gpt-4o"}, + } + + _, err := delegateFoundryProjectResources( + t.Context(), client, "my project", "", "", deployments, constraints, + ) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.delegatedRequests, 2) + require.Equal(t, []any{"eastus"}, + server.delegatedRequests[0]["requirements"].(map[string]any)["allowedLocations"]) + model := server.delegatedRequests[1]["model"].(map[string]any) + assert.Equal(t, []any{"eastus"}, model["allowedLocations"]) + assert.Equal(t, []any{"agentsV2"}, model["requiredCapabilities"]) + assert.Equal(t, []any{"gpt-4o"}, model["excludedModelNames"]) +} + +// TestEmitResourceServices_AlwaysEmitsProjectService verifies delegation +// occurs even when the agent has no deployments, connections, or toolboxes. +// The project service remains the stable provisioning-order anchor every +// agent references rather than being gated on a Foundry resource. func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { t.Parallel() server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) + err := emitResourceServices( + t.Context(), client, "myagent", "", "", "", nil, nil, nil, + ) require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - assert.Equal(t, aiProjectServiceName, server.added[0].Name) - assert.Equal(t, AiProjectHost, server.added[0].Host) + require.Empty(t, server.added) + projectSvc, ok := server.existing[aiProjectServiceName] + require.True(t, ok) + assert.Equal(t, AiProjectHost, projectSvc.Host) assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) } @@ -610,17 +961,20 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { client := newProjectRecorderClient(t, server) conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) + err := emitResourceServices( + t.Context(), client, "myagent", "", "", "", nil, conns, nil, + ) require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 2) - assert.Equal(t, aiProjectServiceName, server.added[0].Name) - assert.Equal(t, AiProjectHost, server.added[0].Host) - assert.Equal(t, "myconn", server.added[1].Name) - assert.Equal(t, AiConnectionHost, server.added[1].Host) + require.Len(t, server.added, 1) + assert.Equal(t, "myconn", server.added[0].Name) + assert.Equal(t, AiConnectionHost, server.added[0].Host) + projectSvc, ok := server.existing[aiProjectServiceName] + require.True(t, ok) + assert.Equal(t, AiProjectHost, projectSvc.Host) assert.Equal(t, []string{aiProjectServiceName}, server.uses["myconn"]) assert.Equal(t, []string{aiProjectServiceName, "myconn"}, server.uses["myagent"]) @@ -643,12 +997,15 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, }} conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", deployments, conns, nil)) + require.NoError(t, emitResourceServices( + t.Context(), client, "myagent", "", "", "", deployments, conns, nil, + )) server.mu.Lock() defer server.mu.Unlock() services := map[string]*azdext.ServiceConfig{} + maps.Copy(services, server.existing) for _, svc := range server.added { // Resource keys must travel at the service level, not under config:. assert.Nil(t, svc.Config, "service %q must not nest keys under config:", svc.Name) @@ -686,13 +1043,16 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", endpoint, nil, nil, nil)) + require.NoError(t, emitResourceServices( + t.Context(), client, "myagent", "", "", endpoint, nil, nil, nil, + )) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - projSvc := server.added[0] + require.Empty(t, server.added) + projSvc, ok := server.existing[aiProjectServiceName] + require.True(t, ok) require.Equal(t, aiProjectServiceName, projSvc.Name) require.NotNil(t, projSvc.AdditionalProperties) assert.Equal(t, endpoint, projSvc.AdditionalProperties.Fields["endpoint"].GetStringValue()) @@ -702,13 +1062,16 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil)) + require.NoError(t, emitResourceServices( + t.Context(), client, "myagent", "", "", "", nil, nil, nil, + )) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - projSvc := server.added[0] + require.Empty(t, server.added) + projSvc, ok := server.existing[aiProjectServiceName] + require.True(t, ok) if projSvc.AdditionalProperties != nil { _, ok := projSvc.AdditionalProperties.Fields["endpoint"] assert.False(t, ok, "endpoint must be omitted for a new project") @@ -727,12 +1090,13 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { client := newProjectRecorderClient(t, server) require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil)) + t.Context(), client, "myagent", "my-foundry-proj", "", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - assert.Equal(t, "my-foundry-proj", server.added[0].Name) + require.Empty(t, server.added) + _, ok := server.existing["my-foundry-proj"] + assert.True(t, ok) assert.Equal(t, []string{"my-foundry-proj"}, server.uses["myagent"]) }) @@ -747,12 +1111,13 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { // A different project name is supplied, but the existing key wins so a // repeated init does not create a second project service. require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil)) + t.Context(), client, "myagent", "a-new-name", "", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - assert.Equal(t, "old-project-key", server.added[0].Name) + require.Empty(t, server.added) + _, ok := server.existing["old-project-key"] + assert.True(t, ok) }) t.Run("falls back when project name collides with agent", func(t *testing.T) { @@ -760,13 +1125,13 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { client := newProjectRecorderClient(t, server) require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my agent", "", nil, nil, nil)) + t.Context(), client, "myagent", "my agent", "", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - // "my agent" sanitizes to "myagent" == agent key, so it falls back. - assert.Equal(t, aiProjectServiceName, server.added[0].Name) + require.Empty(t, server.added) + _, ok := server.existing["my-agent"] + assert.True(t, ok) }) t.Run("falls back when project name unknown", func(t *testing.T) { @@ -774,12 +1139,13 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { client := newProjectRecorderClient(t, server) require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "", "", nil, nil, nil)) + t.Context(), client, "myagent", "", "", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - assert.Equal(t, aiProjectServiceName, server.added[0].Name) + require.Empty(t, server.added) + _, ok := server.existing[aiProjectServiceName] + assert.True(t, ok) }) }