diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index d354e75acf1..d676a5c8765 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -82,6 +82,8 @@ words: - protoimpl - protojson - protoreflect + - protowire + - anypb - SNAPPROCESS - structpb - subtest diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 4aa90b41155..cb9b25f0dc1 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -1054,9 +1054,7 @@ type workflowCmdAdapter struct { // ExecuteContext implements workflow.AzdCommandRunner. // It rebuilds the cobra command tree on each call to ensure a clean slate, // preventing "context canceled" errors from stale command state during retries. -// Global flags from the original process invocation are appended to the step args -// so that persistent flags (e.g., --trace-log-file) are properly parsed and visible -// to telemetry middleware on the fresh command tree. +// Inherited flags are merged into step args without overriding step flags. func (w *workflowCmdAdapter) ExecuteContext(ctx context.Context, args []string) error { // Cancel the child context when the step completes so that any event handlers // registered during this step (e.g. by service target Initialize methods) are @@ -1067,7 +1065,7 @@ func (w *workflowCmdAdapter) ExecuteContext(ctx context.Context, args []string) rootCmd := w.newCommand() // Always set args explicitly to prevent Cobra from falling back to os.Args[1:]. // Cobra uses os.Args when cmd.args is nil (but not when it's an empty slice). - mergedArgs := append(slices.Clone(args), w.globalArgs...) + mergedArgs := mergeWorkflowArgs(args, w.globalArgs) if mergedArgs == nil { mergedArgs = []string{} } @@ -1075,6 +1073,57 @@ func (w *workflowCmdAdapter) ExecuteContext(ctx context.Context, args []string) return rootCmd.ExecuteContext(childCtx) } +// mergeWorkflowArgs combines step and inherited flags. Step flags take +// precedence for both --name=value and --name value syntax. +func mergeWorkflowArgs(stepArgs, globalArgs []string) []string { + stepFlags := make(map[string]struct{}) + for _, arg := range stepArgs { + if arg == "--" { + break + } + + if name, ok := longFlagName(arg); ok { + stepFlags[name] = struct{}{} + } + } + + merged := slices.Clone(stepArgs) + for i := 0; i < len(globalArgs); { + arg := globalArgs[i] + name, ok := longFlagName(arg) + end := i + 1 + if ok && !strings.Contains(arg, "=") && end < len(globalArgs) && + !strings.HasPrefix(globalArgs[end], "-") { + end++ + } + + if !ok { + merged = append(merged, globalArgs[i:end]...) + } else if _, shadowed := stepFlags[name]; !shadowed { + merged = append(merged, globalArgs[i:end]...) + } + i = end + } + + return merged +} + +func longFlagName(arg string) (string, bool) { + if !strings.HasPrefix(arg, "--") || len(arg) == 2 { + return "", false + } + + name := strings.TrimPrefix(arg, "--") + if equal := strings.IndexByte(name, '='); equal >= 0 { + name = name[:equal] + } + if name == "" { + return "", false + } + + return name, true +} + // extractGlobalArgs extracts global flag arguments from the process command line. // It parses os.Args against the global flag set and returns only the flags that were // explicitly set by the user, formatted as command-line arguments. diff --git a/cli/azd/cmd/container_test.go b/cli/azd/cmd/container_test.go index 8eb6781e10d..c434e5b6cfb 100644 --- a/cli/azd/cmd/container_test.go +++ b/cli/azd/cmd/container_test.go @@ -498,6 +498,48 @@ func Test_workflowCmdAdapter_ContextPropagation(t *testing.T) { }) } +func TestMergeWorkflowArgs_StepFlagsShadowInheritedGlobals(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stepArgs []string + globalArgs []string + expected []string + }{ + { + name: "equals syntax", + stepArgs: []string{"deploy", "--output=none"}, + globalArgs: []string{"--output=json", "--debug=true"}, + expected: []string{"deploy", "--output=none", "--debug=true"}, + }, + { + name: "separate value syntax", + stepArgs: []string{"deploy", "--output", "none"}, + globalArgs: []string{"--output", "json", "--debug=true"}, + expected: []string{"deploy", "--output", "none", "--debug=true"}, + }, + { + name: "environment remains step scoped", + stepArgs: []string{"env", "set", "KEY", "VALUE", "--environment=child"}, + globalArgs: []string{"--environment=parent", "--output=json"}, + expected: []string{"env", "set", "KEY", "VALUE", "--environment=child", "--output=json"}, + }, + { + name: "environment is inherited when not overridden", + stepArgs: []string{"deploy"}, + globalArgs: []string{"--environment=parent", "--output=json"}, + expected: []string{"deploy", "--environment=parent", "--output=json"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, mergeWorkflowArgs(tt.stepArgs, tt.globalArgs)) + }) + } +} + func Test_NewRootCmd_ReregistrationReplacesProjectConfig(t *testing.T) { // This test proves the regression from PR #7171: when workflowCmdAdapter called // NewRootCmd (with full registration) for each workflow step, registerCommonDependencies diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 9a22784c156..92606fba749 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -145,17 +145,24 @@ specific version of the tool installed on the machine. > **Note**: These variables are defined and consumed by individual azd extensions. As the extension > ecosystem grows, extension-specific variables may move to each extension's own documentation. -### azure.ai.agents +### Microsoft Foundry extensions + +The `azure.ai.projects` extension is the owner of Foundry project identity +values. The `azure.ai.agents` extension consumes those values when it creates +agent services and keeps its own agent-specific values. | Variable | Description | | --- | --- | -| `AZURE_AI_PROJECT_ID` | The Microsoft Foundry project resource ID used by the `azure.ai.agents` extension. | -| `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by the `azure.ai.agents` extension. Read first from the active azd environment and, if not present, from the host shell environment as an endpoint-resolution fallback. | +| `AZURE_AI_PROJECT_ID` | The Microsoft Foundry project resource ID resolved and persisted by `azure.ai.projects`. | +| `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint resolved and persisted by `azure.ai.projects`. `azure.ai.agents` reads it for agent workflows and endpoint-only compatibility. | | `AZURE_AI_PROJECT_PRINCIPAL_ID` | The principal ID associated with the Microsoft Foundry project identity. | | `AZURE_AI_ACCOUNT_NAME` | The Microsoft Foundry account name associated with the project. | | `AZURE_AI_PROJECT_NAME` | The Microsoft Foundry project name. | +| `AZURE_AI_DEPLOYMENTS_LOCATION` | The location used to resolve and provision managed model deployments. | | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | The default model deployment name used for generated agent code and templates. | -| `AZURE_AI_PROJECT_ACR_CONNECTION_NAME` | The Azure Container Registry connection name used by the extension for hosted agents. | +| `AZURE_AI_PROJECT_CONNECTION_NAMES` | Comma-separated project connection names emitted by Foundry provisioning. | +| `AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT` | The project endpoint used by connection services. | +| `AZURE_AI_PROJECT_ACR_CONNECTION_NAME` | The Azure Container Registry connection name used by hosted agents. | | `AI_PROJECT_DEPLOYMENTS` | JSON-encoded deployment metadata populated by the extension for agent workflows. | | `AI_PROJECT_DEPENDENT_RESOURCES` | JSON-encoded dependent resource metadata populated by the extension for agent workflows. | | `AZD_AGENT_SKIP_ACR` | If `true`, signals the Bicep template to skip Azure Container Registry creation during provisioning. Automatically set by `azd agent init` for code-deploy scenarios (where no container image is built). | diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index fbcffb57aab..a30bfe2503d 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -6,7 +6,7 @@ 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.9 -requiredAzdVersion: ">=1.27.1" +requiredAzdVersion: ">=1.31.0-beta.1" dependencies: - id: azure.ai.inspector version: "~1.0.0-beta.1" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects.go new file mode 100644 index 00000000000..b4822a8c18f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects.go @@ -0,0 +1,670 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/project" + "azureaiagent/internal/version" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/anypb" + "gopkg.in/yaml.v3" +) + +const delegatedProjectsSchemaVersion = 1 + +const ( + delegatedProjectsSource = "azure.ai.agents/init" + delegatedProjectsInit = "ai project init" + delegatedProjectsAdd = "ai project deployment add" +) + +type delegatedProjectTarget struct { + ResourceID string `json:"resourceId,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type delegatedProjectInfra struct { + EjectProvider string `json:"ejectProvider,omitempty"` +} + +type delegatedProjectRequirements struct { + AllowedLocations []string `json:"allowedLocations,omitempty"` +} + +type delegatedProjectInitRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion"` + Project delegatedProjectTarget `json:"project"` + Infra delegatedProjectInfra `json:"infra,omitempty"` + Requirements delegatedProjectRequirements `json:"requirements,omitempty"` + ResolveAzureContext bool `json:"resolveAzureContext"` + Force bool `json:"force"` +} + +type delegatedProjectModel struct { + Name string `json:"name"` + DeploymentName string `json:"deploymentName,omitempty"` + RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` + AllowedLocations []string `json:"allowedLocations,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"` +} + +type delegatedProjectInitResult struct { + SchemaVersion int `json:"schemaVersion"` + ProducerVersion string `json:"producerVersion"` + ServiceName string `json:"serviceName"` + Mode string `json:"mode"` + Mutation string `json:"mutation"` + Endpoint string `json:"endpoint,omitempty"` + ResourceID string `json:"resourceId,omitempty"` +} + +type delegatedProjectDeploymentResult struct { + SchemaVersion int `json:"schemaVersion"` + ProducerVersion string `json:"producerVersion"` + ServiceName string `json:"serviceName"` + DeploymentName string `json:"deploymentName"` + Model delegatedProjectResultModel `json:"model"` + SKU delegatedProjectResultSKU `json:"sku"` + Mutation string `json:"mutation"` +} + +type delegatedProjectResultModel struct { + Format string `json:"format"` + Name string `json:"name"` + Version string `json:"version"` +} + +type delegatedProjectResultSKU struct { + Name string `json:"name"` + Capacity int `json:"capacity"` +} + +var errDelegatedProjectsUnavailable = errors.New("azure.ai.projects delegated commands are unavailable") + +func validateDelegatedProjectInitRequest(request delegatedProjectInitRequest) error { + if request.SchemaVersion != delegatedProjectsSchemaVersion { + return fmt.Errorf("unsupported delegated project schema version %d", request.SchemaVersion) + } + if request.Source != delegatedProjectsSource || strings.TrimSpace(request.SourceVersion) == "" { + return fmt.Errorf("invalid delegated project source") + } + if request.Project.ResourceID != "" && request.Project.Endpoint != "" { + return fmt.Errorf("project.resourceId and project.endpoint are mutually exclusive") + } + if request.Infra.EjectProvider != "" && + request.Infra.EjectProvider != project.BicepProviderName && + request.Infra.EjectProvider != project.TerraformProviderName { + return fmt.Errorf("unsupported delegated infrastructure provider %q", request.Infra.EjectProvider) + } + if request.Requirements.AllowedLocations != nil && len(request.Requirements.AllowedLocations) == 0 { + return fmt.Errorf("requirements.allowedLocations must contain a location") + } + return nil +} + +func validateDelegatedProjectDeploymentRequest(request delegatedProjectDeploymentRequest) error { + if request.SchemaVersion != delegatedProjectsSchemaVersion { + return fmt.Errorf("unsupported delegated project schema version %d", request.SchemaVersion) + } + if request.Source != delegatedProjectsSource || strings.TrimSpace(request.SourceVersion) == "" { + return fmt.Errorf("invalid delegated project source") + } + if strings.TrimSpace(request.Model.Name) == "" { + return fmt.Errorf("model.name is required") + } + for _, capability := range request.Model.RequiredCapabilities { + if capability != agentsV2ModelCapability { + return fmt.Errorf("unknown required capability %q", capability) + } + } + return nil +} + +func validateDelegatedProjectInitResult(result delegatedProjectInitResult) error { + if result.SchemaVersion != delegatedProjectsSchemaVersion || + strings.TrimSpace(result.ProducerVersion) == "" || + strings.TrimSpace(result.ServiceName) == "" { + return fmt.Errorf("delegated project init result is missing required fields") + } + if result.Mode != "new" && result.Mode != "existing-id" && result.Mode != "existing-endpoint" { + return fmt.Errorf("invalid delegated project mode %q", result.Mode) + } + if result.Mutation != "created" && result.Mutation != "updated" && + result.Mutation != "migrated" && result.Mutation != "unchanged" { + return fmt.Errorf("invalid delegated project mutation %q", result.Mutation) + } + if result.Mode == "existing-id" && result.ResourceID == "" { + return fmt.Errorf("delegated existing-id result is missing resourceId") + } + if result.Mode != "new" && result.Endpoint == "" { + return fmt.Errorf("delegated existing project result is missing endpoint") + } + return nil +} + +func validateDelegatedProjectDeploymentResult(result delegatedProjectDeploymentResult) error { + if result.SchemaVersion != delegatedProjectsSchemaVersion || + strings.TrimSpace(result.ProducerVersion) == "" || + strings.TrimSpace(result.ServiceName) == "" || + strings.TrimSpace(result.DeploymentName) == "" || + strings.TrimSpace(result.Model.Name) == "" || + strings.TrimSpace(result.Model.Format) == "" || + strings.TrimSpace(result.Model.Version) == "" || + strings.TrimSpace(result.SKU.Name) == "" || + result.SKU.Capacity <= 0 { + return fmt.Errorf("delegated deployment result is missing required fields") + } + if result.Mutation != "created" && result.Mutation != "replaced" && result.Mutation != "unchanged" { + return fmt.Errorf("invalid delegated deployment mutation %q", result.Mutation) + } + return nil +} + +func (a *InitAction) delegatedProjectRoot() (string, error) { + root := "" + if a.projectConfig != nil { + root = a.projectConfig.Path + } + if root == "" { + var err error + root, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("resolving project root: %w", err) + } + } + root, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolving project root: %w", err) + } + return filepath.Clean(root), nil +} + +func (a *InitAction) delegatedEnvironmentName() string { + if a.flags != nil && a.flags.env != "" { + return a.flags.env + } + if a.environment != nil { + return a.environment.Name + } + return "" +} + +func (a *InitAction) runDelegatedProjectStep( + ctx context.Context, + command []string, + request any, + result any, +) error { + root, err := a.delegatedProjectRoot() + if err != nil { + return err + } + tempDir, err := os.MkdirTemp("", "azd-agent-project-*") + if err != nil { + return exterrors.Dependency( + exterrors.CodeProjectInitFailed, + fmt.Sprintf("creating delegated project workspace: %s", err), + "check write permissions on the system temporary directory", + ) + } + defer func() { _ = os.RemoveAll(tempDir) }() + if err := os.Chmod(tempDir, 0700); err != nil { + return fmt.Errorf("protecting delegated project workspace: %w", err) + } + + requestPath := filepath.Join(tempDir, "request.json") + resultPath := filepath.Join(tempDir, "result.json") + if err := writeDelegatedJSON(requestPath, request); err != nil { + return err + } + + args := append([]string{}, command...) + args = append(args, + "--request-file="+requestPath, + "--result-file="+resultPath, + "--output=none", + "--cwd="+root, + ) + if environment := a.delegatedEnvironmentName(); environment != "" { + args = append(args, "--environment="+environment) + } + + workflow := &azdext.Workflow{ + Name: "agent-project-delegation", + Steps: []*azdext.WorkflowStep{{ + Command: &azdext.WorkflowCommand{Args: args}, + }}, + } + if _, err := a.azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{Workflow: workflow}); err != nil { + // Older projects extensions do not know these commands. Stage A keeps + // the old path for that case only. + if isDelegatedProjectsUnavailable(err) { + return errDelegatedProjectsUnavailable + } + return unwrapDelegatedWorkflowError(err) + } + + if err := readDelegatedJSON(resultPath, result); err != nil { + if errors.Is(err, os.ErrNotExist) { + return errDelegatedProjectsUnavailable + } + return err + } + return nil +} + +func isDelegatedProjectsUnavailable(err error) bool { + if err == nil { + return false + } + + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + return true + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "unknown command") || + strings.Contains(message, "command not found") || + strings.Contains(message, "not installed") || + strings.Contains(message, "unimplemented") +} + +// unwrapDelegatedWorkflowError preserves structured workflow errors. +// It also reads the wire shape used by older azd modules. +func unwrapDelegatedWorkflowError(err error) error { + st, ok := status.FromError(err) + if !ok { + return err + } + for _, detail := range st.Details() { + if anyDetail, ok := detail.(*anypb.Any); ok && + strings.HasSuffix(anyDetail.GetTypeUrl(), "WorkflowErrorDetail") { + if extensionError := extensionErrorFromWorkflowDetail(anyDetail.Value); extensionError != nil { + return azdext.UnwrapError(extensionError) + } + } + if message, ok := detail.(protoreflect.ProtoMessage); ok && + strings.HasSuffix(string(message.ProtoReflect().Descriptor().FullName()), "WorkflowErrorDetail") { + field := message.ProtoReflect().Get(message.ProtoReflect().Descriptor().Fields().ByName("error")) + if field.IsValid() { + if extensionError, ok := field.Message().Interface().(*azdext.ExtensionError); ok { + return azdext.UnwrapError(extensionError) + } + } + } + } + return err +} + +func extensionErrorFromWorkflowDetail(data []byte) *azdext.ExtensionError { + for len(data) > 0 { + fieldNumber, wireType, n := protowire.ConsumeTag(data) + if n < 0 { + return nil + } + data = data[n:] + if fieldNumber == 1 && wireType == protowire.BytesType { + value, consumed := protowire.ConsumeBytes(data) + if consumed < 0 { + return nil + } + result := &azdext.ExtensionError{} + if err := proto.Unmarshal(value, result); err != nil { + return nil + } + return result + } + consumed := protowire.ConsumeFieldValue(fieldNumber, wireType, data) + if consumed < 0 { + return nil + } + data = data[consumed:] + } + return nil +} + +func writeDelegatedJSON(path string, value any) error { + file, err := os.OpenFile( + path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, osutil.PermissionFileOwnerOnly, + ) + if err != nil { + return fmt.Errorf("creating delegated request file: %w", err) + } + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + _ = file.Close() + _ = os.Remove(path) + return fmt.Errorf("writing delegated request file: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + _ = os.Remove(path) + return fmt.Errorf("flushing delegated request file: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return fmt.Errorf("closing delegated request file: %w", err) + } + return nil +} + +func readDelegatedJSON(path string, value any) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(file) + if err := decoder.Decode(value); err != nil { + return fmt.Errorf("reading delegated result file: %w", err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return fmt.Errorf("delegated result file contains multiple JSON documents") + } + return nil +} + +func (a *InitAction) delegateProjectInit( + ctx context.Context, + allowedLocations []string, +) (delegatedProjectInitResult, error) { + request := delegatedProjectInitRequest{ + SchemaVersion: delegatedProjectsSchemaVersion, + Source: delegatedProjectsSource, + SourceVersion: version.Version, + Project: delegatedProjectTarget{ResourceID: a.flags.projectResourceId}, + Infra: delegatedProjectInfra{EjectProvider: a.flags.infra}, + Requirements: delegatedProjectRequirements{AllowedLocations: allowedLocations}, + ResolveAzureContext: true, + Force: a.flags.force, + } + if err := validateDelegatedProjectInitRequest(request); err != nil { + return delegatedProjectInitResult{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid delegated project init request: %s", err), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) + } + var result delegatedProjectInitResult + if err := a.runDelegatedProjectStep(ctx, strings.Fields(delegatedProjectsInit), request, &result); err != nil { + return result, err + } + if err := validateDelegatedProjectInitResult(result); err != nil { + return result, exterrors.Compatibility( + exterrors.CodeIncompatibleAzdVersion, + fmt.Sprintf("azure.ai.projects returned an invalid project init result: %s", err), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) + } + a.projectServiceName = result.ServiceName + if a.flags != nil { + a.flags.delegatedProjectInit = true + } + return result, nil +} + +func (a *InitAction) delegateProjectDeployment( + ctx context.Context, + model, deploymentName string, + setAsDefault bool, + allowedLocations []string, +) (delegatedProjectDeploymentResult, error) { + request := delegatedProjectDeploymentRequest{ + SchemaVersion: delegatedProjectsSchemaVersion, + Source: delegatedProjectsSource, + SourceVersion: version.Version, + Model: delegatedProjectModel{ + Name: model, + DeploymentName: deploymentName, + RequiredCapabilities: []string{agentsV2ModelCapability}, + AllowedLocations: allowedLocations, + }, + SetAsDefault: setAsDefault, + Force: a.flags.force, + } + if err := validateDelegatedProjectDeploymentRequest(request); err != nil { + return delegatedProjectDeploymentResult{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid delegated deployment request: %s", err), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) + } + var result delegatedProjectDeploymentResult + if err := a.runDelegatedProjectStep(ctx, strings.Fields(delegatedProjectsAdd), request, &result); err != nil { + return result, err + } + if err := validateDelegatedProjectDeploymentResult(result); err != nil { + return result, exterrors.Compatibility( + exterrors.CodeIncompatibleAzdVersion, + fmt.Sprintf("azure.ai.projects returned an invalid deployment result: %s", err), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) + } + if a.projectServiceName == "" { + a.projectServiceName = result.ServiceName + } + return result, nil +} + +func (a *InitAction) hostedAgentAllowedLocations(ctx context.Context) ([]string, error) { + if !a.skipACR() { + return nil, nil + } + locations, err := supportedRegionsForInit(ctx) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + fmt.Fprintf(os.Stderr, "warning: failed to resolve hosted-agent regions: %v\n", err) + return nil, nil + } + return locations, nil +} + +func modelResourceFromManifest(resource any) (agent_yaml.ModelResource, bool) { + model, ok := resource.(agent_yaml.ModelResource) + return model, ok +} + +func projectInfoFromDelegatedResult( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + result delegatedProjectInitResult, +) (*FoundryProjectInfo, error) { + if result.Mode != "existing-id" || result.ResourceID == "" { + return nil, nil + } + projectInfo, err := extractProjectDetails(result.ResourceID) + if err != nil { + return nil, err + } + projectInfo.Location, _ = getEnvValue(ctx, azdClient, envName, "AZURE_LOCATION") + if projectInfo.Location == "" { + projectInfo.Location, _ = getEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION") + } + return projectInfo, nil +} + +func (a *InitAction) configureDelegatedAgentResources( + ctx context.Context, + projectInfo *FoundryProjectInfo, + mode string, +) error { + if a.environment == nil { + return nil + } + if projectInfo == nil || mode == "new" { + return setACREnvVar(ctx, a.azdClient, a.environment.Name, a.skipACR()) + } + projectInfo.NetworkInjected = foundryAccountNetworkInjected(ctx, a.credential, projectInfo) + a.selectedFoundryProject = projectInfo + if err := configureExistingProjectAgentConnections( + ctx, a.azdClient, a.credential, a.environment.Name, + *projectInfo, projectInfo.SubscriptionId, a.skipACR(), + ); err != nil { + return err + } + return setACREnvVar(ctx, a.azdClient, a.environment.Name, a.skipACR()) +} + +func (a *InitAction) configureModelChoiceDelegated( + ctx context.Context, + agentManifest *agent_yaml.AgentManifest, +) (*agent_yaml.AgentManifest, error) { + allowedLocations, err := a.hostedAgentAllowedLocations(ctx) + if err != nil { + return nil, err + } + initResult, err := a.delegateProjectInit(ctx, allowedLocations) + if err != nil { + return nil, err + } + projectInfo, err := projectInfoFromDelegatedResult( + ctx, a.azdClient, a.delegatedEnvironmentName(), initResult, + ) + if err != nil { + return nil, err + } + + templateBytes, err := yaml.Marshal(agentManifest.Template) + if err != nil { + return nil, fmt.Errorf("marshaling agent template: %w", err) + } + var definition agent_yaml.AgentDefinition + if err := yaml.Unmarshal(templateBytes, &definition); err != nil { + return nil, fmt.Errorf("reading agent definition: %w", err) + } + paramValues := agent_yaml.ParameterValues{} + var firstResolved *project.Deployment + anyModelProcessed := false + anyNewDeployment := false + managedModelIndex := 0 + + for _, rawResource := range agentManifest.Resources { + resource, ok := modelResourceFromManifest(rawResource) + if !ok || definition.Kind != agent_yaml.AgentKindHosted { + continue + } + var ( + modelDeployment *project.Deployment + isNew = a.flags.modelDeployment == "" + ) + if !isNew { + // External deployment references remain an agent operation and are + // verified against Azure before being injected into the manifest. + deployment, _, resolveErr := a.getModelDeploymentDetails( + ctx, agent_yaml.Model{Id: resource.Id}, + ) + if resolveErr != nil { + if errors.Is(resolveErr, errModelSkipped) { + continue + } + return nil, fmt.Errorf("failed to resolve model %q: %w", resource.Id, resolveErr) + } + modelDeployment = deployment + if modelDeployment == nil { + return nil, fmt.Errorf("model deployment %q was not resolved", a.flags.modelDeployment) + } + } else { + modelName := resource.Id + if managedModelIndex == 0 && strings.TrimSpace(a.flags.model) != "" { + modelName = a.flags.model + } + modelDeployment = &project.Deployment{ + Model: project.DeploymentModel{Name: modelName}, + } + managedModelIndex++ + } + anyModelProcessed = true + finalName := modelDeployment.Name + if isNew { + setAsDefault := firstResolved == nil + result, err := a.delegateProjectDeployment( + ctx, modelDeployment.Model.Name, "", + setAsDefault, allowedLocations, + ) + if err != nil { + return nil, err + } + finalName = result.DeploymentName + modelDeployment = &project.Deployment{ + Name: finalName, + Model: project.DeploymentModel{ + Format: result.Model.Format, + Name: result.Model.Name, + Version: result.Model.Version, + }, + Sku: project.DeploymentSku{ + Name: result.SKU.Name, + Capacity: result.SKU.Capacity, + }, + } + anyNewDeployment = true + } + if firstResolved == nil { + firstResolved = modelDeployment + if !isNew { + if err := setEnvValue( + ctx, a.azdClient, a.environment.Name, + "AZURE_AI_MODEL_DEPLOYMENT_NAME", finalName, + ); err != nil { + return nil, err + } + } + } + paramValues[resource.Name] = finalName + } + + updated, err := agent_yaml.InjectParameterValuesIntoManifest(agentManifest, paramValues) + if err != nil { + return nil, fmt.Errorf("injecting deployment names into manifest: %w", err) + } + if err := a.configureDelegatedAgentResources( + ctx, projectInfo, initResult.Mode, + ); err != nil { + return nil, err + } + a.deploymentDetails = nil + if anyModelProcessed { + if err := updatePendingModelDeploymentSignal( + ctx, a.azdClient, a.environment.Name, true, anyNewDeployment, + ); err != nil { + // The signal is advisory and has the same best-effort semantics as + // the legacy model path. + fmt.Fprintf(os.Stderr, "warning: failed to update model deployment signal: %v\n", err) + } + } + return updated, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects_test.go new file mode 100644 index 00000000000..50f41da4b0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects_test.go @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +type delegatedWorkflowRecorder struct { + azdext.UnimplementedWorkflowServiceServer + commands [][]string + tempDirs []string + requests []map[string]any + projectMode string + deploymentCount int +} + +func (s *delegatedWorkflowRecorder) Run( + _ context.Context, + request *azdext.RunWorkflowRequest, +) (*azdext.EmptyResponse, error) { + args := request.GetWorkflow().GetSteps()[0].GetCommand().GetArgs() + s.commands = append(s.commands, args) + + var requestPath, resultPath string + for _, arg := range args { + switch { + case strings.HasPrefix(arg, "--request-file="): + requestPath = strings.TrimPrefix(arg, "--request-file=") + case strings.HasPrefix(arg, "--result-file="): + resultPath = strings.TrimPrefix(arg, "--result-file=") + } + } + s.tempDirs = append(s.tempDirs, filepath.Dir(requestPath)) + data, err := os.ReadFile(requestPath) + if err != nil { + return nil, err + } + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, err + } + s.requests = append(s.requests, envelope) + + var result any + if strings.Contains(strings.Join(args, " "), "deployment") { + s.deploymentCount++ + result = delegatedProjectDeploymentResult{ + SchemaVersion: 1, + ProducerVersion: "projects-test", + ServiceName: "custom-project", + DeploymentName: fmt.Sprintf("deployment-%d", s.deploymentCount), + Model: delegatedProjectResultModel{ + Format: "OpenAI", + Name: "gpt-4.1", + Version: "2025-04-14", + }, + SKU: delegatedProjectResultSKU{Name: "GlobalStandard", Capacity: 10}, + Mutation: "created", + } + } else { + mode := s.projectMode + if mode == "" { + mode = "existing-id" + } + result = delegatedProjectInitResult{ + SchemaVersion: 1, + ProducerVersion: "projects-test", + ServiceName: "custom-project", + Mode: mode, + Mutation: "created", + Endpoint: "https://account.services.ai.azure.com/api/projects/chat", + ResourceID: "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/chat", + } + if mode == "new" { + result = delegatedProjectInitResult{ + SchemaVersion: 1, + ProducerVersion: "projects-test", + ServiceName: "custom-project", + Mode: mode, + Mutation: "created", + } + } + } + + encoded, err := json.Marshal(result) + if err != nil { + return nil, err + } + if err := os.WriteFile(resultPath, encoded, 0600); err != nil { + return nil, err + } + _ = envelope + return &azdext.EmptyResponse{}, nil +} + +func TestConfigureModelChoiceDelegated_MultipleModels(t *testing.T) { + envName := "dev" + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{envName: {}}, + } + recorder := &delegatedWorkflowRecorder{projectMode: "new"} + client := newTestAzdClient(t, envServer, recorder) + root := t.TempDir() + action := &InitAction{ + azdClient: client, + projectConfig: &azdext.ProjectConfig{Path: root}, + environment: &azdext.Environment{Name: envName}, + flags: &initFlags{env: envName, noPrompt: true}, + } + manifest := &agent_yaml.AgentManifest{ + Name: "delegated-agent", + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "delegated-agent", + Kind: agent_yaml.AgentKindHosted, + }, + }, + Resources: []any{ + agent_yaml.ModelResource{ + Resource: agent_yaml.Resource{Name: "chat", Kind: agent_yaml.ResourceKindModel}, + Id: "gpt-4.1", + }, + agent_yaml.ModelResource{ + Resource: agent_yaml.Resource{Name: "embed", Kind: agent_yaml.ResourceKindModel}, + Id: "text-embedding-3-large", + }, + }, + } + + updated, err := action.configureModelChoiceDelegated(t.Context(), manifest) + require.NoError(t, err) + require.Len(t, recorder.commands, 3) + require.Equal(t, 2, recorder.deploymentCount) + require.Equal(t, true, recorder.requests[1]["setAsDefault"]) + require.Equal(t, false, recorder.requests[2]["setAsDefault"]) + require.NotNil(t, updated) +} + +func TestDelegatedProjectWorkflowMappingAndCleanup(t *testing.T) { + recorder := &delegatedWorkflowRecorder{} + client := newTestAzdClient( + t, + &testEnvironmentServiceServer{}, + recorder, + ) + root := t.TempDir() + action := &InitAction{ + azdClient: client, + projectConfig: &azdext.ProjectConfig{Path: root}, + environment: &azdext.Environment{Name: "dev"}, + flags: &initFlags{ + projectResourceId: "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/chat", + infra: "terraform", + force: true, + env: "dev", + }, + } + + initResult, err := action.delegateProjectInit(t.Context(), []string{"eastus2"}) + require.NoError(t, err) + require.Equal(t, "custom-project", initResult.ServiceName) + require.Equal(t, "custom-project", action.projectServiceName) + + deploymentResult, err := action.delegateProjectDeployment( + t.Context(), "gpt-4.1", "chat", true, []string{"eastus2"}, + ) + require.NoError(t, err) + require.Equal(t, "deployment-1", deploymentResult.DeploymentName) + require.Len(t, recorder.commands, 2) + + for _, args := range recorder.commands { + assertArgContains(t, args, "--output=none") + assertArgContains(t, args, "--cwd="+root) + assertArgContains(t, args, "--environment=dev") + assertArgPrefix(t, args, "--request-file=") + assertArgPrefix(t, args, "--result-file=") + } + require.Equal(t, []string{"ai", "project", "init"}, recorder.commands[0][:3]) + require.Equal(t, []string{"ai", "project", "deployment", "add"}, recorder.commands[1][:4]) + require.Equal(t, float64(1), recorder.requests[0]["schemaVersion"]) + require.Equal(t, "terraform", recorder.requests[0]["infra"].(map[string]any)["ejectProvider"]) + require.Equal(t, true, recorder.requests[0]["force"]) + require.Equal(t, []any{"eastus2"}, + recorder.requests[0]["requirements"].(map[string]any)["allowedLocations"]) + require.Equal(t, "gpt-4.1", recorder.requests[1]["model"].(map[string]any)["name"]) + require.Equal(t, true, recorder.requests[1]["setAsDefault"]) + require.Equal(t, []any{"agentsV2"}, + recorder.requests[1]["model"].(map[string]any)["requiredCapabilities"]) + for _, dir := range recorder.tempDirs { + _, err := os.Stat(dir) + require.ErrorIs(t, err, os.ErrNotExist) + } +} + +func assertArgContains(t *testing.T, args []string, want string) { + t.Helper() + require.Contains(t, args, want) +} + +func assertArgPrefix(t *testing.T, args []string, prefix string) { + t.Helper() + for _, arg := range args { + if strings.HasPrefix(arg, prefix) { + return + } + } + t.Fatalf("arguments %v do not contain prefix %q", args, prefix) +} + +func TestSetServiceUsesOrderedMerge(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "agent": { + Name: "agent", + Uses: []string{"hand-authored", "project"}, + Host: AiAgentHost, + }, + }, + } + client := newProjectRecorderClient(t, server) + + require.NoError(t, setServiceUses( + t.Context(), client, "agent", []string{"project", "connection"}, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Equal(t, []string{"hand-authored", "project", "connection"}, server.uses["agent"]) +} 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 ae55a0588a5..3b4659a37ce 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -85,6 +85,10 @@ type initFlags struct { // and `--infra=bicep` are explicit. The eject runs after a fresh init or // standalone when azure.yaml already exists. infra string + + // delegatedProjectInit prevents the legacy post-init infrastructure writer + // from running after azure.ai.projects handled --infra. + delegatedProjectInit bool } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -122,6 +126,10 @@ type InitAction struct { // interactively selects a template that resolves to a manifest. When true, // the init flow applies opinionated defaults to minimize interactive prompts. userProvidedManifest bool + + // projectServiceName is returned by delegated project initialization. It is + // intentionally not assumed to be "ai-project". + projectServiceName string } // skipACR returns true when ACR provisioning and configuration should be skipped. @@ -981,26 +989,36 @@ func runInitFromManifest( createdFolderDisplay string, userProvidedManifest bool, ) error { - // Ensure project and environment exist (no subscription/location prompting yet) - projectConfig, err := ensureProject(ctx, flags, azdClient, targetDir) - if err != nil { - return err + // Do not scaffold or mutate the project before the manifest is resolved. + // The delegated projects command owns project creation and environment + // reconciliation. A synthetic config is used until that command runs. + projectResponse, projectErr := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + projectConfig := projectResponse.GetProject() + if projectErr != nil || projectConfig == nil { + projectRoot, absErr := filepath.Abs(targetDir) + if absErr != nil { + return fmt.Errorf("resolving target project root: %w", absErr) + } + projectConfig = &azdext.ProjectConfig{Path: projectRoot} } - // Get or create environment + // Resolve the environment name without creating it. The delegated project + // action creates the environment when this is a new workspace. env := getExistingEnvironment(ctx, flags.env, azdClient) if env == nil { - fmt.Println("Lets create a new default azd environment for your project.") - env, err = createNewEnvironment(ctx, azdClient, flags.env) - if err != nil { - return err + if flags.env == "" { + flags.env = deriveEnvName(flags, targetDir) } + env = &azdext.Environment{Name: flags.env} } // Load whatever Azure context values already exist in the environment azureContext, err := loadAzureContext(ctx, azdClient, env.Name) if err != nil { - return err + azureContext = &azdext.AzureContext{ + Scope: &azdext.AzureScope{}, + Resources: []string{}, + } } // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( @@ -1182,10 +1200,12 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if gateErr != nil { return gateErr } - if gate.standaloneEject { - // Reject init inputs the eject path would silently ignore - // instead of pretending they were honored. They stay valid - // on the init fall-through, where they do drive the flow. + if gate.standaloneEject && flags.manifestPointer == "" && + flags.src == "" && flags.agentName == "" && + flags.model == "" && flags.modelDeployment == "" && + flags.image == "" && flags.deployMode == "" && + flags.runtime == "" && flags.entryPoint == "" && + flags.depResolution == "" && len(flags.protocols) == 0 { if err := validateStandaloneEjectArgs(cmd, args); err != nil { return err } @@ -1354,7 +1374,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, ); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return finishInfraEject(flags, infraProvider) } } } @@ -1403,7 +1423,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 finishInfraEject(flags, infraProvider) } } } @@ -1448,7 +1468,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } return err } - return ejectInfraAfterInit(infraProvider) + return finishInfraEject(flags, infraProvider) } return missingAgentServiceError(flags.manifestPointer) } @@ -1654,7 +1674,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 finishInfraEject(flags, infraProvider) }, } @@ -1724,11 +1744,17 @@ func (a *InitAction) Run(ctx context.Context) error { // If src path is absolute, convert it to relative path compared to the azd project path if a.flags.src != "" && filepath.IsAbs(a.flags.src) { projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) - if err != nil { + projectRoot := "" + if err == nil && projectResponse.GetProject() != nil { + projectRoot = projectResponse.GetProject().Path + } else if a.projectConfig != nil { + projectRoot = a.projectConfig.Path + } + if projectRoot == "" { return fmt.Errorf("failed to get project path: %w", err) } - relPath, err := filepath.Rel(projectResponse.Project.Path, a.flags.src) + relPath, err := filepath.Rel(projectRoot, a.flags.src) if err != nil { return fmt.Errorf("failed to convert src path to relative path: %w", err) } @@ -2138,12 +2164,61 @@ func manifestHasModelResources(manifest *agent_yaml.AgentManifest) bool { return false } -// configureModelChoice presents the "use existing / deploy new" model configuration choice -// and establishes the necessary Azure context (subscription, location, project) before -// ProcessModels is called. This defers subscription/location prompting until we know -// which path the user wants. +// configureModelChoice delegates project and managed deployment ownership to +// azure.ai.projects. The legacy implementation remains available only when the +// installed projects extension predates the delegated commands. func (a *InitAction) configureModelChoice( ctx context.Context, agentManifest *agent_yaml.AgentManifest, +) (*agent_yaml.AgentManifest, error) { + updated, err := a.configureModelChoiceDelegated(ctx, agentManifest) + if !errors.Is(err, errDelegatedProjectsUnavailable) { + return updated, err + } + if a.projectConfig != nil { + if err := a.ensureLegacyProjectContext(ctx); err != nil { + return nil, err + } + } + return a.configureModelChoiceLegacy(ctx, agentManifest) +} + +func (a *InitAction) ensureLegacyProjectContext(ctx context.Context) error { + if _, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}); err != nil { + projectConfig, projectErr := ensureProject( + ctx, a.flags, a.azdClient, a.projectConfig.Path, + ) + if projectErr != nil { + return projectErr + } + a.projectConfig = projectConfig + } + if a.environment == nil || a.environment.Name == "" { + if a.flags.env == "" { + a.flags.env = deriveEnvName(a.flags, a.projectConfig.Path) + } + a.environment = getExistingEnvironment(ctx, a.flags.env, a.azdClient) + if a.environment == nil { + environment, err := createNewEnvironment(ctx, a.azdClient, a.flags.env) + if err != nil { + return err + } + a.environment = environment + } + } + if a.azureContext == nil { + azureContext, err := loadAzureContext(ctx, a.azdClient, a.environment.Name) + if err != nil { + return err + } + a.azureContext = azureContext + } + return nil +} + +// configureModelChoiceLegacy is the Stage A compatibility path for projects +// extension versions that do not expose delegated project commands. +func (a *InitAction) configureModelChoiceLegacy( + ctx context.Context, agentManifest *agent_yaml.AgentManifest, ) (*agent_yaml.AgentManifest, error) { // When no --project-id flag was given, check whether the azd environment already // has a Foundry project configured from a previous init. If so, reuse it so the @@ -3032,12 +3107,9 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa agentConfig.StartupCommand = startupCmd } - // 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. - // The agent keeps its container, resources, tool connections, and startup - // command. The provisioning handlers re-source the moved data from the - // sibling services. + // Connections and toolboxes are agent-owned sibling services. Managed model + // declarations are owned by azure.ai.projects and are never copied into the + // agent service or authored here after delegated initialization. resourceDeployments := agentConfig.Deployments resourceConnections := agentConfig.Connections resourceToolboxes := agentConfig.Toolboxes @@ -3073,6 +3145,10 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa Image: preBuiltImage, AdditionalProperties: agentProps, } + preservedUses, err := getServiceUses(ctx, a.azdClient, a.serviceNameOverride) + if err != nil { + return err + } // For hosted agents, configure Docker or code deploy settings if agentDef.Kind == agent_yaml.AgentKindHosted { @@ -3105,17 +3181,30 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa ); err != nil { return err } + if len(preservedUses) > 0 { + if err := setServiceUses(ctx, a.azdClient, a.serviceNameOverride, preservedUses); err != nil { + return err + } + } - // Emit the sibling Foundry resource services (project + deployments, - // connections, toolboxes) and wire the agent's uses: to them. A selected - // existing project contributes its endpoint so provision reuses it. - if err := emitResourceServices( - ctx, a.azdClient, a.serviceNameOverride, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), - resourceDeployments, resourceConnections, resourceToolboxes, - ); err != nil { - return err + if a.projectServiceName != "" { + if err := emitAgentResourceServices( + ctx, a.azdClient, a.serviceNameOverride, a.projectServiceName, + resourceConnections, resourceToolboxes, + ); err != nil { + return err + } + } else { + // Stage A compatibility for an older projects extension. This branch + // retains the pre-delegation writer only when delegation was unavailable. + if err := emitResourceServices( + ctx, a.azdClient, a.serviceNameOverride, + projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + a.selectedFoundryProject.Endpoint(), + resourceDeployments, resourceConnections, resourceToolboxes, + ); err != nil { + return err + } } printAgentAddedMessage(agentDef.Name) 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 24eddacfc7c..93c31fcbd82 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 @@ -23,6 +23,7 @@ import ( "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/foundry" @@ -965,50 +966,21 @@ func runInitFromAzureYaml( return err } - // skipACR is false only for a container deploy whose registry azd - // manages. Code deploy and --image (bring your own registry) both - // skip ACR. - skipACR := !usesContainer || flags.image != "" - - result, err := configureFoundryProject( - ctx, azdClient, azureContext, env.Name, - flags.projectResourceId, flags.noPrompt, - skipACR, + delegated, err := delegateAdoptedProject( + ctx, flags, azdClient, env, azureContext, ) if err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("initialization was cancelled") - } - 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. - if result.FoundryProject != nil { - if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { - return err - } - if err := confirmAdoptedAgentNameConflicts( - ctx, - azdClient, - env, - result.Credential, - flags.noPrompt, - ); err != nil { + if !errors.Is(err, errDelegatedProjectsUnavailable) { return err } - } - - // --- Model deployment verification --- - // Parse deployments from the azure.yaml and verify them against the - // selected Foundry project. If the user opts to use existing deployments - // or skip, we update the on-disk azure.yaml accordingly. - deploymentEntries := foundryDeployments(content) - if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { - keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( - ctx, azdClient, result.Credential, azureContext, env.Name, - deploymentEntries, flags.noPrompt, flags.modelDeployment, flags.model, + delegated = false + } + if !delegated { + skipACR := !usesContainer || flags.image != "" + result, err := configureFoundryProject( + ctx, azdClient, azureContext, env.Name, + flags.projectResourceId, flags.noPrompt, + skipACR, ) if err != nil { if exterrors.IsCancellation(err) { @@ -1017,33 +989,69 @@ func runInitFromAzureYaml( return err } - // 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 - } + // 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. + if result.FoundryProject != nil { + if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { + return err } - for _, kept := range keptEntries { - byService[kept.ServiceName] = append(byService[kept.ServiceName], kept.Deployment) + if err := confirmAdoptedAgentNameConflicts( + ctx, + azdClient, + env, + result.Credential, + flags.noPrompt, + ); err != nil { + return err } + } - for svcName, deps := range byService { - if err := updateAzureYamlDeployments(ctx, azdClient, svcName, deps); err != nil { - return err + // --- Model deployment verification --- + // Parse deployments from the azure.yaml and verify them against the + // selected Foundry project. If the user opts to use existing deployments + // or skip, we update the on-disk azure.yaml accordingly. + deploymentEntries := foundryDeployments(content) + if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { + keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( + ctx, azdClient, result.Credential, azureContext, env.Name, + deploymentEntries, flags.noPrompt, flags.modelDeployment, flags.model, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") } + return err } - } - // Persist the first referenced deployment name as AZURE_AI_MODEL_DEPLOYMENT_NAME. - setEnv := func(ctx context.Context, key, value string) error { - return setEnvValue(ctx, azdClient, env.Name, key, value) - } - if err := persistFirstDeploymentName(ctx, setEnv, referencedDeployments); err != nil { - return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) + // 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 + } + } + for _, kept := range keptEntries { + byService[kept.ServiceName] = append(byService[kept.ServiceName], kept.Deployment) + } + + for svcName, deps := range byService { + if err := updateAzureYamlDeployments(ctx, azdClient, svcName, deps); err != nil { + return err + } + } + } + + // Persist the first referenced deployment name as AZURE_AI_MODEL_DEPLOYMENT_NAME. + setEnv := func(ctx context.Context, key, value string) error { + return setEnvValue(ctx, azdClient, env.Name, key, value) + } + if err := persistFirstDeploymentName(ctx, setEnv, referencedDeployments); err != nil { + return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) + } } } @@ -1067,6 +1075,91 @@ func runInitFromAzureYaml( return nil } +func delegateAdoptedProject( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + environment *azdext.Environment, + azureContext *azdext.AzureContext, +) (bool, error) { + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return false, errDelegatedProjectsUnavailable + } + credential, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }, + ) + if err != nil { + return false, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run 'azd auth login' to authenticate", + ) + } + action := &InitAction{ + azdClient: azdClient, + azureContext: azureContext, + credential: credential, + projectConfig: projectResponse.Project, + environment: environment, + flags: flags, + } + allowedLocations, err := action.hostedAgentAllowedLocations(ctx) + if err != nil { + return false, err + } + result, err := action.delegateProjectInit(ctx, allowedLocations) + if errors.Is(err, errDelegatedProjectsUnavailable) { + return false, nil + } + if err != nil { + return false, err + } + projectInfo, err := projectInfoFromDelegatedResult( + ctx, azdClient, environment.Name, result, + ) + if err != nil { + return false, err + } + if err := action.configureDelegatedAgentResources( + ctx, projectInfo, result.Mode, + ); err != nil { + return false, err + } + if err := mergeProjectServiceUses(ctx, azdClient, result.ServiceName); err != nil { + return false, err + } + if err := confirmAdoptedAgentNameConflicts( + ctx, azdClient, environment, credential, flags.noPrompt, + ); err != nil { + return false, err + } + return true, nil +} + +func mergeProjectServiceUses( + ctx context.Context, + azdClient *azdext.AzdClient, + projectServiceName string, +) error { + response, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("discovering adopted agent services: %w", err) + } + for name, service := range response.GetProject().GetServices() { + if service.GetHost() != AiAgentHost { + continue + } + if err := setServiceUses(ctx, azdClient, name, []string{projectServiceName}); err != nil { + return err + } + } + return nil +} + // adoptTargetDir resolves the directory the adopted project is created in and // the display path for the "created folder" next-step hint. An explicit --src // (or positional directory) wins; otherwise a new folder named after the 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 32085a6668f..dc8d02f8a58 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 @@ -509,6 +509,56 @@ func configureExistingProjectAcr( return configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections) } +// configureExistingProjectAgentConnections preserves the agent-owned +// connection selection that runs after project identity has been delegated. +// The projects extension owns only project identity and managed deployments; +// registry and Application Insights choices remain agent concerns. +func configureExistingProjectAgentConnections( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + envName string, + project FoundryProjectInfo, + subscriptionId string, + skipACR bool, +) error { + foundryClient, err := azure.NewFoundryProjectsClient(project.AccountName, project.ProjectName, credential) + if err != nil { + return fmt.Errorf("creating Foundry client: %w", err) + } + connections, err := foundryClient.GetAllConnections(ctx) + if err != nil { + fmt.Printf( + "Could not get Microsoft Foundry project connections: %v. "+ + "Please set agent connection environment variables manually.\n", err) + return nil + } + + var acrConnections []azure.Connection + var appInsightsConnections []azure.Connection + for _, connection := range connections { + switch connection.Type { + case azure.ConnectionTypeContainerRegistry: + if !skipACR { + acrConnections = append(acrConnections, connection) + } + case azure.ConnectionTypeAppInsights: + if full, getErr := foundryClient.GetConnectionWithCredentials(ctx, connection.Name); getErr == nil && full != nil { + connection = *full + } + appInsightsConnections = append(appInsightsConnections, connection) + } + } + if !skipACR { + if err := configureAcrConnection( + ctx, azdClient, credential, envName, subscriptionId, acrConnections, + ); err != nil { + return err + } + } + return configureAppInsightsConnection(ctx, azdClient, envName, appInsightsConnections) +} + // configureAcrConnection handles ACR connection selection and env var setting. func configureAcrConnection( ctx context.Context, 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 de28fc442bc..f23d60c36b8 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 @@ -9,6 +9,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "context" + "errors" "fmt" "log" "net/http" @@ -40,6 +41,8 @@ type InitFromCodeAction struct { // addToProject can disable remote build for VNET-injected accounts // without issuing a second account read. selectedFoundryProject *FoundryProjectInfo + + projectServiceName string } func (a *InitFromCodeAction) Run(ctx context.Context) error { @@ -125,6 +128,10 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { } if localDefinition != nil { + if err := a.delegateProjectOwnership(ctx); err != nil && + !errors.Is(err, errDelegatedProjectsUnavailable) { + return err + } // Generate .agentignore. The agent definition is written into the // azure.yaml service entry below, not to an on-disk agent.yaml. @@ -163,6 +170,54 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { return nil } +func (a *InitFromCodeAction) delegateProjectOwnership(ctx context.Context) error { + delegated := &InitAction{ + azdClient: a.azdClient, + credential: a.credential, + projectConfig: a.projectConfig, + azureContext: a.azureContext, + environment: a.environment, + flags: a.flags, + } + allowedLocations, err := delegated.hostedAgentAllowedLocations(ctx) + if err != nil { + return err + } + initResult, err := delegated.delegateProjectInit(ctx, allowedLocations) + if err != nil { + return err + } + projectInfo, err := projectInfoFromDelegatedResult( + ctx, a.azdClient, delegated.delegatedEnvironmentName(), initResult, + ) + if err != nil { + return err + } + defaultName, _ := getEnvValue(ctx, a.azdClient, a.environment.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME") + firstManaged := strings.TrimSpace(defaultName) == "" + for _, deployment := range a.deploymentDetails { + result, err := delegated.delegateProjectDeployment( + ctx, deployment.Model.Name, deployment.Name, firstManaged, allowedLocations, + ) + if err != nil { + return err + } + if firstManaged { + defaultName = result.DeploymentName + firstManaged = false + } + } + if err := delegated.configureDelegatedAgentResources( + ctx, projectInfo, initResult.Mode, + ); err != nil { + return err + } + a.deploymentDetails = nil + a.projectServiceName = delegated.projectServiceName + a.selectedFoundryProject = delegated.selectedFoundryProject + return nil +} + func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.ProjectConfig, error) { projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { @@ -819,8 +874,9 @@ 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. + // Managed deployments are delegated to azure.ai.projects before this + // service is authored. The legacy compatibility path still carries the + // selected declarations through the old sibling writer. resourceDeployments := agentConfig.Deployments agentConfig.Deployments = nil @@ -850,6 +906,10 @@ func (a *InitFromCodeAction) addToProject( Image: definition.Image, AdditionalProperties: agentProps, } + preservedUses, err := getServiceUses(ctx, a.azdClient, agentServiceName) + if err != nil { + return err + } // For hosted container-based agents, enable remote build by default. It is // silently disabled when the target Foundry account has VNET network injection @@ -878,17 +938,27 @@ func (a *InitFromCodeAction) addToProject( ); err != nil { return err } + if len(preservedUses) > 0 { + if err := setServiceUses(ctx, a.azdClient, agentServiceName, preservedUses); err != nil { + 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 - // 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), - a.selectedFoundryProject.Endpoint(), - resourceDeployments, nil, nil, - ); err != nil { - return err + if a.projectServiceName != "" { + if err := emitAgentResourceServices( + ctx, a.azdClient, agentServiceName, a.projectServiceName, nil, nil, + ); err != nil { + return err + } + } else { + if err := emitResourceServices( + ctx, a.azdClient, agentServiceName, + projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + a.selectedFoundryProject.Endpoint(), + resourceDeployments, nil, nil, + ); err != nil { + return err + } } printAgentAddedMessage(agentName) 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 7807b2a6a04..91e6e17ba99 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 @@ -450,6 +450,13 @@ func ejectInfraAfterInit(provider string) error { return ejectInfra(projectRoot, provider) } +func finishInfraEject(flags *initFlags, provider string) error { + if flags != nil && flags.delegatedProjectInit { + return nil + } + return ejectInfraAfterInit(provider) +} + // ejectInfra synthesizes the embedded Bicep templates from azure.yaml and // ejectInfra synthesizes infrastructure templates from azure.yaml and writes // them into projectRoot/infra/. Invoked by `azd ai agent init --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..97b8f8755a2 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 @@ -37,11 +37,9 @@ 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 is the Stage A compatibility writer used only when an +// older projects extension does not expose delegated commands. The delegated +// path uses emitAgentResourceServices and never authors the project service. // // projectEndpoint, when non-empty, is written as endpoint: on the project // service to mark an existing (brownfield) Foundry project so provision @@ -164,6 +162,76 @@ func emitResourceServices( return nil } +// emitAgentResourceServices writes only the agent-owned sibling resources. The +// project service and its managed deployments are created by azure.ai.projects; +// projectServiceName is returned by that extension and is treated as opaque. +func emitAgentResourceServices( + ctx context.Context, + azdClient *azdext.AzdClient, + agentServiceName string, + projectServiceName string, + connections []project.Connection, + toolboxes []project.Toolbox, +) error { + if projectServiceName == "" { + return fmt.Errorf("delegated project result did not include a service name") + } + siblingUses := []string{projectServiceName} + agentUses := []string{projectServiceName} + usedNames := map[string]string{ + agentServiceName: "agent service", + projectServiceName: "project service", + } + + for i := range connections { + connection := connections[i] + name := sanitizeServiceName(connection.Name) + if name == "" { + fmt.Fprintf(os.Stderr, + "warning: connection %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the connection so it is written to azure.yaml.\n", + connection.Name) + continue + } + if err := reserveServiceName(usedNames, name, fmt.Sprintf("connection %q", connection.Name)); err != nil { + return err + } + config, err := project.MarshalStruct(&connection) + if err != nil { + return fmt.Errorf("marshaling connection service %q config: %w", name, err) + } + if err := addResourceService(ctx, azdClient, name, AiConnectionHost, config, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, name) + } + + for i := range toolboxes { + toolbox := toolboxes[i] + name := sanitizeServiceName(toolbox.Name) + if name == "" { + fmt.Fprintf(os.Stderr, + "warning: toolbox %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the toolbox so it is written to azure.yaml.\n", + toolbox.Name) + continue + } + if err := reserveServiceName(usedNames, name, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { + return err + } + config, err := project.MarshalStruct(&toolbox) + if err != nil { + return fmt.Errorf("marshaling toolbox service %q config: %w", name, err) + } + if err := addResourceService(ctx, azdClient, name, AiToolboxHost, config, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, name) + } + + return setServiceUses(ctx, azdClient, agentServiceName, agentUses) +} + // resolveProjectServiceKey picks the azure.yaml service key for the single // azure.ai.project service. Precedence: // @@ -426,8 +494,19 @@ func setServiceEnvironment( // core ServiceConfig field, so it is written via SetServiceConfigValue (a raw // map path) rather than AddService's inlined config map, which cannot carry it. func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceName string, uses []string) error { - usesItems := make([]any, len(uses)) - for i, u := range uses { + existing, err := getServiceUses(ctx, azdClient, serviceName) + if err != nil { + return fmt.Errorf("reading uses for service %q: %w", serviceName, err) + } + merged := slices.Clone(existing) + for _, use := range uses { + if slices.Contains(merged, use) { + continue + } + merged = append(merged, use) + } + usesItems := make([]any, len(merged)) + for i, u := range merged { usesItems[i] = u } @@ -447,6 +526,25 @@ func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceNam return nil } +func getServiceUses( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, +) ([]string, error) { + response, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + if response.GetProject() == nil { + return nil, nil + } + service, ok := response.GetProject().GetServices()[serviceName] + if !ok || service == nil { + return nil, nil + } + return slices.Clone(service.GetUses()), nil +} + // sanitizeServiceName converts a resource name into an azure.yaml service key by // trimming surrounding whitespace and removing interior spaces, matching how the // agent service name is derived from the agent name. Only spaces are stripped, so diff --git a/cli/azd/extensions/azure.ai.projects/extension.yaml b/cli/azd/extensions/azure.ai.projects/extension.yaml index ca758f51f41..7b622fa73b2 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -23,4 +23,4 @@ tags: - project usage: azd ai project [options] version: 1.0.0-beta.5 -requiredAzdVersion: ">=1.27.1" +requiredAzdVersion: ">=1.31.0-beta.1" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go new file mode 100644 index 00000000000..31c9d25331e --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go @@ -0,0 +1,430 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/version" + + "github.com/spf13/cobra" +) + +const delegatedSchemaVersion = 1 + +const ( + projectInitSourceAgents = "azure.ai.agents/init" + projectInitSourceProjects = "azure.ai.projects/direct" +) + +type delegatedProject struct { + ResourceID string `json:"resourceId,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type delegatedInfra struct { + EjectProvider string `json:"ejectProvider,omitempty"` +} + +type delegatedRequirements struct { + AllowedLocations []string `json:"allowedLocations,omitempty"` +} + +// projectInitRequest is the versioned IPC contract for agents. +type projectInitRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion,omitempty"` + Project delegatedProject `json:"project"` + Infra delegatedInfra `json:"infra,omitempty"` + Requirements delegatedRequirements `json:"requirements,omitempty"` + ResolveAzureContext bool `json:"resolveAzureContext"` + Force bool `json:"force"` +} + +type delegatedModel struct { + Name string `json:"name"` + DeploymentName string `json:"deploymentName,omitempty"` + RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` + AllowedLocations []string `json:"allowedLocations,omitempty"` + ExcludedModelNames []string `json:"excludedModelNames,omitempty"` +} + +// projectDeploymentAddRequest is the managed-model IPC contract. +type projectDeploymentAddRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion,omitempty"` + Model delegatedModel `json:"model"` + SetAsDefault bool `json:"setAsDefault"` + Force bool `json:"force"` +} + +type deploymentAddRequest = projectDeploymentAddRequest + +type projectInitResult struct { + SchemaVersion int `json:"schemaVersion"` + ProducerVersion string `json:"producerVersion"` + ServiceName string `json:"serviceName"` + Mode string `json:"mode"` + Mutation string `json:"mutation"` + Endpoint string `json:"endpoint,omitempty"` + ResourceID string `json:"resourceId,omitempty"` +} + +type projectDeploymentAddResult struct { + SchemaVersion int `json:"schemaVersion"` + ProducerVersion string `json:"producerVersion"` + ServiceName string `json:"serviceName"` + DeploymentName string `json:"deploymentName"` + Model delegatedResultModel `json:"model"` + SKU delegatedResultSKU `json:"sku"` + Mutation string `json:"mutation"` +} + +type deploymentAddResult = projectDeploymentAddResult + +type delegatedResultModel struct { + Format string `json:"format"` + Name string `json:"name"` + Version string `json:"version"` +} + +type delegatedResultSKU struct { + Name string `json:"name"` + Capacity int `json:"capacity"` +} + +func (r *projectInitRequest) validate() error { + if r == nil { + return contractValidationError("delegated project init request is empty") + } + if r.SchemaVersion != delegatedSchemaVersion { + return contractCompatibilityError(r.SchemaVersion) + } + if r.Source != projectInitSourceAgents && r.Source != projectInitSourceProjects { + return contractValidationError("source must be azure.ai.agents/init or azure.ai.projects/direct") + } + if r.Source == projectInitSourceAgents && strings.TrimSpace(r.SourceVersion) == "" { + return contractValidationError("sourceVersion is required for delegated requests") + } + if r.Project.ResourceID != "" && r.Project.Endpoint != "" { + return contractValidationError("project.resourceId and project.endpoint are mutually exclusive") + } + if r.Infra.EjectProvider != "" { + if _, err := parseInfraProvider(r.Infra.EjectProvider); err != nil { + return err + } + } + locations, err := normalizeLocations(r.Requirements.AllowedLocations) + if err != nil { + return err + } + if r.Requirements.AllowedLocations != nil && len(locations) == 0 { + return contractValidationError("requirements.allowedLocations must contain a location") + } + r.Requirements.AllowedLocations = locations + return nil +} + +func validateProjectInitRequest(request projectInitRequest) error { + return request.validate() +} + +func (r *projectDeploymentAddRequest) validate() error { + if r == nil { + return contractValidationError("delegated deployment request is empty") + } + if r.SchemaVersion != delegatedSchemaVersion { + return contractCompatibilityError(r.SchemaVersion) + } + if r.Source != projectInitSourceAgents && r.Source != projectInitSourceProjects { + return contractValidationError("source must be azure.ai.agents/init or azure.ai.projects/direct") + } + if r.Source == projectInitSourceAgents && strings.TrimSpace(r.SourceVersion) == "" { + return contractValidationError("sourceVersion is required for delegated requests") + } + if strings.TrimSpace(r.Model.Name) == "" { + return contractValidationError("model.name is required") + } + if strings.TrimSpace(r.Model.DeploymentName) == "" && r.Model.DeploymentName != "" { + return contractValidationError("model.deploymentName must not be whitespace") + } + locations, err := normalizeLocations(r.Model.AllowedLocations) + if err != nil { + return err + } + r.Model.AllowedLocations = locations + for _, capability := range r.Model.RequiredCapabilities { + if capability != "agentsV2" { + return contractValidationError(fmt.Sprintf("unknown required capability %q", capability)) + } + } + r.Model.RequiredCapabilities = uniqueStrings(r.Model.RequiredCapabilities, false) + r.Model.ExcludedModelNames = uniqueStrings(r.Model.ExcludedModelNames, true) + return nil +} + +func validateProjectDeploymentAddRequest(request projectDeploymentAddRequest) error { + return request.validate() +} + +func validateProjectInitResult(result projectInitResult) error { + if result.SchemaVersion != delegatedSchemaVersion { + return contractCompatibilityError(result.SchemaVersion) + } + if result.ProducerVersion == "" { + return contractValidationError("producerVersion is required in delegated results") + } + if result.ServiceName == "" { + return contractValidationError("serviceName is required") + } + if !slices.Contains([]string{"new", "existing-id", "existing-endpoint"}, result.Mode) { + return contractValidationError(fmt.Sprintf("invalid project mode %q", result.Mode)) + } + if !slices.Contains([]string{"created", "updated", "migrated", "unchanged"}, result.Mutation) { + return contractValidationError(fmt.Sprintf("invalid project mutation %q", result.Mutation)) + } + if result.Mode == "existing-id" && result.ResourceID == "" { + return contractValidationError("resourceId is required for existing-id results") + } + if result.Mode != "new" && result.Endpoint == "" { + return contractValidationError("endpoint is required for existing project results") + } + return nil +} + +func validateProjectDeploymentAddResult(result projectDeploymentAddResult) error { + if result.SchemaVersion != delegatedSchemaVersion { + return contractCompatibilityError(result.SchemaVersion) + } + if result.ProducerVersion == "" || result.ServiceName == "" || + result.DeploymentName == "" || result.Model.Name == "" || + result.Model.Format == "" || result.Model.Version == "" || + result.SKU.Name == "" || result.SKU.Capacity <= 0 { + return contractValidationError("deployment result is missing a required value") + } + if !slices.Contains([]string{"created", "replaced", "unchanged"}, result.Mutation) { + return contractValidationError(fmt.Sprintf("invalid deployment mutation %q", result.Mutation)) + } + return nil +} + +func contractCompatibilityError(got int) error { + return exterrors.Validation( + "project_contract_incompatible", + fmt.Sprintf( + "unsupported delegated contract schemaVersion %d (projects extension supports %d)", + got, delegatedSchemaVersion, + ), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) +} + +func contractValidationError(message string) error { + return exterrors.Validation("project_contract_invalid", message, "check the delegated request fields") +} + +func normalizeLocations(values []string) ([]string, error) { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, contractValidationError("allowedLocations cannot contain an empty location") + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out, nil +} + +func uniqueStrings(values []string, insensitive bool) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + key := value + if insensitive { + key = strings.ToLower(key) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} + +func decodeDelegatedJSON(path string, value any) error { + if err := validateDelegatedFilePath(path, "request", true); err != nil { + return err + } + file, err := os.Open(path) // #nosec G304 -- path is validated as a delegated file. + if err != nil { + return contractValidationError(fmt.Sprintf("read delegated request: %v", err)) + } + defer file.Close() + + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return contractValidationError(fmt.Sprintf("decode delegated request: %v", err)) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return contractValidationError("delegated request must contain exactly one JSON document") + } + return nil +} + +func decodeDelegatedResultJSON(path string, value any) error { + if err := validateDelegatedFilePath(path, "result", true); err != nil { + return err + } + file, err := os.Open(path) // #nosec G304 -- path is validated as a delegated file. + if err != nil { + return contractValidationError(fmt.Sprintf("read delegated result: %v", err)) + } + defer file.Close() + decoder := json.NewDecoder(file) + if err := decoder.Decode(value); err != nil { + return contractValidationError(fmt.Sprintf("decode delegated result: %v", err)) + } + return nil +} + +func writeDelegatedResult(path string, value any) error { + if err := validateDelegatedFilePath(path, "result", false); err != nil { + return err + } + dir := filepath.Dir(path) + temp, err := os.CreateTemp(dir, ".azd-project-result-*") + if err != nil { + return fmt.Errorf("create delegated result: %w", err) + } + tempName := temp.Name() + cleanup := func() { + _ = temp.Close() + _ = os.Remove(tempName) + } + defer cleanup() + if err := temp.Chmod(0600); err != nil { + return fmt.Errorf("protect delegated result: %w", err) + } + encoder := json.NewEncoder(temp) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return fmt.Errorf("encode delegated result: %w", err) + } + if err := temp.Sync(); err != nil { + return fmt.Errorf("flush delegated result: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close delegated result: %w", err) + } + if err := os.Rename(tempName, path); err != nil { + // Windows does not replace an existing file with Rename. The path was + // validated above and is still in the same private directory. + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return fmt.Errorf("replace delegated result: %w", err) + } + if renameErr := os.Rename(tempName, path); renameErr != nil { + return fmt.Errorf("replace delegated result: %w", renameErr) + } + } + return nil +} + +func validateDelegatedFilePath(path, kind string, requireRegular bool) error { + if path == "" { + return contractValidationError(kind + " file path is required") + } + if !filepath.IsAbs(path) { + return contractValidationError(kind + " file path must be absolute") + } + abs, err := filepath.Abs(path) + if err != nil { + return contractValidationError(kind + " file path must be absolute") + } + if err := rejectSymlinkComponents(abs); err != nil { + return contractValidationError(fmt.Sprintf("%s file path is unsafe: %v", kind, err)) + } + info, statErr := os.Lstat(abs) + if statErr != nil { + if !requireRegular && os.IsNotExist(statErr) { + return nil + } + return contractValidationError(fmt.Sprintf("%s file is not accessible: %v", kind, statErr)) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return contractValidationError(kind + " file must be a regular non-symlink file") + } + return nil +} + +func validateDelegatedPathPair(requestPath, resultPath string) error { + if err := validateDelegatedFilePath(requestPath, "request", true); err != nil { + return err + } + if err := validateDelegatedFilePath(resultPath, "result", false); err != nil { + return err + } + requestAbs, _ := filepath.Abs(requestPath) + resultAbs, _ := filepath.Abs(resultPath) + if !strings.EqualFold(filepath.Dir(requestAbs), filepath.Dir(resultAbs)) { + return contractValidationError("request and result files must be siblings in the delegated temporary directory") + } + return nil +} + +func rejectSymlinkComponents(path string) error { + clean := filepath.Clean(path) + volume := filepath.VolumeName(clean) + rest := strings.TrimPrefix(clean, volume) + current := volume + string(filepath.Separator) + for _, part := range strings.FieldsFunc(rest, func(r rune) bool { + return r == '/' || r == '\\' + }) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("path component %q is a symbolic link", current) + } + } + return nil +} + +func delegatedProducerVersion() string { + return version.Version +} + +func registerDelegatedContractFlags( + cmd *cobra.Command, + requestFile *string, + resultFile *string, +) { + cmd.Flags().StringVar(requestFile, "request-file", "", "Delegated request file") + cmd.Flags().StringVar(resultFile, "result-file", "", "Delegated result file") + _ = cmd.Flags().MarkHidden("request-file") + _ = cmd.Flags().MarkHidden("result-file") +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go new file mode 100644 index 00000000000..603ab9d527b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go @@ -0,0 +1,564 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + "strings" + + "azure.ai.projects/internal/exterrors" + "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" +) + +type deploymentMutation string + +const ( + deploymentCreated deploymentMutation = "created" + deploymentReplaced deploymentMutation = "replaced" + deploymentUnchanged deploymentMutation = "unchanged" +) + +type selectedDeployment struct { + Deployment synthesis.Deployment + Location string +} + +type deploymentSelectionOptions struct { + Version string + SKU string + Capacity int32 + Location string +} + +type deploymentItem struct { + Raw map[string]any + Resolved map[string]any + Referenced bool + Index int +} + +func splitModelReference(raw string) (format, name string) { + raw = strings.TrimSpace(raw) + if slash := strings.IndexByte(raw, '/'); slash > 0 && slash < len(raw)-1 { + return raw[:slash], raw[slash+1:] + } + return "OpenAI", raw +} + +func chooseDeploymentName(requested string, modelName string) string { + if strings.TrimSpace(requested) != "" { + return strings.TrimSpace(requested) + } + return modelName +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func selectModelDeployment( + ctx context.Context, + client *azdext.AzdClient, + azureContext *azdext.AzureContext, + model delegatedModel, + selection deploymentSelectionOptions, + noPrompt bool, +) (*selectedDeployment, error) { + modelFormat, modelName := splitModelReference(model.Name) + if modelName == "" { + return nil, contractValidationError("model.name is required") + } + + locations, err := normalizeLocations(model.AllowedLocations) + if err != nil { + return nil, err + } + if len(locations) == 0 && azureContext != nil && azureContext.Scope != nil && + azureContext.Scope.Location != "" { + locations = []string{azureContext.Scope.Location} + } else if len(locations) > 0 && azureContext != nil && + azureContext.Scope != nil && azureContext.Scope.Location != "" { + if !locationAllowed(azureContext.Scope.Location, locations) { + return nil, exterrors.Validation( + "model_deployment_location_not_allowed", + fmt.Sprintf( + "the project location %q is outside the model's allowed locations", + azureContext.Scope.Location, + ), + "choose a model location that includes the project location", + ) + } + locations = []string{azureContext.Scope.Location} + } + if selection.Location != "" { + if len(locations) > 0 && !locationAllowed(selection.Location, locations) { + return nil, exterrors.Validation( + "model_deployment_location_not_allowed", + fmt.Sprintf( + "deployment location %q is outside the allowed locations", + selection.Location, + ), + "choose a deployment location from the allowed locations", + ) + } + locations = []string{selection.Location} + } + if len(model.RequiredCapabilities) > 0 || len(model.ExcludedModelNames) > 0 { + catalog, catalogErr := client.Ai().ListModels(ctx, &azdext.ListModelsRequest{ + AzureContext: azureContext, + Filter: &azdext.AiModelFilterOptions{ + Locations: locations, + Capabilities: model.RequiredCapabilities, + ExcludeModelNames: model.ExcludedModelNames, + }, + }) + if catalogErr != nil { + return nil, fmt.Errorf("filter model catalog: %w", catalogErr) + } + found := false + for _, candidate := range catalog.GetModels() { + if candidate != nil && strings.EqualFold(candidate.GetName(), modelName) { + found = true + for _, required := range model.RequiredCapabilities { + if !slices.Contains(candidate.GetCapabilities(), required) { + found = false + break + } + } + for _, excluded := range model.ExcludedModelNames { + if strings.EqualFold(excluded, candidate.GetName()) { + found = false + break + } + } + break + } + } + if !found { + return nil, exterrors.Validation( + "model_not_available", + fmt.Sprintf("model %q does not satisfy the requested capability or exclusion filters", modelName), + "choose a compatible model or remove the consumer-specific filter", + ) + } + } + + options := &azdext.AiModelDeploymentOptions{ + Locations: locations, + Versions: nonEmptyStringSlice(selection.Version), + Skus: nonEmptyStringSlice(selection.SKU), + } + if selection.Capacity > 0 { + options.Capacity = new(selection.Capacity) + } + candidates, err := resolveDeploymentCandidates( + ctx, client, azureContext, modelName, options, + ) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return nil, exterrors.Validation( + "model_deployment_unavailable", + fmt.Sprintf("no deployable version or SKU was found for model %q", modelName), + "choose a model and location supported by your subscription", + ) + } + slices.SortFunc(candidates, func(left, right *azdext.AiModelDeployment) int { + leftKey := deploymentCandidateKey(left) + rightKey := deploymentCandidateKey(right) + return strings.Compare(leftKey, rightKey) + }) + if noPrompt && len(candidates) > 1 { + return nil, exterrors.Validation( + "model_deployment_ambiguous", + fmt.Sprintf("more than one deployment choice is valid for %q", modelName), + "specify the model version, SKU, capacity, or location before retrying", + ) + } + candidate := candidates[0] + if !noPrompt && len(candidates) > 1 { + candidate, err = promptForDeploymentCandidate(ctx, client, candidates) + if err != nil { + return nil, err + } + } + if candidate == nil || candidate.GetSku() == nil { + return nil, exterrors.Validation( + "model_deployment_unavailable", + fmt.Sprintf("model %q has no usable deployment SKU", modelName), + "choose a different model", + ) + } + capacity := candidate.GetCapacity() + if capacity <= 0 { + capacity = candidate.GetSku().GetDefaultCapacity() + } + if capacity <= 0 { + capacity = 1 + } + location := candidate.GetLocation() + if location == "" && len(locations) == 1 { + location = locations[0] + } + return &selectedDeployment{ + Deployment: synthesis.Deployment{ + Name: chooseDeploymentName(model.DeploymentName, candidate.GetModelName()), + Model: synthesis.DeploymentModel{ + Format: firstNonEmpty(candidate.GetFormat(), modelFormat), + Name: firstNonEmpty(candidate.GetModelName(), modelName), + Version: candidate.GetVersion(), + }, + Sku: synthesis.DeploymentSku{ + Name: candidate.GetSku().GetName(), + Capacity: int(capacity), + }, + }, + Location: location, + }, nil +} + +func nonEmptyStringSlice(value string) []string { + if value == "" { + return nil + } + return []string{value} +} + +func resolveDeploymentCandidates( + ctx context.Context, + client *azdext.AzdClient, + azureContext *azdext.AzureContext, + modelName string, + options *azdext.AiModelDeploymentOptions, +) ([]*azdext.AiModelDeployment, error) { + locations := options.GetLocations() + if len(locations) <= 1 { + return resolveDeploymentCandidatesAtLocation( + ctx, client, azureContext, modelName, options, + len(locations) == 1, + ) + } + + var candidates []*azdext.AiModelDeployment + for _, location := range locations { + locationOptions := *options + locationOptions.Locations = []string{location} + locationCandidates, err := resolveDeploymentCandidatesAtLocation( + ctx, client, azureContext, modelName, &locationOptions, true, + ) + if err != nil { + return nil, err + } + candidates = append(candidates, locationCandidates...) + } + return candidates, nil +} + +func resolveDeploymentCandidatesAtLocation( + ctx context.Context, + client *azdext.AzdClient, + azureContext *azdext.AzureContext, + modelName string, + options *azdext.AiModelDeploymentOptions, + checkQuota bool, +) ([]*azdext.AiModelDeployment, error) { + request := &azdext.ResolveModelDeploymentsRequest{ + AzureContext: azureContext, + ModelName: modelName, + Options: options, + } + if checkQuota { + request.Quota = &azdext.QuotaCheckOptions{MinRemainingCapacity: 1} + } + response, err := client.Ai().ResolveModelDeployments(ctx, request) + if err != nil { + return nil, fmt.Errorf("resolve model deployment %q: %w", modelName, err) + } + return response.GetDeployments(), nil +} + +func promptForDeploymentCandidate( + ctx context.Context, + client *azdext.AzdClient, + candidates []*azdext.AiModelDeployment, +) (*azdext.AiModelDeployment, error) { + choices := make([]*azdext.SelectChoice, len(candidates)) + for index, candidate := range candidates { + choices[index] = &azdext.SelectChoice{ + Value: fmt.Sprintf("%d", index), + Label: deploymentCandidateLabel(candidate), + } + } + response, err := client.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a model deployment", + Choices: choices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("model deployment selection was cancelled") + } + return nil, fmt.Errorf("select model deployment: %w", err) + } + index := int(response.GetValue()) + if index < 0 || index >= len(candidates) { + return nil, exterrors.Validation( + "model_deployment_selection_invalid", + "the model deployment selection response was invalid", + "retry model deployment selection", + ) + } + return candidates[index], nil +} + +func deploymentCandidateLabel(candidate *azdext.AiModelDeployment) string { + if candidate == nil { + return "Unavailable deployment" + } + sku := "" + if candidate.GetSku() != nil { + sku = candidate.GetSku().GetName() + } + return fmt.Sprintf( + "%s %s (%s, capacity %d, %s)", + candidate.GetModelName(), + candidate.GetVersion(), + sku, + candidate.GetCapacity(), + candidate.GetLocation(), + ) +} + +func deploymentCandidateKey(candidate *azdext.AiModelDeployment) string { + if candidate == nil { + return "~" + } + sku := "" + if candidate.GetSku() != nil { + sku = candidate.GetSku().GetName() + } + return strings.Join([]string{ + candidate.GetLocation(), + candidate.GetModelName(), + candidate.GetVersion(), + sku, + fmt.Sprintf("%09d", candidate.GetCapacity()), + }, "\x00") +} + +func reconcileDeployment( + ctx context.Context, + reconciler *projectServiceReconciler, + serviceName string, + requested synthesis.Deployment, + force bool, +) (deploymentMutation, error) { + service, _, err := reconciler.discoverProjectService(ctx) + if err != nil { + return "", err + } + if service == nil || service.Name != serviceName { + return "", exterrors.Dependency( + "project_service_not_found", + fmt.Sprintf("project service %q was not found", serviceName), + "run `azd ai project init` before adding a deployment", + ) + } + rawItems, resolvedItems, err := deploymentItems(service, reconciler.projectRoot) + if err != nil { + return "", err + } + seenNames := map[string]struct{}{} + for _, item := range resolvedItems { + name, ok := item["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return "", exterrors.Validation( + "project_deployment_invalid", + fmt.Sprintf("project service %q contains a deployment without a name", service.Name), + "add a unique name to every deployment declaration", + ) + } + key := strings.ToLower(name) + if _, exists := seenNames[key]; exists { + return "", exterrors.Validation( + "project_deployment_duplicate", + fmt.Sprintf("project service %q contains duplicate deployment name %q", service.Name, name), + "remove the duplicate deployment declarations and retry", + ) + } + seenNames[key] = struct{}{} + } + requestedName := strings.ToLower(requested.Name) + for index, item := range resolvedItems { + name := item["name"].(string) + if strings.ToLower(name) != requestedName { + continue + } + if !deploymentSemanticallyEqual(item, requested) { + if service.ServiceRef != "" { + return "", projectServiceRefError(service.Name, service.ServiceRef) + } + if index < len(rawItems) { + if _, referenced := rawItems[index]["$ref"]; referenced { + return "", exterrors.Validation( + "project_deployment_ref_conflict", + fmt.Sprintf("deployment %q is defined by a referenced file", name), + "edit the referenced deployment file instead of using --force", + ) + } + } + if !force { + return "", exterrors.Validation( + "project_deployment_conflict", + fmt.Sprintf("deployment %q already exists with different settings", name), + "use --force to replace the inline declaration", + ) + } + rawItems[index] = deploymentMap(requested) + return deploymentMutationUpdate(ctx, reconciler, service.Name, rawItems, deploymentReplaced) + } + return deploymentUnchanged, nil + } + if service.ServiceRef != "" { + return "", projectServiceRefError(service.Name, service.ServiceRef) + } + rawItems = append(rawItems, deploymentMap(requested)) + return deploymentMutationUpdate(ctx, reconciler, service.Name, rawItems, deploymentCreated) +} + +func deploymentMutationUpdate( + ctx context.Context, + reconciler *projectServiceReconciler, + serviceName string, + items []map[string]any, + mutation deploymentMutation, +) (deploymentMutation, error) { + values := make([]any, len(items)) + for i := range items { + values[i] = items[i] + } + value, err := structpb.NewValue(values) + if err != nil { + return "", fmt.Errorf("encode project deployments: %w", err) + } + if _, err := reconciler.client.Project().SetServiceConfigValue(ctx, + &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "deployments", + Value: value, + }); err != nil { + return "", fmt.Errorf("update project service %q deployments: %w", serviceName, err) + } + return mutation, nil +} + +func deploymentItems( + service *projectServiceInfo, + projectRoot string, +) ([]map[string]any, []map[string]any, error) { + rawValue, _ := service.Raw["deployments"].([]any) + resolvedSource, _ := service.Resolved["deployments"].([]any) + var err error + if rawValue == nil && resolvedSource != nil { + rawValue = resolvedSource + } + if rawValue == nil { + rawValue = []any{} + } + raw := make([]map[string]any, len(rawValue)) + for i, value := range rawValue { + item, ok := value.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("deployment item %d is not an object", i) + } + raw[i], err = cloneMap(item) + if err != nil { + return nil, nil, fmt.Errorf("copy deployment item %d: %w", i, err) + } + } + resolvedMap := map[string]any{"deployments": rawValue} + if projectRoot != "" { + cloned, cloneErr := cloneMap(resolvedMap) + if cloneErr != nil { + return nil, nil, fmt.Errorf("copy project deployments: %w", cloneErr) + } + resolvedMap, err = foundry.ResolveFileRefs(cloned, projectRoot) + if err != nil { + return nil, nil, fmt.Errorf("resolve project deployment references: %w", err) + } + } + resolvedValue, _ := resolvedMap["deployments"].([]any) + resolved := make([]map[string]any, len(resolvedValue)) + for i, value := range resolvedValue { + item, ok := value.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("resolved deployment item %d is not an object", i) + } + resolved[i] = item + } + if len(resolved) != len(raw) { + return nil, nil, fmt.Errorf("resolved project deployments changed item count") + } + return raw, resolved, nil +} + +func deploymentMap(deployment synthesis.Deployment) map[string]any { + return 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, + }, + } +} + +func deploymentSemanticallyEqual(value map[string]any, expected synthesis.Deployment) bool { + model, _ := value["model"].(map[string]any) + sku, _ := value["sku"].(map[string]any) + name, _ := value["name"].(string) + return strings.EqualFold(name, expected.Name) && + stringValue(model, "format") == expected.Model.Format && + stringValue(model, "name") == expected.Model.Name && + stringValue(model, "version") == expected.Model.Version && + stringValue(sku, "name") == expected.Sku.Name && + intValue(sku, "capacity") == expected.Sku.Capacity +} + +func stringValue(value map[string]any, key string) string { + result, _ := value[key].(string) + return result +} + +func intValue(value map[string]any, key string) int { + switch result := value[key].(type) { + case int: + return result + case int32: + return int(result) + case int64: + return int(result) + case float64: + return int(result) + default: + return 0 + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go new file mode 100644 index 00000000000..10098cc4de6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectDeploymentFlags struct { + model string + name string + version string + sku string + capacity int32 + location string + force bool + requestFile string + resultFile string + output string +} + +// ProjectDeploymentAddAction implements deployment add. +type ProjectDeploymentAddAction struct { + client *azdext.AzdClient + flags *projectDeploymentFlags + extCtx *azdext.ExtensionContext +} + +func newProjectDeploymentCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + cmd := &cobra.Command{ + Use: "deployment", + Short: "Manage managed model deployments for a Foundry project.", + } + cmd.AddCommand(newProjectDeploymentAddCommand(extCtx)) + return cmd +} + +func newProjectDeploymentAddCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &projectDeploymentFlags{} + cmd := &cobra.Command{ + Use: "add", + Short: "Add an azd-managed model deployment to the project.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags.output = extCtx.OutputFormat + if flags.requestFile != "" { + for _, name := range []string{"model", "name", "version", "sku", "capacity", "location", "force"} { + if cmd.Flags().Changed(name) { + return contractValidationError( + fmt.Sprintf("--%s cannot be combined with --request-file", name), + ) + } + } + } + action := &ProjectDeploymentAddAction{flags: flags, extCtx: extCtx} + return action.Run(cmd.Context()) + }, + } + cmd.Flags().StringVar(&flags.model, "model", "", "Model name or publisher/model") + cmd.Flags().StringVar(&flags.name, "name", "", "Deployment name") + cmd.Flags().StringVar(&flags.version, "version", "", "Model version") + cmd.Flags().StringVar(&flags.sku, "sku", "", "Deployment SKU name") + cmd.Flags().Int32Var(&flags.capacity, "capacity", 0, "Deployment capacity") + cmd.Flags().StringVar(&flags.location, "location", "", "Deployment location") + cmd.Flags().BoolVar(&flags.force, "force", false, "Replace a conflicting inline declaration") + registerDelegatedContractFlags(cmd, &flags.requestFile, &flags.resultFile) + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"default", "json", "none"}, + Default: "default", + Usage: "The output format", + }) + return cmd +} + +func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error { + if a.flags == nil { + a.flags = &projectDeploymentFlags{} + } + request, err := a.loadRequest() + if err != nil { + return err + } + if request == nil && strings.TrimSpace(a.flags.model) == "" { + if a.noPrompt() { + return contractValidationError("--model is required in --no-prompt mode") + } + } + client := a.client + if client == nil { + client, err = azdext.NewAzdClient() + if err != nil { + return exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "could not connect to the azd daemon", + "run this command from an azd extension host", + ) + } + defer client.Close() + } + a.client = client + if request == nil && strings.TrimSpace(a.flags.model) == "" { + response, promptErr := client.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Model name", + IgnoreHintKeys: true, + }, + }) + if promptErr != nil { + return fmt.Errorf("select model: %w", promptErr) + } + a.flags.model = response.GetValue() + if strings.TrimSpace(a.flags.model) == "" { + return contractValidationError("model name cannot be empty") + } + } + + projectRoot := projectRootPath() + reconciler := &projectServiceReconciler{client: client, projectRoot: projectRoot} + service, project, err := reconciler.discoverProjectService(ctx) + if err != nil { + return err + } + if service == nil { + return exterrors.Dependency( + "project_service_not_found", + "no azure.ai.project service was found in the azd project", + "run `azd ai project init` before adding a deployment", + ) + } + envName, err := resolveProjectEnvironmentName(ctx, client, a.environmentName(), project.GetPath()) + if err != nil { + return err + } + values, err := currentProjectEnvironment(ctx, client, envName) + if err != nil { + return err + } + if values["AZURE_AI_PROJECT_ID"] == "" { + return exterrors.Validation( + "project_deployment_requires_id", + "managed model deployments require an existing Foundry project resource ID", + "rerun `azd ai project init --project-id ` after the project is provisioned", + ) + } + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + TenantId: values["AZURE_TENANT_ID"], + SubscriptionId: values["AZURE_SUBSCRIPTION_ID"], + Location: values["AZURE_AI_DEPLOYMENTS_LOCATION"], + }, + } + if azureContext.Scope.Location == "" { + azureContext.Scope.Location = values["AZURE_LOCATION"] + } + if azureContext.Scope.SubscriptionId == "" { + if deploymentContext, contextErr := client.Deployment().GetDeploymentContext( + ctx, &azdext.EmptyRequest{}, + ); contextErr == nil && deploymentContext.GetAzureContext() != nil { + azureContext = deploymentContext.AzureContext + } + } + model := delegatedModel{ + Name: a.flags.model, + DeploymentName: a.flags.name, + } + force := a.flags.force + setAsDefault := true + if request != nil { + model = request.Model + force = request.Force + setAsDefault = request.SetAsDefault + } + selection := deploymentSelectionOptions{ + Version: a.flags.version, + SKU: a.flags.sku, + Capacity: a.flags.capacity, + Location: a.flags.location, + } + selected, err := selectModelDeployment( + ctx, client, azureContext, model, selection, a.noPrompt(), + ) + if err != nil { + return err + } + if model.DeploymentName != "" { + selected.Deployment.Name = model.DeploymentName + } + if selected.Deployment.Model.Format == "" || + selected.Deployment.Model.Name == "" || + selected.Deployment.Model.Version == "" || + selected.Deployment.Sku.Name == "" || + selected.Deployment.Sku.Capacity <= 0 { + return exterrors.Validation( + "model_deployment_invalid", + "the selected model deployment is missing a required version, SKU, or capacity", + "specify a deployable model tuple and retry", + ) + } + mutation, err := reconcileDeployment( + ctx, reconciler, service.Name, selected.Deployment, force, + ) + if err != nil { + return err + } + if selected.Location != "" && selected.Location != values["AZURE_AI_DEPLOYMENTS_LOCATION"] { + if _, err := client.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: "AZURE_AI_DEPLOYMENTS_LOCATION", + Value: selected.Location, + }); err != nil { + return fmt.Errorf("set deployment location: %w", err) + } + } + if setAsDefault { + if _, err := client.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: "AZURE_AI_MODEL_DEPLOYMENT_NAME", + Value: selected.Deployment.Name, + }); err != nil { + return fmt.Errorf("set default model deployment: %w", err) + } + } + result := projectDeploymentAddResult{ + SchemaVersion: delegatedSchemaVersion, + ProducerVersion: delegatedProducerVersion(), + ServiceName: service.Name, + DeploymentName: selected.Deployment.Name, + Model: delegatedResultModel{ + Format: selected.Deployment.Model.Format, + Name: selected.Deployment.Model.Name, + Version: selected.Deployment.Model.Version, + }, + SKU: delegatedResultSKU{ + Name: selected.Deployment.Sku.Name, + Capacity: selected.Deployment.Sku.Capacity, + }, + Mutation: string(mutation), + } + if err := validateProjectDeploymentAddResult(result); err != nil { + return err + } + if request != nil { + return writeDelegatedResult(a.flags.resultFile, result) + } + if a.flags.output == "none" { + return nil + } + if a.flags.output == "json" { + return json.NewEncoder(os.Stdout).Encode(result) + } + switch mutation { + case deploymentUnchanged: + fmt.Printf("Managed deployment %q is unchanged.\n", selected.Deployment.Name) + default: + fmt.Printf("Managed deployment %q %s.\n", selected.Deployment.Name, mutation) + } + return nil +} + +func (a *ProjectDeploymentAddAction) loadRequest() (*projectDeploymentAddRequest, error) { + if a.flags.requestFile == "" { + return nil, nil + } + if a.flags.resultFile == "" { + return nil, contractValidationError("--result-file is required with --request-file") + } + if err := validateDelegatedPathPair(a.flags.requestFile, a.flags.resultFile); err != nil { + return nil, err + } + request := &projectDeploymentAddRequest{} + if err := decodeDelegatedJSON(a.flags.requestFile, request); err != nil { + return nil, err + } + if err := request.validate(); err != nil { + return nil, err + } + a.flags.model = request.Model.Name + a.flags.force = request.Force + return request, nil +} + +func (a *ProjectDeploymentAddAction) noPrompt() bool { + return a.extCtx != nil && a.extCtx.NoPrompt +} + +func (a *ProjectDeploymentAddAction) environmentName() string { + if a.extCtx != nil { + return a.extCtx.Environment + } + return "" +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go new file mode 100644 index 00000000000..9703f8420a2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "reflect" + "slices" + "strings" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +var projectEnvironmentKeys = []string{ + "AZURE_AI_ACCOUNT_NAME", + "AZURE_AI_DEPLOYMENTS_LOCATION", + "AZURE_AI_MODEL_DEPLOYMENT_NAME", + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", + "AZURE_AI_PROJECT_ID", + "AZURE_AI_PROJECT_NAME", + "AZURE_LOCATION", + "AZURE_OPENAI_ENDPOINT", + "AZURE_RESOURCE_GROUP", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TENANT_ID", + "FOUNDRY_PROJECT_ENDPOINT", + "USE_EXISTING_AI_PROJECT", +} + +type environmentPlan struct { + Sets map[string]string + Unsets []string +} + +// planProjectEnvironment calculates environment mutations. +// It is independent from gRPC for daemon-free tests. +func planProjectEnvironment( + oldValues map[string]string, + mode projectMode, + project *resolvedProject, + identityChanged bool, +) environmentPlan { + sets := map[string]string{} + if project == nil { + project = &resolvedProject{} + } + if project.SubscriptionId != "" { + sets["AZURE_SUBSCRIPTION_ID"] = project.SubscriptionId + } + if project.UserTenantId != "" { + sets["AZURE_TENANT_ID"] = project.UserTenantId + } + + switch mode { + case projectModeExistingID: + for key, value := range map[string]string{ + "AZURE_AI_PROJECT_ID": project.ResourceId, + "AZURE_RESOURCE_GROUP": project.ResourceGroupName, + "AZURE_AI_ACCOUNT_NAME": project.AccountName, + "AZURE_AI_PROJECT_NAME": project.ProjectName, + "FOUNDRY_PROJECT_ENDPOINT": project.Endpoint, + "AZURE_OPENAI_ENDPOINT": project.OpenAIEndpoint, + "AZURE_AI_DEPLOYMENTS_LOCATION": project.Location, + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": project.Endpoint, + "USE_EXISTING_AI_PROJECT": "true", + } { + if value != "" { + sets[key] = value + } + } + // Do not overwrite a preselected location when ARM omits one. + if project.Location != "" { + sets["AZURE_LOCATION"] = project.Location + } + case projectModeExistingEndpoint: + for key, value := range map[string]string{ + "AZURE_AI_PROJECT_NAME": project.ProjectName, + "FOUNDRY_PROJECT_ENDPOINT": project.Endpoint, + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT": project.Endpoint, + "USE_EXISTING_AI_PROJECT": "true", + } { + if value != "" { + sets[key] = value + } + } + case projectModeNew: + sets["USE_EXISTING_AI_PROJECT"] = "false" + if project.Location != "" { + sets["AZURE_LOCATION"] = project.Location + sets["AZURE_AI_DEPLOYMENTS_LOCATION"] = project.Location + } + } + + deleteKeys := map[string]struct{}{} + switch mode { + case projectModeNew: + for _, key := range []string{ + "AZURE_AI_PROJECT_ID", "AZURE_RESOURCE_GROUP", "AZURE_AI_ACCOUNT_NAME", + "AZURE_AI_PROJECT_NAME", "FOUNDRY_PROJECT_ENDPOINT", + "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", "AZURE_OPENAI_ENDPOINT", + "AZURE_AI_DEPLOYMENTS_LOCATION", + } { + deleteKeys[key] = struct{}{} + } + case projectModeExistingEndpoint: + for _, key := range []string{ + "AZURE_AI_PROJECT_ID", "AZURE_RESOURCE_GROUP", "AZURE_AI_ACCOUNT_NAME", + "AZURE_OPENAI_ENDPOINT", "AZURE_AI_DEPLOYMENTS_LOCATION", + } { + deleteKeys[key] = struct{}{} + } + } + if identityChanged { + deleteKeys["AZURE_AI_MODEL_DEPLOYMENT_NAME"] = struct{}{} + } + for key := range sets { + delete(deleteKeys, key) + } + unsets := make([]string, 0, len(deleteKeys)) + for key := range deleteKeys { + if _, exists := oldValues[key]; exists { + unsets = append(unsets, key) + } + } + slices.Sort(unsets) + return environmentPlan{Sets: sets, Unsets: unsets} +} + +func reconcileProjectEnvironment( + ctx context.Context, + client *azdext.AzdClient, + envName string, + mode projectMode, + project *resolvedProject, + identityChanged bool, +) error { + response, err := client.Environment().GetValues(ctx, + &azdext.GetEnvironmentRequest{Name: envName}) + if err != nil { + return exterrors.Dependency( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("read project environment %q: %s", envName, err), + "select or create an azd environment before initializing a project", + ) + } + old := map[string]string{} + for _, pair := range response.GetKeyValues() { + if pair != nil { + old[pair.GetKey()] = pair.GetValue() + } + } + plan := planProjectEnvironment(old, mode, project, identityChanged) + keys := make([]string, 0, len(plan.Sets)) + for key := range plan.Sets { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + if _, err := client.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: key, + Value: plan.Sets[key], + }); err != nil { + return fmt.Errorf("set project environment value %s: %w", key, err) + } + } + for _, key := range plan.Unsets { + if err := unsetEnvironmentValue(ctx, client.Environment(), envName, key); err != nil { + return fmt.Errorf("unset project environment value %s: %w", key, err) + } + } + return nil +} + +// unsetEnvironmentValue calls the versioned UnsetValue RPC. +func unsetEnvironmentValue( + ctx context.Context, + client azdext.EnvironmentServiceClient, + envName, key string, +) error { + method := reflect.ValueOf(client).MethodByName("UnsetValue") + if method.IsValid() { + methodType := method.Type() + if methodType.NumIn() >= 2 { + requestType := methodType.In(1) + if requestType.Kind() != reflect.Pointer { + return unsupportedEnvironmentUnset() + } + request := reflect.New(requestType.Elem()) + envField := request.Elem().FieldByName("EnvName") + keyField := request.Elem().FieldByName("Key") + if envField.IsValid() && envField.CanSet() && envField.Kind() == reflect.String && + keyField.IsValid() && keyField.CanSet() && keyField.Kind() == reflect.String { + envField.SetString(envName) + keyField.SetString(key) + args := []reflect.Value{reflect.ValueOf(ctx), request} + if methodType.IsVariadic() { + args = append(args, reflect.Zero(methodType.In(methodType.NumIn()-1))) + return reflectCallError(method.Call(args)) + } + return reflectCallError(method.Call(args)) + } + } + } + return unsupportedEnvironmentUnset() +} + +func unsupportedEnvironmentUnset() error { + return exterrors.Compatibility( + exterrors.CodeEnvironmentUnsetUnsupported, + "the azd host does not support environment key deletion", + "upgrade azd to the coordinated core version and retry", + ) +} + +func reflectCallError(results []reflect.Value) error { + if len(results) == 0 { + return nil + } + result := results[len(results)-1] + switch result.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, + reflect.Pointer, reflect.Slice: + if result.IsNil() { + return nil + } + default: + return fmt.Errorf("environment unset returned %s instead of an error", result.Type()) + } + if result.IsNil() { + return nil + } + err, ok := result.Interface().(error) + if !ok { + return fmt.Errorf("environment unset returned %s instead of an error", result.Type()) + } + return err +} + +func currentProjectEnvironment( + ctx context.Context, + client *azdext.AzdClient, + envName string, +) (map[string]string, error) { + response, err := client.Environment().GetValues(ctx, + &azdext.GetEnvironmentRequest{Name: envName}) + if err != nil { + return nil, err + } + values := make(map[string]string, len(response.GetKeyValues())) + for _, pair := range response.GetKeyValues() { + if pair != nil { + values[pair.GetKey()] = pair.GetValue() + } + } + return values, nil +} + +func resolveProjectEnvironmentName( + ctx context.Context, + client *azdext.AzdClient, + explicit string, + projectRoot string, +) (string, error) { + if strings.TrimSpace(explicit) != "" { + if _, err := client.Environment().Select(ctx, + &azdext.SelectEnvironmentRequest{Name: explicit}); err != nil { + return "", fmt.Errorf("select environment %q: %w", explicit, err) + } + return explicit, nil + } + if response, err := client.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil && + response.GetEnvironment() != nil && response.GetEnvironment().GetName() != "" { + return response.GetEnvironment().GetName(), nil + } + name := deriveProjectEnvironmentName(projectRoot) + if _, err := client.Environment().Select(ctx, + &azdext.SelectEnvironmentRequest{Name: name}); err != nil { + return "", exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + fmt.Sprintf("select environment %q: %s", name, err), + "run `azd env new` or pass --environment with an existing environment", + ) + } + return name, nil +} + +func deriveProjectEnvironmentName(projectRoot string) string { + base := projectRoot + if base == "" { + base = "project" + } + if index := strings.LastIndexAny(base, `/\`); index >= 0 { + base = base[index+1:] + } + base = strings.ToLower(base) + var builder strings.Builder + for _, r := range base { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + builder.WriteRune(r) + } else { + builder.WriteByte('-') + } + } + name := strings.Trim(builder.String(), "-") + if name == "" { + name = "project" + } + if len(name) > 59 { + name = strings.TrimRight(name[:59], "-") + } + return name + "-dev" +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go new file mode 100644 index 00000000000..ac24a02a79b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go @@ -0,0 +1,1190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "azure.ai.projects/internal/azure" + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/provisioning" + "azure.ai.projects/internal/synthesis" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + armresources "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/structpb" +) + +type projectInitFlags struct { + projectID string + projectEndpoint string + infra string + force bool + noPrompt bool + requestFile string + resultFile string + output string +} + +type resolvedProject struct { + Mode projectMode + ResourceId string + SubscriptionId string + UserTenantId string + ResourceGroupName string + AccountName string + ProjectName string + Location string + Endpoint string + OpenAIEndpoint string +} + +var projectResourceIDPattern = regexp.MustCompile( + `(?i)^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/` + + `Microsoft\.CognitiveServices/accounts/([^/]+)/projects/([^/]+)$`, +) + +const foundryProjectResourceType = "Microsoft.CognitiveServices/accounts/projects" + +// ProjectInitAction implements `azd ai project init`. +type ProjectInitAction struct { + client *azdext.AzdClient + flags *projectInitFlags + extCtx *azdext.ExtensionContext +} + +func newProjectInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &projectInitFlags{} + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize or adopt a Microsoft Foundry project.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + if flags.requestFile != "" { + for _, name := range []string{"project-id", "project-endpoint", "infra", "force"} { + if cmd.Flags().Changed(name) { + return contractValidationError( + fmt.Sprintf("--%s cannot be combined with --request-file", name), + ) + } + } + } + action := &ProjectInitAction{flags: flags, extCtx: extCtx} + return action.Run(cmd.Context()) + }, + } + cmd.Flags().StringVar(&flags.projectID, "project-id", "", "Existing Foundry project ARM resource ID") + cmd.Flags().StringVar(&flags.projectEndpoint, "project-endpoint", "", "Existing Foundry project endpoint") + cmd.Flags().StringVar( + &flags.infra, "infra", "", "Eject Bicep or Terraform infrastructure (optional value)", + ) + _ = cmd.Flags().Lookup("infra").NoOptDefVal + cmd.Flags().Lookup("infra").NoOptDefVal = provisioning.BicepProviderName + cmd.Flags().BoolVar(&flags.force, "force", false, "Replace a different configured project") + registerDelegatedContractFlags(cmd, &flags.requestFile, &flags.resultFile) + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"default", "json", "none"}, + Default: "default", + Usage: "The output format", + }) + return cmd +} + +func (a *ProjectInitAction) Run(ctx context.Context) error { + if a.flags == nil { + a.flags = &projectInitFlags{} + } + request, err := a.loadRequest() + if err != nil { + return err + } + if request == nil { + if a.flags.projectID != "" && a.flags.projectEndpoint != "" { + return contractValidationError("--project-id and --project-endpoint are mutually exclusive") + } + if a.flags.infra != "" { + if a.flags.infra, err = parseInfraProvider(a.flags.infra); err != nil { + return err + } + } + } + + client := a.client + if client == nil { + client, err = azdext.NewAzdClient() + if err != nil { + return exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "could not connect to the azd daemon", + "run this command from an azd extension host", + ) + } + defer client.Close() + } + a.client = client + + projectRoot := projectRootPath() + project, _, err := ensureProject(ctx, client, projectRoot) + if err != nil { + return err + } + projectRoot = project.GetPath() + if projectRoot == "" { + projectRoot = projectRootPath() + } + reconciler := &projectServiceReconciler{client: client, projectRoot: projectRoot} + service, projectConfig, err := reconciler.discoverProjectService(ctx) + if err != nil { + return err + } + envName, err := resolveProjectEnvironmentName(ctx, client, a.environmentName(), projectRoot) + if err != nil { + return err + } + oldValues, err := currentProjectEnvironment(ctx, client, envName) + if err != nil { + return err + } + + target, err := resolveProjectTarget( + ctx, client, projectConfig, service, oldValues, request, a.flags, + ) + if err != nil { + return err + } + if err := confirmExplicitProjectReplacement( + ctx, client, target, service, oldValues, request, a.flags, + ); err != nil { + return err + } + if err := resolveAzureContextForInit( + ctx, client, target, oldValues, allowedLocations(request), + request != nil && request.ResolveAzureContext, + a.flags.noPrompt, + ); err != nil { + return err + } + if err := validateAllowedProjectLocation(target, allowedLocations(request)); err != nil { + return err + } + if target.Mode == projectModeExistingEndpoint { + if infra := infraFromRequest(request, a.flags); infra != "" { + return exterrors.Dependency( + "managed_deployment_requires_project_id", + "infrastructure ejection requires a verified Foundry project resource ID", + "rerun `azd ai project init --project-id --infra", + ) + } + if service != nil && + !equalProjectEndpoint(serviceEndpoint(service.Resolved), target.Endpoint) && + hasManagedProjectFields(service.Raw) { + return exterrors.Dependency( + "project_reconciliation_requires_project_id", + "changing the project endpoint would move managed project configuration", + "rerun `azd ai project init --project-id ` before changing project identity", + ) + } + } else if err := validateFoundryProvider(projectConfig); err != nil { + return err + } + oldEndpoint := serviceEndpoint(nil) + if service != nil { + oldEndpoint = serviceEndpoint(service.Resolved) + } + identityChanged := !equalProjectEndpoint(oldEndpoint, target.Endpoint) || + !strings.EqualFold(oldValues["AZURE_AI_PROJECT_ID"], target.ResourceId) + if err := reconcileProjectEnvironment( + ctx, client, envName, target.Mode, target, identityChanged, + ); err != nil { + return err + } + if target.Mode != projectModeExistingEndpoint && + (projectConfig.GetInfra() == nil || projectConfig.GetInfra().GetProvider() == "") { + if err := writeFoundryProvider(ctx, client, projectConfig); err != nil { + return err + } + } + serviceProjectName := target.ProjectName + if serviceProjectName == "" { + serviceProjectName = projectConfig.GetName() + } + serviceName, mutation, err := reconciler.reconcileEndpoint( + ctx, serviceProjectName, target.Endpoint, target.Mode, + ) + if err != nil { + return err + } + if infra := infraFromRequest(request, a.flags); infra != "" { + if err := ejectProjectInfra(ctx, client, projectRoot, serviceName, infra); err != nil { + return err + } + } + + result := projectInitResult{ + SchemaVersion: delegatedSchemaVersion, + ProducerVersion: delegatedProducerVersion(), + ServiceName: serviceName, + Mode: string(target.Mode), + Mutation: mutation, + Endpoint: target.Endpoint, + ResourceID: target.ResourceId, + } + if err := validateProjectInitResult(result); err != nil { + return err + } + if request != nil { + return writeDelegatedResult(a.flags.resultFile, result) + } + if a.flags.output == "none" { + return nil + } + if a.flags.output == "json" { + return json.NewEncoder(os.Stdout).Encode(result) + } + if mutation == "unchanged" { + fmt.Printf("Foundry project configuration unchanged (%s).\n", serviceName) + } else { + fmt.Printf("Foundry project configuration %s in services.%s.\n", mutation, serviceName) + } + return nil +} + +func (a *ProjectInitAction) loadRequest() (*projectInitRequest, error) { + if a.flags.requestFile == "" { + return nil, nil + } + if a.flags.resultFile == "" { + return nil, contractValidationError("--result-file is required with --request-file") + } + if err := validateDelegatedPathPair(a.flags.requestFile, a.flags.resultFile); err != nil { + return nil, err + } + request := &projectInitRequest{} + if err := decodeDelegatedJSON(a.flags.requestFile, request); err != nil { + return nil, err + } + if err := request.validate(); err != nil { + return nil, err + } + a.flags.projectID = request.Project.ResourceID + a.flags.projectEndpoint = request.Project.Endpoint + a.flags.infra = request.Infra.EjectProvider + a.flags.force = request.Force + return request, nil +} + +func (a *ProjectInitAction) environmentName() string { + if a.extCtx != nil { + return a.extCtx.Environment + } + return "" +} + +func allowedLocations(request *projectInitRequest) []string { + if request == nil { + return nil + } + return request.Requirements.AllowedLocations +} + +func infraFromRequest(request *projectInitRequest, flags *projectInitFlags) string { + if request != nil { + return request.Infra.EjectProvider + } + return flags.infra +} + +func resolveProjectTarget( + ctx context.Context, + client *azdext.AzdClient, + project *azdext.ProjectConfig, + service *projectServiceInfo, + values map[string]string, + request *projectInitRequest, + flags *projectInitFlags, +) (*resolvedProject, error) { + projectID, endpoint := flags.projectID, flags.projectEndpoint + if request != nil { + projectID, endpoint = request.Project.ResourceID, request.Project.Endpoint + } + if projectID != "" { + return lookupResolvedProject(ctx, client, projectID) + } + if endpoint != "" { + return resolvedProjectFromEndpoint(endpoint) + } + serviceEndpointValue := "" + if service != nil { + serviceEndpointValue = serviceEndpoint(service.Resolved) + } + envProjectID := values["AZURE_AI_PROJECT_ID"] + if serviceEndpointValue != "" && envProjectID != "" { + inferred, err := projectFromResourceID(envProjectID) + if err != nil { + return nil, err + } + if !equalProjectEndpoint(serviceEndpointValue, inferred.Endpoint) { + if noPromptForRequest(request, flags) { + return nil, exterrors.Validation( + "project_target_mismatch", + "the configured project endpoint and AZURE_AI_PROJECT_ID identify different projects", + "rerun with --project-id or --project-endpoint to select the intended project", + ) + } + choice, promptErr := client.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "The project service and environment identify different projects. Which should be used?", + Choices: []*azdext.SelectChoice{ + {Label: "Use the environment project", Value: "environment"}, + {Label: "Keep the configured endpoint", Value: "endpoint"}, + }, + }, + }) + if promptErr != nil { + return nil, fmt.Errorf("resolve project target mismatch: %w", promptErr) + } + if choice.GetValue() == 1 { + return resolvedProjectFromEndpoint(serviceEndpointValue) + } + } else { + return lookupResolvedProject(ctx, client, envProjectID) + } + } + if envProjectID != "" { + return lookupResolvedProject(ctx, client, envProjectID) + } + if serviceEndpointValue != "" { + return resolvedProjectFromEndpoint(serviceEndpointValue) + } + if noPromptForRequest(request, flags) { + return &resolvedProject{Mode: projectModeNew}, nil + } + return promptProjectTarget(ctx, client, values, allowedLocations(request)) +} + +func noPromptForRequest(_ *projectInitRequest, flags *projectInitFlags) bool { + return flags.noPrompt +} + +func promptProjectTarget( + ctx context.Context, + client *azdext.AzdClient, + values map[string]string, + allowed []string, +) (*resolvedProject, error) { + choices := []*azdext.SelectChoice{ + {Label: "Create a new Foundry project", Value: "new"}, + {Label: "Use an existing Foundry project", Value: "existing"}, + } + response, err := client.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Foundry project configuration", + Choices: choices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("project selection was cancelled") + } + return nil, fmt.Errorf("select Foundry project configuration: %w", err) + } + index := int(response.GetValue()) + if index < 0 || index >= len(choices) { + return nil, exterrors.Validation( + "project_selection_invalid", + "the project selection response was invalid", + "retry project initialization", + ) + } + if choices[index].GetValue() == "new" { + return &resolvedProject{Mode: projectModeNew}, nil + } + + subscriptionID, userTenantID, err := resolveInteractiveSubscription( + ctx, client, values, + ) + if err != nil { + return nil, err + } + projects, err := listFoundryProjects( + ctx, subscriptionID, userTenantID, allowed, + ) + if err != nil { + return nil, err + } + if len(projects) == 0 { + return nil, exterrors.Validation( + "project_not_found", + "no Foundry projects in the selected subscription satisfy the location restriction", + "choose a different subscription or create a new project", + ) + } + projectChoices := make([]*azdext.SelectChoice, len(projects)) + for i := range projects { + projectChoices[i] = &azdext.SelectChoice{ + Label: fmt.Sprintf( + "%s (%s, %s)", + projects[i].ProjectName, + projects[i].AccountName, + projects[i].Location, + ), + Value: projects[i].ResourceId, + } + } + response, err = client.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select an existing Foundry project", + Choices: projectChoices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("Foundry project selection was cancelled") + } + return nil, fmt.Errorf("select existing Foundry project: %w", err) + } + index = int(response.GetValue()) + if index < 0 || index >= len(projects) { + return nil, exterrors.Validation( + "project_selection_invalid", + "the Foundry project selection response was invalid", + "retry project initialization", + ) + } + return &projects[index], nil +} + +func resolveInteractiveSubscription( + ctx context.Context, + client *azdext.AzdClient, + values map[string]string, +) (string, string, error) { + subscriptionID := strings.TrimSpace(values["AZURE_SUBSCRIPTION_ID"]) + userTenantID := strings.TrimSpace(values["AZURE_TENANT_ID"]) + if subscriptionID == "" { + response, err := client.Prompt().PromptSubscription( + ctx, &azdext.PromptSubscriptionRequest{}, + ) + if err != nil { + return "", "", fmt.Errorf("select Azure subscription: %w", err) + } + if response.GetSubscription() == nil || + strings.TrimSpace(response.Subscription.GetId()) == "" { + return "", "", exterrors.Dependency( + exterrors.CodeMissingAzureSubscription, + "no Azure subscription was selected", + "select an Azure subscription and retry", + ) + } + subscriptionID = response.Subscription.GetId() + userTenantID = response.Subscription.GetUserTenantId() + } else { + tenantResponse, err := client.Account().LookupTenant( + ctx, &azdext.LookupTenantRequest{SubscriptionId: subscriptionID}, + ) + if err != nil { + return "", "", exterrors.Auth( + exterrors.CodeTenantLookupFailed, + fmt.Sprintf( + "failed to lookup tenant for subscription %s: %s", + subscriptionID, + err, + ), + "verify your Azure login with `azd auth login`", + ) + } + if tenantResponse.GetTenantId() != "" { + userTenantID = tenantResponse.GetTenantId() + } + } + return subscriptionID, userTenantID, nil +} + +func listFoundryProjects( + ctx context.Context, + subscriptionID, userTenantID string, + allowed []string, +) ([]resolvedProject, error) { + credential, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: userTenantID, + AdditionallyAllowedTenants: []string{"*"}, + }, + ) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run `azd auth login` and retry", + ) + } + resourcesClient, err := armresources.NewClient( + subscriptionID, credential, azure.NewArmClientOptions(), + ) + if err != nil { + return nil, fmt.Errorf("create Azure resources client: %w", err) + } + pager := resourcesClient.NewListPager(&armresources.ClientListOptions{ + Filter: new(fmt.Sprintf("resourceType eq '%s'", foundryProjectResourceType)), + }) + var projects []resolvedProject + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, exterrors.ServiceFromAzure( + err, exterrors.OpCognitiveAccountList, + ) + } + for _, resource := range page.Value { + if resource == nil || resource.ID == nil { + continue + } + project, err := projectFromResourceID(*resource.ID) + if err != nil { + continue + } + project.UserTenantId = userTenantID + if resource.Location != nil { + project.Location = *resource.Location + } + if len(allowed) == 0 || + (project.Location != "" && locationAllowed(project.Location, allowed)) { + projects = append(projects, *project) + } + } + } + slices.SortFunc(projects, func(left, right resolvedProject) int { + return strings.Compare( + strings.ToLower(left.ResourceId), + strings.ToLower(right.ResourceId), + ) + }) + return projects, nil +} + +func confirmExplicitProjectReplacement( + ctx context.Context, + client *azdext.AzdClient, + target *resolvedProject, + service *projectServiceInfo, + values map[string]string, + request *projectInitRequest, + flags *projectInitFlags, +) error { + if target == nil || !explicitProjectTarget(request, flags) || flags.force { + return nil + } + oldEndpoint := serviceEndpoint(nil) + if service != nil { + oldEndpoint = serviceEndpoint(service.Resolved) + } + oldID := strings.TrimSpace(values["AZURE_AI_PROJECT_ID"]) + if (oldEndpoint == "" && oldID == "") || + (oldEndpoint == "" || equalProjectEndpoint(oldEndpoint, target.Endpoint)) && + (oldID == "" || strings.EqualFold(oldID, target.ResourceId)) { + return nil + } + if flags.noPrompt { + return exterrors.Validation( + "project_replacement_requires_force", + "the explicit project target differs from the configured project", + "rerun with --force to replace the configured project in --no-prompt mode", + ) + } + choices := []*azdext.SelectChoice{ + { + Label: "Update the project configuration", + Value: "update", + }, + { + Label: "Cancel", + Value: "cancel", + }, + } + response, err := client.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: fmt.Sprintf( + "Replace the configured project %q with %q?", + firstNonEmpty(oldEndpoint, oldID), + firstNonEmpty(target.Endpoint, target.ResourceId), + ), + Choices: choices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("project replacement was cancelled") + } + return fmt.Errorf("confirm project replacement: %w", err) + } + if response.GetValue() != 0 { + return exterrors.Cancelled("project replacement was cancelled") + } + return nil +} + +func explicitProjectTarget( + request *projectInitRequest, + flags *projectInitFlags, +) bool { + if request != nil { + return request.Project.ResourceID != "" || request.Project.Endpoint != "" + } + return strings.TrimSpace(flags.projectID) != "" || + strings.TrimSpace(flags.projectEndpoint) != "" +} + +func validateAllowedProjectLocation(project *resolvedProject, allowed []string) error { + if project == nil || len(allowed) == 0 || project.Location == "" { + return nil + } + for _, location := range allowed { + if strings.EqualFold(strings.TrimSpace(location), project.Location) { + return nil + } + } + return exterrors.Validation( + "project_location_not_allowed", + fmt.Sprintf("project location %q is outside the allowed locations", project.Location), + "choose a project in one of the allowed locations", + ) +} + +func locationAllowed(location string, allowed []string) bool { + for _, candidate := range allowed { + if strings.EqualFold(strings.TrimSpace(candidate), location) { + return true + } + } + return false +} + +func resolveAzureContextForInit( + ctx context.Context, + client *azdext.AzdClient, + target *resolvedProject, + values map[string]string, + allowed []string, + required bool, + noPrompt bool, +) error { + if target == nil || target.Mode == projectModeExistingEndpoint { + return nil + } + needSubscription := target.SubscriptionId == "" && values["AZURE_SUBSCRIPTION_ID"] == "" + needLocation := target.Location == "" && values["AZURE_LOCATION"] == "" + if !required && (noPrompt || (!needSubscription && !needLocation)) { + return nil + } + if noPrompt && (needSubscription || needLocation) { + missing := make([]string, 0, 2) + if needSubscription { + missing = append(missing, "AZURE_SUBSCRIPTION_ID") + } + if needLocation { + missing = append(missing, "AZURE_LOCATION") + } + return exterrors.Dependency( + exterrors.CodeMissingAzureSubscription, + fmt.Sprintf("Azure context is incomplete; missing %s", strings.Join(missing, ", ")), + "set the missing values in the active azd environment and retry", + ) + } + if needSubscription { + response, err := client.Prompt().PromptSubscription(ctx, + &azdext.PromptSubscriptionRequest{}) + if err != nil { + return fmt.Errorf("select Azure subscription: %w", err) + } + if response.GetSubscription() == nil || response.Subscription.GetId() == "" { + return exterrors.Dependency( + exterrors.CodeMissingAzureSubscription, + "no Azure subscription was selected", + "select an Azure subscription and retry", + ) + } + target.SubscriptionId = response.Subscription.GetId() + target.UserTenantId = response.Subscription.GetUserTenantId() + } + if needLocation { + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + SubscriptionId: target.SubscriptionId, + TenantId: target.UserTenantId, + }, + } + response, err := client.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ + AzureContext: azureContext, + AllowedLocations: allowed, + }) + if err != nil { + return fmt.Errorf("select Azure location: %w", err) + } + if response.GetLocation() == nil || response.Location.GetName() == "" { + return exterrors.Validation( + "project_location_required", + "an Azure location is required to create a Foundry project", + "select an Azure location and retry", + ) + } + target.Location = response.Location.GetName() + } + return nil +} + +func projectFromResourceID(resourceID string) (*resolvedProject, error) { + resourceID = strings.TrimSpace(resourceID) + matches := projectResourceIDPattern.FindStringSubmatch(resourceID) + if len(matches) != 5 { + return nil, exterrors.Validation( + "invalid_project_id", + "the project ID must be a Microsoft.CognitiveServices project resource ID", + "provide /subscriptions//resourceGroups//providers/"+ + "Microsoft.CognitiveServices/accounts//projects/", + ) + } + canonicalID := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + matches[1], matches[2], matches[3], matches[4], + ) + return &resolvedProject{ + Mode: projectModeExistingID, + ResourceId: canonicalID, + SubscriptionId: matches[1], + ResourceGroupName: matches[2], + AccountName: matches[3], + ProjectName: matches[4], + Endpoint: fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", matches[3], matches[4]), + OpenAIEndpoint: fmt.Sprintf("https://%s.openai.azure.com/", matches[3]), + }, nil +} + +func resolvedProjectFromEndpoint(endpoint string) (*resolvedProject, error) { + normalized, _, err := validateProjectEndpoint(endpoint) + if err != nil { + return nil, err + } + parsed := strings.TrimPrefix(normalized, "https://") + host, path, _ := strings.Cut(parsed, "/") + account := strings.TrimSuffix(host, ".services.ai.azure.com") + projectName := "" + if index := strings.Index(path, "/api/projects/"); index >= 0 { + projectName = strings.Trim(strings.TrimPrefix(path[index:], "/api/projects/"), "/") + } + return &resolvedProject{ + Mode: projectModeExistingEndpoint, + AccountName: account, + ProjectName: projectName, + Endpoint: normalized, + }, nil +} + +func lookupResolvedProject( + ctx context.Context, + client *azdext.AzdClient, + resourceID string, +) (*resolvedProject, error) { + project, err := projectFromResourceID(resourceID) + if err != nil { + return nil, err + } + tenantResponse, err := client.Account().LookupTenant(ctx, + &azdext.LookupTenantRequest{SubscriptionId: project.SubscriptionId}) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeTenantLookupFailed, + fmt.Sprintf("failed to lookup tenant for subscription %s: %s", project.SubscriptionId, err), + "verify your Azure login with `azd auth login`", + ) + } + project.UserTenantId = tenantResponse.GetTenantId() + credential, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: project.UserTenantId, + AdditionallyAllowedTenants: []string{"*"}, + }, + ) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run `azd auth login` and retry", + ) + } + projectsClient, err := armcognitiveservices.NewProjectsClient( + project.SubscriptionId, credential, azure.NewArmClientOptions(), + ) + if err != nil { + return nil, fmt.Errorf("create Foundry projects client: %w", err) + } + response, err := projectsClient.Get(ctx, + project.ResourceGroupName, project.AccountName, project.ProjectName, nil) + if err != nil { + return nil, exterrors.ServiceFromAzure(err, exterrors.OpCognitiveAccountList) + } + if response.Project.Location != nil { + project.Location = *response.Project.Location + } + return project, nil +} + +func projectRootPath() string { + if root, err := azdext.GetProjectDir(); err == nil && root != "" { + return root + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "." +} + +func ensureProject( + ctx context.Context, + client *azdext.AzdClient, + projectRoot string, +) (*azdext.ProjectConfig, bool, error) { + exists, err := projectFileExists(projectRoot) + if err != nil { + return nil, false, err + } + if !exists { + envName := deriveProjectEnvironmentName(projectRoot) + if err := scaffoldProject(ctx, client, projectRoot, envName); err != nil { + return nil, false, err + } + } + + response, err := client.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, false, fmt.Errorf("load project configuration: %w", err) + } + if response.GetProject() != nil { + if !exists { + return response.Project, true, nil + } + return response.Project, false, nil + } + return nil, false, exterrors.Dependency( + "project_not_found", + "the azd host returned no project configuration", + "create an azure.yaml project and retry", + ) +} + +func projectFileExists(projectRoot string) (bool, error) { + for _, name := range []string{"azure.yaml", "azure.yml"} { + path := filepath.Join(projectRoot, name) + info, err := os.Stat(path) + switch { + case err == nil: + if !info.IsDir() { + return true, nil + } + case errors.Is(err, fs.ErrNotExist): + continue + default: + return false, fmt.Errorf("check project file %q: %w", path, err) + } + } + return false, nil +} + +func scaffoldProject( + ctx context.Context, + client *azdext.AzdClient, + projectRoot string, + envName string, +) error { + templateDir, err := os.MkdirTemp(filepath.Dir(projectRoot), ".azd-foundry-template-*") + if err != nil { + return fmt.Errorf("create project template directory: %w", err) + } + defer os.RemoveAll(templateDir) + workflow := &azdext.Workflow{ + Name: "init", + Steps: []*azdext.WorkflowStep{{ + Command: &azdext.WorkflowCommand{Args: []string{ + "init", "-t", templateDir, projectRoot, + "--environment", envName, "--output=none", + }}, + }}, + } + if _, err := client.Workflow().Run(ctx, &azdext.RunWorkflowRequest{Workflow: workflow}); err != nil { + if errors.Is(err, context.Canceled) { + return exterrors.Cancelled("project initialization was cancelled") + } + return exterrors.Dependency( + "project_init_failed", + fmt.Sprintf("failed to initialize project: %s", err), + "check the project directory is writable and retry", + ) + } + return nil +} + +func writeFoundryProvider( + ctx context.Context, + client *azdext.AzdClient, + project *azdext.ProjectConfig, +) error { + if err := validateFoundryProvider(project); err != nil { + return err + } + if project != nil && project.GetInfra() != nil && + project.GetInfra().GetProvider() != "" { + return nil + } + value, err := structpb.NewValue(provisioning.FoundryProviderName) + if err != nil { + return err + } + if _, err := client.Project().SetConfigValue(ctx, + &azdext.SetProjectConfigValueRequest{Path: "infra.provider", Value: value}); err != nil { + return fmt.Errorf("set Foundry infrastructure provider: %w", err) + } + if _, err := client.Project().UnsetConfig(ctx, + &azdext.UnsetProjectConfigRequest{Path: "infra.path"}); err != nil { + return fmt.Errorf("remove starter infrastructure path: %w", err) + } + return nil +} + +func validateFoundryProvider(project *azdext.ProjectConfig) error { + if project != nil && project.GetInfra() != nil && + project.GetInfra().GetProvider() != "" && + project.GetInfra().GetProvider() != provisioning.FoundryProviderName { + return exterrors.Validation( + "infra_provider_conflict", + fmt.Sprintf( + "azure.yaml declares incompatible infrastructure provider %q", + project.GetInfra().GetProvider(), + ), + "keep the existing provider or remove it before generating Foundry infrastructure", + ) + } + if project != nil && project.GetInfra() != nil && + project.GetInfra().GetProvider() != "" { + return nil + } + if project != nil && project.GetInfra() != nil && + project.GetInfra().GetPath() != "" && + project.GetInfra().GetPath() != "." && + project.GetInfra().GetPath() != "./infra" { + return exterrors.Validation( + "infra_provider_conflict", + fmt.Sprintf("azure.yaml uses custom infrastructure path %q", project.GetInfra().GetPath()), + "remove the custom infrastructure path or keep the existing provider", + ) + } + if project != nil && project.GetPath() != "" { + if _, err := os.Stat(filepath.Join(project.GetPath(), "infra")); err == nil { + return exterrors.Validation( + "infra_provider_conflict", + "the project already contains user-owned infra/ files", + "keep the existing infrastructure provider or remove infra/ explicitly", + ) + } else if !os.IsNotExist(err) { + return fmt.Errorf("check project infrastructure: %w", err) + } + } + return nil +} + +func parseInfraProvider(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case provisioning.BicepProviderName: + return provisioning.BicepProviderName, nil + case provisioning.TerraformProviderName: + return provisioning.TerraformProviderName, nil + default: + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unsupported --infra value %q", value), + "pass --infra=bicep or --infra=terraform", + ) + } +} + +func ejectProjectInfra( + ctx context.Context, + client *azdext.AzdClient, + projectRoot, serviceName, provider string, +) error { + projectResponse, projectErr := client.Project().Get(ctx, &azdext.EmptyRequest{}) + if projectErr != nil { + return fmt.Errorf("read project configuration before infrastructure ejection: %w", projectErr) + } + if projectResponse.GetProject() != nil && + projectResponse.Project.GetInfra() != nil && + projectResponse.Project.Infra.GetProvider() != "" && + projectResponse.Project.Infra.GetProvider() != provisioning.FoundryProviderName { + return exterrors.Validation( + "infra_provider_conflict", + fmt.Sprintf( + "azure.yaml declares incompatible infrastructure provider %q", + projectResponse.Project.Infra.GetProvider(), + ), + "remove --infra or change the project to microsoft.foundry explicitly", + ) + } + projectFile, err := projectFilePath(projectRoot) + if err != nil { + return err + } + raw, err := os.ReadFile(projectFile) + if err != nil { + return fmt.Errorf("read %s for infrastructure ejection: %w", projectFile, err) + } + if _, err := os.Stat(filepath.Join(projectRoot, "infra")); err == nil { + return exterrors.Validation( + "infra_eject_exists", + "cannot eject Foundry infrastructure because infra/ already exists", + "remove or rename the existing infra/ directory and retry", + ) + } else if !os.IsNotExist(err) { + return fmt.Errorf("check infra directory: %w", err) + } + result, err := synthesis.Synthesize(synthesis.Input{ + RawAzureYAML: raw, + ServiceName: serviceName, + AcceptedHosts: provisioning.FoundryProvisioningServiceHosts, + ProjectRoot: projectRoot, + PreserveVarRefs: true, + }) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("cannot synthesize Foundry infrastructure: %s", err), + "fix the project service configuration and retry", + ) + } + infraDir := filepath.Join(projectRoot, "infra") + if err := os.MkdirAll(infraDir, 0755); err != nil { + return fmt.Errorf("create infra directory: %w", err) + } + if provider == provisioning.TerraformProviderName { + if result.NetworkMode != synthesis.NetworkModeNone { + _ = os.RemoveAll(infraDir) + return exterrors.Validation( + "infra_eject_network_unsupported", + "Terraform ejection does not support the project's network block", + "eject Bicep instead", + ) + } + if err := copyEmbeddedTerraform(infraDir); err != nil { + _ = os.RemoveAll(infraDir) + return err + } + if err := writeJSONFile(filepath.Join(infraDir, "main.tfvars.json"), result.Parameters); err != nil { + _ = os.RemoveAll(infraDir) + return err + } + value, _ := structpb.NewValue(provisioning.TerraformProviderName) + if _, err := client.Project().SetConfigValue(ctx, + &azdext.SetProjectConfigValueRequest{Path: "infra.provider", Value: value}); err != nil { + _ = os.RemoveAll(infraDir) + return fmt.Errorf("stamp Terraform provider: %w", err) + } + if _, err := client.Project().UnsetConfig(ctx, + &azdext.UnsetProjectConfigRequest{Path: "infra.path"}); err != nil { + _ = os.RemoveAll(infraDir) + return fmt.Errorf("remove infra.path: %w", err) + } + } else { + if err := copyEmbeddedBicep(infraDir); err != nil { + _ = os.RemoveAll(infraDir) + return err + } + parameters := map[string]any{"parameters": map[string]any{}} + for key, value := range result.Parameters { + parameters["parameters"].(map[string]any)[key] = map[string]any{"value": value} + } + if err := writeJSONFile(filepath.Join(infraDir, "main.parameters.json"), parameters); err != nil { + _ = os.RemoveAll(infraDir) + return err + } + } + return nil +} + +func projectFilePath(projectRoot string) (string, error) { + for _, name := range []string{"azure.yaml", "azure.yml"} { + path := filepath.Join(projectRoot, name) + info, err := os.Stat(path) + switch { + case err == nil && !info.IsDir(): + return path, nil + case errors.Is(err, fs.ErrNotExist): + continue + case err != nil: + return "", fmt.Errorf("check project file %q: %w", path, err) + } + } + return "", exterrors.Dependency( + "project_file_not_found", + "no azure.yaml or azure.yml project file was found", + "create an azd project before ejecting infrastructure", + ) +} + +func copyEmbeddedBicep(destination string) error { + return copyEmbeddedTree(synthesis.TemplatesFS(), "templates", destination, + map[string]struct{}{"main.arm.json": {}, "brownfield.bicep": {}, "brownfield.arm.json": {}}) +} + +func copyEmbeddedTerraform(destination string) error { + return copyEmbeddedTree(synthesis.TerraformTemplatesFS(), "templates/terraform", destination, + map[string]struct{}{"outputs.tf.tmpl": {}}) +} + +func copyEmbeddedTree(files fs.FS, root, destination string, skip map[string]struct{}) error { + return fs.WalkDir(files, root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == root { + return nil + } + relative, err := filepath.Rel(root, filepath.FromSlash(path)) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0755) + } + if _, excluded := skip[filepath.Base(path)]; excluded { + return nil + } + data, err := fs.ReadFile(files, path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0644) + }) +} + +func writeJSONFile(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0644) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go new file mode 100644 index 00000000000..a660a5a3778 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "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" +) + +func TestDelegatedProjectInitRequestValidation(t *testing.T) { + request := &projectInitRequest{ + SchemaVersion: delegatedSchemaVersion, + Source: projectInitSourceAgents, + SourceVersion: "1.0.0-beta.9", + Project: delegatedProject{ResourceID: "/subscriptions/s"}, + } + require.NoError(t, request.validate()) + + request.Project.Endpoint = "https://account.services.ai.azure.com/api/projects/p" + require.Error(t, request.validate()) + request.Project.Endpoint = "" + request.SchemaVersion = 2 + require.Error(t, request.validate()) +} + +func TestDelegatedRequestRejectsUnknownFields(t *testing.T) { + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + resultPath := filepath.Join(dir, "result.json") + require.NoError(t, os.WriteFile(requestPath, []byte(`{ + "schemaVersion": 1, + "source": "azure.ai.projects/direct", + "unknown": true + }`), 0600)) + request := &projectInitRequest{} + require.NoError(t, validateDelegatedPathPair(requestPath, resultPath)) + require.Error(t, decodeDelegatedJSON(requestPath, request)) +} + +func TestDelegatedResultWritesAtomically(t *testing.T) { + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + resultPath := filepath.Join(dir, "result.json") + require.NoError(t, os.WriteFile(requestPath, []byte(`{}`), 0600)) + require.NoError(t, writeDelegatedResult(resultPath, map[string]any{"ok": true})) + var decoded map[string]any + data, err := os.ReadFile(resultPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, true, decoded["ok"]) +} + +func TestProjectCommandsRegistered(t *testing.T) { + root := NewRootCommand() + initCommand, _, err := root.Find([]string{"init"}) + require.NoError(t, err) + assert.Equal(t, "init", initCommand.Name()) + deploymentCommand, _, err := root.Find([]string{"deployment", "add"}) + require.NoError(t, err) + assert.Equal(t, "add", deploymentCommand.Name()) + assert.Equal(t, "bicep", initCommand.Flags().Lookup("infra").NoOptDefVal) + assert.True(t, initCommand.Flags().Lookup("request-file").Hidden) + assert.True(t, deploymentCommand.Flags().Lookup("result-file").Hidden) +} + +func TestProjectFileExists(t *testing.T) { + root := t.TempDir() + + exists, err := projectFileExists(root) + require.NoError(t, err) + assert.False(t, exists) + + require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yml"), []byte("name: test\n"), 0600)) + exists, err = projectFileExists(root) + require.NoError(t, err) + assert.True(t, exists) + + require.NoError(t, os.Remove(filepath.Join(root, "azure.yml"))) + require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte("name: test\n"), 0600)) + exists, err = projectFileExists(root) + require.NoError(t, err) + assert.True(t, exists) +} + +func TestProjectServiceNameDeterministic(t *testing.T) { + services := map[string]*azdext.ServiceConfig{ + "chat-app": {Host: "azure.ai.agent"}, + "ai-project": {Host: "custom"}, + "ai-project-2": {Host: "custom"}, + } + assert.Equal(t, "new-project", projectServiceName("New Project", services)) + assert.Equal(t, "ai-project-3", projectServiceName("", services)) +} + +func TestLegacyProjectServiceBodyPreservesConfiguration(t *testing.T) { + body, err := legacyProjectServiceBody(map[string]any{ + "host": "azure.ai.agents", + "endpoint": "https://old.services.ai.azure.com/api/projects/old", + "deployments": []any{map[string]any{"name": "chat"}}, + "hooks": map[string]any{"predeploy": "echo ok"}, + "uses": []any{"connection"}, + "customField": "preserve-me", + }, "https://new.services.ai.azure.com/api/projects/new") + require.NoError(t, err) + + assert.NotContains(t, body, "host") + assert.Equal(t, "https://new.services.ai.azure.com/api/projects/new", body["endpoint"]) + assert.Contains(t, body, "deployments") + assert.Contains(t, body, "hooks") + assert.Contains(t, body, "uses") + assert.Equal(t, "preserve-me", body["customField"]) +} + +func TestLegacyProjectServiceBodyRemovesEndpointForNewProject(t *testing.T) { + body, err := legacyProjectServiceBody(map[string]any{ + "host": "azure.ai.agents", + "endpoint": "https://old.services.ai.azure.com/api/projects/old", + "hooks": map[string]any{"predeploy": "echo ok"}, + }, "") + require.NoError(t, err) + + assert.NotContains(t, body, "host") + assert.NotContains(t, body, "endpoint") + assert.Contains(t, body, "hooks") +} + +func TestProjectEnvironmentTransitions(t *testing.T) { + old := map[string]string{ + "AZURE_AI_PROJECT_ID": "old-id", + "AZURE_AI_ACCOUNT_NAME": "old-account", + "AZURE_AI_PROJECT_NAME": "old-project", + "FOUNDRY_PROJECT_ENDPOINT": "https://old.services.ai.azure.com/api/projects/old", + "AZURE_OPENAI_ENDPOINT": "https://old.openai.azure.com/", + "AZURE_RESOURCE_GROUP": "old-rg", + "AZURE_AI_DEPLOYMENTS_LOCATION": "eastus", + "AZURE_AI_MODEL_DEPLOYMENT_NAME": "chat", + } + plan := planProjectEnvironment(old, projectModeExistingEndpoint, &resolvedProject{ + Endpoint: "https://new.services.ai.azure.com/api/projects/new", + ProjectName: "new", + }, true) + assert.Equal(t, "true", plan.Sets["USE_EXISTING_AI_PROJECT"]) + assert.Equal(t, []string{ + "AZURE_AI_ACCOUNT_NAME", + "AZURE_AI_DEPLOYMENTS_LOCATION", + "AZURE_AI_MODEL_DEPLOYMENT_NAME", + "AZURE_AI_PROJECT_ID", + "AZURE_OPENAI_ENDPOINT", + "AZURE_RESOURCE_GROUP", + }, plan.Unsets) +} + +func TestProjectEnvironmentPreservesLocationWhenProjectLocationIsUnknown(t *testing.T) { + old := map[string]string{ + "AZURE_LOCATION": "westus2", + "AZURE_AI_DEPLOYMENTS_LOCATION": "eastus", + "AZURE_AI_PROJECT_ID": "old-id", + "FOUNDRY_PROJECT_ENDPOINT": "https://old.services.ai.azure.com/api/projects/old", + } + plan := planProjectEnvironment(old, projectModeExistingID, &resolvedProject{ + ResourceId: "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/new", + Endpoint: "https://account.services.ai.azure.com/api/projects/new", + }, true) + + assert.NotContains(t, plan.Sets, "AZURE_LOCATION") + assert.NotContains(t, plan.Unsets, "AZURE_LOCATION") +} + +func TestDeploymentSemanticEqualityIgnoresNameCase(t *testing.T) { + value := map[string]any{ + "name": "Chat", + "model": map[string]any{ + "format": "OpenAI", "name": "gpt-4.1", "version": "2025-04-14", + }, + "sku": map[string]any{"name": "GlobalStandard", "capacity": float64(10)}, + } + assert.True(t, deploymentSemanticallyEqual(value, synthesisDeploymentForTest())) +} + +func synthesisDeploymentForTest() synthesis.Deployment { + return synthesis.Deployment{ + Name: "chat", + Model: synthesis.DeploymentModel{ + Format: "OpenAI", Name: "gpt-4.1", Version: "2025-04-14", + }, + Sku: synthesis.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go new file mode 100644 index 00000000000..cd8e8c70137 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/provisioning" + + "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" +) + +type projectMode string + +const ( + projectModeNew projectMode = "new" + projectModeExistingID projectMode = "existing-id" + projectModeExistingEndpoint projectMode = "existing-endpoint" +) + +type projectServiceInfo struct { + Name string + Raw map[string]any + Resolved map[string]any + Expanded *azdext.ServiceConfig + ServiceRef string + Legacy bool +} + +type projectServiceReconciler struct { + client *azdext.AzdClient + projectRoot string +} + +// discoverProjectService loads persisted and expanded views. +// Writes use persisted data; discovery uses expanded data. +func (r *projectServiceReconciler) discoverProjectService( + ctx context.Context, +) (*projectServiceInfo, *azdext.ProjectConfig, error) { + response, err := r.client.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, nil, err + } + if response.Project == nil { + return nil, nil, fmt.Errorf("azd project is empty") + } + project := response.Project + + rawServices := map[string]any{} + section, sectionErr := r.client.Project().GetConfigSection(ctx, + &azdext.GetProjectConfigSectionRequest{Path: "services"}) + if sectionErr != nil { + return nil, project, fmt.Errorf("read persisted project services: %w", sectionErr) + } + if section.GetFound() && section.GetSection() != nil { + rawServices = section.GetSection().AsMap() + } + + projectHosts := provisioning.FoundryProjectServiceHosts + legacyHosts := provisioning.FoundryLegacyProvisioningHosts + var projectNames, legacyNames []string + for name, service := range project.GetServices() { + if service == nil { + continue + } + if slices.Contains(projectHosts, service.GetHost()) { + projectNames = append(projectNames, name) + } else if slices.Contains(legacyHosts, service.GetHost()) && + (hasProjectOwnedFields(rawServices[name]) || hasServiceRef(rawServices[name])) { + legacyNames = append(legacyNames, name) + } + } + slices.Sort(projectNames) + slices.Sort(legacyNames) + + if len(projectNames) > 1 { + return nil, project, ambiguousProjectServiceError(projectNames) + } + legacy := false + names := projectNames + if len(names) == 0 { + names = legacyNames + legacy = len(names) > 0 + } + if len(names) > 1 { + return nil, project, ambiguousProjectServiceError(names) + } + if len(names) == 0 { + return nil, project, nil + } + + name := names[0] + raw, _ := rawServices[name].(map[string]any) + raw, err = cloneMap(raw) + if err != nil { + return nil, project, fmt.Errorf( + "copy persisted project service %q: %w", name, err, + ) + } + resolved, err := cloneMap(raw) + if err != nil { + return nil, project, fmt.Errorf( + "copy resolved project service %q: %w", name, err, + ) + } + if resolved == nil { + resolved = map[string]any{} + } + if len(resolved) > 0 && r.projectRoot != "" { + resolved, err = foundry.ResolveFileRefs(resolved, r.projectRoot) + if err != nil { + return nil, project, fmt.Errorf("resolve project service %q $ref includes: %w", name, err) + } + } + var serviceRef string + if ref, ok := raw["$ref"].(string); ok { + serviceRef = ref + } + return &projectServiceInfo{ + Name: name, + Raw: raw, + Resolved: resolved, + Expanded: project.GetServices()[name], + ServiceRef: serviceRef, + Legacy: legacy, + }, project, nil +} + +func (r *projectServiceReconciler) reconcileEndpoint( + ctx context.Context, + projectName, endpoint string, + mode projectMode, +) (string, string, error) { + service, project, err := r.discoverProjectService(ctx) + if err != nil { + return "", "", err + } + if service == nil { + name := projectServiceName(projectName, project.GetServices()) + body := map[string]any{} + if endpoint != "" { + body["endpoint"] = endpoint + } + if err := r.addService(ctx, name, body); err != nil { + return "", "", err + } + return name, "created", nil + } + + // Copy legacy services instead of editing them in place. + // A service-level ref cannot use the shallow overlay RPC. + if service.Legacy { + if service.ServiceRef != "" { + return "", "", projectServiceRefError(service.Name, service.ServiceRef) + } + body, err := legacyProjectServiceBody(service.Raw, endpoint) + if err != nil { + return "", "", fmt.Errorf( + "copy legacy project service %q: %w", service.Name, err, + ) + } + name := projectServiceName(projectName, project.GetServices()) + if err := r.addService(ctx, name, body); err != nil { + return "", "", err + } + return name, "migrated", nil + } + + currentEndpoint := serviceEndpoint(service.Resolved) + if endpoint != "" { + normalized, _, err := validateProjectEndpoint(endpoint) + if err != nil { + return "", "", err + } + endpoint = normalized + } + if equalProjectEndpoint(currentEndpoint, endpoint) { + return service.Name, "unchanged", nil + } + if service.ServiceRef != "" { + return "", "", projectServiceRefError(service.Name, service.ServiceRef) + } + + if endpoint == "" { + if _, ok := service.Raw["endpoint"]; !ok { + return service.Name, "unchanged", nil + } + _, err = r.client.Project().UnsetServiceConfig(ctx, + &azdext.UnsetServiceConfigRequest{ServiceName: service.Name, Path: "endpoint"}) + } else { + value, valueErr := structpb.NewValue(endpoint) + if valueErr != nil { + return "", "", valueErr + } + _, err = r.client.Project().SetServiceConfigValue(ctx, + &azdext.SetServiceConfigValueRequest{ + ServiceName: service.Name, + Path: "endpoint", + Value: value, + }) + } + if err != nil { + return "", "", fmt.Errorf("update project service %q endpoint: %w", service.Name, err) + } + _ = mode + return service.Name, "updated", nil +} + +func legacyProjectServiceBody( + raw map[string]any, + endpoint string, +) (map[string]any, error) { + body, err := cloneMap(raw) + if err != nil { + return nil, err + } + if body == nil { + body = map[string]any{} + } + delete(body, "host") + if endpoint != "" { + body["endpoint"] = endpoint + } else { + delete(body, "endpoint") + } + return body, nil +} + +func (r *projectServiceReconciler) addService( + ctx context.Context, + name string, + body map[string]any, +) error { + var err error + body, err = cloneMap(body) + if err != nil { + return fmt.Errorf("copy project service %q: %w", name, err) + } + delete(body, "host") + properties, err := structpb.NewStruct(body) + if err != nil { + return fmt.Errorf("encode project service %q: %w", name, err) + } + _, err = r.client.Project().AddService(ctx, &azdext.AddServiceRequest{ + Service: &azdext.ServiceConfig{ + Name: name, + Host: provisioning.FoundryProjectHost, + AdditionalProperties: properties, + }, + }) + if err != nil { + return fmt.Errorf("add project service %q: %w", name, err) + } + return nil +} + +func hasProjectOwnedFields(value any) bool { + body, ok := value.(map[string]any) + if !ok { + return false + } + for _, key := range []string{"endpoint", "deployments", "network"} { + if _, found := body[key]; found { + return true + } + } + return false +} + +func hasManagedProjectFields(value map[string]any) bool { + if value == nil { + return false + } + for _, key := range []string{"deployments", "network"} { + if _, found := value[key]; found { + return true + } + } + return false +} + +func hasServiceRef(value any) bool { + body, ok := value.(map[string]any) + if !ok { + return false + } + _, exists := body["$ref"] + return exists +} + +func ambiguousProjectServiceError(names []string) error { + slices.Sort(names) + return exterrors.Validation( + "project_service_ambiguous", + fmt.Sprintf("multiple Foundry project services found: %s", strings.Join(names, ", ")), + "keep exactly one service with host azure.ai.project and retry", + ) +} + +func projectServiceRefError(name, ref string) error { + return exterrors.Validation( + "project_service_ref_update_unsupported", + fmt.Sprintf("project service %q is referenced from %q and cannot be updated safely", name, ref), + fmt.Sprintf("edit %q directly or inline the service before retrying", ref), + ) +} + +var serviceNameInvalid = regexp.MustCompile(`[^a-z0-9-]+`) + +func projectServiceName(projectName string, services map[string]*azdext.ServiceConfig) string { + used := make(map[string]struct{}, len(services)) + for name := range services { + used[strings.ToLower(name)] = struct{}{} + } + base := serviceNameInvalid.ReplaceAllString(strings.ToLower(strings.TrimSpace(projectName)), "-") + base = strings.Trim(base, "-") + if len(base) > 63 { + base = strings.TrimRight(base[:63], "-") + } + if base != "" { + if _, exists := used[base]; !exists { + return base + } + } + if _, exists := used["ai-project"]; !exists { + return "ai-project" + } + for i := 2; ; i++ { + name := fmt.Sprintf("ai-project-%d", i) + if _, exists := used[name]; !exists { + return name + } + } +} + +func serviceEndpoint(service map[string]any) string { + if service == nil { + return "" + } + endpoint, _ := service["endpoint"].(string) + return endpoint +} + +func equalProjectEndpoint(left, right string) bool { + if left == "" || right == "" { + return left == right + } + leftNormalized, _, leftErr := validateProjectEndpoint(left) + rightNormalized, _, rightErr := validateProjectEndpoint(right) + if leftErr != nil || rightErr != nil { + return strings.TrimRight(strings.TrimSpace(left), "/") == + strings.TrimRight(strings.TrimSpace(right), "/") + } + return strings.EqualFold(leftNormalized, rightNormalized) +} + +func cloneMap(value map[string]any) (map[string]any, error) { + if value == nil { + return nil, nil + } + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("serialize map: %w", err) + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("deserialize map: %w", err) + } + return result, nil +} 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 f376368b423..9d95d395523 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -33,6 +33,8 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newProjectSetCommand(extCtx)) rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) rootCmd.AddCommand(newProjectShowCommand(extCtx)) + rootCmd.AddCommand(newProjectInitCommand(extCtx)) + rootCmd.AddCommand(newProjectDeploymentCommand(extCtx)) rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) return rootCmd 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 d6a94cf3212..e0c64809fad 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -30,6 +30,7 @@ const ( CodeAzdClientFailed = "azd_client_failed" CodeEnvironmentNotFound = "environment_not_found" CodeEnvironmentValuesFailed = "environment_values_failed" + CodeEnvironmentUnsetUnsupported = "environment_unset_unsupported" CodeMissingAzureSubscription = "missing_azure_subscription_id" CodeMissingAzureLocation = "missing_azure_location" CodeProvisioningServiceNotFound = "provisioning_service_not_found" 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 35027f7f4a6..b16e65bc91f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go @@ -49,6 +49,16 @@ func Dependency(code, message, suggestion string) error { } } +// Compatibility returns a compatibility error for version mismatches. +func Compatibility(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryCompatibility, + Suggestion: suggestion, + } +} + // Auth returns an authentication or authorization error. func Auth(code, message, suggestion string) error { return &azdext.LocalError{ diff --git a/cli/azd/extensions/microsoft.foundry/extension.yaml b/cli/azd/extensions/microsoft.foundry/extension.yaml index 336bc749643..b457f824f3f 100644 --- a/cli/azd/extensions/microsoft.foundry/extension.yaml +++ b/cli/azd/extensions/microsoft.foundry/extension.yaml @@ -6,7 +6,7 @@ tags: - ai - foundry version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.1" +requiredAzdVersion: ">=1.31.0-beta.1" dependencies: - id: azure.ai.agents version: "~1.0.0-beta.7" diff --git a/cli/azd/grpc/proto/environment.proto b/cli/azd/grpc/proto/environment.proto index 969b4b9f49f..56d6a30fb25 100644 --- a/cli/azd/grpc/proto/environment.proto +++ b/cli/azd/grpc/proto/environment.proto @@ -31,6 +31,9 @@ service EnvironmentService { // SetValue sets the value of a key in the specified environment. rpc SetValue (SetEnvRequest) returns (EmptyResponse); + // UnsetValue removes a key from the specified environment. + rpc UnsetValue (UnsetEnvRequest) returns (EmptyResponse); + // GetConfig retrieves a config value by path rpc GetConfig (GetConfigRequest) returns (GetConfigResponse); @@ -69,6 +72,11 @@ message SetEnvRequest { string value = 3; // Value to set for the key. } +message UnsetEnvRequest { + string env_name = 1; // Optional: Name of the environment. If empty, uses default. + string key = 2; // Key to remove. +} + // Response containing details of an environment. message EnvironmentResponse { Environment environment = 1; // Environment details. diff --git a/cli/azd/grpc/proto/errors.proto b/cli/azd/grpc/proto/errors.proto index dfad9f6ac0f..f18c3ad6251 100644 --- a/cli/azd/grpc/proto/errors.proto +++ b/cli/azd/grpc/proto/errors.proto @@ -50,6 +50,11 @@ message ActionableErrorDetail { repeated ErrorLink links = 2; // Optional reference links rendered alongside the suggestion } +// WorkflowErrorDetail carries an extension error from a delegated workflow. +message WorkflowErrorDetail { + ExtensionError error = 1; +} + // ExtensionError is a unified error message that can represent errors from different sources. // It provides structured error information for telemetry and error handling. // diff --git a/cli/azd/internal/grpcserver/environment_service.go b/cli/azd/internal/grpcserver/environment_service.go index afe5a4355bc..9c6c03609a0 100644 --- a/cli/azd/internal/grpcserver/environment_service.go +++ b/cli/azd/internal/grpcserver/environment_service.go @@ -195,6 +195,33 @@ func (s *environmentService) SetValue(ctx context.Context, req *azdext.SetEnvReq return &azdext.EmptyResponse{}, nil } +// UnsetValue removes a key from the specified environment. +func (s *environmentService) UnsetValue( + ctx context.Context, + req *azdext.UnsetEnvRequest, +) (*azdext.EmptyResponse, error) { + if req == nil || req.Key == "" { + return nil, status.Error(codes.InvalidArgument, "key is required") + } + + envManager, err := s.lazyEnvManager.GetValue() + if err != nil { + return nil, err + } + + env, err := s.resolveEnvironment(ctx, req.EnvName) + if err != nil { + return nil, err + } + + env.DotenvDelete(req.Key) + if err := envManager.Save(ctx, env); err != nil { + return nil, fmt.Errorf("failed to save environment: %w", err) + } + + return &azdext.EmptyResponse{}, nil +} + func (s *environmentService) currentEnvironment(ctx context.Context) (*environment.Environment, error) { azdContext, err := s.lazyAzdContext.GetValue() if err != nil { diff --git a/cli/azd/internal/grpcserver/environment_service_test.go b/cli/azd/internal/grpcserver/environment_service_test.go index a2404c815ce..fd095c2eeb3 100644 --- a/cli/azd/internal/grpcserver/environment_service_test.go +++ b/cli/azd/internal/grpcserver/environment_service_test.go @@ -337,7 +337,7 @@ func Test_EnvironmentService_ResolveEnvironment(t *testing.T) { }) } -// Test_EnvironmentService_EmptyKeyValidation verifies that GetValue and SetValue +// Test_EnvironmentService_EmptyKeyValidation verifies that GetValue, SetValue, and UnsetValue // return InvalidArgument when called with an empty key. func Test_EnvironmentService_EmptyKeyValidation(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) @@ -371,6 +371,7 @@ func Test_EnvironmentService_EmptyKeyValidation(t *testing.T) { }{ {"GetValue_empty_key", "GetValue"}, {"SetValue_empty_key", "SetValue"}, + {"UnsetValue_empty_key", "UnsetValue"}, } for _, tt := range tests { @@ -383,6 +384,8 @@ func Test_EnvironmentService_EmptyKeyValidation(t *testing.T) { _, callErr = service.SetValue( ctx, &azdext.SetEnvRequest{Key: "", Value: "v"}, ) + case "UnsetValue": + _, callErr = service.UnsetValue(ctx, &azdext.UnsetEnvRequest{Key: ""}) } require.Error(t, callErr) @@ -760,6 +763,46 @@ func TestEnvironmentService_SetValue_SaveError(t *testing.T) { require.Contains(t, err.Error(), "save failed") } +func TestEnvironmentService_UnsetValue_SuccessAndMissingKey(t *testing.T) { + t.Parallel() + env := environment.NewWithValues("dev", map[string]string{ + "REMOVE": "value", + "KEEP": "value", + }) + saveCount := 0 + mockMgr := &mockEnvManager{ + getFunc: func(_ context.Context, _ string) (*environment.Environment, error) { + return env, nil + }, + saveFunc: func(_ context.Context, savedEnv *environment.Environment) error { + saveCount++ + require.Same(t, env, savedEnv) + return nil + }, + } + lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { + return mockMgr, nil + }) + svc := NewEnvironmentService(nil, lazyEnvManager) + + _, err := svc.UnsetValue(t.Context(), &azdext.UnsetEnvRequest{ + Key: "REMOVE", + EnvName: "dev", + }) + require.NoError(t, err) + _, found := env.Dotenv()["REMOVE"] + require.False(t, found) + _, found = env.Dotenv()["KEEP"] + require.True(t, found) + + _, err = svc.UnsetValue(t.Context(), &azdext.UnsetEnvRequest{ + Key: "MISSING", + EnvName: "dev", + }) + require.NoError(t, err) + require.Equal(t, 2, saveCount) +} + func TestEnvironmentService_GetValues_LazyEnvManagerError(t *testing.T) { t.Parallel() lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { diff --git a/cli/azd/internal/grpcserver/project_service.go b/cli/azd/internal/grpcserver/project_service.go index 1421db208af..480e4aed844 100644 --- a/cli/azd/internal/grpcserver/project_service.go +++ b/cli/azd/internal/grpcserver/project_service.go @@ -817,10 +817,17 @@ func (s *projectService) UnsetServiceConfig( return nil, err } - // Construct path to service config: "services.." - servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + services, ok := cfg.Raw()["services"].(map[string]any) + if !ok { + return nil, fmt.Errorf("services configuration not found") + } + + serviceConfig, ok := services[req.ServiceName].(map[string]any) + if !ok { + return nil, fmt.Errorf("service configuration for '%s' not found", req.ServiceName) + } - if err := cfg.Unset(servicePath); err != nil { + if err := config.NewConfig(serviceConfig).Unset(req.Path); err != nil { return nil, fmt.Errorf("failed to unset service config: %w", err) } diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index 1c195fcfa90..834d326e410 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -2798,6 +2798,42 @@ func TestProjectService_UnsetServiceConfig_HappyPath(t *testing.T) { require.NoError(t, err) } +func TestProjectService_UnsetServiceConfig_DottedServiceName(t *testing.T) { + t.Parallel() + svc := newProjectServiceWithYaml(t, `name: test-project +services: + my.agent: + host: azure.ai.agent + endpoint: https://example.test + custom: + endpoint: https://custom.example.test +`) + + _, err := svc.UnsetServiceConfig(t.Context(), &azdext.UnsetServiceConfigRequest{ + ServiceName: "my.agent", + Path: "endpoint", + }) + require.NoError(t, err) + + projectSvc := svc.(*projectService) + azdContext, err := projectSvc.lazyAzdContext.GetValue() + require.NoError(t, err) + cfg, err := project.LoadConfig(t.Context(), azdContext.ProjectPath()) + require.NoError(t, err) + + services, ok := cfg.Raw()["services"].(map[string]any) + require.True(t, ok) + serviceConfig, ok := services["my.agent"].(map[string]any) + require.True(t, ok) + _, found := config.NewConfig(serviceConfig).Get("endpoint") + require.False(t, found) + customEndpoint, found := config.NewConfig(serviceConfig).Get("custom.endpoint") + require.True(t, found) + require.Equal(t, "https://custom.example.test", customEndpoint) + _, found = services["my"] + require.False(t, found) +} + func TestProjectService_AddService_HappyPath(t *testing.T) { t.Parallel() svc := newProjectServiceWithYaml(t, "name: test-project\n") diff --git a/cli/azd/internal/grpcserver/workflow_service.go b/cli/azd/internal/grpcserver/workflow_service.go index 273d45f0b03..dc5978f2b22 100644 --- a/cli/azd/internal/grpcserver/workflow_service.go +++ b/cli/azd/internal/grpcserver/workflow_service.go @@ -6,6 +6,7 @@ package grpcserver import ( "context" "errors" + "fmt" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/environment" @@ -41,10 +42,24 @@ func (s *workflowService) Run(ctx context.Context, request *azdext.RunWorkflowRe } if err := s.runner.Run(ctx, azdWorkflow); err != nil { - if errors.Is(err, environment.ErrExists) { - return nil, status.Errorf(codes.AlreadyExists, "failed to run workflow: %v", err) + code := codes.Internal + switch { + case errors.Is(err, context.Canceled): + code = codes.Canceled + case errors.Is(err, context.DeadlineExceeded): + code = codes.DeadlineExceeded + case errors.Is(err, environment.ErrExists): + code = codes.AlreadyExists } - return nil, status.Errorf(codes.Internal, "failed to run workflow: %v", err) + + st, statusErr := status.New(code, fmt.Sprintf("failed to run workflow: %v", err)). + WithDetails(&azdext.WorkflowErrorDetail{ + Error: azdext.WrapError(err), + }) + if statusErr != nil { + return nil, statusErr + } + return nil, st.Err() } return &azdext.EmptyResponse{}, nil diff --git a/cli/azd/internal/grpcserver/workflow_service_test.go b/cli/azd/internal/grpcserver/workflow_service_test.go index 933c43eb851..233883b9a6c 100644 --- a/cli/azd/internal/grpcserver/workflow_service_test.go +++ b/cli/azd/internal/grpcserver/workflow_service_test.go @@ -16,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/workflow" "github.com/azure/azure-dev/cli/azd/test/mocks" ) @@ -115,6 +116,79 @@ func Test_WorkflowService_Run_Success(t *testing.T) { }) } +func Test_WorkflowService_Run_PreservesStructuredErrorDetail(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + expectedErr := &azdext.ServiceError{ + Message: "request failed", + ErrorCode: "RequestFailed", + StatusCode: 422, + ServiceName: "example.azure.com", + Suggestion: "Check the request and try again", + Links: []errorhandler.ErrorLink{{ + URL: "https://aka.ms/request-failed", + Title: "Request failure help", + }}, + } + testRunner := &TestWorkflowRunner{} + testRunner.On("ExecuteContext", mock.Anything, mock.Anything).Return(expectedErr) + service := NewWorkflowService(workflow.NewRunner(testRunner, mockContext.Console)) + + _, err := service.Run(*mockContext.Context, validWorkflowRequest()) + + require.Error(t, err) + require.Equal(t, codes.Internal, status.Code(err)) + st, ok := status.FromError(err) + require.True(t, ok) + require.Len(t, st.Details(), 1) + detail, ok := st.Details()[0].(*azdext.WorkflowErrorDetail) + require.True(t, ok) + require.NotNil(t, detail.GetError()) + require.Equal(t, "request failed", detail.GetError().GetMessage()) + require.Equal(t, azdext.ErrorOrigin_ERROR_ORIGIN_SERVICE, detail.GetError().GetOrigin()) + require.Equal(t, "Check the request and try again", detail.GetError().GetSuggestion()) + require.Equal(t, "https://aka.ms/request-failed", detail.GetError().GetLinks()[0].GetUrl()) + require.Equal(t, "Request failure help", detail.GetError().GetLinks()[0].GetTitle()) + require.Equal(t, "RequestFailed", detail.GetError().GetServiceError().GetErrorCode()) + require.Equal(t, int32(422), detail.GetError().GetServiceError().GetStatusCode()) + require.Equal(t, "example.azure.com", detail.GetError().GetServiceError().GetServiceName()) +} + +func Test_WorkflowService_Run_ContextErrorsUseStandardCodes(t *testing.T) { + tests := []struct { + name string + err error + code codes.Code + }{ + {name: "Canceled", err: context.Canceled, code: codes.Canceled}, + {name: "DeadlineExceeded", err: context.DeadlineExceeded, code: codes.DeadlineExceeded}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + testRunner := &TestWorkflowRunner{} + testRunner.On("ExecuteContext", mock.Anything, mock.Anything).Return(fmt.Errorf("step failed: %w", tt.err)) + service := NewWorkflowService(workflow.NewRunner(testRunner, mockContext.Console)) + + _, err := service.Run(*mockContext.Context, validWorkflowRequest()) + + require.Error(t, err) + require.Equal(t, tt.code, status.Code(err)) + }) + } +} + +func validWorkflowRequest() *azdext.RunWorkflowRequest { + return &azdext.RunWorkflowRequest{ + Workflow: &azdext.Workflow{ + Name: "testWorkflow", + Steps: []*azdext.WorkflowStep{{ + Command: &azdext.WorkflowCommand{Args: []string{"provision"}}, + }}, + }, + } +} + // Updated TestWorkflowRunner using testify/mock. type TestWorkflowRunner struct { mock.Mock diff --git a/cli/azd/pkg/azdext/config_helper_test.go b/cli/azd/pkg/azdext/config_helper_test.go index 3538c71c53a..7612a4053f5 100644 --- a/cli/azd/pkg/azdext/config_helper_test.go +++ b/cli/azd/pkg/azdext/config_helper_test.go @@ -107,6 +107,12 @@ func (s *stubEnvironmentService) SetValue( return nil, nil } +func (s *stubEnvironmentService) UnsetValue( + _ context.Context, _ *UnsetEnvRequest, _ ...grpc.CallOption, +) (*EmptyResponse, error) { + return nil, nil +} + func (s *stubEnvironmentService) GetConfig( _ context.Context, _ *GetConfigRequest, _ ...grpc.CallOption, ) (*GetConfigResponse, error) { diff --git a/cli/azd/pkg/azdext/environment.pb.go b/cli/azd/pkg/azdext/environment.pb.go index c9b120270d3..9358ec7da31 100644 --- a/cli/azd/pkg/azdext/environment.pb.go +++ b/cli/azd/pkg/azdext/environment.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.10 -// protoc v6.32.1 +// protoc v7.35.0 // source: environment.proto package azdext @@ -227,6 +227,58 @@ func (x *SetEnvRequest) GetValue() string { return "" } +type UnsetEnvRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EnvName string `protobuf:"bytes,1,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` // Optional: Name of the environment. If empty, uses default. + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` // Key to remove. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnsetEnvRequest) Reset() { + *x = UnsetEnvRequest{} + mi := &file_environment_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnsetEnvRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnsetEnvRequest) ProtoMessage() {} + +func (x *UnsetEnvRequest) ProtoReflect() protoreflect.Message { + mi := &file_environment_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnsetEnvRequest.ProtoReflect.Descriptor instead. +func (*UnsetEnvRequest) Descriptor() ([]byte, []int) { + return file_environment_proto_rawDescGZIP(), []int{4} +} + +func (x *UnsetEnvRequest) GetEnvName() string { + if x != nil { + return x.EnvName + } + return "" +} + +func (x *UnsetEnvRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + // Response containing details of an environment. type EnvironmentResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -237,7 +289,7 @@ type EnvironmentResponse struct { func (x *EnvironmentResponse) Reset() { *x = EnvironmentResponse{} - mi := &file_environment_proto_msgTypes[4] + mi := &file_environment_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -249,7 +301,7 @@ func (x *EnvironmentResponse) String() string { func (*EnvironmentResponse) ProtoMessage() {} func (x *EnvironmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[4] + mi := &file_environment_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -262,7 +314,7 @@ func (x *EnvironmentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvironmentResponse.ProtoReflect.Descriptor instead. func (*EnvironmentResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{4} + return file_environment_proto_rawDescGZIP(), []int{5} } func (x *EnvironmentResponse) GetEnvironment() *Environment { @@ -281,7 +333,7 @@ type EnvironmentListResponse struct { func (x *EnvironmentListResponse) Reset() { *x = EnvironmentListResponse{} - mi := &file_environment_proto_msgTypes[5] + mi := &file_environment_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -293,7 +345,7 @@ func (x *EnvironmentListResponse) String() string { func (*EnvironmentListResponse) ProtoMessage() {} func (x *EnvironmentListResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[5] + mi := &file_environment_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -306,7 +358,7 @@ func (x *EnvironmentListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvironmentListResponse.ProtoReflect.Descriptor instead. func (*EnvironmentListResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{5} + return file_environment_proto_rawDescGZIP(), []int{6} } func (x *EnvironmentListResponse) GetEnvironments() []*EnvironmentDescription { @@ -326,7 +378,7 @@ type KeyValueListResponse struct { func (x *KeyValueListResponse) Reset() { *x = KeyValueListResponse{} - mi := &file_environment_proto_msgTypes[6] + mi := &file_environment_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -338,7 +390,7 @@ func (x *KeyValueListResponse) String() string { func (*KeyValueListResponse) ProtoMessage() {} func (x *KeyValueListResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[6] + mi := &file_environment_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -351,7 +403,7 @@ func (x *KeyValueListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KeyValueListResponse.ProtoReflect.Descriptor instead. func (*KeyValueListResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{6} + return file_environment_proto_rawDescGZIP(), []int{7} } func (x *KeyValueListResponse) GetKeyValues() []*KeyValue { @@ -372,7 +424,7 @@ type KeyValueResponse struct { func (x *KeyValueResponse) Reset() { *x = KeyValueResponse{} - mi := &file_environment_proto_msgTypes[7] + mi := &file_environment_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -384,7 +436,7 @@ func (x *KeyValueResponse) String() string { func (*KeyValueResponse) ProtoMessage() {} func (x *KeyValueResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[7] + mi := &file_environment_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -397,7 +449,7 @@ func (x *KeyValueResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KeyValueResponse.ProtoReflect.Descriptor instead. func (*KeyValueResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{7} + return file_environment_proto_rawDescGZIP(), []int{8} } func (x *KeyValueResponse) GetKey() string { @@ -424,7 +476,7 @@ type Environment struct { func (x *Environment) Reset() { *x = Environment{} - mi := &file_environment_proto_msgTypes[8] + mi := &file_environment_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -436,7 +488,7 @@ func (x *Environment) String() string { func (*Environment) ProtoMessage() {} func (x *Environment) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[8] + mi := &file_environment_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -449,7 +501,7 @@ func (x *Environment) ProtoReflect() protoreflect.Message { // Deprecated: Use Environment.ProtoReflect.Descriptor instead. func (*Environment) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{8} + return file_environment_proto_rawDescGZIP(), []int{9} } func (x *Environment) GetName() string { @@ -471,7 +523,7 @@ type EnvironmentDescription struct { func (x *EnvironmentDescription) Reset() { *x = EnvironmentDescription{} - mi := &file_environment_proto_msgTypes[9] + mi := &file_environment_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -483,7 +535,7 @@ func (x *EnvironmentDescription) String() string { func (*EnvironmentDescription) ProtoMessage() {} func (x *EnvironmentDescription) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[9] + mi := &file_environment_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -496,7 +548,7 @@ func (x *EnvironmentDescription) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvironmentDescription.ProtoReflect.Descriptor instead. func (*EnvironmentDescription) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{9} + return file_environment_proto_rawDescGZIP(), []int{10} } func (x *EnvironmentDescription) GetName() string { @@ -538,7 +590,7 @@ type KeyValue struct { func (x *KeyValue) Reset() { *x = KeyValue{} - mi := &file_environment_proto_msgTypes[10] + mi := &file_environment_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -550,7 +602,7 @@ func (x *KeyValue) String() string { func (*KeyValue) ProtoMessage() {} func (x *KeyValue) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[10] + mi := &file_environment_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -563,7 +615,7 @@ func (x *KeyValue) ProtoReflect() protoreflect.Message { // Deprecated: Use KeyValue.ProtoReflect.Descriptor instead. func (*KeyValue) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{10} + return file_environment_proto_rawDescGZIP(), []int{11} } func (x *KeyValue) GetKey() string { @@ -591,7 +643,7 @@ type GetConfigRequest struct { func (x *GetConfigRequest) Reset() { *x = GetConfigRequest{} - mi := &file_environment_proto_msgTypes[11] + mi := &file_environment_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -603,7 +655,7 @@ func (x *GetConfigRequest) String() string { func (*GetConfigRequest) ProtoMessage() {} func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[11] + mi := &file_environment_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -616,7 +668,7 @@ func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. func (*GetConfigRequest) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{11} + return file_environment_proto_rawDescGZIP(), []int{12} } func (x *GetConfigRequest) GetPath() string { @@ -644,7 +696,7 @@ type GetConfigResponse struct { func (x *GetConfigResponse) Reset() { *x = GetConfigResponse{} - mi := &file_environment_proto_msgTypes[12] + mi := &file_environment_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -656,7 +708,7 @@ func (x *GetConfigResponse) String() string { func (*GetConfigResponse) ProtoMessage() {} func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[12] + mi := &file_environment_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -669,7 +721,7 @@ func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. func (*GetConfigResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{12} + return file_environment_proto_rawDescGZIP(), []int{13} } func (x *GetConfigResponse) GetValue() []byte { @@ -697,7 +749,7 @@ type GetConfigStringRequest struct { func (x *GetConfigStringRequest) Reset() { *x = GetConfigStringRequest{} - mi := &file_environment_proto_msgTypes[13] + mi := &file_environment_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -709,7 +761,7 @@ func (x *GetConfigStringRequest) String() string { func (*GetConfigStringRequest) ProtoMessage() {} func (x *GetConfigStringRequest) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[13] + mi := &file_environment_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -722,7 +774,7 @@ func (x *GetConfigStringRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigStringRequest.ProtoReflect.Descriptor instead. func (*GetConfigStringRequest) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{13} + return file_environment_proto_rawDescGZIP(), []int{14} } func (x *GetConfigStringRequest) GetPath() string { @@ -750,7 +802,7 @@ type GetConfigStringResponse struct { func (x *GetConfigStringResponse) Reset() { *x = GetConfigStringResponse{} - mi := &file_environment_proto_msgTypes[14] + mi := &file_environment_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -762,7 +814,7 @@ func (x *GetConfigStringResponse) String() string { func (*GetConfigStringResponse) ProtoMessage() {} func (x *GetConfigStringResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[14] + mi := &file_environment_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -775,7 +827,7 @@ func (x *GetConfigStringResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigStringResponse.ProtoReflect.Descriptor instead. func (*GetConfigStringResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{14} + return file_environment_proto_rawDescGZIP(), []int{15} } func (x *GetConfigStringResponse) GetValue() string { @@ -803,7 +855,7 @@ type GetConfigSectionRequest struct { func (x *GetConfigSectionRequest) Reset() { *x = GetConfigSectionRequest{} - mi := &file_environment_proto_msgTypes[15] + mi := &file_environment_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -815,7 +867,7 @@ func (x *GetConfigSectionRequest) String() string { func (*GetConfigSectionRequest) ProtoMessage() {} func (x *GetConfigSectionRequest) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[15] + mi := &file_environment_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -828,7 +880,7 @@ func (x *GetConfigSectionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigSectionRequest.ProtoReflect.Descriptor instead. func (*GetConfigSectionRequest) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{15} + return file_environment_proto_rawDescGZIP(), []int{16} } func (x *GetConfigSectionRequest) GetPath() string { @@ -856,7 +908,7 @@ type GetConfigSectionResponse struct { func (x *GetConfigSectionResponse) Reset() { *x = GetConfigSectionResponse{} - mi := &file_environment_proto_msgTypes[16] + mi := &file_environment_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -868,7 +920,7 @@ func (x *GetConfigSectionResponse) String() string { func (*GetConfigSectionResponse) ProtoMessage() {} func (x *GetConfigSectionResponse) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[16] + mi := &file_environment_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -881,7 +933,7 @@ func (x *GetConfigSectionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigSectionResponse.ProtoReflect.Descriptor instead. func (*GetConfigSectionResponse) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{16} + return file_environment_proto_rawDescGZIP(), []int{17} } func (x *GetConfigSectionResponse) GetSection() []byte { @@ -910,7 +962,7 @@ type SetConfigRequest struct { func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_environment_proto_msgTypes[17] + mi := &file_environment_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -922,7 +974,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[17] + mi := &file_environment_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -935,7 +987,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{17} + return file_environment_proto_rawDescGZIP(), []int{18} } func (x *SetConfigRequest) GetPath() string { @@ -970,7 +1022,7 @@ type UnsetConfigRequest struct { func (x *UnsetConfigRequest) Reset() { *x = UnsetConfigRequest{} - mi := &file_environment_proto_msgTypes[18] + mi := &file_environment_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -982,7 +1034,7 @@ func (x *UnsetConfigRequest) String() string { func (*UnsetConfigRequest) ProtoMessage() {} func (x *UnsetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_environment_proto_msgTypes[18] + mi := &file_environment_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -995,7 +1047,7 @@ func (x *UnsetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnsetConfigRequest.ProtoReflect.Descriptor instead. func (*UnsetConfigRequest) Descriptor() ([]byte, []int) { - return file_environment_proto_rawDescGZIP(), []int{18} + return file_environment_proto_rawDescGZIP(), []int{19} } func (x *UnsetConfigRequest) GetPath() string { @@ -1027,7 +1079,10 @@ const file_environment_proto_rawDesc = "" + "\rSetEnvRequest\x12\x19\n" + "\benv_name\x18\x01 \x01(\tR\aenvName\x12\x10\n" + "\x03key\x18\x02 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x03 \x01(\tR\x05value\"L\n" + + "\x05value\x18\x03 \x01(\tR\x05value\">\n" + + "\x0fUnsetEnvRequest\x12\x19\n" + + "\benv_name\x18\x01 \x01(\tR\aenvName\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\"L\n" + "\x13EnvironmentResponse\x125\n" + "\venvironment\x18\x01 \x01(\v2\x13.azdext.EnvironmentR\venvironment\"]\n" + "\x17EnvironmentListResponse\x12B\n" + @@ -1072,7 +1127,7 @@ const file_environment_proto_rawDesc = "" + "\benv_name\x18\x03 \x01(\tR\aenvName\"C\n" + "\x12UnsetConfigRequest\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x19\n" + - "\benv_name\x18\x02 \x01(\tR\aenvName2\xc8\x06\n" + + "\benv_name\x18\x02 \x01(\tR\aenvName2\x86\a\n" + "\x12EnvironmentService\x12?\n" + "\n" + "GetCurrent\x12\x14.azdext.EmptyRequest\x1a\x1b.azdext.EnvironmentResponse\x12=\n" + @@ -1081,7 +1136,9 @@ const file_environment_proto_rawDesc = "" + "\x06Select\x12 .azdext.SelectEnvironmentRequest\x1a\x15.azdext.EmptyResponse\x12H\n" + "\tGetValues\x12\x1d.azdext.GetEnvironmentRequest\x1a\x1c.azdext.KeyValueListResponse\x12;\n" + "\bGetValue\x12\x15.azdext.GetEnvRequest\x1a\x18.azdext.KeyValueResponse\x128\n" + - "\bSetValue\x12\x15.azdext.SetEnvRequest\x1a\x15.azdext.EmptyResponse\x12@\n" + + "\bSetValue\x12\x15.azdext.SetEnvRequest\x1a\x15.azdext.EmptyResponse\x12<\n" + + "\n" + + "UnsetValue\x12\x17.azdext.UnsetEnvRequest\x1a\x15.azdext.EmptyResponse\x12@\n" + "\tGetConfig\x12\x18.azdext.GetConfigRequest\x1a\x19.azdext.GetConfigResponse\x12R\n" + "\x0fGetConfigString\x12\x1e.azdext.GetConfigStringRequest\x1a\x1f.azdext.GetConfigStringResponse\x12U\n" + "\x10GetConfigSection\x12\x1f.azdext.GetConfigSectionRequest\x1a .azdext.GetConfigSectionResponse\x12<\n" + @@ -1100,60 +1157,63 @@ func file_environment_proto_rawDescGZIP() []byte { return file_environment_proto_rawDescData } -var file_environment_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_environment_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_environment_proto_goTypes = []any{ (*GetEnvironmentRequest)(nil), // 0: azdext.GetEnvironmentRequest (*SelectEnvironmentRequest)(nil), // 1: azdext.SelectEnvironmentRequest (*GetEnvRequest)(nil), // 2: azdext.GetEnvRequest (*SetEnvRequest)(nil), // 3: azdext.SetEnvRequest - (*EnvironmentResponse)(nil), // 4: azdext.EnvironmentResponse - (*EnvironmentListResponse)(nil), // 5: azdext.EnvironmentListResponse - (*KeyValueListResponse)(nil), // 6: azdext.KeyValueListResponse - (*KeyValueResponse)(nil), // 7: azdext.KeyValueResponse - (*Environment)(nil), // 8: azdext.Environment - (*EnvironmentDescription)(nil), // 9: azdext.EnvironmentDescription - (*KeyValue)(nil), // 10: azdext.KeyValue - (*GetConfigRequest)(nil), // 11: azdext.GetConfigRequest - (*GetConfigResponse)(nil), // 12: azdext.GetConfigResponse - (*GetConfigStringRequest)(nil), // 13: azdext.GetConfigStringRequest - (*GetConfigStringResponse)(nil), // 14: azdext.GetConfigStringResponse - (*GetConfigSectionRequest)(nil), // 15: azdext.GetConfigSectionRequest - (*GetConfigSectionResponse)(nil), // 16: azdext.GetConfigSectionResponse - (*SetConfigRequest)(nil), // 17: azdext.SetConfigRequest - (*UnsetConfigRequest)(nil), // 18: azdext.UnsetConfigRequest - (*EmptyRequest)(nil), // 19: azdext.EmptyRequest - (*EmptyResponse)(nil), // 20: azdext.EmptyResponse + (*UnsetEnvRequest)(nil), // 4: azdext.UnsetEnvRequest + (*EnvironmentResponse)(nil), // 5: azdext.EnvironmentResponse + (*EnvironmentListResponse)(nil), // 6: azdext.EnvironmentListResponse + (*KeyValueListResponse)(nil), // 7: azdext.KeyValueListResponse + (*KeyValueResponse)(nil), // 8: azdext.KeyValueResponse + (*Environment)(nil), // 9: azdext.Environment + (*EnvironmentDescription)(nil), // 10: azdext.EnvironmentDescription + (*KeyValue)(nil), // 11: azdext.KeyValue + (*GetConfigRequest)(nil), // 12: azdext.GetConfigRequest + (*GetConfigResponse)(nil), // 13: azdext.GetConfigResponse + (*GetConfigStringRequest)(nil), // 14: azdext.GetConfigStringRequest + (*GetConfigStringResponse)(nil), // 15: azdext.GetConfigStringResponse + (*GetConfigSectionRequest)(nil), // 16: azdext.GetConfigSectionRequest + (*GetConfigSectionResponse)(nil), // 17: azdext.GetConfigSectionResponse + (*SetConfigRequest)(nil), // 18: azdext.SetConfigRequest + (*UnsetConfigRequest)(nil), // 19: azdext.UnsetConfigRequest + (*EmptyRequest)(nil), // 20: azdext.EmptyRequest + (*EmptyResponse)(nil), // 21: azdext.EmptyResponse } var file_environment_proto_depIdxs = []int32{ - 8, // 0: azdext.EnvironmentResponse.environment:type_name -> azdext.Environment - 9, // 1: azdext.EnvironmentListResponse.environments:type_name -> azdext.EnvironmentDescription - 10, // 2: azdext.KeyValueListResponse.key_values:type_name -> azdext.KeyValue - 19, // 3: azdext.EnvironmentService.GetCurrent:input_type -> azdext.EmptyRequest - 19, // 4: azdext.EnvironmentService.List:input_type -> azdext.EmptyRequest + 9, // 0: azdext.EnvironmentResponse.environment:type_name -> azdext.Environment + 10, // 1: azdext.EnvironmentListResponse.environments:type_name -> azdext.EnvironmentDescription + 11, // 2: azdext.KeyValueListResponse.key_values:type_name -> azdext.KeyValue + 20, // 3: azdext.EnvironmentService.GetCurrent:input_type -> azdext.EmptyRequest + 20, // 4: azdext.EnvironmentService.List:input_type -> azdext.EmptyRequest 0, // 5: azdext.EnvironmentService.Get:input_type -> azdext.GetEnvironmentRequest 1, // 6: azdext.EnvironmentService.Select:input_type -> azdext.SelectEnvironmentRequest 0, // 7: azdext.EnvironmentService.GetValues:input_type -> azdext.GetEnvironmentRequest 2, // 8: azdext.EnvironmentService.GetValue:input_type -> azdext.GetEnvRequest 3, // 9: azdext.EnvironmentService.SetValue:input_type -> azdext.SetEnvRequest - 11, // 10: azdext.EnvironmentService.GetConfig:input_type -> azdext.GetConfigRequest - 13, // 11: azdext.EnvironmentService.GetConfigString:input_type -> azdext.GetConfigStringRequest - 15, // 12: azdext.EnvironmentService.GetConfigSection:input_type -> azdext.GetConfigSectionRequest - 17, // 13: azdext.EnvironmentService.SetConfig:input_type -> azdext.SetConfigRequest - 18, // 14: azdext.EnvironmentService.UnsetConfig:input_type -> azdext.UnsetConfigRequest - 4, // 15: azdext.EnvironmentService.GetCurrent:output_type -> azdext.EnvironmentResponse - 5, // 16: azdext.EnvironmentService.List:output_type -> azdext.EnvironmentListResponse - 4, // 17: azdext.EnvironmentService.Get:output_type -> azdext.EnvironmentResponse - 20, // 18: azdext.EnvironmentService.Select:output_type -> azdext.EmptyResponse - 6, // 19: azdext.EnvironmentService.GetValues:output_type -> azdext.KeyValueListResponse - 7, // 20: azdext.EnvironmentService.GetValue:output_type -> azdext.KeyValueResponse - 20, // 21: azdext.EnvironmentService.SetValue:output_type -> azdext.EmptyResponse - 12, // 22: azdext.EnvironmentService.GetConfig:output_type -> azdext.GetConfigResponse - 14, // 23: azdext.EnvironmentService.GetConfigString:output_type -> azdext.GetConfigStringResponse - 16, // 24: azdext.EnvironmentService.GetConfigSection:output_type -> azdext.GetConfigSectionResponse - 20, // 25: azdext.EnvironmentService.SetConfig:output_type -> azdext.EmptyResponse - 20, // 26: azdext.EnvironmentService.UnsetConfig:output_type -> azdext.EmptyResponse - 15, // [15:27] is the sub-list for method output_type - 3, // [3:15] is the sub-list for method input_type + 4, // 10: azdext.EnvironmentService.UnsetValue:input_type -> azdext.UnsetEnvRequest + 12, // 11: azdext.EnvironmentService.GetConfig:input_type -> azdext.GetConfigRequest + 14, // 12: azdext.EnvironmentService.GetConfigString:input_type -> azdext.GetConfigStringRequest + 16, // 13: azdext.EnvironmentService.GetConfigSection:input_type -> azdext.GetConfigSectionRequest + 18, // 14: azdext.EnvironmentService.SetConfig:input_type -> azdext.SetConfigRequest + 19, // 15: azdext.EnvironmentService.UnsetConfig:input_type -> azdext.UnsetConfigRequest + 5, // 16: azdext.EnvironmentService.GetCurrent:output_type -> azdext.EnvironmentResponse + 6, // 17: azdext.EnvironmentService.List:output_type -> azdext.EnvironmentListResponse + 5, // 18: azdext.EnvironmentService.Get:output_type -> azdext.EnvironmentResponse + 21, // 19: azdext.EnvironmentService.Select:output_type -> azdext.EmptyResponse + 7, // 20: azdext.EnvironmentService.GetValues:output_type -> azdext.KeyValueListResponse + 8, // 21: azdext.EnvironmentService.GetValue:output_type -> azdext.KeyValueResponse + 21, // 22: azdext.EnvironmentService.SetValue:output_type -> azdext.EmptyResponse + 21, // 23: azdext.EnvironmentService.UnsetValue:output_type -> azdext.EmptyResponse + 13, // 24: azdext.EnvironmentService.GetConfig:output_type -> azdext.GetConfigResponse + 15, // 25: azdext.EnvironmentService.GetConfigString:output_type -> azdext.GetConfigStringResponse + 17, // 26: azdext.EnvironmentService.GetConfigSection:output_type -> azdext.GetConfigSectionResponse + 21, // 27: azdext.EnvironmentService.SetConfig:output_type -> azdext.EmptyResponse + 21, // 28: azdext.EnvironmentService.UnsetConfig:output_type -> azdext.EmptyResponse + 16, // [16:29] is the sub-list for method output_type + 3, // [3:16] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name @@ -1171,7 +1231,7 @@ func file_environment_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_environment_proto_rawDesc), len(file_environment_proto_rawDesc)), NumEnums: 0, - NumMessages: 19, + NumMessages: 20, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/environment_grpc.pb.go b/cli/azd/pkg/azdext/environment_grpc.pb.go index 0795f439e16..2f93c93f3c4 100644 --- a/cli/azd/pkg/azdext/environment_grpc.pb.go +++ b/cli/azd/pkg/azdext/environment_grpc.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v6.32.1 +// - protoc v7.35.0 // source: environment.proto package azdext @@ -29,6 +29,7 @@ const ( EnvironmentService_GetValues_FullMethodName = "/azdext.EnvironmentService/GetValues" EnvironmentService_GetValue_FullMethodName = "/azdext.EnvironmentService/GetValue" EnvironmentService_SetValue_FullMethodName = "/azdext.EnvironmentService/SetValue" + EnvironmentService_UnsetValue_FullMethodName = "/azdext.EnvironmentService/UnsetValue" EnvironmentService_GetConfig_FullMethodName = "/azdext.EnvironmentService/GetConfig" EnvironmentService_GetConfigString_FullMethodName = "/azdext.EnvironmentService/GetConfigString" EnvironmentService_GetConfigSection_FullMethodName = "/azdext.EnvironmentService/GetConfigSection" @@ -56,6 +57,8 @@ type EnvironmentServiceClient interface { GetValue(ctx context.Context, in *GetEnvRequest, opts ...grpc.CallOption) (*KeyValueResponse, error) // SetValue sets the value of a key in the specified environment. SetValue(ctx context.Context, in *SetEnvRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // UnsetValue removes a key from the specified environment. + UnsetValue(ctx context.Context, in *UnsetEnvRequest, opts ...grpc.CallOption) (*EmptyResponse, error) // GetConfig retrieves a config value by path GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) // GetConfigString retrieves a config value by path and returns it as a string @@ -146,6 +149,16 @@ func (c *environmentServiceClient) SetValue(ctx context.Context, in *SetEnvReque return out, nil } +func (c *environmentServiceClient) UnsetValue(ctx context.Context, in *UnsetEnvRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, EnvironmentService_UnsetValue_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *environmentServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetConfigResponse) @@ -216,6 +229,8 @@ type EnvironmentServiceServer interface { GetValue(context.Context, *GetEnvRequest) (*KeyValueResponse, error) // SetValue sets the value of a key in the specified environment. SetValue(context.Context, *SetEnvRequest) (*EmptyResponse, error) + // UnsetValue removes a key from the specified environment. + UnsetValue(context.Context, *UnsetEnvRequest) (*EmptyResponse, error) // GetConfig retrieves a config value by path GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) // GetConfigString retrieves a config value by path and returns it as a string @@ -257,6 +272,9 @@ func (UnimplementedEnvironmentServiceServer) GetValue(context.Context, *GetEnvRe func (UnimplementedEnvironmentServiceServer) SetValue(context.Context, *SetEnvRequest) (*EmptyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SetValue not implemented") } +func (UnimplementedEnvironmentServiceServer) UnsetValue(context.Context, *UnsetEnvRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnsetValue not implemented") +} func (UnimplementedEnvironmentServiceServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented") } @@ -419,6 +437,24 @@ func _EnvironmentService_SetValue_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _EnvironmentService_UnsetValue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnsetEnvRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EnvironmentServiceServer).UnsetValue(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EnvironmentService_UnsetValue_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EnvironmentServiceServer).UnsetValue(ctx, req.(*UnsetEnvRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _EnvironmentService_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetConfigRequest) if err := dec(in); err != nil { @@ -544,6 +580,10 @@ var EnvironmentService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SetValue", Handler: _EnvironmentService_SetValue_Handler, }, + { + MethodName: "UnsetValue", + Handler: _EnvironmentService_UnsetValue_Handler, + }, { MethodName: "GetConfig", Handler: _EnvironmentService_GetConfig_Handler, diff --git a/cli/azd/pkg/azdext/errors.pb.go b/cli/azd/pkg/azdext/errors.pb.go index 3411e247d6c..da1ae5f084b 100644 --- a/cli/azd/pkg/azdext/errors.pb.go +++ b/cli/azd/pkg/azdext/errors.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.10 -// protoc v6.32.1 +// protoc v7.35.0 // source: errors.proto package azdext @@ -308,6 +308,51 @@ func (x *ActionableErrorDetail) GetLinks() []*ErrorLink { return nil } +// WorkflowErrorDetail carries an extension error from a delegated workflow. +type WorkflowErrorDetail struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error *ExtensionError `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkflowErrorDetail) Reset() { + *x = WorkflowErrorDetail{} + mi := &file_errors_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkflowErrorDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkflowErrorDetail) ProtoMessage() {} + +func (x *WorkflowErrorDetail) ProtoReflect() protoreflect.Message { + mi := &file_errors_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkflowErrorDetail.ProtoReflect.Descriptor instead. +func (*WorkflowErrorDetail) Descriptor() ([]byte, []int) { + return file_errors_proto_rawDescGZIP(), []int{4} +} + +func (x *WorkflowErrorDetail) GetError() *ExtensionError { + if x != nil { + return x.Error + } + return nil +} + // ExtensionError is a unified error message that can represent errors from different sources. // It provides structured error information for telemetry and error handling. // @@ -332,7 +377,7 @@ type ExtensionError struct { func (x *ExtensionError) Reset() { *x = ExtensionError{} - mi := &file_errors_proto_msgTypes[4] + mi := &file_errors_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -344,7 +389,7 @@ func (x *ExtensionError) String() string { func (*ExtensionError) ProtoMessage() {} func (x *ExtensionError) ProtoReflect() protoreflect.Message { - mi := &file_errors_proto_msgTypes[4] + mi := &file_errors_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -357,7 +402,7 @@ func (x *ExtensionError) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionError.ProtoReflect.Descriptor instead. func (*ExtensionError) Descriptor() ([]byte, []int) { - return file_errors_proto_rawDescGZIP(), []int{4} + return file_errors_proto_rawDescGZIP(), []int{5} } func (x *ExtensionError) GetMessage() string { @@ -450,7 +495,9 @@ const file_errors_proto_rawDesc = "" + "\n" + "suggestion\x18\x01 \x01(\tR\n" + "suggestion\x12'\n" + - "\x05links\x18\x02 \x03(\v2\x11.azdext.ErrorLinkR\x05links\"\xb6\x02\n" + + "\x05links\x18\x02 \x03(\v2\x11.azdext.ErrorLinkR\x05links\"C\n" + + "\x13WorkflowErrorDetail\x12,\n" + + "\x05error\x18\x01 \x01(\v2\x16.azdext.ExtensionErrorR\x05error\"\xb6\x02\n" + "\x0eExtensionError\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12+\n" + "\x06origin\x18\x04 \x01(\x0e2\x13.azdext.ErrorOriginR\x06origin\x12\x1e\n" + @@ -482,26 +529,28 @@ func file_errors_proto_rawDescGZIP() []byte { } var file_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_errors_proto_goTypes = []any{ (ErrorOrigin)(0), // 0: azdext.ErrorOrigin (*ServiceErrorDetail)(nil), // 1: azdext.ServiceErrorDetail (*LocalErrorDetail)(nil), // 2: azdext.LocalErrorDetail (*ErrorLink)(nil), // 3: azdext.ErrorLink (*ActionableErrorDetail)(nil), // 4: azdext.ActionableErrorDetail - (*ExtensionError)(nil), // 5: azdext.ExtensionError + (*WorkflowErrorDetail)(nil), // 5: azdext.WorkflowErrorDetail + (*ExtensionError)(nil), // 6: azdext.ExtensionError } var file_errors_proto_depIdxs = []int32{ 3, // 0: azdext.ActionableErrorDetail.links:type_name -> azdext.ErrorLink - 0, // 1: azdext.ExtensionError.origin:type_name -> azdext.ErrorOrigin - 3, // 2: azdext.ExtensionError.links:type_name -> azdext.ErrorLink - 1, // 3: azdext.ExtensionError.service_error:type_name -> azdext.ServiceErrorDetail - 2, // 4: azdext.ExtensionError.local_error:type_name -> azdext.LocalErrorDetail - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 6, // 1: azdext.WorkflowErrorDetail.error:type_name -> azdext.ExtensionError + 0, // 2: azdext.ExtensionError.origin:type_name -> azdext.ErrorOrigin + 3, // 3: azdext.ExtensionError.links:type_name -> azdext.ErrorLink + 1, // 4: azdext.ExtensionError.service_error:type_name -> azdext.ServiceErrorDetail + 2, // 5: azdext.ExtensionError.local_error:type_name -> azdext.LocalErrorDetail + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_errors_proto_init() } @@ -509,7 +558,7 @@ func file_errors_proto_init() { if File_errors_proto != nil { return } - file_errors_proto_msgTypes[4].OneofWrappers = []any{ + file_errors_proto_msgTypes[5].OneofWrappers = []any{ (*ExtensionError_ServiceError)(nil), (*ExtensionError_LocalError)(nil), } @@ -519,7 +568,7 @@ func file_errors_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_errors_proto_rawDesc), len(file_errors_proto_rawDesc)), NumEnums: 1, - NumMessages: 5, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/azd/pkg/azdext/extension_error.go b/cli/azd/pkg/azdext/extension_error.go index d0a59dfdef0..943db4e10ab 100644 --- a/cli/azd/pkg/azdext/extension_error.go +++ b/cli/azd/pkg/azdext/extension_error.go @@ -215,6 +215,34 @@ func ActionableErrorDetailFromStatus(st *status.Status) *ActionableErrorDetail { return nil } +// WorkflowErrorDetailFromError extracts a delegated workflow error detail from +// a gRPC status error, including errors wrapped with additional context. +func WorkflowErrorDetailFromError(err error) *WorkflowErrorDetail { + st, ok := GRPCStatusFromError(err) + if !ok { + return nil + } + + for _, detail := range st.Details() { + if workflow, ok := detail.(*WorkflowErrorDetail); ok { + return workflow + } + } + + return nil +} + +// UnwrapWorkflowError extracts and converts a delegated workflow error from a +// gRPC status error. It returns nil when the error has no workflow detail. +func UnwrapWorkflowError(err error) error { + detail := WorkflowErrorDetailFromError(err) + if detail == nil { + return nil + } + + return UnwrapError(detail.GetError()) +} + func authLocalErrorCode(st *status.Status) string { switch AuthErrorReason(st) { case AuthErrorReasonNotLoggedIn: diff --git a/cli/azd/pkg/azdext/extension_error_test.go b/cli/azd/pkg/azdext/extension_error_test.go index 1656c9855a0..ab04e3d7cab 100644 --- a/cli/azd/pkg/azdext/extension_error_test.go +++ b/cli/azd/pkg/azdext/extension_error_test.go @@ -439,3 +439,25 @@ func TestActionableErrorDetailFromError(t *testing.T) { assert.Equal(t, "try harder", actionable.GetSuggestion()) }) } + +func TestUnwrapWorkflowError(t *testing.T) { + t.Parallel() + + workflowErr := &LocalError{ + Message: "invalid project", + Code: "invalid_project", + Category: LocalErrorCategoryValidation, + Suggestion: "Select a valid project", + } + statusErr := mustStatusErrorWithDetails(codes.Internal, "workflow failed", &WorkflowErrorDetail{ + Error: WrapError(workflowErr), + }) + + unwrapped := UnwrapWorkflowError(fmt.Errorf("run workflow: %w", statusErr)) + + localErr, ok := errors.AsType[*LocalError](unwrapped) + require.True(t, ok) + require.Equal(t, "invalid_project", localErr.Code) + require.Equal(t, LocalErrorCategoryValidation, localErr.Category) + require.Equal(t, "Select a valid project", localErr.Suggestion) +} diff --git a/docs/README.md b/docs/README.md index 62ed080bf87..5f924d7440d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,22 @@ System overviews, design context, and decision records. - [Telemetry Architecture](architecture/telemetry.md) — How azd collects and exports telemetry - [ADR Template](architecture/adr-template.md) — Template for lightweight architecture decision records +## Microsoft Foundry project ownership + +The `azure.ai.projects` extension owns Foundry project identity and managed +model deployments: + +```text +azd ai project init +azd ai project deployment add --model +``` + +`azure.ai.agents` delegates project initialization and managed deployment +selection to those commands. It continues to own agent services and external +deployment references. Endpoint-only projects are valid for data-plane use, +but operations that need ARM identity, such as infrastructure ejection or +managed deployment creation, require a verified project resource ID. + --- ## Where do new docs go? diff --git a/docs/architecture/extension-framework.md b/docs/architecture/extension-framework.md index 6f961f5430a..60180bb1967 100644 --- a/docs/architecture/extension-framework.md +++ b/docs/architecture/extension-framework.md @@ -75,6 +75,10 @@ Extensions can access these azd services via gRPC: - **Framework** — Framework service operations - **Service Target** — Deployment target operations +`Environment.UnsetValue` removes a key from the active environment rather +than writing an empty value. Extensions should use it when a project identity +transition makes a previously persisted value invalid. + ## Error Handling Extensions use two structured error types: @@ -84,6 +88,12 @@ Extensions use two structured error types: Error precedence: ServiceError → LocalError → azcore.ResponseError → gRPC auth → fallback +Workflow execution preserves structured extension errors in +`WorkflowErrorDetail`, so a parent extension can distinguish compatibility, +validation, and Azure service failures without parsing human-readable output. +Delegated extension commands use versioned request/result files and keep the +parent command as the sole JSON producer. + ## First-Party Extensions First-party extensions live in `cli/azd/extensions/` and are registered in `cli/azd/extensions/registry.json`. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 241eda4f1cb..b765beb51c7 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -59,6 +59,22 @@ Set by IDE hosts (VS Code, Visual Studio) when spawning azd as a subprocess. Use For details on the external authentication protocol, see [cli/azd/docs/external-authentication.md](../../cli/azd/docs/external-authentication.md). +## Microsoft Foundry extensions + +The `azure.ai.projects` extension owns the project identity values below. +`azure.ai.agents` consumes them for agent workflows. + +| Variable | Description | +|---|---| +| `AZURE_AI_PROJECT_ID` | Microsoft Foundry project resource ID | +| `FOUNDRY_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | +| `AZURE_AI_ACCOUNT_NAME` | Microsoft Foundry account name | +| `AZURE_AI_PROJECT_NAME` | Microsoft Foundry project name | +| `AZURE_AI_DEPLOYMENTS_LOCATION` | Managed deployment location | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Default managed model deployment name | +| `AZURE_AI_PROJECT_CONNECTION_NAMES` | Comma-separated project connection names | +| `AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT` | Project endpoint used by connection services | + ## See Also For the full reference with implementation details, see [cli/azd/docs/environment-variables.md](../../cli/azd/docs/environment-variables.md). diff --git a/docs/reference/feature-status.md b/docs/reference/feature-status.md index a1172238081..d955e7c9ee0 100644 --- a/docs/reference/feature-status.md +++ b/docs/reference/feature-status.md @@ -15,6 +15,8 @@ Current maturity status of Azure Developer CLI features. See [Feature Stages](.. | `help` | Stable | | `infra generate` | Beta | | `init` | Stable | +| `ai project init` | Beta | +| `ai project deployment add` | Beta | | `monitor` | Beta | | `package` | Beta | | `pipeline` | Beta | diff --git a/docs/specs/foundry-project-service-ownership/spec.md b/docs/specs/foundry-project-service-ownership/spec.md new file mode 100644 index 00000000000..9cf2d8a3714 --- /dev/null +++ b/docs/specs/foundry-project-service-ownership/spec.md @@ -0,0 +1,1815 @@ + + + + +# Foundry project service ownership technical design + +## Source design and implementation boundary + +This specification is the engineering design for the product decisions in +[PR #9441](https://github.com/Azure/azure-dev/pull/9441). It builds on the +project provisioning transfer in +[PR #9133](https://github.com/Azure/azure-dev/pull/9133). It does not redefine +the product requirements or the public command grouping selected there. + +The implementation has three owners: + +- `azure.ai.projects` owns the `azure.ai.project` service, project selection, + project environment state, managed model deployment declarations, and + Foundry infrastructure generation. +- `azure.ai.agents` owns agent source, manifests, runtimes, images, agent + services, and references to existing external model deployments. +- Core `azd` owns only the extension workflow transport and environment APIs + needed for safe composition. + +The public command `azd ai project deployment add` is an intentional exception +to the verb-first guidance in +`cli/azd/docs/extensions/extensions-style-guide.md`. The product design uses +the noun hierarchy to make project ownership visible and to leave room for +other project deployment operations. + +## Part 1: End-to-end experience + +### 1.1 Initialize a new project + +`azd ai project init` initializes the current directory as an azd project when +needed, creates one `azure.ai.project` service, and configures Foundry +infrastructure. It does not select or declare a model. + +```bash +azd ai project init +``` + +The interactive flow asks only for values that cannot be resolved from an +explicit flag, the current environment, or existing project configuration: + +```text +? Select how to configure the Foundry project: +> Create a new Foundry project + Use an existing Foundry project + +? Select an Azure subscription: ... +? Select an Azure location: ... + +Foundry project configuration added to azure.yaml. +Run `azd ai project deployment add` to add a managed model deployment. +``` + +For a new project, the resulting service has no endpoint because the endpoint +does not exist until provisioning completes: + +```yaml +name: chat-app +metadata: + template: chat-app@0.0.1 +infra: + provider: microsoft.foundry +services: + ai-project: + host: azure.ai.project +``` + +If `azure.yaml` already declares an infrastructure provider, the command +preserves it. If no provider exists, the command writes +`microsoft.foundry` only when the workspace has no user-owned infrastructure +path or files. It never replaces an existing non-Foundry provider. Explicit +infrastructure ejection is described in section 1.10. + +`--no-prompt` deterministically selects a new project when neither +`--project-id` nor `--project-endpoint` is supplied. For project-only setup, +missing Azure subscription and location are deferred until deployment +selection or provisioning. Agent delegation requests immediate Azure context, +so missing values on that path produce a structured error instead of opening a +prompt. + +```bash +azd ai project init --no-prompt +``` + +### 1.2 Adopt an existing project by resource ID + +A resource ID is the preferred input for an existing project because it +contains the subscription, resource group, account, and project names needed +by the provisioning provider. + +```bash +azd ai project init \ + --project-id "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ai-rg/providers/Microsoft.CognitiveServices/accounts/ai-account/projects/chat-project" +``` + +The command validates the resource ID, looks up the project, verifies the +endpoint, and writes only the project-owned service field: + +```yaml +services: + chat-project: + host: azure.ai.project + endpoint: https://ai-account.services.ai.azure.com/api/projects/chat-project +``` + +It also writes the canonical environment values needed by the existing +resource path: + +```text +AZURE_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +AZURE_TENANT_ID=11111111-1111-1111-1111-111111111111 +AZURE_LOCATION=eastus2 +AZURE_AI_PROJECT_ID=/subscriptions/.../projects/chat-project +AZURE_RESOURCE_GROUP=ai-rg +AZURE_AI_ACCOUNT_NAME=ai-account +AZURE_AI_PROJECT_NAME=chat-project +FOUNDRY_PROJECT_ENDPOINT=https://ai-account.services.ai.azure.com/api/projects/chat-project +AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT=https://ai-account.services.ai.azure.com/api/projects/chat-project +AZURE_OPENAI_ENDPOINT=https://ai-account.openai.azure.com +AZURE_AI_DEPLOYMENTS_LOCATION=eastus2 +USE_EXISTING_AI_PROJECT=true +``` + +When the project belongs to a tenant different from the resource tenant, the +command authenticates through the user's access tenant. A subscription chosen +by `PromptSubscription()` therefore always uses +`Subscription.UserTenantId`. An explicitly supplied subscription is resolved +through `LookupTenant()` before credentials are created. + +### 1.3 Adopt an existing project by endpoint + +An endpoint is accepted for tools that need project connectivity but do not +manage Azure resources: + +```bash +azd ai project init \ + --project-endpoint https://ai-account.services.ai.azure.com/api/projects/chat-project +``` + +The endpoint is normalized with `validateProjectEndpoint` and written to the +project service and active environment. Any stale ARM identity from a previous +resource-ID adoption is removed. + +Endpoint-only projects cannot add a managed deployment or generate +brownfield infrastructure because those operations require a complete project +resource ID. The error directs the user to rerun `project init` with +`--project-id`. The first implementation also requires a project ID when agent +init must validate an existing external deployment because the current +`listProjectDeployments` helper uses the ARM deployments client. Endpoint-only +mode remains valid when no resource or deployment reconciliation is needed. +Project init rejects endpoint-only mode before mutation when it would associate +existing managed deployments or network declarations with a different +endpoint. Repeating the same endpoint remains an allowed no-op. + +### 1.4 Reconcile an existing project service + +Only one `azure.ai.project` service is supported. If one exists, `project init` +reuses its key and updates only `endpoint`. It never replaces `deployments`, +`network`, hooks, `uses`, or unknown fields. + +When the requested project differs from the configured project, interactive +mode shows both identities and asks for confirmation: + +```text +The current environment points to: + https://old-account.services.ai.azure.com/api/projects/old-project + +Replace it with: + https://new-account.services.ai.azure.com/api/projects/new-project + +? Update the project configuration? (y/N) +``` + +`--no-prompt` does not switch projects unless the explicit +`--project-id` or `--project-endpoint` identifies the replacement. Explicit +input always wins over defaults. Repeating the same command produces no +`azure.yaml` diff and reports `unchanged`. + +If multiple project services exist, the command fails before any file or +environment mutation and lists the conflicting service keys. + +### 1.5 Add an azd-managed model deployment + +The deployment command selects a model, resolves a deployable version and SKU, +and adds a declaration to the existing project service: + +```bash +azd ai project deployment add +``` + +The interactive sequence is: + +```text +? Select a model: gpt-4.1 +? Select a model version: 2025-04-14 +? Select a deployment type: Global Standard +? Deployment name: chat + +Managed deployment `chat` added to services.ai-project.deployments. +``` + +The command writes the resolved values rather than defaults that could change +between runs: + +```yaml +services: + ai-project: + host: azure.ai.project + deployments: + - name: chat + model: + format: OpenAI + name: gpt-4.1 + version: "2025-04-14" + sku: + name: GlobalStandard + capacity: 10 +``` + +In non-interactive mode, `--model` is required. The model may use the +`publisher/model` form accepted by the current model catalog. The command does +not inherit the agent extension's preferred model. When `--name` is absent, +the deployment name defaults to the resolved catalog model name without its +publisher prefix. A conflict is reported rather than silently adding a suffix. + +```bash +azd ai project deployment add \ + --model OpenAI/gpt-4.1 \ + --name chat \ + --no-prompt +``` + +The command resolves version, SKU, capacity, and location from explicit input, +the model catalog, and quota data. If more than one valid choice remains in +`--no-prompt` mode, it returns an error naming the additional input needed. It +does not silently choose the first catalog entry. + +Deployment names are compared case-insensitively because Azure treats them as +case-insensitive. An exact existing declaration is an idempotent success and +preserves its original spelling. A declaration with the same name and +different model settings is a conflict. `--force` may replace an inline +declaration after confirmation rules are satisfied, but it cannot rewrite a +referenced file. + +An external deployment that already exists in Azure is not added to +`azure.yaml`. That path remains an agent operation: + +```yaml +models: + chat: + type: azure_openai + model: existing-production-deployment +``` + +### 1.6 Preserve referenced configuration + +Deployment item references remain intact: + +```yaml +services: + ai-project: + host: azure.ai.project + deployments: + - $ref: ./deployments/chat.yaml + - name: embeddings + model: + format: OpenAI + name: text-embedding-3-large + version: "1" + sku: + name: GlobalStandard + capacity: 10 +``` + +The command resolves the referenced item for comparison. If the requested +deployment is identical, it returns `unchanged`. If the same name has +different settings, it fails and tells the user to edit the referenced file. +If the name is new, the command appends an inline item without changing the +reference. + +A service-level `$ref` is safe to read but is not safe to mutate through the +current shallow overlay model. If a requested operation would add or change +`endpoint` or `deployments`, the command fails with instructions to edit the +referenced service file or inline the service first. A no-op may succeed. + +### 1.7 Initialize an agent through the project extension + +The public agent experience remains one command: + +```bash +azd ai agent init +``` + +The agent extension performs its source and manifest work, then delegates +project setup and each new managed deployment to `azure.ai.projects`. +Delegation is not shown as a second command. The projects extension owns any +project and model prompts, while the agent extension owns agent prompts. + +For an agent with one managed model, the final configuration resembles: + +```yaml +services: + ai-project: + host: azure.ai.project + deployments: + - name: chat + model: + format: OpenAI + name: gpt-4.1 + version: "2025-04-14" + sku: + name: GlobalStandard + capacity: 10 + agent: + host: azure.ai.agent + project: src + uses: + - ai-project +``` + +The agent request adds the `agentsV2` model capability constraint. A model +available in a location without that capability is excluded even if unrelated +usage data is empty or unknown. For a manifest with multiple managed models, +the agent extension delegates one deployment at a time and injects each final +deployment name into the manifest. Only the first resolved model updates the +legacy `AZURE_AI_MODEL_DEPLOYMENT_NAME` value. + +If the agent chooses an existing Azure deployment, it verifies the deployment +and injects the existing name without calling `project deployment add`. When +that external deployment is the first resolved model, agents also persists it +as `AZURE_AI_MODEL_DEPLOYMENT_NAME`. + +Existing agent automation remains valid: + +```bash +azd ai agent init \ + --project-id "" \ + --model OpenAI/gpt-4.1 \ + --infra +``` + +The agent maps `--project-id` and `--infra` into its project init request and +maps `--model` into a deployment add request when a new managed deployment is +needed. These three project-authoring flags write a deprecation notice to +stderr with the equivalent project command. `--model-deployment` remains an +agent-owned input for an existing external deployment and does not show that +notice. Plain `azd ai agent init` remains supported and shows no deprecation +notice. + +`azd ai agent init --output json` writes exactly one JSON document. Nested +project operations never write their own JSON document to stdout: + +```json +{ + "serviceName": "agent", + "projectServiceName": "ai-project", + "models": { + "chat": "chat" + } +} +``` + +### 1.8 Adopt or scan an existing agent project + +Agent adoption treats `azure.ai.project` as an opaque host dependency. It +discovers the single project service key and merges that key into the agent +service's `uses` list. It does not parse or rewrite the project's `endpoint`, +`deployments`, or `network`. + +Hand-authored `uses` entries keep their order. The project key is appended only +when no case-sensitive exact entry exists. Existing duplicates are not +silently removed because that would modify unrelated user-authored content. + +### 1.9 Migrate a pre-split configuration + +The provisioning provider continues to accept the legacy +`azure.ai.agent` and `microsoft.foundry` hosts during migration. When no +`azure.ai.project` service exists and exactly one legacy service contains +project-owned fields, `project init` creates a dedicated project service and +copies only raw `endpoint`, `deployments`, and `network` values. It does not +remove the legacy fields. + +```text +Foundry project configuration was copied to services.ai-project. +Legacy project fields were left unchanged for compatibility. +``` + +The new key follows the normal deterministic naming rules. A legacy service +with a service-level `$ref` is not automatically copied because doing so would +materialize or relocate referenced content. The provider compatibility path +continues to work, but project init fails without mutation and tells the user +how to make the split explicit. + +This compatibility is removed only after the coordinated extension rollout +described in section 2.14. + +### 1.10 Generate infrastructure + +Without `--infra`, the `microsoft.foundry` provider keeps infrastructure +generation internal. Users who want editable files can eject either supported +format during project initialization: + +```bash +azd ai project init --infra +azd ai project init --infra=terraform +``` + +A bare `--infra` means `--infra=bicep`. Because the flag has an optional value, +Terraform uses the equals form. The implementation moves the current +`parseInfraProvider` and `ejectInfra` behavior from agents into projects: + +- Bicep writes the current `infra/main.bicep` tree and preserves the Foundry + provider behavior. +- Terraform writes the current Terraform tree, stamps + `infra.provider: terraform`, and removes a starter `infra.path`. +- Existing user-owned infrastructure is never merged or overwritten. + +Agent initialization forwards its existing `--infra` value to project init. It +does not retain another project synthesizer or ejection implementation. + +Brownfield ejection is not required for this ownership transfer. Existing +limitations remain tied to issue #9127 and PR #9348. A project that already +uses an incompatible provider receives a structured error before files change. + +### 1.11 Partial failure and retry + +Each delegated operation first parses and validates the complete request, +resolves Azure choices without changing project files, and computes the full +mutation. Project init then applies environment reconciliation before the +narrow `azure.yaml` mutation. Deployment add applies the `azure.yaml` merge +before its optional default-deployment environment write. Both operations +atomically write the result file last. + +If project initialization fails, no deployment or agent service is written. If +one deployment in a multi-model agent succeeds and a later deployment fails, +the successful declaration remains. Rerunning the command recognizes it as an +exact match and resumes with the next model. Agent source already downloaded +to disk also remains available for the retry. + +Environment values are persisted one key at a time through the existing +Environment service. An environment write failure can therefore leave a subset +of the desired values, but it leaves `azure.yaml` unchanged and does not write +a success result. The provisioning provider checks that a managed +existing-project declaration has a matching `AZURE_AI_PROJECT_ID`, so a partial +identity fails closed instead of targeting a different project. + +A retry with an explicit project ID or endpoint reconciles both stores to that +target. When no explicit target is present and the environment ID conflicts +with the service endpoint, interactive mode asks whether to update +`azure.yaml` to the environment project or keep the service endpoint and +reconcile the environment as endpoint-only. `--no-prompt` returns +`project_target_mismatch` and requires an explicit `--project-id` or +`--project-endpoint`. The command never chooses one side silently. + +If all environment writes succeed and the later service mutation fails, the +same recovery rule applies on retry. This cross-file recovery behavior is +required because separate `.env` and `azure.yaml` saves cannot form one +transaction. + +For deployment add, a failure to persist +`AZURE_AI_MODEL_DEPLOYMENT_NAME` may leave a valid deployment declaration. +Rerunning recognizes the declaration as unchanged, retries the environment +write, and then writes the result. + +Temporary request and result directories are removed on success, error, and +context cancellation. + +### 1.12 Teardown + +The ownership split does not change deletion policy: + +- A project created through the new-project path is part of the generated + infrastructure and follows the existing `azd down` behavior. +- A project adopted by resource ID or endpoint has + `USE_EXISTING_AI_PROJECT=true` and is not deleted by `azd down`. +- External model deployments referenced only by an agent are never deleted. +- Managed deployment declarations follow the project provisioning provider's + lifecycle. + +## Part 2: Technical design + +### 2.1 Component boundaries + +| Component | Owns after this change | Must no longer own | +|---|---|---| +| `azure.ai.projects` | Project init, project selection, environment identity, project service mutation, managed deployment selection, Foundry synthesis | Agent source, agent service authoring, agent runtime choices | +| `azure.ai.agents` | Source acquisition, manifest processing, existing deployment references, agent service authoring, agent-specific connections | Project service fields, managed deployment declarations, project selection, project synthesis | +| Core `azd` | Workflow execution, structured workflow error transport, project and environment RPCs | Foundry-specific policy or model selection | + +The current project command tree is registered in +`cli/azd/extensions/azure.ai.projects/internal/cmd/root.go`. The implementation +adds: + +- `newProjectInitCommand` in `internal/cmd/project_init.go`. +- `newProjectDeploymentCommand` and `newProjectDeploymentAddCommand` in + `internal/cmd/project_deployment_add.go`. +- Request and result validation in + `internal/cmd/delegated_contract.go`. +- Narrow service reconciliation in + `internal/cmd/project_service_reconciler.go`. +- Project environment reconciliation in + `internal/cmd/project_environment.go`. +- Model choice and declaration reconciliation in + `internal/cmd/project_deployment.go`. + +Names above are the required implementation layout. Existing helpers should be +moved into these files rather than copied between extensions. + +### 2.1.1 `azure.yaml` schema + +This migration does not change +`cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json`. The +existing schema already defines `endpoint`, `deployments`, `network`, and +deployment item `$ref` values. The command writes the existing deployment +shape shown in section 1.5. + +The project ARM resource ID remains in `AZURE_AI_PROJECT_ID`; it is not added +to the service. This avoids two persisted authorities for identity. The +request and result files are local extension orchestration contracts, not +`azure.yaml` schema. + +### 2.2 Public command contracts + +#### `azd ai project init` + +```text +Usage: + azd ai project init [flags] + +Flags: + --project-id string Existing Foundry project ARM resource ID + --project-endpoint string Existing Foundry project endpoint + --infra string[="bicep"] Eject Bicep or Terraform infrastructure + --force Replace a different configured project +``` + +`--project-id` and `--project-endpoint` are mutually exclusive. `--infra` +accepts `bicep` or `terraform`; a bare flag resolves to `bicep`. `--force` +removes the interactive replacement confirmation, but it does not bypass +schema validation, infrastructure conflict checks, or the service-level +`$ref` restriction. + +The action returns this logical result: + +```json +{ + "schemaVersion": 1, + "producerVersion": "", + "serviceName": "chat-project", + "mode": "existing-id", + "mutation": "created", + "endpoint": "https://ai-account.services.ai.azure.com/api/projects/chat-project", + "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ai-rg/providers/Microsoft.CognitiveServices/accounts/ai-account/projects/chat-project" +} +``` + +Allowed `mode` values are `new`, `existing-id`, and `existing-endpoint`. +Allowed `mutation` values are `created`, `updated`, `migrated`, and +`unchanged`. `endpoint` and `resourceId` are omitted when unavailable. + +#### `azd ai project deployment add` + +```text +Usage: + azd ai project deployment add [flags] + +Flags: + --model string Model name or publisher/model + --name string Deployment name + --force Replace a conflicting inline declaration +``` + +The implementation retains the existing advanced model inputs currently used +by agent initialization when they are needed to disambiguate non-interactive +selection. Inherited azd execution options such as `--no-prompt` and +`--output` are not redefined by the extension. Subscription and location come +from the active environment or interactive selection. + +The action returns: + +```json +{ + "schemaVersion": 1, + "producerVersion": "", + "serviceName": "ai-project", + "deploymentName": "chat", + "model": { + "format": "OpenAI", + "name": "gpt-4.1", + "version": "2025-04-14" + }, + "sku": { + "name": "GlobalStandard", + "capacity": 10 + }, + "mutation": "created" +} +``` + +Allowed `mutation` values are `created`, `replaced`, and `unchanged`. + +### 2.3 Delegated request and result files + +The extensions compose through the existing `WorkflowService.Run` API, not by +starting a second `azd` process. Two hidden flags add a versioned local IPC +contract to both project actions: + +```text +--request-file +--result-file +``` + +The flags are hidden from help and completion. They are not a public extension +API. They allow coordinated versions of Microsoft-owned extensions to exchange +structured data while the core workflow RPC remains `EmptyResponse`. + +When `--request-file` is present: + +- `--result-file` is required. +- The action rejects overlapping direct action flags. +- The action validates the schema version and every field before invoking + `scaffoldProject`, `Environment.Select`, or any mutation RPC. +- The workflow step explicitly uses `--output=none`. +- The action does not write to stdout. Human prompts and progress use stderr. +- The result is written to a sibling temporary file, flushed, closed, and + renamed over `--result-file` only after all requested mutations succeed. +- The parent validates the result schema and semantic values before using it. +- Paths must be absolute, point to regular files within the parent-created + temporary directory, and must not be symbolic links. + +The parent creates the temporary directory with user-only permissions and +removes it with a deferred cleanup. Request files contain identifiers but no +access tokens, connection secrets, or model credentials. + +The workflow command also carries SDK-managed workspace options instead of +duplicating them in JSON: + +```text +ai project init \ + --request-file= \ + --result-file= \ + --output=none \ + --cwd= \ + --environment= +``` + +Agents resolves the same target project root it uses today and passes it as an +explicit child `--cwd`. It passes `--environment` when the caller selected one. +These step-level values override parent globals under the merge rule in +section 2.4. The projects extension derives an environment name only when the +step does not provide one. Deployment add receives the same `--cwd` and +`--environment` values. + +#### Project init request + +```json +{ + "schemaVersion": 1, + "source": "azure.ai.agents/init", + "sourceVersion": "", + "project": { + "resourceId": "", + "endpoint": "" + }, + "infra": { + "ejectProvider": "terraform" + }, + "requirements": { + "allowedLocations": [ + "eastus2" + ] + }, + "resolveAzureContext": true, + "force": false +} +``` + +`source` is a low-cardinality enum for diagnostics and telemetry. It does not +select a hidden product default. The allowed values in version 1 are +`azure.ai.agents/init` and `azure.ai.projects/direct`. +`sourceVersion` is required for delegated requests and is used only for +compatibility errors and diagnostics. It is not recorded as telemetry. + +An empty `project` object requests the normal new-or-existing selection flow. +`resourceId` and `endpoint` remain mutually exclusive. +`resolveAzureContext` tells the project action whether the caller needs +subscription and location immediately. Agent initialization sets it to +`true`. Direct `--no-prompt` project-only setup sets it to `false`; direct +interactive setup resolves context through its documented prompts. Existing +resource-ID adoption always resolves the tenant and resource regardless of +this field. + +`infra.ejectProvider` is optional. Its allowed non-empty values are `bicep` +and `terraform`. An agent invocation forwards the normalized value of its +existing `--infra` flag. An empty value keeps extension-managed +`microsoft.foundry` infrastructure. + +`requirements.allowedLocations` is optional. When omitted, project init applies +no consumer-specific location restriction. When present, it must contain at +least one location. The projects extension removes case-insensitive +duplicates, filters the existing-project picker and new-project location +choices, and rejects an explicit project ID outside the allowed set. The +constraint can narrow service-supported locations but cannot expand them. + +Agents resolves deployment mode before project init. For code deploy and +prebuilt-image flows, it calls its existing hosted-agent region resolver and +passes the resulting locations in this field. It omits the field for flows +that do not require the hosted-agent restriction. Cancellation and region +lookup failure retain the current agent behavior. + +`force` carries explicit caller consent for replacement prompts. It never +bypasses reference safety, schema validation, provider conflicts, or project +target consistency checks. + +The result uses the public logical result in section 2.2. The agent uses +`serviceName`, `mode`, and `mutation`; it must not rediscover a project service +by assuming the key is `ai-project`. + +#### Deployment add request + +```json +{ + "schemaVersion": 1, + "source": "azure.ai.agents/init", + "sourceVersion": "", + "model": { + "name": "OpenAI/gpt-4.1", + "deploymentName": "chat", + "requiredCapabilities": [ + "agentsV2" + ], + "allowedLocations": [ + "eastus2" + ], + "excludedModelNames": [ + "modelrouter" + ] + }, + "setAsDefault": true, + "force": false +} +``` + +All arrays are optional and preserve caller order after duplicate removal. +Unknown capability names fail validation. `allowedLocations` narrows the +project-derived locations but cannot expand them. `excludedModelNames` is +compared case-insensitively. + +Direct invocation builds the same in-memory request with no required +capabilities and with `setAsDefault=true`. Agent initialization sends +`agentsV2`, sends `setAsDefault=true` only when that managed model is the first +resolved manifest model, and sends one request per managed model. If an +external deployment resolves first, agents persists that name and every +managed request uses `setAsDefault=false`. It forwards its existing `--force` +value to `force`; without that explicit consent, a delegated non-interactive +conflict fails. + +The result uses the public logical result in section 2.2. The agent consumes +only the final `deploymentName` and model mapping. It does not duplicate the +declaration in `azure.yaml`. + +Version 1 readers reject unknown `schemaVersion` values with a structured +compatibility error that names both installed extension versions and the +minimum compatible version. Request readers reject unknown fields so a typo +cannot fall back to a destructive default. Result readers ignore unknown +fields within version 1 so a newer producer can add diagnostic output without +breaking an older consumer. `producerVersion` is required in delegated +results and is not recorded as telemetry. + +### 2.4 Core workflow changes + +`cli/azd/internal/grpcserver/workflow_service.go` currently converts every +workflow failure to a plain gRPC `Internal` status. This loses the extension +error code, category, suggestion, and links created by +`pkg/azdext.WrapError`. + +Add a host-to-extension wrapper in +`cli/azd/grpc/proto/errors.proto`: + +```proto +message WorkflowErrorDetail { + ExtensionError error = 1; +} +``` + +The wrapper is necessary because `ExtensionError` is documented as an +extension-to-host message. The nested value still represents the extension +that failed inside the host workflow. The `WorkflowService.Run` response in +`cli/azd/grpc/proto/workflow.proto` remains `EmptyResponse`. +`WorkflowService.Run` must: + +1. Call `azdext.WrapError(err)` for the workflow error. +2. Put the returned value in `WorkflowErrorDetail` and attach it as a gRPC + status detail. +3. Preserve the current gRPC code selection for callers that do not understand + the detail, except map context cancellation and deadline errors to their + standard gRPC codes. +4. Return that status without logging a second user-facing error. + +Add an `azdext` helper that walks wrapped gRPC errors, extracts +`WorkflowErrorDetail`, and passes its nested value to `azdext.UnwrapError`. +The agents and projects extensions call this helper whenever +`WorkflowService.Run` returns an error. This preserves the originating +structured error at the top-level command. + +`workflowCmdAdapter.ExecuteContext` in `cli/azd/cmd/container.go` currently +appends explicitly changed global parameters, including `--output=json`, after +child arguments. This can override an explicit child value. Add a merge helper +that treats the workflow step as the higher-priority source: + +1. Parse long flag names from the step arguments in both `--name=value` and + `--name value` forms. +2. Drop an inherited global argument when the step already supplies that flag. +3. Append only the remaining inherited arguments. +4. Preserve the current special handling for `--environment`. + +Every delegated project step supplies `--output=none`. +`scaffoldProject` supplies the same option to its nested `azd init` workflow. +The projects commands register `none` as an accepted output value and avoid +calling a formatter in delegated mode. This makes the parent agent command the +only JSON producer while still inheriting `--no-prompt`, tracing, and other +execution options that the step does not override. + +Cancellation from the parent command is already carried through the workflow +context. File writes and Azure calls must use that context and return its +error without converting cancellation into a dependency failure. + +`ProjectService.UnsetServiceConfig` also needs an exact-key correction before +the projects extension relies on it for mode transitions. The current server +constructs `services..` and passes that string to the +dot-path config API. A valid service key such as `my.agent` is therefore +interpreted as two nested keys. + +Keep the existing RPC shape and mutation lock, but implement the unset in the +same exact-key form as `SetServiceConfigValue`: + +1. Load the raw `services` map. +2. Index `services[req.ServiceName]` without parsing the service name as a + config path. +3. Call `config.NewConfig(serviceConfig).Unset(req.Path)` only within that + service map. +4. Save and reload through the existing `ProjectService` path. + +A missing nested path remains an idempotent success. This core change is +required before project init may unset `endpoint` on a discovered service with +a dotted key. It does not add a new RPC or change the meaning of `req.Path`. + +### 2.5 Environment value deletion + +Mode changes require real deletion from `.env`, not an empty string. Add +`UnsetValue` to the Environment service in +`cli/azd/grpc/proto/environment.proto` and implement it in +`cli/azd/internal/grpcserver/environment_service.go`: + +```proto +rpc UnsetValue (UnsetEnvRequest) returns (EmptyResponse); + +message UnsetEnvRequest { + string env_name = 1; + string key = 2; +} +``` + +The server: + +1. Resolves the environment using the same rules as `SetValue`. +2. Calls `environment.DotenvDelete(key)`. +3. Saves the environment. +4. Returns `EmptyResponse`. + +Deleting a missing key is an idempotent success. Invalid environment names or +keys use the same errors as `SetValue`. The generated client exposes +`Environment().UnsetValue`. + +The projects extension batches the desired set and deletion operations in +memory, removes keys from the deletion set when they also have a new value, +applies sets in stable key order, then applies deletions in stable key order. +Each RPC persists one key. On the first failure, the action stops before the +project service mutation and result write. A later invocation recovers through +the explicit-target or interactive mismatch rules in section 2.7. + +This feature does not add a bulk environment mutation API. Such an API would +reduce partial environment states, but it would not make the separate +environment and `azure.yaml` saves transactional. The required safety property +is deterministic recovery without provisioning a different project. + +### 2.6 Project and environment creation + +Move the project-neutral initialization helpers from +`cli/azd/extensions/azure.ai.agents/internal/cmd/init.go` into the projects +extension: + +- `ensureProject` +- `deriveEnvName` +- `scaffoldProject` +- `writeFoundryProvider` + +The projects implementation calls `Project.Get` to detect the workspace. When +no project exists, it invokes `scaffoldProject` only after the delegated +request has passed schema and argument validation. It creates `azure.yaml` +only on that path. It selects or creates the environment before writing +project environment values. + +`writeFoundryProvider` remains a narrow update. It writes +`infra.provider: microsoft.foundry` for a newly scaffolded workspace and +removes only the starter `infra.path` created by that same scaffold. For an +existing workspace: + +- An existing `microsoft.foundry` provider is unchanged. +- An empty provider with no `infra.path` and no owned infrastructure files may + be set to `microsoft.foundry`. +- An empty provider with `infra.path` or existing infrastructure files is + treated as user-owned infrastructure and returns a conflict. +- When project resource generation is required, any other provider returns a + conflict. +- Endpoint-only adoption that needs no resource reconciliation leaves the + provider unchanged. + +The helper does not overwrite a user-owned path, module settings, hooks, +metadata, or service fields. It receives whether `scaffoldProject` created the +workspace in this invocation so it never infers ownership from a path name +alone. + +Move `parseInfraProvider`, `ejectInfra`, and their Bicep and Terraform helpers +from `cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go`. Preserve +the current path safety, existing-file conflict, generated-file ownership, and +provider stamping rules. + +### 2.7 Project mode resolution and Azure lookup + +The project action resolves intent in this order: + +| Priority | Input | Result | +|---|---|---| +| 1 | Explicit `--project-id` or delegated `project.resourceId` | `existing-id` | +| 2 | Explicit `--project-endpoint` or delegated `project.endpoint` | `existing-endpoint` | +| 3 | Existing service endpoint and active `AZURE_AI_PROJECT_ID` | Reuse `existing-id` when they match; otherwise apply the mismatch recovery rule | +| 4 | Active environment `AZURE_AI_PROJECT_ID` only | Reuse `existing-id` after lookup and reconcile the service endpoint | +| 5 | Existing single project service endpoint only | Reuse `existing-endpoint` after validation | +| 6 | Interactive choice | Prompt for new or existing | +| 7 | `--no-prompt` with no existing identity | `new` | + +Before accepting a project, apply delegated +`requirements.allowedLocations`. Filter interactive existing-project and +new-location choices before prompting. Validate an explicit project ID after +ARM lookup and reject it when its location is outside the allowed set. + +An invalid explicit value is a hard error. The action never falls back to a +lower-priority value after explicit input fails validation. When inferred +service and environment identities differ, neither one silently wins: + +- Interactive mode displays both identities. The user may update the service + to the environment project or keep the service endpoint and clear the stale + ARM identity into endpoint-only mode. +- `--no-prompt` returns `project_target_mismatch` and names the explicit + `--project-id` and `--project-endpoint` recovery forms. +- An explicit target remains highest priority and, after normal replacement + confirmation rules, reconciles both stores to that target. + +Move the project resource ID parser and generic project picker from +`cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go` +to the projects extension. The parser must continue to accept resource group +and provider casing differences while validating the exact +`Microsoft.CognitiveServices/accounts//projects/` shape. + +Project lookup returns one normalized structure: + +```go +type resolvedProject struct { + Mode projectMode + ResourceId string + SubscriptionId string + UserTenantId string + ResourceGroupName string + AccountName string + ProjectName string + Location string + Endpoint string + OpenAIEndpoint string +} +``` + +`UserTenantId` is always the tenant used to create credentials. It is never +populated from `Subscription.TenantId` after `PromptSubscription()`. + +Endpoint-only mode parses the account and project names only for display and +environment compatibility. Parsed names are not treated as a verified ARM +identity. + +### 2.8 Environment state reconciliation + +The projects extension becomes the sole writer for project identity values. +Agent-specific values remain with the agents extension. + +| Key | Project mode owner | Rule | +|---|---|---| +| `AZURE_SUBSCRIPTION_ID` | `new`, `existing-id` | Set when context is resolved; retain in endpoint-only mode because it is shared azd context | +| `AZURE_TENANT_ID` | `new`, `existing-id` | Set to the user access tenant when context is resolved; retain in endpoint-only mode because it is shared azd context | +| `AZURE_LOCATION` | `new`, `existing-id` | Set when new-project context is resolved; in existing-ID mode seed from the project only when the value is absent | +| `AZURE_AI_PROJECT_ID` | `existing-id` | Set to the canonical ARM ID; unset in other modes | +| `AZURE_RESOURCE_GROUP` | `new`, `existing-id` | Set from resolved Azure context; clear stale adopted values on a mode switch | +| `AZURE_AI_ACCOUNT_NAME` | `existing-id` | Set from ARM lookup; unset in other modes | +| `AZURE_AI_PROJECT_NAME` | All modes | Set when known; unset when switching to `new` before provisioning | +| `FOUNDRY_PROJECT_ENDPOINT` | Existing modes | Set to the normalized endpoint; unset for a new project that is not yet provisioned | +| `AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT` | Existing modes | Mirror the normalized project endpoint; unset for a new project that is not yet provisioned | +| `AZURE_OPENAI_ENDPOINT` | `existing-id` | Set from the verified account; unset in other modes | +| `AZURE_AI_DEPLOYMENTS_LOCATION` | `new`, `existing-id` | Set from the selected or verified project location; unset in endpoint-only mode | +| `USE_EXISTING_AI_PROJECT` | All modes | Set to `true` for existing modes and `false` for new mode | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Project init, deployment add, and agent external-model path | Unset when project identity changes; set for the first resolved model only | + +The following values stay outside project init's identity reconciler. Their +current agent or provisioning paths continue to own them: + +- `AZURE_CONTAINER_REGISTRY_NAME` +- `AZURE_CONTAINER_REGISTRY_ENDPOINT` +- `APPLICATIONINSIGHTS_CONNECTION_STRING` +- `AZD_AGENT_DEPLOYMENT_MODE` +- `AZD_AGENT_PROTOCOL` +- `AZD_AGENT_RUNTIME` + +Mode transitions use this matrix: + +| From | To | Required cleanup | +|---|---|---| +| `new` | `existing-id` | Replace generated context with verified project ID, names, endpoints, and location | +| `new` | `existing-endpoint` | Remove the project resource group and deployment location; retain shared subscription, tenant, and Azure location values | +| `existing-id` | `new` | Remove project ID, account name, project name, all project endpoint aliases, and adopted resource group before selecting new context | +| `existing-id` | `existing-endpoint` | Remove project ID, account name, OpenAI endpoint, adopted resource group, and deployment location | +| `existing-endpoint` | `new` | Remove endpoint and parsed project name, then select new Azure context | +| `existing-endpoint` | `existing-id` | Replace parsed values with verified ARM values | + +Cleanup is based on the old service endpoint and the presence of +`AZURE_AI_PROJECT_ID`, not on a new hidden mode variable. Only the +project-owned keys listed in the table are candidates for cleanup. Other +environment values are preserved. Every transition to a different normalized +project identity also clears `AZURE_AI_MODEL_DEPLOYMENT_NAME`; a no-op init +keeps it. + +`AI_PROJECT_DEPLOYMENTS` remains the lifecycle projection produced by +`cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go`. +It is not directly persisted by `project init`. + +Project init writes only the active project and environment. It does not call +`setProjectContext` or modify `extensions.ai-projects.context` in user config. +`azd ai project set` remains the explicit command for changing that global +default. + +### 2.9 Project service discovery and naming + +Service discovery must share the host constants already defined in +`cli/azd/extensions/azure.ai.projects/internal/provisioning/provisioning_provider.go`: + +- `FoundryProjectHost` +- `FoundryProjectServiceHosts` +- `FoundryLegacyProvisioningHosts` +- `FoundryProvisioningServiceHosts` + +Do not introduce a second list in the command package. Move the constants to a +small internal contract package only if command implementation creates an +import cycle. The current `internal/cmd/root.go` already imports +`internal/provisioning`, so the expected implementation reuses the exported +provisioning constants directly. + +Before mutation: + +1. Load the project through `Project.Get`. +2. Find services whose host is in `FoundryProjectServiceHosts`. +3. Return the existing key when exactly one exists. +4. Continue to legacy discovery when none exists. +5. Return a structured ambiguity error when more than one exists. + +For a new service, choose the key in this order: + +1. The existing project service key, when one was found. +2. The sanitized Foundry project name when it is non-empty and unused. +3. `ai-project` when unused. +4. The first unused `ai-project-`, starting at 2. + +Sanitization uses the same service-name rules as the core project service. A +key collision is checked against all services, not only Foundry hosts. + +`Project.AddService` in +`cli/azd/internal/grpcserver/project_service.go` replaces the full existing +service object. The projects extension may call it only when the chosen key +does not exist. Every update to an existing service uses +`SetServiceConfigValue` or `UnsetServiceConfig`. + +### 2.10 Narrow project service reconciliation + +The reconciler keeps persisted and semantic views separate: + +1. Call `Project.Get` for the project path and its environment-expanded service + view. +2. Call `Project.GetConfigSection` with path `services` for the persisted + service map before environment substitution. +3. Index the returned map by the exact service key. Do not construct a dotted + path from the key, because valid service names may contain `.`. +4. Deep-clone the service maps before passing them to + `foundry.ResolveFileRefs`. + +The persisted view determines reference safety and supplies every value used +in a mutation payload. The resolved view is used only for discovery, equality, +and validation. Values returned by `Project.Get` must never be written back to +`azure.yaml`, because doing so could replace `${VAR}` templates with their +current value or an empty string. + +For `project init`, the desired field set is limited to: + +```text +services..endpoint +``` + +For `project deployment add`, the desired field set is limited to: + +```text +services..deployments +``` + +The reconciler does not reconstruct a typed service and does not call +`SetServiceConfigSection` with a partial map. It carries the current raw +service values through each narrow mutation, preserving unknown schema fields, +hooks, `uses`, `network`, and values added by newer extension versions. Comment +preservation remains whatever `ProjectService` currently provides and is not a +new guarantee of this feature. + +Both `SetServiceConfigValue` and `UnsetServiceConfig` must treat the service +name as an exact map key. The core correction in section 2.4 is therefore a +prerequisite for mode transitions on existing services whose keys contain `.`. + +#### Service-level references + +`foundry.ResolveFileRefs` in `cli/azd/pkg/foundry/includes.go` applies a shallow +overlay. Writing an inline `deployments` array over a service-level `$ref` +would hide the entire referenced array. + +When the raw service has `$ref`: + +- Resolve it for discovery and equality checks. +- Succeed when the requested operation is a no-op. +- Reject any endpoint or deployment mutation. +- Return the reference path and suggest editing that file or inlining the + service. + +The initial implementation does not use `foundry.YAMLDocument` with the +`foundry.EditRefFile` target from +`cli/azd/pkg/foundry/includes_edit.go`. That would create a second direct file +write path outside `ProjectService` locking and cache invalidation. + +#### Deployment item references + +For each deployment item: + +1. Preserve the raw item and its index. +2. Resolve an item-level `$ref` for semantic comparison. +3. Normalize the deployment name for case-insensitive lookup. +4. Reject duplicate names already present in the resolved configuration. +5. Treat a semantically identical request as `unchanged`. +6. Reject a conflicting referenced item, including when `--force` is set. +7. Replace a conflicting inline item only when `--force` is set. +8. Append a new inline item after all existing items. + +Semantic equality includes name, model format, model name, model version, SKU +name, and capacity. It ignores YAML key order. Unknown fields on the existing +item are outside the command's managed shape. When all managed fields match, +the command returns `unchanged` and preserves those unknown fields. +Replacement requires explicit `--force` and preserves no unknown fields, so +the confirmation names those fields before proceeding. + +#### Legacy service migration + +When there is no project service, inspect hosts in +`FoundryLegacyProvisioningHosts`. A migration is eligible only when exactly one +legacy service has at least one raw `endpoint`, `deployments`, or `network` +field and has no service-level `$ref`. + +Create the new project service with: + +- `host: azure.ai.project` +- The raw `endpoint`, when present +- The raw `deployments` array, including item-level references +- The raw `network` object + +Do not remove or edit the legacy service. Do not copy `project`, `language`, +`docker`, hooks, `uses`, or unknown agent fields. If multiple eligible legacy +services exist, return an ambiguity error. If a single legacy service uses a +service-level `$ref`, leave it in the provider compatibility path and return +`project_service_ref_update_unsupported` without changing the workspace. + +### 2.11 Managed deployment selection + +Move project and model logic out of these agent files: + +- `cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go` +- `cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go` +- `cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go` + +The projects extension owns the parts currently represented by +`getModelDeploymentDetails`, the model catalog and quota helpers used by that +function, project location resolution, deployment name validation, and the +managed declaration write. + +The agent extension keeps `ProcessModels` because it walks the agent manifest, +distinguishes managed from existing deployments, and injects the selected +deployment names into agent model configuration. Its model resolver becomes +an interface with two paths: + +- Verify and return an existing Azure deployment. +- Delegate creation of a managed declaration and consume the result. + +The selector applies filters in this order: + +1. Resolve the project location or allowed new-project locations. +2. Load catalog models available in those locations. +3. Apply `requiredCapabilities`. +4. Apply `excludedModelNames`. +5. Join quota and usage only to the same location and model. +6. Remove choices with known insufficient quota. +7. Ask for model, version, SKU, and capacity only when unresolved. +8. Validate the final tuple again immediately before mutation. + +Unknown or empty usage from another location must never make a model eligible. +Unknown usage in the selected location may remain selectable only when the +service API treats the SKU as having no enforceable quota check. Otherwise it +produces a quota-unavailable error. + +Direct `project deployment add` uses no hidden preferred model. Agent requests +must pass `agentsV2`, preserving the current `agentModelFilter` behavior. + +The resolved deployment is converted to the existing +`synthesis.Deployment` shape used by +`cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go`. +No second deployment schema is introduced. + +### 2.12 Agent initialization changes + +`configureModelChoice` in +`cli/azd/extensions/azure.ai.agents/internal/cmd/init.go` is split into +agent-owned manifest work and project workflow calls. + +The new order is: + +1. Resolve or download agent source. +2. Read and validate the agent manifest. +3. Resolve deploy mode and prebuilt-image input, then compute any project + location restriction required by that agent mode. +4. Invoke delegated `project init` with the location restriction. +5. For each model, verify an existing deployment or invoke delegated + `project deployment add`. +6. Inject final deployment names into the manifest or generated agent config. +7. Complete agent-specific registry, protocol, runtime, and account + network work. +8. Author the agent service. +9. Merge the returned project service key into agent `uses`. + +The projects extension does not accept an agent manifest or write agent source. +The agent extension does not pass a prebuilt project service map. + +Retain `persistFirstDeploymentName` only for the case where the first resolved +model is an existing external deployment. Managed deployments rely on +`setAsDefault` in the projects request, so agents must not write the same value +again. + +Split `configureFoundryProjectEnv` in +`cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go` +instead of moving it wholesale. The projects extension takes the project +identity writes in section 2.8. The agents extension retains +`configureExistingProjectAcr`, ACR and Application Insights connection +selection, and `foundryAccountNetworkInjected`. + +After project init succeeds, agents reads the verified project identity from +the active environment and performs those agent-specific lookups. This +preserves the current behavior that disables remote build for a +network-injected existing account without adding agent state to the project +result contract. A new project that has not been provisioned has no existing +account network to inspect. + +Code deploy and `--image` currently restrict project selection to hosted-agent +regions. Agents keeps the hosted-agent region lookup and passes those locations +through `requirements.allowedLocations`; projects owns applying the constraint +to project selection. This preserves the existing explicit-ID and interactive +eligibility checks without moving agent deployment policy into projects. + +Existing compatibility flags map directly to delegated request fields: + +| Agent flag | Delegated behavior | +|---|---| +| `--project-id` | Project init `project.resourceId` | +| `--infra[=]` | Project init `infra.ejectProvider` | +| `--model` | Deployment add `model.name` for a managed deployment | +| `--force` | Both requests' `force` field | +| `--model-deployment` | No project request; validate and reference externally | + +Deprecation notices use stderr and are suppressed from structured stdout. Flag +removal is outside this feature and requires a separately announced +compatibility change. + +In `cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go`: + +- Remove project service creation and project endpoint mutation from + `emitResourceServices`. +- Remove managed deployment creation and the legacy deployment reader. +- Keep connection and toolbox handling until those sibling owners define + separate contracts. +- Change `setServiceUses` from replacement to ordered merge. +- Never call `AddService` for a key returned by project init. + +In `cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go`: + +- Remove `foundryDeployments` and `verifyAzureYamlDeployments`. +- Stop interpreting project endpoint and deployment fields. +- Discover one `azure.ai.project` host as an opaque dependency. +- Preserve the existing ambiguity error when more than one host is present. + +The `azure.ai.agents/internal/synthesis` copy is removed after the parity tests +prove that the projects synthesizer accepts every supported pre-split input. +The agent extension invokes the host infrastructure workflow instead of +calling project synthesis directly. + +### 2.13 Provisioning safety + +The projects provisioning provider already chooses brownfield behavior from +`AZURE_AI_PROJECT_ID` in +`cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go`. +The ownership change adds a target consistency check before synthesis or +deployment: + +1. Parse the service endpoint into account and project names when present. +2. Parse `AZURE_AI_PROJECT_ID` with the shared resource ID parser when present. +3. If managed deployments or other project resources require brownfield + targeting, require a project ID. +4. When both values exist, compare account and project names + case-insensitively. +5. Fail with a reconciliation error when they differ. + +This prevents a partial environment transition from provisioning against the +old ARM project while `azure.yaml` names a new endpoint. + +Endpoint-only mode remains valid for commands that use only the data-plane +endpoint. It is not silently upgraded to a resource-managing mode. + +The provider continues to accept `FoundryLegacyProvisioningHosts` during the +rollout. `findFoundryProjectService` uses the shared host contract and keeps +its current single-service requirement. + +### 2.14 Infrastructure ownership and rollout + +The change must ship in compatibility stages. Publishing only the new projects +command before changing the agents writer would leave an older +`emitResourceServices` path able to replace the new project service and remove +`network`, hooks, `uses`, references, or unknown fields. + +#### Stage A: safe legacy writers + +Release `azure.ai.agents` with no public behavior change, but change its +existing project authoring path to: + +- Discover by host instead of assuming a key. +- Use narrow config mutations. +- Merge deployments by case-insensitive name. +- Merge `uses` without replacing unrelated entries. +- Preserve references and unknown project fields. + +This version remains compatible with the current projects extension and core +azd. + +#### Stage B: coordinated ownership transition + +Release together: + +- Core azd with structured workflow errors and `Environment.UnsetValue`. +- `azure.ai.projects` with project init, deployment add, environment + reconciliation, and delegated contract version 1. +- `azure.ai.agents` with delegated project operations and a direct dependency + on the compatible `azure.ai.projects` prerelease. +- `microsoft.foundry` with dependency versions that select the compatible + projects and agents extensions. + +Update `cli/azd/extensions/azure.ai.agents/extension.yaml` so its +`azure.ai.projects` dependency selects the first version that implements +delegated contract version 1. The existing extension dependency resolver is +the primary compatibility gate: normal install and upgrade operations reject +or reconcile an installed projects version outside that constraint. Update the +meta-package constraints to select the same pair. + +No new runtime extension-capability API is required. Request +`schemaVersion` and `sourceVersion` continue to detect contract skew after the +command is available. Users who explicitly install or upgrade with +`--no-dependencies` bypass the dependency guarantee and may receive the +structured compatibility or missing-command error from delegation. + +Raise each extension's minimum azd version from the current `>=1.27.1` to the +first core version containing both required APIs. Validate the minimum in +extension manifests and registry metadata. + +As of the source design, the relevant package versions are +`azure.ai.projects` `1.0.0-beta.5`, `azure.ai.agents` `1.0.0-beta.9`, and +`microsoft.foundry` `1.0.0-beta.2`. The implementation uses the next +coordinated prerelease versions rather than hard-coding a partial combination. + +#### Stage C: duplicate removal + +After the coordinated versions are available and telemetry shows no +compatibility regression: + +- Remove project selection and persistence code from agents. +- Remove the agents synthesis copy. +- Remove agents fallback project authoring. +- Announce the version in which legacy service-host provisioning fallback will + be removed. + +The projects command keeps delegated schema version 1 readers for at least one +coordinated extension release cycle after agents stops sending an older shape. + +Stage C is a separate follow-up change, not part of the initial Stage B +implementation assignment. The release owner may approve it only after: + +- At least one complete coordinated prerelease cycle has shipped. +- The exact-version compatibility matrix in section 2.18 is green. +- No confirmed regression remains open for a supported install, upgrade, + agent-init, project-init, or provision path. +- The release issue records the telemetry comparison window and baseline used + to evaluate delegated compatibility errors and command failure rates. + +If any condition is not met, the compatibility readers, legacy host support, +and agents fallback code remain in place. + +### 2.15 Errors + +Both extensions use `cli/azd/pkg/azdext/extension_error.go` and their existing +`internal/exterrors` constructors. Add specific stable codes only where a +caller or support workflow needs to distinguish remediation: + +| Code | Category | Condition | Suggestion | +|---|---|---|---| +| `invalid_project_init_request` | validation | Conflicting or malformed project input | Correct the named flag or request field | +| `unsupported_delegated_schema` | compatibility | Unknown request or result version | Upgrade the named extension | +| `delegated_contract_io_failed` | internal | Request or atomic result file operation fails | Retry and verify access to the system temporary directory | +| `project_service_ambiguous` | validation | More than one project service or migration source | Keep one project service | +| `project_switch_confirmation_required` | validation | Non-interactive implicit project replacement | Pass the explicit replacement ID or endpoint | +| `project_service_ref_update_unsupported` | validation | A service-level `$ref` would be mutated | Edit the referenced file or inline the service | +| `deployment_name_conflict` | validation | Same deployment name with different settings | Choose another name or use `--force` for inline content | +| `deployment_ref_conflict` | validation | Conflicting item is referenced | Edit the referenced deployment file | +| `project_location_unsupported` | validation | Selected project does not meet a delegated location restriction | Choose a project or new-project location supported by the calling experience | +| `managed_deployment_requires_project_id` | dependency | Endpoint-only project requests managed resources | Reinitialize with `--project-id` | +| `project_reconciliation_requires_project_id` | dependency | Endpoint-only init would move managed project declarations to another endpoint | Reinitialize with `--project-id` before changing project identity | +| `project_target_mismatch` | validation | Endpoint and ARM ID name different projects in non-interactive mode | Pass the project to keep with `--project-id` or `--project-endpoint` | +| `unsupported_infra_provider` | validation | Requested eject format is unknown | Pass `--infra=bicep` or `--infra=terraform` | +| `infra_provider_conflict` | validation | Project setup would replace a user-owned provider | Remove `--infra`, use a compatible workspace, or wait for multi-provider support | + +Azure API failures retain status and request identifiers in diagnostic details, +but user-facing messages do not expose tokens or response bodies. A child +error crossing `WorkflowService.Run` retains its code, category, message, +suggestion, and links through the gRPC detail described in section 2.4. + +### 2.16 Telemetry + +Record only low-cardinality fields: + +| Field | Values | +|---|---| +| `ai.project.operation` | `init`, `deployment.add` | +| `ai.project.source` | `direct`, `agent.init` | +| `ai.project.mode` | `new`, `existing.id`, `existing.endpoint` | +| `ai.project.mutation` | `created`, `updated`, `migrated`, `unchanged` | +| `ai.project.deployment.action` | `created`, `replaced`, `unchanged`, `external` | +| `ai.project.infra.mode` | `extension`, `bicep`, `terraform`, `existing`, `unsupported` | + +Do not record subscription IDs, tenant IDs, resource groups, account names, +project names, endpoints, resource IDs, deployment names, file paths, or model +names. + +Each new field is classified as `SystemMetadata` for `FeatureInsight`. Update: + +- `cli/azd/internal/tracing/fields/fields.go` +- `docs/reference/telemetry-data.md` +- The metrics audit schema and matrix +- The telemetry privacy checklist +- Telemetry field and redaction tests + +Fixed enums do not require hashing. Errors record the stable error code, not +the resource value embedded in a message. + +### 2.17 Documentation updates + +Update the projects extension README and command help with: + +- The distinction between project init and deployment add. +- Resource-ID and endpoint-only capabilities. +- Non-interactive requirements. +- Managed versus external deployments. +- The service-level `$ref` mutation limitation. +- The intentional `project deployment add` command hierarchy. + +Update `cli/azd/docs/environment-variables.md` to identify +`azure.ai.projects` as the writer for the project values in section 2.8. +Keep `docs/reference/environment-variables.md` synchronized where it lists the +same values. Update `docs/architecture/extension-framework.md` only for the +generic structured workflow error and environment unset capabilities, not for +Foundry-specific policy. + +Record the preview commands in `docs/reference/feature-status.md`. If delegated +output isolation becomes the recommended pattern for other extension +commands, add it to `docs/guides/adding-a-new-command.md`; otherwise keep that +detail in the projects extension documentation. + +### 2.18 Test strategy + +#### Core azd tests + +Add targeted tests for: + +- `WorkflowService.Run` preserving every `ExtensionError` field in status + details. +- The client helper finding a detail through wrapped gRPC errors and returning + `azdext.UnwrapError`. +- A legacy client receiving the existing gRPC code without understanding the + detail. +- Workflow cancellation and deadline errors retaining their standard gRPC + codes. +- `workflowCmdAdapter` forwarding explicit global options while a delegated + child option shadows a same-named global option. +- Delegated projects and nested scaffold workflows using `--output=none`. +- One valid JSON document from a parent with `--output json`. +- `ProjectService.UnsetServiceConfig` removing a nested value from a service + whose exact key contains `.` without changing a similarly named nested map. +- `Environment.UnsetValue` deleting an existing dotenv key. +- `Environment.UnsetValue` succeeding for a missing key. +- Environment save and validation failures propagating without a + success-shaped response. + +#### Projects extension unit tests + +Add table-driven tests for: + +- Public command registration, help, examples, and structured output. +- Direct flags taking precedence over service and environment defaults. +- Mutual exclusion and delegated schema validation before `scaffoldProject`. +- Request version, source version, unknown-field, and result producer version + validation. +- Direct `--no-prompt` choosing new mode with no project or Azure context. +- Delegated agent init requiring subscription and location when + `resolveAzureContext` is true. +- Delegated project location constraints filtering existing projects and new + locations, and rejecting an ineligible explicit project ID. +- Direct deployment add requiring `--model` in `--no-prompt`. +- Bare `--infra` resolving to Bicep and explicit Terraform resolving only + through the equals form. +- Bicep and Terraform ejection preserving the current generated-file and + provider stamping behavior. +- Existing user-owned infrastructure rejecting an unsafe merge before writes. +- Resource-ID parsing across provider and resource-group casing. +- Credentials using `UserTenantId` for prompted subscriptions. +- Explicit subscriptions calling `LookupTenant()`. +- All six environment mode transitions and every set or unset in section 2.8. +- Default deployment cleanup when project identity changes and preservation on + a no-op init. +- Environment mutation failure returning no delegated result or project config + diff. +- Existing service reuse and deterministic new service naming. +- Mode-transition cleanup on a project service whose key contains `.`. +- Multiple project services failing before mutation. +- New service creation using `AddService` only for an absent key. +- Existing service updates preserving hooks, `uses`, `network`, unknown fields, + and unrelated deployment items. +- Persisted service reads preserving `${VAR}` templates and indexing dotted + service keys without treating them as config paths. +- Eligible legacy service migration and unchanged legacy content. +- Legacy ambiguity and service-level `$ref` migration refusal. +- Service-level `$ref` no-op and mutation refusal. +- Item-level `$ref` equality, conflict, and unrelated inline append. +- Case-insensitive deployment duplicate detection with spelling preservation. +- Inline replacement requiring `--force`. +- Model capability filtering with `agentsV2`. +- Cross-location quota data never enabling a model in another location. +- Deterministic version, SKU, and capacity resolution in `--no-prompt`. +- Endpoint-only managed deployment rejection. +- Endpoint-only project init rejecting a target change with deployment or + network declarations while allowing a same-target no-op. +- Interactive project endpoint and ARM ID mismatch recovery in both + directions. +- Non-interactive mismatch rejection without an explicit target and recovery + with an explicit target. +- Default-deployment environment failure preserving the declaration and + succeeding on retry. +- Atomic result writing and cleanup on success, error, and cancellation. +- Telemetry containing only approved enum values. + +Retain and extend +`cli/azd/extensions/azure.ai.projects/internal/provisioning/contract_parity_test.go` +so shared host values stay aligned. Extend synthesis parity tests before +removing the agents copy. + +#### Agents extension unit tests + +Add tests for: + +- Project init delegated before any model or agent service mutation. +- Deploy mode and image eligibility resolved before project init, with hosted + agent locations forwarded only for restricted modes. +- One deployment request per managed manifest model. +- Every managed agent request carrying `agentsV2`. +- Existing external deployment validation without a deployment add request. +- External-first model selection persisting the default name and making every + later managed request use `setAsDefault=false`. +- Existing account network injection still disabling agent remote build after + project delegation. +- Existing project ACR and Application Insights selection still running before + agent service authoring. +- The first resolved managed model setting `setAsDefault` and later models not + setting it. +- Child result service keys used instead of assuming `ai-project`. +- Explicit delegated working directory and environment reaching project init. +- Project workflow failure preventing agent service authoring. +- Partial multi-model failure preserving prior project declarations and + allowing retry. +- `setServiceUses` preserving hand-authored entries and appending the project + key once. +- Adopt and scan treating the project service as opaque. +- No project endpoint or deployment parsing remaining in agent adoption. +- JSON output containing one document and no child document. +- Temporary delegated files containing no credentials and always being + removed. + +#### End-to-end tests + +Exercise these scenarios against exact extension versions: + +- New project init, managed deployment add, and provision. +- New project init with Bicep ejection. +- New project init with Terraform ejection. +- Existing project resource-ID adoption and managed deployment add. +- Endpoint-only adoption and expected managed-deployment rejection. +- One-command agent initialization with one managed model. +- Agent initialization against a project initialized in a previous command. +- Agent manifest with multiple managed models. +- Agent manifest referencing an existing external deployment. +- Pre-split legacy service migration without deleting legacy fields. +- Existing project service with custom hooks, `uses`, `network`, unknown + fields, and deployment item references. +- Repeating every successful init path with no second diff. +- `--no-prompt --output json` producing one valid document. +- Guest-tenant subscription authentication through the user access tenant. +- A forced service mutation failure after environment reconciliation followed + by a successful retry. +- Stage A agents with the prior projects version, then coordinated Stage B + versions from `microsoft.foundry`. +- Direct agents installation and upgrade selecting a projects version that + satisfies the delegated-contract dependency. + +The authenticated compatibility owner is +`eng/pipelines/ext-azure-ai-agents-live.yml`. Extend that pipeline instead of +creating a second live-Azure pipeline. The current job installs locally built +agents and projects binaries by writing `0.0.0-test` entries directly into +`~/.azd/config.json`. That remains acceptable for the generic golden path, but +it bypasses dependency resolution and cannot satisfy the exact-version cases +above. + +Add a compatibility matrix with these requirements: + +1. Package candidate extensions with the repository's existing local-registry + or bundle tooling so registry entries retain the versions and dependency + constraints from `extension.yaml`. +2. Install through `azd extension install` without `--no-dependencies`; do not + hand-author installed extension records for compatibility jobs. +3. Start each combination from a clean `AZD_CONFIG_DIR`. +4. Assert the installed IDs and versions through + `azd extension list --installed --output json` before invoking a product + command. +5. Pin every prior released artifact by exact version. Never use `latest` in + this matrix. + +The matrix contains: + +| Combination | Version set | Required assertion | +|---|---|---| +| Stage A backward compatibility | Candidate Stage A agents plus the prior released projects version | Existing agent init and provisioning remain successful without a new projects command | +| Stage B meta-package | Candidate `microsoft.foundry` only | Normal dependency resolution installs the coordinated agents and projects versions, then the live golden path succeeds | +| Stage B direct agents | Candidate agents install and upgrade | The resolver selects a projects version satisfying delegated contract version 1 | +| Dependency bypass | Candidate agents with `--no-dependencies` | A missing or incompatible projects command produces the documented structured compatibility or missing-command error | + +Add `E2E_BASE_AZD_CONFIG_DIR` to the agents live runner. When set, the runner +copies that seed instead of the ambient `~/.azd`; document it in the live E2E +README. Each matrix job installs its exact combination into a different seed. +The existing per-mode private config copy and cleanup behavior then remain +unchanged. + +Dependency-resolution assertions that do not require Azure belong in core +functional tests using a temporary file registry. The live pipeline proves only +the command and provisioning combinations that require Azure. The release owner +records both results in the Stage B release issue and blocks publication when +either set fails. + +### 2.19 Implementation and merge plan + +This feature is delivered as independently reviewable PRs. Each PR includes the +tests, telemetry, help, and documentation required by the behavior it changes; +those are not deferred to a final cleanup PR. + +| Order | Pull request | Scope | Prerequisites | Required owner or reviewer | +|---|---|---|---|---| +| 1 | Stage A safe writer | Make the current agents writer use host discovery, narrow mutations, case-insensitive deployment merge, ordered `uses` merge, and reference preservation without changing public behavior | None | Agents component owner | +| 2 | Core composition APIs | Add structured workflow error transport, child-flag precedence, `Environment.UnsetValue`, and exact-key `ProjectService.UnsetServiceConfig`, including generated SDK changes | None | Core azd maintainer | +| 3 | Projects init ownership | Add delegated contract plumbing, project init, project mode and environment reconciliation, narrow service migration, infrastructure ejection, and provisioning target consistency | Core composition APIs | Projects component owner | +| 4 | Projects deployment ownership | Add deployment add, move model and quota selection, enforce location and capability filters, and reconcile managed declarations | Projects init ownership | Projects component owner | +| 5 | Agent delegation | Delegate project init and managed deployments, retain external deployment handling, isolate JSON output, and stop Stage B project authoring in agents | Projects init and deployment ownership; Stage A version released | Agents component owner | +| 6 | Stage B compatibility release | Set exact manifest and registry constraints, raise minimum azd versions, run the compatibility matrix, and publish the coordinated meta-package set | Core and extension behavior PRs merged; PM decisions in Part 3 recorded | Release owner | +| 7 | Stage C cleanup | Remove duplicate agents selection, persistence, synthesis, and fallback code | Stage C gates in section 2.14 satisfied | Agents and projects component owners plus release owner | + +PRs 2 through 4 may be developed concurrently after their shared interfaces are +agreed, but they merge in dependency order. PR 5 must not add a temporary +fallback that bypasses the delegated contract. PR 6 owns release metadata and +go/no-go evidence; an implementation contributor must not infer compatible +versions or approve Stage C from source state alone. + +## Part 3: Dependencies that need PM confirmation + +1. **Infrastructure provider composition.** The command can eject Bicep and + Terraform, but `azure.yaml` still selects one infrastructure provider. PM + must confirm that a workspace with an existing non-Foundry provider fails + rather than attempting an unsafe merge. Multi-provider composition remains + separate work. +2. **Brownfield generated infrastructure.** Issue + [#9127](https://github.com/Azure/azure-dev/issues/9127) and + [PR #9348](https://github.com/Azure/azure-dev/pull/9348) affect how adopted + project resources appear after infrastructure ejection. PM must confirm + whether the ownership transition waits for those experiences or ships with + the current brownfield limitations documented. +3. **Shared-resource teardown.** Issue + [#6215](https://github.com/Azure/azure-dev/issues/6215) tracks broader + existing-resource lifecycle behavior. PM must confirm that this feature + preserves the current `USE_EXISTING_AI_PROJECT` teardown boundary rather + than expanding shared-resource ownership. +4. **Sibling service ownership.** Connections and toolboxes remain in the + agents path during this change. PM must confirm whether follow-up ownership + designs belong to the projects extension or separate extensions before the + Stage C cleanup removes all compatibility code. +5. **Coordinated release train.** PM and release owners must confirm that core + azd, `azure.ai.projects`, `azure.ai.agents`, and `microsoft.foundry` can ship + as one compatible set. If not, Stage B must remain disabled until the + meta-package can prevent an unsafe version combination. The release owner + records the selected versions, compatibility-matrix runs, and Stage B + go/no-go decision in the release issue described in section 2.19. + +## Part 4: New open questions + +No unresolved technical questions remain for the first implementation. The +scope decisions that can change product behavior or release timing are listed +in Part 3 and require PM confirmation before Stage B ships. + +## Summary of required changes + +### Core azd + +- Add a host-to-extension workflow error detail around `ExtensionError`. +- Preserve and unwrap extension errors across `WorkflowService.Run`. +- Make explicit workflow step flags override inherited global flags. +- Add `Environment.UnsetValue` and generated client support. +- Make `ProjectService.UnsetServiceConfig` exact-key safe for dotted service + names. +- Test delegated stdout ownership, cancellation, error compatibility, and + dotenv deletion. +- Raise the core API version consumed by coordinated extension releases. + +### `azure.ai.projects` + +- Register `project init` and `project deployment add` in `internal/cmd/root.go`. +- Add public flags, JSON output, and hidden request and result file flags. +- Implement delegated schema version 1 validation and atomic result writes. +- Move project scaffolding, selection, ARM lookup, tenant, location, and + project environment logic from agents. +- Move Bicep and Terraform infrastructure ejection from agents. +- Implement deterministic project service discovery and key selection. +- Implement narrow endpoint and deployment reconciliation through + `ProjectService`. +- Read persisted services through `Project.GetConfigSection("services")` and + keep expanded values out of mutation payloads. +- Implement legacy service migration without deleting legacy fields. +- Enforce service-level and item-level `$ref` rules. +- Apply delegated project location restrictions to project lookup and + selection. +- Move managed model catalog, capability, quota, SKU, capacity, and deployment + selection from agents. +- Preserve `agentsV2` filtering for delegated agent requests. +- Add project target consistency checks to the provisioning provider. +- Consolidate shared host constants and synthesis parity. +- Add structured errors and low-cardinality telemetry. +- Update command, feature status, environment, telemetry, and extension + framework docs. +- Add the unit and end-to-end coverage in section 2.18. + +### `azure.ai.agents` + +- First release the Stage A narrow project writer and ordered `uses` merge. +- Resolve agent project-location requirements before project delegation. +- Replace project initialization with the delegated project init workflow. +- Replace managed declaration authoring with delegated deployment add calls. +- Keep existing external deployment validation in `ProcessModels`. +- Remove project parsing from adopt and scan. +- Author the agent service only after all project operations succeed. +- Make the agent command the sole JSON producer. +- Remove duplicate project selection, persistence, infrastructure, and + synthesis code after the coordinated transition. +- Pin the direct projects dependency to delegated contract version 1. +- Add delegation, retry, multi-model, adoption, and output isolation tests. + +### `microsoft.foundry` and release metadata + +- Pin compatible projects and agents extension prereleases. +- Raise minimum azd versions after the core APIs ship. +- Prevent partial Stage B extension combinations. +- Document the legacy host fallback removal window.