From 5d9489c5d7b2eed5d5f928c949a215a5f1851fcb Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 13 Aug 2026 13:12:32 +0800 Subject: [PATCH 1/2] feat: add Foundry project ownership core --- cli/azd/extensions/azure.ai.projects/go.mod | 2 +- .../internal/cmd/delegated_contract.go | 284 ++++ .../internal/cmd/project_deployment.go | 605 ++++++++ .../internal/cmd/project_deployment_add.go | 297 ++++ .../internal/cmd/project_environment.go | 239 +++ .../internal/cmd/project_init.go | 1282 +++++++++++++++++ .../internal/cmd/project_ownership_test.go | 514 +++++++ .../cmd/project_service_reconciler.go | 429 ++++++ .../azure.ai.projects/internal/cmd/root.go | 2 + .../internal/exterrors/errors.go | 10 + 10 files changed, 3663 insertions(+), 1 deletion(-) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go diff --git a/cli/azd/extensions/azure.ai.projects/go.mod b/cli/azd/extensions/azure.ai.projects/go.mod index c12354f5e20..5fe91f0b86a 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -15,6 +15,7 @@ require ( go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.yaml.in/yaml/v3 v3.0.4 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 ) @@ -103,6 +104,5 @@ require ( golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) 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..7e7e9b24962 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/version" + + "github.com/spf13/cobra" +) + +const delegatedSchemaVersion = 1 + +const ( + projectInitSourceAgents = "azure.ai.agents/init" +) + +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"` + Requirements delegatedRequirements `json:"requirements"` + 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 projectInitOutput 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 projectDeploymentAddOutput struct { + SchemaVersion int `json:"schemaVersion"` + ProducerVersion string `json:"producerVersion"` + ServiceName string `json:"serviceName"` + DeploymentName string `json:"deploymentName"` + Model deploymentOutputModel `json:"model"` + SKU deploymentOutputSKU `json:"sku"` + Mutation string `json:"mutation"` +} + +type deploymentOutputModel struct { + Format string `json:"format"` + Name string `json:"name"` + Version string `json:"version"` +} + +type deploymentOutputSKU 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 { + return contractValidationError("source must be azure.ai.agents/init") + } + 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 { + return contractValidationError("source must be azure.ai.agents/init") + } + 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 contractCompatibilityError(got int) error { + return exterrors.Compatibility( + "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 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") + } + 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)) + } + // Parent directories may use OS-provided aliases such as macOS /var. + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return contractValidationError(kind + " file must be a regular non-symlink file") + } + return nil +} + +func delegatedProducerVersion() string { + return version.Version +} + +func registerDelegatedContractFlags( + cmd *cobra.Command, + requestFile *string, +) { + cmd.Flags().StringVar(requestFile, "request-file", "", "Delegated request file") + _ = cmd.Flags().MarkHidden("request-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..e7f7741ea12 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "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/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/status" + "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 +} + +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 := deploymentLocations( + model.AllowedLocations, + azureContextLocation(azureContext), + selection.Location, + ) + if err != nil { + return nil, err + } + 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 deploymentLocations( + allowed []string, + projectLocation string, + explicitLocation string, +) ([]string, error) { + locations, err := normalizeLocations(allowed) + if err != nil { + return nil, err + } + if explicitLocation != "" { + if len(locations) > 0 && !locationAllowed(explicitLocation, locations) { + return nil, exterrors.Validation( + "model_deployment_location_not_allowed", + fmt.Sprintf( + "deployment location %q is outside the allowed locations", + explicitLocation, + ), + "choose a deployment location from the allowed locations", + ) + } + return []string{explicitLocation}, nil + } + if projectLocation == "" { + return locations, nil + } + if len(locations) > 0 && !locationAllowed(projectLocation, locations) { + return nil, exterrors.Validation( + "model_deployment_location_not_allowed", + fmt.Sprintf( + "the project location %q is outside the model's allowed locations", + projectLocation, + ), + "choose a model location that includes the project location", + ) + } + return []string{projectLocation}, nil +} + +func azureContextLocation(azureContext *azdext.AzureContext) string { + if azureContext == nil || azureContext.Scope == nil { + return "" + } + return azureContext.Scope.Location +} + +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 + var lastNoMatch error + for _, location := range locations { + locationOptions := *options + locationOptions.Locations = []string{location} + locationCandidates, err := resolveDeploymentCandidatesAtLocation( + ctx, client, azureContext, modelName, &locationOptions, true, + ) + if err != nil { + if isDeploymentNoMatchError(err) { + lastNoMatch = err + continue + } + return nil, err + } + candidates = append(candidates, locationCandidates...) + } + if len(candidates) == 0 && lastNoMatch != nil { + return nil, lastNoMatch + } + return candidates, nil +} + +func isDeploymentNoMatchError(err error) bool { + for current := err; current != nil; current = errors.Unwrap(current) { + st, ok := status.FromError(current) + if !ok { + continue + } + for _, detail := range st.Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if ok && info.Domain == azdext.AiErrorDomain && + (info.Reason == azdext.AiErrorReasonModelNotFound || + info.Reason == azdext.AiErrorReasonNoDeploymentMatch) { + return true + } + } + } + return false +} + +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..2c0c2d7054b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go @@ -0,0 +1,297 @@ +// 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 + 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) + 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 := projectDeploymentAddOutput{ + SchemaVersion: delegatedSchemaVersion, + ProducerVersion: delegatedProducerVersion(), + ServiceName: service.Name, + DeploymentName: selected.Deployment.Name, + Model: deploymentOutputModel{ + Format: selected.Deployment.Model.Format, + Name: selected.Deployment.Model.Name, + Version: selected.Deployment.Model.Version, + }, + SKU: deploymentOutputSKU{ + Name: selected.Deployment.Sku.Name, + Capacity: selected.Deployment.Sku.Capacity, + }, + Mutation: string(mutation), + } + if request != nil { + return nil + } + 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() (*deploymentAddRequest, error) { + if a.flags.requestFile == "" { + return nil, nil + } + if err := validateDelegatedFilePath(a.flags.requestFile, "request", true); err != nil { + return nil, err + } + request := &deploymentAddRequest{} + if err := decodeDelegatedJSON(a.flags.requestFile, request); err != nil { + return nil, err + } + if err := validateProjectDeploymentAddRequest(*request); 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..4dd67304225 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + "strings" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +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 value, exists := oldValues[key]; exists && value != "" { + 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 := client.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: key, + Value: "", + }); err != nil { + return fmt.Errorf("clear project environment value %s: %w", key, err) + } + } + return nil +} + +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..92f33b16ef3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go @@ -0,0 +1,1282 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "text/template" + + "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 + 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) + 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 + } + serviceProjectName := target.ProjectName + if serviceProjectName == "" { + serviceProjectName = projectConfig.GetName() + } + if err := validateProjectServiceMutation( + service, + target.Endpoint, + infraFromRequest(request, a.flags), + ); 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 + } + } + 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 := projectInitOutput{ + SchemaVersion: delegatedSchemaVersion, + ProducerVersion: delegatedProducerVersion(), + ServiceName: serviceName, + Mode: string(target.Mode), + Mutation: mutation, + Endpoint: target.Endpoint, + ResourceID: target.ResourceId, + } + if request != nil { + return nil + } + 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 err := validateDelegatedFilePath(a.flags.requestFile, "request", true); err != nil { + return nil, err + } + request := &projectInitRequest{} + if err := decodeDelegatedJSON(a.flags.requestFile, request); err != nil { + return nil, err + } + if err := validateProjectInitRequest(*request); 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 := "" + projectPath := "/" + path + if index := strings.Index(projectPath, projectEndpointPathPrefix); index >= 0 { + projectName = strings.Trim( + strings.TrimPrefix(projectPath[index:], projectEndpointPathPrefix), + "/", + ) + } + 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 + } + // #nosec G304 + 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") + // #nosec G301 + 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 := writeTerraformEjectedInfra(infraDir, 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 writeTerraformEjectedInfra(infraDir string, parameters map[string]any) error { + variables, includeAcr, err := terraformEjectionVariables(parameters) + if err != nil { + return err + } + if err := copyEmbeddedTerraform(infraDir, includeAcr); err != nil { + return fmt.Errorf("copy Terraform templates: %w", err) + } + if err := renderTerraformOutputs(infraDir, includeAcr); err != nil { + return fmt.Errorf("render Terraform outputs: %w", err) + } + if err := writeJSONFile(filepath.Join(infraDir, "main.tfvars.json"), variables); err != nil { + return fmt.Errorf("write Terraform variables: %w", err) + } + return nil +} + +func terraformEjectionVariables(parameters map[string]any) (map[string]any, bool, error) { + includeAcr, ok := parameters["includeAcr"].(bool) + if !ok { + return nil, false, fmt.Errorf( + "includeAcr parameter has unexpected type %T", + parameters["includeAcr"], + ) + } + deployments, ok := parameters["deployments"].([]synthesis.Deployment) + if !ok { + return nil, false, fmt.Errorf( + "deployments parameter has unexpected type %T", + parameters["deployments"], + ) + } + connections, ok := parameters["connections"].([]synthesis.Connection) + if !ok { + return nil, false, fmt.Errorf( + "connections parameter has unexpected type %T", + parameters["connections"], + ) + } + credentials, ok := parameters["connectionCredentials"].(map[string]map[string]any) + if !ok { + return nil, false, fmt.Errorf( + "connectionCredentials parameter has unexpected type %T", + parameters["connectionCredentials"], + ) + } + // #nosec G101 + return map[string]any{ + "subscription_id": "${AZURE_SUBSCRIPTION_ID}", + "location": "${AZURE_LOCATION}", + "resource_group_name": "${AZURE_RESOURCE_GROUP}", + "environment_name": "${AZURE_ENV_NAME}", + "foundry_project_name": "${AZURE_AI_PROJECT_NAME}", + "principal_id": "${AZURE_PRINCIPAL_ID}", + "resource_token_salt": "${AZD_RESOURCE_TOKEN_SALT}", + "deployments": deployments, + "connections": synthesis.JoinConnectionCredentials(connections, credentials), + }, includeAcr, nil +} + +func copyEmbeddedTerraform(destination string, includeAcr bool) error { + skip := map[string]struct{}{"outputs.tf.tmpl": {}} + if !includeAcr { + skip["acr.tf"] = struct{}{} + } + return copyEmbeddedTree(synthesis.TerraformTemplatesFS(), "templates/terraform", destination, + skip) +} + +func renderTerraformOutputs(destination string, includeAcr bool) error { + const templatePath = "templates/terraform/outputs.tf.tmpl" + source, err := fs.ReadFile(synthesis.TerraformTemplatesFS(), templatePath) + if err != nil { + return fmt.Errorf("read Terraform outputs template: %w", err) + } + tmpl, err := template.New("outputs.tf").Parse(string(source)) + if err != nil { + return fmt.Errorf("parse Terraform outputs template: %w", err) + } + var output bytes.Buffer + if err := tmpl.Execute(&output, struct { + IncludeAcr bool + Layer bool + }{IncludeAcr: includeAcr}); err != nil { + return fmt.Errorf("render Terraform outputs template: %w", err) + } + // #nosec G306 + return os.WriteFile(filepath.Join(destination, "outputs.tf"), output.Bytes(), 0644) +} + +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() { + // #nosec G301 + 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 + } + // #nosec G306 + return os.WriteFile(target, data, 0644) + }) +} + +func writeJSONFile(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + // #nosec G306 + 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..e45561a0340 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net" + "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" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +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 + err := request.validate() + require.Error(t, err) + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Equal(t, azdext.LocalErrorCategoryCompatibility, localErr.Category) +} + +func TestDelegatedRequestRejectsUnknownFields(t *testing.T) { + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + require.NoError(t, os.WriteFile(requestPath, []byte(`{ + "schemaVersion": 1, + "source": "azure.ai.agents/init", + "sourceVersion": "1.0.0", + "unknown": true + }`), 0600)) + request := &projectInitRequest{} + require.Error(t, decodeDelegatedJSON(requestPath, request)) +} + +func TestDelegatedRequestIsInputOnly(t *testing.T) { + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + require.NoError(t, os.WriteFile(requestPath, []byte(`{}`), 0600)) + root := NewRootCommand() + initCommand, _, err := root.Find([]string{"init"}) + require.NoError(t, err) + deploymentCommand, _, err := root.Find([]string{"deployment", "add"}) + require.NoError(t, err) + assert.Nil(t, initCommand.Flags().Lookup("result-file")) + assert.Nil(t, deploymentCommand.Flags().Lookup("result-file")) + assert.NoError(t, validateDelegatedFilePath(requestPath, "request", true)) +} + +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("request-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 TestResolvedProjectFromEndpoint(t *testing.T) { + project, err := resolvedProjectFromEndpoint( + "https://account.services.ai.azure.com/api/projects/foundry-project/", + ) + require.NoError(t, err) + assert.Equal(t, projectModeExistingEndpoint, project.Mode) + assert.Equal(t, "account", project.AccountName) + assert.Equal(t, "foundry-project", project.ProjectName) + assert.Equal( + t, + "https://account.services.ai.azure.com/api/projects/foundry-project", + project.Endpoint, + ) +} + +func TestWriteTerraformEjectedInfra(t *testing.T) { + for _, test := range []struct { + name string + includeAcr bool + }{ + {name: "without ACR", includeAcr: false}, + {name: "with ACR", includeAcr: true}, + } { + t.Run(test.name, func(t *testing.T) { + infraDir := filepath.Join(t.TempDir(), "infra") + require.NoError(t, os.MkdirAll(infraDir, 0750)) + parameters := map[string]any{ + "includeAcr": test.includeAcr, + "deployments": []synthesis.Deployment{{ + Name: "chat", + Model: synthesis.DeploymentModel{ + Format: "OpenAI", + Name: "gpt-4.1", + Version: "2025-04-14", + }, + Sku: synthesis.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, + }}, + "connections": []synthesis.Connection{{ + Name: "search", + Category: "CognitiveSearch", + Target: "https://search.example.com", + AuthType: "ApiKey", + }}, + "connectionCredentials": map[string]map[string]any{ + "search": {"key": "${SEARCH_API_KEY}"}, + }, + } + + require.NoError(t, writeTerraformEjectedInfra(infraDir, parameters)) + + // #nosec G304 + outputs, err := os.ReadFile(filepath.Join(infraDir, "outputs.tf")) + require.NoError(t, err) + assert.Contains(t, string(outputs), "AZURE_AI_PROJECT_ID") + if test.includeAcr { + assert.Contains(t, string(outputs), "AZURE_CONTAINER_REGISTRY_ENDPOINT") + _, err := os.Stat(filepath.Join(infraDir, "acr.tf")) + assert.NoError(t, err) + } else { + assert.NotContains(t, string(outputs), "AZURE_CONTAINER_REGISTRY_ENDPOINT") + _, err := os.Stat(filepath.Join(infraDir, "acr.tf")) + assert.ErrorIs(t, err, os.ErrNotExist) + } + + // #nosec G304 + rawTfvars, err := os.ReadFile(filepath.Join(infraDir, "main.tfvars.json")) + require.NoError(t, err) + tfvars := map[string]any{} + require.NoError(t, json.Unmarshal(rawTfvars, &tfvars)) + assert.Equal(t, "${AZURE_SUBSCRIPTION_ID}", tfvars["subscription_id"]) + assert.Equal(t, "${AZURE_LOCATION}", tfvars["location"]) + assert.Equal(t, "${AZURE_RESOURCE_GROUP}", tfvars["resource_group_name"]) + assert.Equal(t, "${AZURE_ENV_NAME}", tfvars["environment_name"]) + assert.Equal(t, "${AZURE_AI_PROJECT_NAME}", tfvars["foundry_project_name"]) + assert.Equal(t, "${AZURE_PRINCIPAL_ID}", tfvars["principal_id"]) + assert.Equal(t, "${AZD_RESOURCE_TOKEN_SALT}", tfvars["resource_token_salt"]) + assert.NotContains(t, tfvars, "connectionCredentials") + assert.NotContains(t, tfvars, "includeAcr") + + connections, ok := tfvars["connections"].([]any) + require.True(t, ok) + require.Len(t, connections, 1) + connection, ok := connections[0].(map[string]any) + require.True(t, ok) + credentials, ok := connection["credentials"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "${SEARCH_API_KEY}", credentials["key"]) + }) + } +} + +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 TestDeploymentLocationsExplicitSelectionWins(t *testing.T) { + locations, err := deploymentLocations( + []string{"eastus", "westus"}, + "eastus", + "westus", + ) + require.NoError(t, err) + assert.Equal(t, []string{"westus"}, locations) + + _, err = deploymentLocations( + []string{"eastus"}, + "eastus", + "westus", + ) + require.Error(t, err) +} + +func TestDeploymentLocationsUsesProjectLocationByDefault(t *testing.T) { + locations, err := deploymentLocations( + []string{"eastus", "westus"}, + "westus", + "", + ) + require.NoError(t, err) + assert.Equal(t, []string{"westus"}, locations) +} + +func TestDeploymentNoMatchErrorsAreRecoverable(t *testing.T) { + detail := &errdetails.ErrorInfo{ + Domain: azdext.AiErrorDomain, + Reason: azdext.AiErrorReasonNoDeploymentMatch, + } + st, detailErr := status.New( + codes.FailedPrecondition, + "no match", + ).WithDetails(detail) + require.NoError(t, detailErr) + err := st.Err() + assert.True(t, isDeploymentNoMatchError(err)) + assert.True(t, isDeploymentNoMatchError( + fmt.Errorf("resolve: %w", err), + )) + assert.False(t, isDeploymentNoMatchError(status.Error( + codes.PermissionDenied, + "permission denied", + ))) +} + +func TestProjectServiceReferenceMutationPreflight(t *testing.T) { + service := &projectServiceInfo{ + Name: "foundry", + Raw: map[string]any{ + "endpoint": "https://account.services.ai.azure.com/api/projects/old", + }, + Resolved: map[string]any{ + "endpoint": "https://account.services.ai.azure.com/api/projects/old", + }, + ServiceRef: "./services/foundry.yaml", + } + + require.NoError(t, validateProjectServiceMutation( + service, + "https://account.services.ai.azure.com/api/projects/old", + "", + )) + require.Error(t, validateProjectServiceMutation( + service, + "https://account.services.ai.azure.com/api/projects/new", + "", + )) + require.Error(t, validateProjectServiceMutation( + service, + "https://account.services.ai.azure.com/api/projects/old", + "bicep", + )) + + service.Legacy = true + require.Error(t, validateProjectServiceMutation( + service, + "https://account.services.ai.azure.com/api/projects/old", + "", + )) +} + +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 TestProjectEnvironmentClearsOnlyNonEmptyValues(t *testing.T) { + old := map[string]string{ + "AZURE_AI_PROJECT_ID": "old-id", + "AZURE_RESOURCE_GROUP": "", + "AZURE_OPENAI_ENDPOINT": "https://old.openai.azure.com/", + } + plan := planProjectEnvironment(old, projectModeExistingEndpoint, &resolvedProject{ + Endpoint: "https://new.services.ai.azure.com/api/projects/new", + }, false) + + assert.Contains(t, plan.Unsets, "AZURE_AI_PROJECT_ID") + assert.Contains(t, plan.Unsets, "AZURE_OPENAI_ENDPOINT") + assert.NotContains(t, plan.Unsets, "AZURE_RESOURCE_GROUP") +} + +func TestProjectServiceEndpointUsesExactKeyTombstone(t *testing.T) { + server := grpc.NewServer() + projectServer := &recordingProjectServiceServer{} + azdext.RegisterProjectServiceServer(server, projectServer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + client, err := azdext.NewAzdClient( + azdext.WithAddress(listener.Addr().String()), + ) + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + }) + + require.NoError(t, setProjectServiceEndpoint( + t.Context(), client, "project", "", + )) + require.NotNil(t, projectServer.request) + assert.Equal(t, "project", projectServer.request.ServiceName) + assert.Equal(t, "endpoint", projectServer.request.Path) + assert.Equal(t, "", projectServer.request.Value.GetStringValue()) +} + +func TestAddServicePersistsCompleteBodyThroughConfigSection(t *testing.T) { + server := grpc.NewServer() + projectServer := &recordingProjectServiceServer{} + azdext.RegisterProjectServiceServer(server, projectServer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + client, err := azdext.NewAzdClient( + azdext.WithAddress(listener.Addr().String()), + ) + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + }) + + reconciler := &projectServiceReconciler{client: client} + require.NoError(t, reconciler.addService( + t.Context(), + "foundry", + map[string]any{ + "endpoint": "https://account.services.ai.azure.com/api/projects/p", + "deployments": []any{map[string]any{"name": "chat"}}, + "hooks": map[string]any{ + "predeploy": map[string]any{"kind": "sh", "run": "echo ok"}, + }, + "uses": []any{"connection"}, + "env": map[string]any{"PROJECT_MODE": "managed"}, + "customField": "preserve-me", + }, + )) + + require.NotNil(t, projectServer.addRequest) + assert.Nil(t, projectServer.addRequest.Service.AdditionalProperties) + require.NotNil(t, projectServer.sectionRequest) + assert.Equal(t, "foundry", projectServer.sectionRequest.ServiceName) + assert.Empty(t, projectServer.sectionRequest.Path) + section := projectServer.sectionRequest.Section.AsMap() + assert.Equal(t, "azure.ai.project", section["host"]) + assert.Contains(t, section, "deployments") + assert.Contains(t, section, "hooks") + assert.Contains(t, section, "uses") + assert.Contains(t, section, "env") + assert.Equal(t, "preserve-me", section["customField"]) +} + +type recordingProjectServiceServer struct { + azdext.UnimplementedProjectServiceServer + request *azdext.SetServiceConfigValueRequest + addRequest *azdext.AddServiceRequest + sectionRequest *azdext.SetServiceConfigSectionRequest +} + +func (s *recordingProjectServiceServer) AddService( + _ context.Context, + request *azdext.AddServiceRequest, +) (*azdext.EmptyResponse, error) { + s.addRequest = request + return &azdext.EmptyResponse{}, nil +} + +func (s *recordingProjectServiceServer) SetServiceConfigSection( + _ context.Context, + request *azdext.SetServiceConfigSectionRequest, +) (*azdext.EmptyResponse, error) { + s.sectionRequest = request + return &azdext.EmptyResponse{}, nil +} + +func (s *recordingProjectServiceServer) SetServiceConfigValue( + _ context.Context, + request *azdext.SetServiceConfigValueRequest, +) (*azdext.EmptyResponse, error) { + s.request = request + return &azdext.EmptyResponse{}, nil +} + +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..665818ea56c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go @@ -0,0 +1,429 @@ +// 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 + } + } + if err := setProjectServiceEndpoint(ctx, r.client, service.Name, endpoint); err != nil { + return "", "", err + } + _ = mode + return service.Name, "updated", nil +} + +func setProjectServiceEndpoint( + ctx context.Context, + client *azdext.AzdClient, + serviceName, endpoint string, +) error { + value, err := structpb.NewValue(endpoint) + if err != nil { + return err + } + if _, err := client.Project().SetServiceConfigValue(ctx, + &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "endpoint", + Value: value, + }); err != nil { + return fmt.Errorf("update project service %q endpoint: %w", serviceName, err) + } + return 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) + } + if body == nil { + body = map[string]any{} + } + body["host"] = provisioning.FoundryProjectHost + 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, + }, + }) + if err != nil { + return fmt.Errorf("add project service %q: %w", name, err) + } + if len(body) == 1 { + return nil + } + if _, err := r.client.Project().SetServiceConfigSection( + ctx, + &azdext.SetServiceConfigSectionRequest{ + ServiceName: name, + Section: properties, + }, + ); err != nil { + return fmt.Errorf("persist project service %q configuration: %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), + ) +} + +func validateProjectServiceMutation( + service *projectServiceInfo, + endpoint string, + infra string, +) error { + if service == nil || service.ServiceRef == "" { + return nil + } + if service.Legacy || infra != "" { + return projectServiceRefError(service.Name, service.ServiceRef) + } + if endpoint != "" { + normalized, _, err := validateProjectEndpoint(endpoint) + if err != nil { + return err + } + endpoint = normalized + } + if equalProjectEndpoint(serviceEndpoint(service.Resolved), endpoint) { + return nil + } + if endpoint == "" { + if _, exists := service.Raw["endpoint"]; !exists { + return nil + } + } + return projectServiceRefError(service.Name, service.ServiceRef) +} + +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/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{ From ae7802113a6a9ddaa9bd86c77eb87eada6b4cdb6 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 13 Aug 2026 14:39:27 +0800 Subject: [PATCH 2/2] fix: harden Foundry project ownership flows --- .../extensions/azure.ai.projects/README.md | 30 +++ .../internal/cmd/project_deployment_add.go | 24 ++- .../internal/cmd/project_init.go | 179 ++++++++++++++---- .../internal/cmd/project_ownership_test.go | 60 ++++++ 4 files changed, 258 insertions(+), 35 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 9a03c2eb064..6bbc48396db 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -27,6 +27,36 @@ services: When `endpoint` is omitted, `azd provision` creates a Foundry account and project. When it is set, provisioning reuses that project and reconciles the declarations that can be applied to an existing account. +## Project authoring + +Initialize a new Foundry project in the current azd workspace: + +```sh +azd ai project init +``` + +For a new project, managed deployment declarations can be added before the +first provision: + +```sh +azd ai project init +azd ai project deployment add --model +azd provision +``` + +To use an existing project in automation, initialize it with its full ARM +resource ID. This stores the project identity in the active azd environment +and allows managed deployment declarations to be reconciled: + +```sh +azd ai project init --project-id "" +azd ai project deployment add --model +``` + +An endpoint-only project is suitable for configuration that does not manage +resources on the existing project. Use the full project resource ID before +adding managed deployments. + To reconcile deployments, connections, or a pending container registry on an existing project, set the project's full ARM resource ID in the active azd environment: ```sh 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 index 2c0c2d7054b..7285e77c6e0 100644 --- 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 @@ -147,11 +147,13 @@ func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error { if err != nil { return err } - if values["AZURE_AI_PROJECT_ID"] == "" { + if requiresExistingProjectID(values, service) { 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", + "managed model deployments for an existing Foundry project "+ + "require a resource ID", + "rerun `azd ai project init --project-id ` "+ + "before adding managed deployments", ) } azureContext := &azdext.AzureContext{ @@ -266,6 +268,22 @@ func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error { return nil } +func requiresExistingProjectID( + values map[string]string, + service *projectServiceInfo, +) bool { + if strings.TrimSpace(values["AZURE_AI_PROJECT_ID"]) != "" { + return false + } + if strings.EqualFold( + strings.TrimSpace(values["USE_EXISTING_AI_PROJECT"]), + "true", + ) { + return true + } + return service != nil && serviceEndpoint(service.Resolved) != "" +} + func (a *ProjectDeploymentAddAction) loadRequest() (*deploymentAddRequest, error) { if a.flags.requestFile == "" { return nil, nil 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 index 92f33b16ef3..439f8cad6c0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go @@ -182,7 +182,11 @@ func (a *ProjectInitAction) Run(ctx context.Context) error { ); err != nil { return err } - if err := validateAllowedProjectLocation(target, allowedLocations(request)); err != nil { + if err := validateAllowedProjectLocation( + target, + allowedLocations(request), + oldValues["AZURE_LOCATION"], + ); err != nil { return err } if target.Mode == projectModeExistingEndpoint { @@ -653,18 +657,29 @@ func explicitProjectTarget( strings.TrimSpace(flags.projectEndpoint) != "" } -func validateAllowedProjectLocation(project *resolvedProject, allowed []string) error { - if project == nil || len(allowed) == 0 || project.Location == "" { +func validateAllowedProjectLocation( + project *resolvedProject, + allowed []string, + fallbackLocation string, +) error { + if project == nil || len(allowed) == 0 { + return nil + } + location := strings.TrimSpace(project.Location) + if location == "" { + location = strings.TrimSpace(fallbackLocation) + } + if location == "" { return nil } - for _, location := range allowed { - if strings.EqualFold(strings.TrimSpace(location), project.Location) { + for _, allowedLocation := range allowed { + if strings.EqualFold(strings.TrimSpace(allowedLocation), location) { return nil } } return exterrors.Validation( "project_location_not_allowed", - fmt.Sprintf("project location %q is outside the allowed locations", project.Location), + fmt.Sprintf("project location %q is outside the allowed locations", location), "choose a project in one of the allowed locations", ) } @@ -957,19 +972,98 @@ func writeFoundryProvider( project.GetInfra().GetProvider() != "" { return nil } - value, err := structpb.NewValue(provisioning.FoundryProviderName) + oldProvider, oldPath := projectInfraConfig(project) + if err := setProjectConfigString( + ctx, + client, + "infra.provider", + provisioning.FoundryProviderName, + ); err != nil { + return fmt.Errorf("set Foundry infrastructure provider: %w", err) + } + if err := unsetProjectConfigValue(ctx, client, "infra.path"); err != nil { + operationErr := fmt.Errorf("remove starter infrastructure path: %w", err) + if restoreErr := restoreProjectInfraConfig( + ctx, + client, + oldProvider, + oldPath, + ); restoreErr != nil { + return errors.Join( + operationErr, + fmt.Errorf("restore project infrastructure config: %w", restoreErr), + ) + } + return operationErr + } + return nil +} + +func projectInfraConfig(project *azdext.ProjectConfig) (provider, path string) { + if project == nil || project.GetInfra() == nil { + return "", "" + } + return project.GetInfra().GetProvider(), project.GetInfra().GetPath() +} + +func setProjectConfigString( + ctx context.Context, + client *azdext.AzdClient, + path, value string, +) error { + structValue, err := structpb.NewValue(value) 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) + _, err = client.Project().SetConfigValue(ctx, + &azdext.SetProjectConfigValueRequest{ + Path: path, + Value: structValue, + }) + return err +} + +func unsetProjectConfigValue( + ctx context.Context, + client *azdext.AzdClient, + path string, +) error { + _, err := client.Project().UnsetConfig(ctx, + &azdext.UnsetProjectConfigRequest{Path: path}) + return err +} + +func restoreProjectInfraConfig( + ctx context.Context, + client *azdext.AzdClient, + provider, path string, +) error { + var restoreErrs []error + if provider == "" { + if err := unsetProjectConfigValue(ctx, client, "infra.provider"); err != nil { + restoreErrs = append(restoreErrs, err) + } + } else if err := setProjectConfigString( + ctx, + client, + "infra.provider", + provider, + ); err != nil { + restoreErrs = append(restoreErrs, err) } - if _, err := client.Project().UnsetConfig(ctx, - &azdext.UnsetProjectConfigRequest{Path: "infra.path"}); err != nil { - return fmt.Errorf("remove starter infrastructure path: %w", err) + if path == "" { + if err := unsetProjectConfigValue(ctx, client, "infra.path"); err != nil { + restoreErrs = append(restoreErrs, err) + } + } else if err := setProjectConfigString( + ctx, + client, + "infra.path", + path, + ); err != nil { + restoreErrs = append(restoreErrs, err) } - return nil + return errors.Join(restoreErrs...) } func validateFoundryProvider(project *azdext.ProjectConfig) error { @@ -1050,6 +1144,7 @@ func ejectProjectInfra( "remove --infra or change the project to microsoft.foundry explicitly", ) } + oldProvider, oldPath := projectInfraConfig(projectResponse.GetProject()) projectFile, err := projectFilePath(projectRoot) if err != nil { return err @@ -1087,42 +1182,62 @@ func ejectProjectInfra( if err := os.MkdirAll(infraDir, 0755); err != nil { return fmt.Errorf("create infra directory: %w", err) } + cleanup := func(operationErr error) error { + if cleanupErr := os.RemoveAll(infraDir); cleanupErr != nil { + return errors.Join( + operationErr, + fmt.Errorf("remove generated infrastructure: %w", cleanupErr), + ) + } + return operationErr + } + rollback := func(operationErr error) error { + operationErr = cleanup(operationErr) + if restoreErr := restoreProjectInfraConfig( + ctx, + client, + oldProvider, + oldPath, + ); restoreErr != nil { + return errors.Join( + operationErr, + fmt.Errorf("restore project infrastructure config: %w", restoreErr), + ) + } + return operationErr + } if provider == provisioning.TerraformProviderName { if result.NetworkMode != synthesis.NetworkModeNone { - _ = os.RemoveAll(infraDir) - return exterrors.Validation( + return cleanup(exterrors.Validation( "infra_eject_network_unsupported", "Terraform ejection does not support the project's network block", "eject Bicep instead", - ) + )) } if err := writeTerraformEjectedInfra(infraDir, result.Parameters); err != nil { - _ = os.RemoveAll(infraDir) - return err + return cleanup(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 := setProjectConfigString( + ctx, + client, + "infra.provider", + provisioning.TerraformProviderName, + ); err != nil { + return rollback(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) + if err := unsetProjectConfigValue(ctx, client, "infra.path"); err != nil { + return rollback(fmt.Errorf("remove infra.path: %w", err)) } } else { if err := copyEmbeddedBicep(infraDir); err != nil { - _ = os.RemoveAll(infraDir) - return err + return cleanup(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 cleanup(err) } } return nil 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 index e45561a0340..24167fcec27 100644 --- 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 @@ -263,6 +263,66 @@ func TestDeploymentLocationsUsesProjectLocationByDefault(t *testing.T) { assert.Equal(t, []string{"westus"}, locations) } +func TestRequiresExistingProjectID(t *testing.T) { + existingEndpointService := &projectServiceInfo{ + Resolved: map[string]any{ + "endpoint": "https://account.services.ai.azure.com/api/projects/p", + }, + } + tests := []struct { + name string + values map[string]string + service *projectServiceInfo + want bool + }{ + { + name: "greenfield", + values: map[string]string{"USE_EXISTING_AI_PROJECT": "false"}, + }, + { + name: "existing endpoint marker", + values: map[string]string{"USE_EXISTING_AI_PROJECT": "true"}, + want: true, + }, + { + name: "existing endpoint service", + service: existingEndpointService, + want: true, + }, + { + name: "existing project ID", + values: map[string]string{"AZURE_AI_PROJECT_ID": "project-id"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal( + t, + test.want, + requiresExistingProjectID(test.values, test.service), + ) + }) + } +} + +func TestValidateAllowedProjectLocationUsesFallback(t *testing.T) { + require.Error(t, validateAllowedProjectLocation( + &resolvedProject{}, + []string{"eastus"}, + "westus", + )) + require.NoError(t, validateAllowedProjectLocation( + &resolvedProject{}, + []string{"eastus"}, + "eastus", + )) + require.NoError(t, validateAllowedProjectLocation( + &resolvedProject{Location: "eastus"}, + []string{"eastus"}, + "westus", + )) +} + func TestDeploymentNoMatchErrorsAreRecoverable(t *testing.T) { detail := &errdetails.ErrorInfo{ Domain: azdext.AiErrorDomain,