From 958beb40fb67456deeeaba79c19ffef2078efc48 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 13 Aug 2026 13:13:05 +0800 Subject: [PATCH] feat: add Foundry project infrastructure --- .../internal/cmd/delegated_contract.go | 49 +- .../internal/cmd/project_deployment.go | 59 +- .../internal/cmd/project_deployment_add.go | 3 +- .../internal/cmd/project_environment.go | 4 +- .../internal/cmd/project_infra_layers.go | 1141 +++++++++++++++++ .../internal/cmd/project_init.go | 237 ++-- .../internal/cmd/project_ownership_test.go | 386 +++++- .../cmd/project_service_reconciler.go | 89 +- .../internal/exterrors/codes.go | 5 + 9 files changed, 1854 insertions(+), 119 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_infra_layers.go 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 index 7e7e9b24962..cb02345b69c 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go @@ -18,7 +18,7 @@ import ( "github.com/spf13/cobra" ) -const delegatedSchemaVersion = 1 +const delegatedSchemaVersion = 2 const ( projectInitSourceAgents = "azure.ai.agents/init" @@ -27,6 +27,7 @@ const ( type delegatedProject struct { ResourceID string `json:"resourceId,omitempty"` Endpoint string `json:"endpoint,omitempty"` + Name string `json:"name,omitempty"` } type delegatedInfra struct { @@ -37,6 +38,23 @@ type delegatedRequirements struct { AllowedLocations []string `json:"allowedLocations,omitempty"` } +type delegatedDeployment struct { + Name string `json:"name"` + Model delegatedDeploymentModel `json:"model"` + SKU delegatedDeploymentSKU `json:"sku"` +} + +type delegatedDeploymentModel struct { + Format string `json:"format"` + Name string `json:"name"` + Version string `json:"version"` +} + +type delegatedDeploymentSKU struct { + Name string `json:"name"` + Capacity int `json:"capacity"` +} + // projectInitRequest is the versioned IPC contract for agents. type projectInitRequest struct { SchemaVersion int `json:"schemaVersion"` @@ -47,11 +65,17 @@ type projectInitRequest struct { Requirements delegatedRequirements `json:"requirements"` ResolveAzureContext bool `json:"resolveAzureContext"` Force bool `json:"force"` + ReplaceDeployments bool `json:"replaceDeployments,omitempty"` + Deployments []delegatedDeployment `json:"deployments,omitempty"` } type delegatedModel struct { Name string `json:"name"` DeploymentName string `json:"deploymentName,omitempty"` + Format string `json:"format,omitempty"` + Version string `json:"version,omitempty"` + SKU string `json:"sku,omitempty"` + Capacity int32 `json:"capacity,omitempty"` RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` AllowedLocations []string `json:"allowedLocations,omitempty"` ExcludedModelNames []string `json:"excludedModelNames,omitempty"` @@ -129,6 +153,29 @@ func (r *projectInitRequest) validate() error { return contractValidationError("requirements.allowedLocations must contain a location") } r.Requirements.AllowedLocations = locations + if len(r.Deployments) > 0 && !r.ReplaceDeployments { + return contractValidationError( + "deployments requires replaceDeployments", + ) + } + if r.ReplaceDeployments { + seen := make(map[string]struct{}, len(r.Deployments)) + for _, deployment := range r.Deployments { + name := strings.TrimSpace(deployment.Name) + if name == "" { + return contractValidationError( + "deployment names cannot be empty", + ) + } + key := strings.ToLower(name) + if _, exists := seen[key]; exists { + return contractValidationError( + fmt.Sprintf("deployment %q is duplicated", name), + ) + } + seen[key] = struct{}{} + } + } return nil } 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 index e7f7741ea12..79e237d302d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go @@ -73,9 +73,21 @@ func selectModelDeployment( noPrompt bool, ) (*selectedDeployment, error) { modelFormat, modelName := splitModelReference(model.Name) + if model.Format != "" { + modelFormat = model.Format + } if modelName == "" { return nil, contractValidationError("model.name is required") } + if model.Version != "" { + selection.Version = model.Version + } + if model.SKU != "" { + selection.SKU = model.SKU + } + if model.Capacity > 0 { + selection.Capacity = model.Capacity + } locations, err := deploymentLocations( model.AllowedLocations, @@ -183,7 +195,7 @@ func selectModelDeployment( if location == "" && len(locations) == 1 { location = locations[0] } - return &selectedDeployment{ + selected := &selectedDeployment{ Deployment: synthesis.Deployment{ Name: chooseDeploymentName(model.DeploymentName, candidate.GetModelName()), Model: synthesis.DeploymentModel{ @@ -197,7 +209,50 @@ func selectModelDeployment( }, }, Location: location, - }, nil + } + if model.Format != "" && + !strings.EqualFold(selected.Deployment.Model.Format, model.Format) { + return nil, exterrors.Validation( + "model_deployment_unavailable", + fmt.Sprintf( + "model %q does not have the requested format %q", + modelName, model.Format, + ), + "specify a deployment format supported by the selected model", + ) + } + if model.Version != "" && selected.Deployment.Model.Version != model.Version { + return nil, exterrors.Validation( + "model_deployment_unavailable", + fmt.Sprintf( + "model %q does not have the requested version %q", + modelName, model.Version, + ), + "specify a deployment version supported by the selected model", + ) + } + if model.SKU != "" && + !strings.EqualFold(selected.Deployment.Sku.Name, model.SKU) { + return nil, exterrors.Validation( + "model_deployment_unavailable", + fmt.Sprintf( + "model %q does not have the requested SKU %q", + modelName, model.SKU, + ), + "specify a deployment SKU supported by the selected model", + ) + } + if model.Capacity > 0 && selected.Deployment.Sku.Capacity != int(model.Capacity) { + return nil, exterrors.Validation( + "model_deployment_unavailable", + fmt.Sprintf( + "model %q does not have the requested capacity %d", + modelName, model.Capacity, + ), + "specify a deployment capacity supported by the selected model", + ) + } + return selected, nil } func deploymentLocations( 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..3dd62473a9f 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,7 +147,8 @@ func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error { if err != nil { return err } - if values["AZURE_AI_PROJECT_ID"] == "" { + if values["AZURE_AI_PROJECT_ID"] == "" && + serviceEndpoint(service.Resolved) != "" { return exterrors.Validation( "project_deployment_requires_id", "managed model deployments require an existing Foundry project resource ID", 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 index 4dd67304225..72cbbc33284 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_environment.go @@ -55,8 +55,8 @@ func planProjectEnvironment( sets[key] = value } } - // Do not overwrite a preselected location when ARM omits one. - if project.Location != "" { + // Keep the resource-group location selected by the user. + if project.Location != "" && oldValues["AZURE_LOCATION"] == "" { sets["AZURE_LOCATION"] = project.Location } case projectModeExistingEndpoint: diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_infra_layers.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_infra_layers.go new file mode 100644 index 00000000000..efa4cb0b7a4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_infra_layers.go @@ -0,0 +1,1141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/provisioning" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "go.yaml.in/yaml/v3" +) + +const ( + projectDefaultInfraPath = "infra" + projectDefaultInfraModule = "main" + projectFoundryLayerName = "foundry" + projectFoundryLayerPath = "infra/foundry" + projectTerraformMarker = ".azd-foundry" + projectTerraformMarkerV1 = "terraform-v1\n" +) + +type projectInfraEjectPlan struct { + targetDir string + targetPath string + module string + layer bool + mergeExisting bool + updatedYAML []byte + updateDescription string +} + +type projectInfraTarget struct { + dir string + exists bool + empty bool +} + +type projectInfraConfig struct { + root *yaml.Node + infra *yaml.Node + layersNode *yaml.Node + rootProvider string + layers []projectInfraLayer +} + +type projectInfraLayer struct { + node *yaml.Node + name string + path string + module string + provider string + effectiveProvider string +} + +func planProjectInfraEject( + projectRoot string, + rawYAML []byte, + provider string, +) (*projectInfraEjectPlan, error) { + if provider != provisioning.BicepProviderName && + provider != provisioning.TerraformProviderName { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unsupported infrastructure provider %q", provider), + "choose bicep or terraform for --infra", + ) + } + config, document, err := parseProjectInfraConfig(rawYAML) + if err != nil { + return nil, err + } + infraProvider := projectFoundryProvider(provider) + if config.layersNode != nil { + return planProjectLayeredInfra( + projectRoot, &document, config, infraProvider, + ) + } + return planProjectRootInfra( + projectRoot, &document, config, infraProvider, + ) +} + +func parseProjectInfraConfig( + rawYAML []byte, +) (*projectInfraConfig, yaml.Node, error) { + var document yaml.Node + if err := yaml.Unmarshal(rawYAML, &document); err != nil { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("parse azure.yaml: %s", err), + "verify azure.yaml is valid YAML", + ) + } + if len(document.Content) == 0 || + document.Content[0].Kind != yaml.MappingNode { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "azure.yaml is not a YAML mapping at the top level", + "verify azure.yaml is a valid azd project file", + ) + } + + root := document.Content[0] + infra := yamlMappingValue(root, "infra") + if infra == nil { + infra = yamlMappingNode() + root.Content = append(root.Content, yamlScalarNode("infra"), infra) + } + if infra.Kind != yaml.MappingNode { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "infra must be a mapping", + "fix the infra section in azure.yaml and retry", + ) + } + for _, key := range []string{"name", "path", "module", "provider"} { + if value := yamlMappingValue(infra, key); value != nil && + value.Kind != yaml.ScalarNode { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("infra.%s must be a string", key), + "fix the infra section in azure.yaml and retry", + ) + } + } + + layersNode := yamlMappingValue(infra, "layers") + config := &projectInfraConfig{ + root: root, + infra: infra, + layersNode: layersNode, + rootProvider: yamlMappingScalar(infra, "provider"), + } + if layersNode == nil { + return config, document, nil + } + if layersNode.Kind != yaml.SequenceNode { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "infra.layers must be a sequence", + "fix the infra section in azure.yaml and retry", + ) + } + + names := make(map[string]struct{}, len(layersNode.Content)) + paths := make(map[string]string, len(layersNode.Content)) + for _, node := range layersNode.Content { + if node.Kind != yaml.MappingNode { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "each infra.layers entry must be a mapping", + "fix the infra section in azure.yaml and retry", + ) + } + for _, key := range []string{"name", "path", "module", "provider"} { + if value := yamlMappingValue(node, key); value != nil && + value.Kind != yaml.ScalarNode { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("infra.layers[].%s must be a string", key), + "fix the infra section in azure.yaml and retry", + ) + } + } + name := yamlMappingScalar(node, "name") + path := yamlMappingScalar(node, "path") + if name == "" || path == "" { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "each infra.layers entry needs name and path", + "set name and path on every infrastructure layer", + ) + } + nameKey := strings.ToLower(name) + if _, exists := names[nameKey]; exists { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("infrastructure layer %q is duplicated", name), + "give each infrastructure layer a unique name", + ) + } + names[nameKey] = struct{}{} + + cleanPath := filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) + pathKey := strings.ToLower(cleanPath) + if previous, exists := paths[pathKey]; exists { + return nil, yaml.Node{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "infrastructure layers %q and %q use path %q", + previous, name, path, + ), + "use a unique path for every infrastructure layer", + ) + } + paths[pathKey] = name + + layerProvider := yamlMappingScalar(node, "provider") + effectiveProvider := layerProvider + if effectiveProvider == "" { + effectiveProvider = config.rootProvider + } + config.layers = append(config.layers, projectInfraLayer{ + node: node, + name: name, + path: path, + module: yamlMappingScalar(node, "module"), + provider: layerProvider, + effectiveProvider: effectiveProvider, + }) + } + return config, document, nil +} + +func planProjectRootInfra( + projectRoot string, + document *yaml.Node, + config *projectInfraConfig, + infraProvider string, +) (*projectInfraEjectPlan, error) { + rootPath := yamlMappingScalar(config.infra, "path") + if rootPath == "" { + rootPath = projectDefaultInfraPath + } + rootModule := yamlMappingScalar(config.infra, "module") + if rootModule == "" { + rootModule = projectDefaultInfraModule + } + target, err := inspectProjectInfraTarget(projectRoot, rootPath) + if err != nil { + return nil, err + } + userOwned, err := projectRootInfraUserOwned( + target, rootPath, config.rootProvider, rootModule, + ) + if err != nil { + return nil, err + } + if !userOwned { + var updated []byte + if config.rootProvider != infraProvider { + yamlSetMappingScalar(config.infra, "provider", infraProvider) + updated, err = marshalProjectAzureYAML(document) + if err != nil { + return nil, err + } + } + return newProjectInfraEjectPlan( + projectRoot, rootPath, rootModule, false, + target.exists && target.empty, updated, + ) + } + + existingName := yamlMappingScalar(config.infra, "name") + if existingName == "" { + existingName = projectDefaultInfraPath + } + if sameProjectInfraPath(rootPath, projectFoundryLayerPath) { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "existing infrastructure already uses the Foundry layer path %q", + projectFoundryLayerPath, + ), + "set infra.path to a different project-relative path", + ) + } + if existingName == projectFoundryLayerName { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "existing infrastructure name %q conflicts with the Foundry layer name", + existingName, + ), + "set infra.name to a different layer name", + ) + } + existingProvider := config.rootProvider + if existingProvider == "" { + existingProvider = provisioning.BicepProviderName + } + existingLayer, err := cloneProjectYAMLNode(config.infra) + if err != nil { + return nil, err + } + yamlSetMappingScalar(existingLayer, "name", existingName) + yamlSetMappingScalar(existingLayer, "path", filepath.ToSlash(rootPath)) + yamlSetMappingScalar(existingLayer, "provider", existingProvider) + yamlRemoveMappingKey(existingLayer, "layers") + + foundryLayer := newProjectInfraLayerNode( + projectFoundryLayerName, projectFoundryLayerPath, infraProvider, + ) + config.infra.Content = []*yaml.Node{ + yamlScalarNode("layers"), + { + Kind: yaml.SequenceNode, + Tag: "!!seq", + Content: []*yaml.Node{existingLayer, foundryLayer}, + }, + } + updated, err := marshalProjectAzureYAML(document) + if err != nil { + return nil, err + } + foundryTarget, err := inspectProjectInfraTarget( + projectRoot, projectFoundryLayerPath, + ) + if err != nil { + return nil, err + } + return newProjectInfraEjectPlan( + projectRoot, projectFoundryLayerPath, "", true, + foundryTarget.exists, updated, + ) +} + +func planProjectLayeredInfra( + projectRoot string, + document *yaml.Node, + config *projectInfraConfig, + infraProvider string, +) (*projectInfraEjectPlan, error) { + for _, layer := range config.layers { + if _, _, err := resolveProjectInfraPath(projectRoot, layer.path); err != nil { + return nil, err + } + } + + var foundry *projectInfraLayer + for i := range config.layers { + layer := &config.layers[i] + effective := layer.effectiveProvider + if effective == "" { + effective = provisioning.BicepProviderName + } + if layer.name == projectFoundryLayerName { + if foundry != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "azure.yaml has multiple Foundry layers", + "keep only one layer named foundry", + ) + } + foundry = layer + if layer.provider == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "the Foundry layer must declare provider", + "set provider on the foundry layer", + ) + } + if layer.provider != infraProvider { + return nil, exterrors.Validation( + "infra_provider_conflict", + fmt.Sprintf( + "Foundry layer %q already uses provider %q", + projectFoundryLayerName, layer.provider, + ), + "keep the existing provider or change --infra to match it", + ) + } + } else if effective == provisioning.FoundryProviderName { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "Foundry infrastructure already exists as layer %q", + layer.name, + ), + "keep the Foundry infrastructure in the layer named foundry", + ) + } + layer.effectiveProvider = effective + } + + if foundry == nil { + for i := range config.layers { + if sameProjectInfraPath( + config.layers[i].path, projectFoundryLayerPath, + ) { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "infra layer %q already uses the Foundry layer path %q", + config.layers[i].name, projectFoundryLayerPath, + ), + "set the Foundry layer path to a unique project-relative directory", + ) + } + } + config.layersNode.Content = append( + config.layersNode.Content, + newProjectInfraLayerNode( + projectFoundryLayerName, projectFoundryLayerPath, infraProvider, + ), + ) + updated, err := marshalProjectAzureYAML(document) + if err != nil { + return nil, err + } + target, err := inspectProjectInfraTarget(projectRoot, projectFoundryLayerPath) + if err != nil { + return nil, err + } + plan, err := newProjectInfraEjectPlan( + projectRoot, projectFoundryLayerPath, "", true, + target.exists, updated, + ) + return plan, err + } + if len(config.layers) == 1 { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + "infra.layers contains only a Foundry layer; use a root infra configuration for a Foundry-only project", + "remove infra.layers or add an existing infrastructure layer", + ) + } + + target, err := inspectProjectInfraTarget(projectRoot, foundry.path) + if err != nil { + return nil, err + } + hasEntrypoint := false + if target.exists { + hasEntrypoint, err = projectInfraHasEntrypoint( + target.dir, infraProvider, foundry.module, + ) + if err != nil { + return nil, err + } + } + if target.exists && !target.empty { + return nil, projectInfraExistsError( + foundry.path, + "the Foundry layer already contains infrastructure", + ) + } + if target.exists && hasEntrypoint { + return nil, projectInfraExistsError( + foundry.path, + "the Foundry layer already contains generated infrastructure", + ) + } + for i := range config.layers { + layer := &config.layers[i] + if layer != foundry && + sameProjectInfraPath(layer.path, foundry.path) { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "infra layer %q already uses the Foundry layer path %q", + layer.name, foundry.path, + ), + "set the Foundry layer path to a unique project-relative directory", + ) + } + } + return newProjectInfraEjectPlan( + projectRoot, foundry.path, foundry.module, true, + target.exists, nil, + ) +} + +func projectRootInfraUserOwned( + target projectInfraTarget, + targetPath string, + provider string, + module string, +) (bool, error) { + hasEntrypoint := false + if target.exists { + var err error + hasEntrypoint, err = projectInfraHasEntrypoint( + target.dir, provider, module, + ) + if err != nil { + return false, err + } + if provider == provisioning.TerraformProviderName && + !hasEntrypoint { + hasEntrypoint, err = projectTerraformHasEntrypoint(target.dir) + if err != nil { + return false, err + } + } + } + if provider == provisioning.FoundryProviderName && hasEntrypoint { + return false, projectInfraExistsError( + targetPath, + "the Foundry infrastructure already contains generated infrastructure", + ) + } + if provider == provisioning.TerraformProviderName { + hasFoundryInfra, err := projectFoundryTerraformInfra( + target.dir, module, + ) + if err != nil { + return false, err + } + if hasFoundryInfra { + return false, projectInfraExistsError( + targetPath, + "the Foundry infrastructure already contains generated infrastructure", + ) + } + } + builtIn := provider == provisioning.BicepProviderName || + provider == provisioning.TerraformProviderName + if target.exists && !target.empty && + provider != "" && builtIn && !hasEntrypoint { + return false, projectInfraProviderEntrypointError( + provider, targetPath, + ) + } + custom := provider != "" && !builtIn && + provider != provisioning.FoundryProviderName + if !hasEntrypoint && !custom && target.exists && !target.empty { + return false, projectInfraMissingEntrypointError(targetPath) + } + return provider != provisioning.FoundryProviderName && + (hasEntrypoint || custom), nil +} + +func sameProjectInfraPath(left, right string) bool { + return strings.EqualFold( + filepath.Clean(filepath.FromSlash(left)), + filepath.Clean(filepath.FromSlash(right)), + ) +} + +func newProjectInfraEjectPlan( + projectRoot, targetPath, module string, + layer bool, + mergeExisting bool, + updatedYAML []byte, +) (*projectInfraEjectPlan, error) { + if module == "" { + module = projectDefaultInfraModule + } + if module == "." || module == ".." || + filepath.Base(module) != module || + strings.ContainsAny(module, `/\`) { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "Foundry infrastructure module %q must be a file name", + module, + ), + "set infra.module or infra.layers[].module to a file name", + ) + } + if filepath.Ext(module) != "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "Foundry infrastructure module %q must not include a file extension", + module, + ), + "set infra.module or infra.layers[].module to a module base name", + ) + } + _, cleanPath, err := resolveProjectInfraPath(projectRoot, targetPath) + if err != nil { + return nil, err + } + target, err := inspectProjectInfraTarget( + projectRoot, cleanPath, + ) + if err != nil { + return nil, err + } + if target.exists && !target.empty && !mergeExisting { + return nil, projectInfraExistsError( + filepath.ToSlash(cleanPath), + "the target directory is not empty", + ) + } + return &projectInfraEjectPlan{ + targetDir: target.dir, + targetPath: cleanPath, + module: module, + layer: layer, + mergeExisting: mergeExisting, + updatedYAML: updatedYAML, + updateDescription: filepath.ToSlash(cleanPath), + }, nil +} + +func inspectProjectInfraTarget( + projectRoot, targetPath string, +) (projectInfraTarget, error) { + dir, cleanPath, err := resolveProjectInfraPath(projectRoot, targetPath) + if err != nil { + return projectInfraTarget{}, err + } + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + return projectInfraTarget{dir: dir}, nil + } + if err != nil { + return projectInfraTarget{}, fmt.Errorf( + "check infrastructure path %s: %w", cleanPath, err, + ) + } + if info.Mode()&os.ModeSymlink != 0 { + return projectInfraTarget{}, projectInfraExistsError( + cleanPath, "the infrastructure path is a symbolic link", + ) + } + if !info.IsDir() { + return projectInfraTarget{}, projectInfraExistsError( + cleanPath, "the infrastructure path is not a directory", + ) + } + empty, err := projectInfraDirectoryEmpty(dir) + if err != nil { + return projectInfraTarget{}, fmt.Errorf( + "inspect infrastructure path %s: %w", cleanPath, err, + ) + } + return projectInfraTarget{ + dir: dir, + exists: true, + empty: empty, + }, nil +} + +func projectInfraDirectoryEmpty(path string) (bool, error) { + // #nosec G304 + file, err := os.Open(path) + if err != nil { + return false, err + } + defer file.Close() + _, err = file.Readdirnames(1) + switch { + case errors.Is(err, io.EOF): + return true, nil + case err != nil: + return false, err + default: + return false, nil + } +} + +func resolveProjectInfraPath( + projectRoot, targetPath string, +) (string, string, error) { + relativeTarget := filepath.FromSlash(targetPath) + if filepath.IsAbs(relativeTarget) { + return "", "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("infrastructure path %q is not relative", targetPath), + "set infra.path to a project-relative directory", + ) + } + cleanPath := filepath.Clean(relativeTarget) + if cleanPath == "." || cleanPath == ".." { + return "", "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("infrastructure path %q is not a project directory", targetPath), + "set infra.path to a project-relative directory below the project root", + ) + } + absolute, err := filepath.Abs(filepath.Join(projectRoot, cleanPath)) + if err != nil { + return "", "", fmt.Errorf("resolve infrastructure path: %w", err) + } + relative, err := filepath.Rel(projectRoot, absolute) + if err != nil || relative == "." || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("infrastructure path %q is outside the project", targetPath), + "set infra.path to a project-relative directory", + ) + } + if err := ensureProjectInfraPathIsSafe(projectRoot, absolute); err != nil { + return "", "", err + } + return absolute, filepath.ToSlash(relative), nil +} + +func ensureProjectInfraPathIsSafe(projectRoot, target string) error { + relative, err := filepath.Rel(projectRoot, target) + if err != nil { + return fmt.Errorf("resolve infrastructure path: %w", err) + } + current := projectRoot + for part := range strings.SplitSeq(relative, string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + break + } + if err != nil { + return fmt.Errorf("inspect infrastructure path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return projectInfraExistsError( + filepath.ToSlash(relative), + "the infrastructure path contains a symbolic link", + ) + } + } + return nil +} + +func projectInfraExistsError(path, reason string) error { + return exterrors.Validation( + "infra_eject_exists", + fmt.Sprintf("infrastructure path %q: %s", path, reason), + "remove or rename the conflicting path and retry", + ) +} + +func projectFoundryProvider(provider string) string { + if provider == provisioning.TerraformProviderName { + return provisioning.TerraformProviderName + } + return provisioning.FoundryProviderName +} + +func projectInfraHasEntrypoint( + dir, provider, module string, +) (bool, error) { + if module == "" { + module = projectDefaultInfraModule + } + switch provider { + case provisioning.TerraformProviderName: + return projectFoundryTerraformInfra(dir, module) + case provisioning.FoundryProviderName, provisioning.BicepProviderName: + return projectBicepHasEntrypoint(dir, module), nil + default: + info, err := os.Stat(dir) + return err == nil && info.IsDir(), nil + } +} + +func projectBicepHasEntrypoint(dir, module string) bool { + return fileExists(filepath.Join(dir, module+".bicep")) || + fileExists(filepath.Join(dir, module+".bicepparam")) +} + +func projectTerraformHasEntrypoint(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := strings.ToLower(entry.Name()) + if strings.HasSuffix(name, ".tf") || + strings.HasSuffix(name, ".tf.json") { + return true, nil + } + } + return false, nil +} + +func projectInfraMissingEntrypointError(path string) error { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "infrastructure path %q contains files but no detectable entry point", + path, + ), + "fix the infra configuration in azure.yaml and retry", + ) +} + +func projectInfraProviderEntrypointError(provider, path string) error { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "infrastructure declares provider %q but path %q contains no matching entry point", + provider, path, + ), + "fix the infra configuration in azure.yaml and retry", + ) +} + +func projectFoundryTerraformInfra(dir, module string) (bool, error) { + markerPath := filepath.Join(dir, projectTerraformMarker) + markerInfo, err := os.Lstat(markerPath) + if err == nil { + if !markerInfo.Mode().IsRegular() { + return false, invalidProjectTerraformMarker( + markerPath, "marker is not a regular file", + ) + } + // #nosec G304 + marker, err := os.ReadFile(markerPath) + if err != nil { + return false, invalidProjectTerraformMarker(markerPath, err.Error()) + } + if string(marker) != projectTerraformMarkerV1 { + return false, invalidProjectTerraformMarker( + markerPath, "marker version is unsupported or edited", + ) + } + return true, nil + } + if !errors.Is(err, os.ErrNotExist) { + return false, invalidProjectTerraformMarker(markerPath, err.Error()) + } + + if !fileExists(filepath.Join(dir, module+".tfvars.json")) { + return false, nil + } + // #nosec G304 + main, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + return false, nil + } + source := string(main) + return strings.Contains( + source, `resource "azapi_resource" "foundry_account"`, + ) && strings.Contains( + source, `resource "azapi_resource" "project"`, + ) && strings.Contains( + source, "Microsoft.CognitiveServices/accounts", + ) && strings.Contains( + source, "Microsoft.CognitiveServices/accounts/projects", + ), nil +} + +func invalidProjectTerraformMarker(path, reason string) error { + return exterrors.Validation( + exterrors.CodeInfraEjectMarkerInvalid, + fmt.Sprintf( + "Foundry ownership marker %q cannot be used: %s; eject did not modify the infrastructure", + filepath.ToSlash(path), reason, + ), + "restore the marker from source control or verify the infrastructure is user-owned", + ) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func installProjectInfraStage( + stageDir string, + plan *projectInfraEjectPlan, +) (func(), error) { + if plan.mergeExisting { + created, createdDirs, err := mergeProjectInfraStage( + stageDir, plan.targetDir, + ) + if err != nil { + return nil, err + } + return func() { + removeProjectInfraFiles(created) + removeProjectInfraDirectories(createdDirs) + }, nil + } + + targetInfo, err := os.Lstat(plan.targetDir) + if err == nil { + if !targetInfo.IsDir() { + return nil, projectInfraExistsError( + plan.targetPath, "the target is not a directory", + ) + } + empty, readErr := projectInfraDirectoryEmpty(plan.targetDir) + if readErr != nil { + return nil, fmt.Errorf( + "inspect infrastructure path %s: %w", + plan.targetPath, readErr, + ) + } + if !empty { + return nil, projectInfraExistsError( + plan.targetPath, "the target directory is not empty", + ) + } + if err := os.Remove(plan.targetDir); err != nil { + return nil, fmt.Errorf( + "prepare infrastructure path %s: %w", + plan.targetPath, err, + ) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf( + "inspect infrastructure path %s: %w", + plan.targetPath, err, + ) + } + + // #nosec G301 + if err := os.MkdirAll(filepath.Dir(plan.targetDir), 0o755); err != nil { + return nil, fmt.Errorf("create infrastructure path: %w", err) + } + if err := os.Rename(stageDir, plan.targetDir); err != nil { + return nil, fmt.Errorf( + "install infrastructure in %s: %w", plan.targetPath, err, + ) + } + return func() { + // #nosec G703 + _ = os.RemoveAll(plan.targetDir) + // #nosec G301 G703 + _ = os.MkdirAll(plan.targetDir, 0o755) + }, nil +} + +func mergeProjectInfraStage( + stageDir, targetDir string, +) ([]string, []string, error) { + var files []string + err := filepath.WalkDir(stageDir, func( + path string, entry fs.DirEntry, walkErr error, + ) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return projectInfraExistsError( + filepath.ToSlash(path), + "generated infrastructure contains a symbolic link", + ) + } + relative, err := filepath.Rel(stageDir, path) + if err != nil || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return projectInfraExistsError( + filepath.ToSlash(relative), + "generated infrastructure path is invalid", + ) + } + destination := filepath.Join(targetDir, relative) + if err := ensureProjectInfraPathIsSafe( + targetDir, filepath.Dir(destination), + ); err != nil { + return err + } + if _, err := os.Lstat(destination); err == nil { + return projectInfraExistsError( + filepath.ToSlash(relative), + "generated infrastructure conflicts with an existing file", + ) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + files = append(files, path) + return nil + }) + if err != nil { + return nil, nil, err + } + + var created []string + var createdDirs []string + for _, source := range files { + relative, err := filepath.Rel(stageDir, source) + if err != nil { + removeProjectInfraFiles(created) + removeProjectInfraDirectories(createdDirs) + return nil, nil, err + } + destination := filepath.Join(targetDir, relative) + if err := ensureProjectInfraPathIsSafe( + targetDir, filepath.Dir(destination), + ); err != nil { + removeProjectInfraFiles(created) + removeProjectInfraDirectories(createdDirs) + return nil, nil, err + } + directories, err := projectInfraDirectoriesToCreate( + targetDir, filepath.Dir(destination), + ) + if err != nil { + removeProjectInfraFiles(created) + removeProjectInfraDirectories(createdDirs) + return nil, nil, err + } + createdDirs = append(createdDirs, directories...) + // #nosec G301 + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + removeProjectInfraFiles(created) + removeProjectInfraDirectories(createdDirs) + return nil, nil, fmt.Errorf( + "create infrastructure directory: %w", err, + ) + } + if err := azdext.CopyFileAtomic(source, destination, 0o644); err != nil { + removeProjectInfraFiles(created) + removeProjectInfraDirectories(createdDirs) + return nil, nil, fmt.Errorf( + "install infrastructure file: %w", err, + ) + } + created = append(created, destination) + } + return created, createdDirs, nil +} + +func removeProjectInfraFiles(files []string) { + for i := len(files) - 1; i >= 0; i-- { + _ = os.Remove(files[i]) + } +} + +func removeProjectInfraDirectories(directories []string) { + for i := len(directories) - 1; i >= 0; i-- { + _ = os.Remove(directories[i]) + } +} + +func projectInfraDirectoriesToCreate( + root, target string, +) ([]string, error) { + relative, err := filepath.Rel(root, target) + if err != nil { + return nil, fmt.Errorf("resolve infrastructure directory: %w", err) + } + current := root + var directories []string + for part := range strings.SplitSeq(relative, string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + directories = append(directories, current) + continue + } + if err != nil { + return nil, fmt.Errorf("inspect infrastructure directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, projectInfraExistsError( + filepath.ToSlash(relative), + "the infrastructure directory is not safe", + ) + } + } + return directories, nil +} + +func marshalProjectAzureYAML(document *yaml.Node) ([]byte, error) { + data, err := yaml.Marshal(document) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("marshal azure.yaml after infrastructure eject: %s", err), + ) + } + return data, nil +} + +func yamlMappingValue(mapping *yaml.Node, key string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +func yamlMappingScalar(mapping *yaml.Node, key string) string { + value := yamlMappingValue(mapping, key) + if value == nil || value.Kind != yaml.ScalarNode { + return "" + } + return strings.TrimSpace(value.Value) +} + +func yamlMappingNode() *yaml.Node { + return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} +} + +func yamlScalarNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func yamlSetMappingScalar(mapping *yaml.Node, key, value string) { + if existing := yamlMappingValue(mapping, key); existing != nil { + existing.Value = value + existing.Tag = "!!str" + return + } + mapping.Content = append(mapping.Content, yamlScalarNode(key), yamlScalarNode(value)) +} + +func yamlRemoveMappingKey(mapping *yaml.Node, key string) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content = append(mapping.Content[:i], mapping.Content[i+2:]...) + return + } + } +} + +func newProjectInfraLayerNode(name, path, provider string) *yaml.Node { + node := yamlMappingNode() + yamlSetMappingScalar(node, "name", name) + yamlSetMappingScalar(node, "path", filepath.ToSlash(path)) + yamlSetMappingScalar(node, "provider", provider) + return node +} + +func cloneProjectYAMLNode(value *yaml.Node) (*yaml.Node, error) { + data, err := yaml.Marshal(value) + if err != nil { + return nil, fmt.Errorf("clone infrastructure configuration: %w", err) + } + var clone yaml.Node + if err := yaml.Unmarshal(data, &clone); err != nil || len(clone.Content) == 0 { + if err != nil { + return nil, fmt.Errorf("clone infrastructure configuration: %w", err) + } + return nil, fmt.Errorf("clone infrastructure configuration is empty") + } + return clone.Content[0], 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..e6cd71fdcf0 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 @@ -186,6 +186,13 @@ func (a *ProjectInitAction) Run(ctx context.Context) error { return err } if target.Mode == projectModeExistingEndpoint { + if request != nil && request.ReplaceDeployments { + return exterrors.Dependency( + "managed_deployment_requires_project_id", + "managed model deployments require an existing Foundry project resource ID", + "rerun `azd ai project init --project-id ` before reconciling deployments", + ) + } if infra := infraFromRequest(request, a.flags); infra != "" { return exterrors.Dependency( "managed_deployment_requires_project_id", @@ -206,6 +213,9 @@ func (a *ProjectInitAction) Run(ctx context.Context) error { return err } serviceProjectName := target.ProjectName + if request != nil && request.Project.Name != "" { + serviceProjectName = request.Project.Name + } if serviceProjectName == "" { serviceProjectName = projectConfig.GetName() } @@ -229,9 +239,15 @@ func (a *ProjectInitAction) Run(ctx context.Context) error { } if target.Mode != projectModeExistingEndpoint && (projectConfig.GetInfra() == nil || projectConfig.GetInfra().GetProvider() == "") { - if err := writeFoundryProvider(ctx, client, projectConfig); err != nil { + hasUserInfra, err := projectHasNonEmptyInfra(projectRoot) + if err != nil { return err } + if !hasUserInfra { + if err := writeFoundryProvider(ctx, client, projectConfig); err != nil { + return err + } + } } serviceName, mutation, err := reconciler.reconcileEndpoint( ctx, serviceProjectName, target.Endpoint, target.Mode, @@ -239,8 +255,15 @@ func (a *ProjectInitAction) Run(ctx context.Context) error { if err != nil { return err } + if request != nil && request.ReplaceDeployments { + if err := reconciler.replaceDeployments( + ctx, serviceName, request.Deployments, + ); err != nil { + return err + } + } if infra := infraFromRequest(request, a.flags); infra != "" { - if err := ejectProjectInfra(ctx, client, projectRoot, serviceName, infra); err != nil { + if err := ejectProjectInfra(projectRoot, serviceName, infra); err != nil { return err } } @@ -976,6 +999,13 @@ func validateFoundryProvider(project *azdext.ProjectConfig) error { if project != nil && project.GetInfra() != nil && project.GetInfra().GetProvider() != "" && project.GetInfra().GetProvider() != provisioning.FoundryProviderName { + hasUserInfra, err := projectHasNonEmptyInfra(project.GetPath()) + if err != nil { + return err + } + if hasUserInfra { + return nil + } return exterrors.Validation( "infra_provider_conflict", fmt.Sprintf( @@ -993,26 +1023,37 @@ func validateFoundryProvider(project *azdext.ProjectConfig) error { project.GetInfra().GetPath() != "" && project.GetInfra().GetPath() != "." && project.GetInfra().GetPath() != "./infra" { + hasUserInfra, err := projectHasNonEmptyInfra(project.GetPath()) + if err != nil { + return err + } + if hasUserInfra { + return nil + } 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 projectHasNonEmptyInfra(projectRoot string) (bool, error) { + if strings.TrimSpace(projectRoot) == "" { + return false, nil + } + target := filepath.Join(projectRoot, "infra") + empty, err := projectInfraDirectoryEmpty(target) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("check project infrastructure: %w", err) + } + return !empty, nil +} + func parseInfraProvider(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case provisioning.BicepProviderName: @@ -1029,27 +1070,8 @@ func parseInfraProvider(value string) (string, error) { } 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 @@ -1059,14 +1081,9 @@ func ejectProjectInfra( 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) + plan, err := planProjectInfraEject(projectRoot, raw, provider) + if err != nil { + return err } result, err := synthesis.Synthesize(synthesis.Input{ RawAzureYAML: raw, @@ -1082,49 +1099,70 @@ func ejectProjectInfra( "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 && + result.NetworkMode != synthesis.NetworkModeNone { + return exterrors.Validation( + "infra_eject_network_unsupported", + "Terraform ejection does not support the project's network block", + "eject Bicep instead", + ) } + stageDir, err := os.MkdirTemp(projectRoot, ".azd-foundry-stage-*") + if err != nil { + return fmt.Errorf("create infrastructure staging directory: %w", err) + } + defer os.RemoveAll(stageDir) + 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) + if err := writeTerraformEjectedInfraWithModule( + stageDir, result.Parameters, plan.layer, plan.module, + ); err != nil { 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) + if err := copyEmbeddedBicep(stageDir, plan.module); err != nil { 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) + if err := writeJSONFile( + filepath.Join(stageDir, plan.module+".parameters.json"), + parameters, + ); err != nil { return err } } + + rollback, err := installProjectInfraStage(stageDir, plan) + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + rollback() + } + }() + if plan.updatedYAML != nil { + // #nosec G304 + current, err := os.ReadFile(projectFile) + if err != nil { + return fmt.Errorf("read %s before updating infrastructure: %w", projectFile, err) + } + if !bytes.Equal(current, raw) { + return exterrors.Validation( + "infra_eject_azure_yaml_changed", + fmt.Sprintf("%s changed during infrastructure ejection", projectFile), + "retry the infrastructure ejection with the latest azure.yaml", + ) + } + if err := azdext.WriteFileAtomic(projectFile, plan.updatedYAML, 0); err != nil { + return fmt.Errorf("write azure.yaml after infrastructure ejection: %w", err) + } + } + committed = true return nil } @@ -1148,12 +1186,43 @@ func projectFilePath(projectRoot string) (string, error) { ) } -func copyEmbeddedBicep(destination string) error { - return copyEmbeddedTree(synthesis.TemplatesFS(), "templates", destination, - map[string]struct{}{"main.arm.json": {}, "brownfield.bicep": {}, "brownfield.arm.json": {}}) +func copyEmbeddedBicep(destination, module string) error { + if err := copyEmbeddedTree( + synthesis.TemplatesFS(), "templates", destination, + map[string]struct{}{ + "main.arm.json": {}, + "brownfield.bicep": {}, + "brownfield.arm.json": {}, + }, + ); err != nil { + return err + } + if module == "main" { + return nil + } + return os.Rename( + filepath.Join(destination, "main.bicep"), + filepath.Join(destination, module+".bicep"), + ) +} + +func writeTerraformEjectedInfra( + infraDir string, + parameters map[string]any, + layer ...bool, +) error { + isLayer := len(layer) > 0 && layer[0] + return writeTerraformEjectedInfraWithModule( + infraDir, parameters, isLayer, "main", + ) } -func writeTerraformEjectedInfra(infraDir string, parameters map[string]any) error { +func writeTerraformEjectedInfraWithModule( + infraDir string, + parameters map[string]any, + isLayer bool, + module string, +) error { variables, includeAcr, err := terraformEjectionVariables(parameters) if err != nil { return err @@ -1161,12 +1230,21 @@ func writeTerraformEjectedInfra(infraDir string, parameters map[string]any) erro if err := copyEmbeddedTerraform(infraDir, includeAcr); err != nil { return fmt.Errorf("copy Terraform templates: %w", err) } - if err := renderTerraformOutputs(infraDir, includeAcr); err != nil { + if err := renderTerraformOutputs(infraDir, includeAcr, isLayer); err != nil { return fmt.Errorf("render Terraform outputs: %w", err) } - if err := writeJSONFile(filepath.Join(infraDir, "main.tfvars.json"), variables); err != nil { + if err := writeJSONFile(filepath.Join(infraDir, module+".tfvars.json"), variables); err != nil { return fmt.Errorf("write Terraform variables: %w", err) } + // Mark generated Terraform so later ejection can identify ownership. + // #nosec G306 + if err := os.WriteFile( + filepath.Join(infraDir, projectTerraformMarker), + []byte(projectTerraformMarkerV1), + 0644, + ); err != nil { + return fmt.Errorf("write Terraform ownership marker: %w", err) + } return nil } @@ -1222,7 +1300,11 @@ func copyEmbeddedTerraform(destination string, includeAcr bool) error { skip) } -func renderTerraformOutputs(destination string, includeAcr bool) error { +func renderTerraformOutputs( + destination string, + includeAcr bool, + layer ...bool, +) error { const templatePath = "templates/terraform/outputs.tf.tmpl" source, err := fs.ReadFile(synthesis.TerraformTemplatesFS(), templatePath) if err != nil { @@ -1233,10 +1315,11 @@ func renderTerraformOutputs(destination string, includeAcr bool) error { return fmt.Errorf("parse Terraform outputs template: %w", err) } var output bytes.Buffer + isLayer := len(layer) > 0 && layer[0] if err := tmpl.Execute(&output, struct { IncludeAcr bool Layer bool - }{IncludeAcr: includeAcr}); err != nil { + }{IncludeAcr: includeAcr, Layer: isLayer}); err != nil { return fmt.Errorf("render Terraform outputs template: %w", err) } // #nosec G306 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..30545a5ecde 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 @@ -12,6 +12,8 @@ import ( "path/filepath" "testing" + "azure.ai.projects/internal/exterrors" + "azure.ai.projects/internal/provisioning" "azure.ai.projects/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -36,7 +38,7 @@ func TestDelegatedProjectInitRequestValidation(t *testing.T) { request.Project.Endpoint = "https://account.services.ai.azure.com/api/projects/p" require.Error(t, request.validate()) request.Project.Endpoint = "" - request.SchemaVersion = 2 + request.SchemaVersion = 3 err := request.validate() require.Error(t, err) var localErr *azdext.LocalError @@ -44,11 +46,27 @@ func TestDelegatedProjectInitRequestValidation(t *testing.T) { assert.Equal(t, azdext.LocalErrorCategoryCompatibility, localErr.Category) } +func TestDelegatedProjectInitDeploymentReplacement(t *testing.T) { + request := &projectInitRequest{ + SchemaVersion: delegatedSchemaVersion, + Source: projectInitSourceAgents, + SourceVersion: "1.0.0-beta.9", + ReplaceDeployments: true, + Deployments: []delegatedDeployment{{ + Name: "chat", + }}, + } + require.NoError(t, request.validate()) + + request.Deployments = []delegatedDeployment{{Name: "Chat"}, {Name: "chat"}} + require.Error(t, request.validate()) +} + func TestDelegatedRequestRejectsUnknownFields(t *testing.T) { dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") require.NoError(t, os.WriteFile(requestPath, []byte(`{ - "schemaVersion": 1, + "schemaVersion": 2, "source": "azure.ai.agents/init", "sourceVersion": "1.0.0", "unknown": true @@ -157,6 +175,12 @@ func TestWriteTerraformEjectedInfra(t *testing.T) { outputs, err := os.ReadFile(filepath.Join(infraDir, "outputs.tf")) require.NoError(t, err) assert.Contains(t, string(outputs), "AZURE_AI_PROJECT_ID") + // #nosec G304 + marker, err := os.ReadFile( + filepath.Join(infraDir, projectTerraformMarker), + ) + require.NoError(t, err) + assert.Equal(t, projectTerraformMarkerV1, string(marker)) if test.includeAcr { assert.Contains(t, string(outputs), "AZURE_CONTAINER_REGISTRY_ENDPOINT") _, err := os.Stat(filepath.Join(infraDir, "acr.tf")) @@ -194,6 +218,38 @@ func TestWriteTerraformEjectedInfra(t *testing.T) { } } +func TestProjectInfraEjectUsesLayerModule(t *testing.T) { + projectRoot := t.TempDir() + raw := []byte(`infra: + layers: + - name: platform + path: infra/platform + provider: bicep + - name: foundry + path: infra/foundry + provider: microsoft.foundry + module: project +`) + + plan, err := planProjectInfraEject(projectRoot, raw, "bicep") + require.NoError(t, err) + assert.Equal(t, "project", plan.module) + assert.Equal( + t, + filepath.Join(projectRoot, "infra", "foundry"), + plan.targetDir, + ) +} + +func TestCopyEmbeddedBicepUsesModuleName(t *testing.T) { + infraDir := filepath.Join(t.TempDir(), "infra") + require.NoError(t, os.MkdirAll(infraDir, 0750)) + + require.NoError(t, copyEmbeddedBicep(infraDir, "project")) + assert.FileExists(t, filepath.Join(infraDir, "project.bicep")) + assert.NoFileExists(t, filepath.Join(infraDir, "main.bicep")) +} + func TestProjectServiceNameDeterministic(t *testing.T) { services := map[string]*azdext.ServiceConfig{ "chat-app": {Host: "azure.ai.agent"}, @@ -204,7 +260,7 @@ func TestProjectServiceNameDeterministic(t *testing.T) { assert.Equal(t, "ai-project-3", projectServiceName("", services)) } -func TestLegacyProjectServiceBodyPreservesConfiguration(t *testing.T) { +func TestLegacyProjectServiceBodyPreservesProjectConfiguration(t *testing.T) { body, err := legacyProjectServiceBody(map[string]any{ "host": "azure.ai.agents", "endpoint": "https://old.services.ai.azure.com/api/projects/old", @@ -218,9 +274,9 @@ func TestLegacyProjectServiceBodyPreservesConfiguration(t *testing.T) { 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"]) + assert.NotContains(t, body, "hooks") + assert.NotContains(t, body, "uses") + assert.NotContains(t, body, "customField") } func TestLegacyProjectServiceBodyRemovesEndpointForNewProject(t *testing.T) { @@ -233,7 +289,7 @@ func TestLegacyProjectServiceBodyRemovesEndpointForNewProject(t *testing.T) { assert.NotContains(t, body, "host") assert.NotContains(t, body, "endpoint") - assert.Contains(t, body, "hooks") + assert.NotContains(t, body, "hooks") } func TestDeploymentLocationsExplicitSelectionWins(t *testing.T) { @@ -392,7 +448,7 @@ func TestProjectServiceEndpointUsesExactKeyTombstone(t *testing.T) { assert.Equal(t, "", projectServer.request.Value.GetStringValue()) } -func TestAddServicePersistsCompleteBodyThroughConfigSection(t *testing.T) { +func TestAddServicePersistsCompleteBodyAtomically(t *testing.T) { server := grpc.NewServer() projectServer := &recordingProjectServiceServer{} azdext.RegisterProjectServiceServer(server, projectServer) @@ -431,12 +487,10 @@ func TestAddServicePersistsCompleteBodyThroughConfigSection(t *testing.T) { )) 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"]) + require.NotNil(t, projectServer.addRequest.Service.AdditionalProperties) + assert.Nil(t, projectServer.sectionRequest) + section := projectServer.addRequest.Service.AdditionalProperties.AsMap() + assert.Equal(t, "https://account.services.ai.azure.com/api/projects/p", section["endpoint"]) assert.Contains(t, section, "deployments") assert.Contains(t, section, "hooks") assert.Contains(t, section, "uses") @@ -512,3 +566,307 @@ func synthesisDeploymentForTest() synthesis.Deployment { Sku: synthesis.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, } } + +func TestPlanProjectInfraEjectMergesExistingFoundryDirectory(t *testing.T) { + projectRoot := t.TempDir() + raw := []byte(`name: test +infra: + provider: bicep +services: + foundry: + host: azure.ai.project +`) + require.NoError(t, os.MkdirAll( + filepath.Join(projectRoot, "infra", "foundry"), 0750, + )) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "main.bicep"), + []byte("// existing infrastructure\n"), 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "infra", "foundry", "README.md"), + []byte("user-owned notes\n"), 0600, + )) + + plan, err := planProjectInfraEject(projectRoot, raw, "bicep") + require.NoError(t, err) + assert.True(t, plan.layer) + assert.True(t, plan.mergeExisting) + assert.Equal( + t, + filepath.Join(projectRoot, "infra", "foundry"), + plan.targetDir, + ) + assert.Contains(t, string(plan.updatedYAML), "name: foundry") +} + +func TestPlanProjectInfraEjectCreatesExplicitProviderTarget(t *testing.T) { + for _, targetState := range []string{"missing", "empty"} { + t.Run(targetState, func(t *testing.T) { + projectRoot := t.TempDir() + raw := []byte(`name: test +infra: + provider: bicep +services: + foundry: + host: azure.ai.project +`) + if targetState == "empty" { + require.NoError(t, os.MkdirAll( + filepath.Join(projectRoot, "infra"), 0750, + )) + } + + plan, err := planProjectInfraEject( + projectRoot, raw, provisioning.BicepProviderName, + ) + require.NoError(t, err) + assert.False(t, plan.layer) + assert.Equal( + t, + filepath.Join(projectRoot, "infra"), + plan.targetDir, + ) + assert.Contains(t, string(plan.updatedYAML), "microsoft.foundry") + }) + } +} + +func TestInstallProjectInfraStageMergesAndRollsBack(t *testing.T) { + projectRoot := t.TempDir() + targetDir := filepath.Join(projectRoot, "infra", "foundry") + stageDir := filepath.Join(projectRoot, ".stage") + require.NoError(t, os.MkdirAll(targetDir, 0750)) + require.NoError(t, os.MkdirAll( + filepath.Join(targetDir, "modules"), 0750, + )) + require.NoError(t, os.MkdirAll( + filepath.Join(stageDir, "modules"), 0750, + )) + require.NoError(t, os.WriteFile( + filepath.Join(targetDir, "README.md"), + []byte("keep me\n"), 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(stageDir, "main.bicep"), + []byte("generated\n"), 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(stageDir, "modules", "generated.bicep"), + []byte("generated module\n"), 0600, + )) + + rollback, err := installProjectInfraStage(stageDir, &projectInfraEjectPlan{ + targetDir: targetDir, + targetPath: "infra/foundry", + mergeExisting: true, + }) + require.NoError(t, err) + assert.FileExists(t, filepath.Join(targetDir, "README.md")) + assert.FileExists(t, filepath.Join(targetDir, "main.bicep")) + assert.FileExists(t, filepath.Join( + targetDir, "modules", "generated.bicep", + )) + + rollback() + assert.FileExists(t, filepath.Join(targetDir, "README.md")) + assert.NoFileExists(t, filepath.Join(targetDir, "main.bicep")) + assert.DirExists(t, filepath.Join(targetDir, "modules")) + assert.NoFileExists(t, filepath.Join( + targetDir, "modules", "generated.bicep", + )) +} + +func TestInstallProjectInfraStageRejectsConflictsBeforeWriting(t *testing.T) { + projectRoot := t.TempDir() + targetDir := filepath.Join(projectRoot, "infra", "foundry") + stageDir := filepath.Join(projectRoot, ".stage") + require.NoError(t, os.MkdirAll(targetDir, 0750)) + require.NoError(t, os.MkdirAll(stageDir, 0750)) + require.NoError(t, os.WriteFile( + filepath.Join(targetDir, "main.bicep"), + []byte("original\n"), 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(stageDir, "main.bicep"), + []byte("replacement\n"), 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(stageDir, "new.bicep"), + []byte("new\n"), 0600, + )) + + _, err := installProjectInfraStage(stageDir, &projectInfraEjectPlan{ + targetDir: targetDir, + targetPath: "infra/foundry", + mergeExisting: true, + }) + require.Error(t, err) + // #nosec G304 + content, err := os.ReadFile(filepath.Join(targetDir, "main.bicep")) + require.NoError(t, err) + assert.Equal(t, "original\n", string(content)) + assert.NoFileExists(t, filepath.Join(targetDir, "new.bicep")) +} + +func TestInstallProjectInfraStageRollbackRestoresEmptyTarget(t *testing.T) { + projectRoot := t.TempDir() + targetDir := filepath.Join(projectRoot, "infra", "foundry") + stageDir := filepath.Join(projectRoot, ".stage") + require.NoError(t, os.MkdirAll(stageDir, 0750)) + require.NoError(t, os.WriteFile( + filepath.Join(stageDir, "main.bicep"), + []byte("generated\n"), 0600, + )) + + rollback, err := installProjectInfraStage(stageDir, &projectInfraEjectPlan{ + targetDir: targetDir, + targetPath: "infra/foundry", + }) + require.NoError(t, err) + assert.FileExists(t, filepath.Join(targetDir, "main.bicep")) + + rollback() + empty, err := projectInfraDirectoryEmpty(targetDir) + require.NoError(t, err) + assert.True(t, empty) +} + +func TestProjectInfraTerraformOwnershipDetection(t *testing.T) { + dir := t.TempDir() + markerPath := filepath.Join(dir, projectTerraformMarker) + require.NoError(t, os.WriteFile( + markerPath, []byte(projectTerraformMarkerV1), 0600, + )) + + owned, err := projectInfraHasEntrypoint( + dir, provisioning.TerraformProviderName, "main", + ) + require.NoError(t, err) + assert.True(t, owned) + + require.NoError(t, os.WriteFile( + markerPath, []byte("edited\n"), 0600, + )) + owned, err = projectInfraHasEntrypoint( + dir, provisioning.TerraformProviderName, "main", + ) + require.Error(t, err) + assert.False(t, owned) + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Equal(t, exterrors.CodeInfraEjectMarkerInvalid, localErr.Code) + + require.NoError(t, os.Remove(markerPath)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "main.tfvars.json"), []byte("{}\n"), 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "main.tf"), + []byte("resource \"azurerm_resource_group\" \"app\" {}\n"), 0600, + )) + owned, err = projectInfraHasEntrypoint( + dir, provisioning.TerraformProviderName, "main", + ) + require.NoError(t, err) + assert.False(t, owned) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "main.tf"), + []byte(`resource "azapi_resource" "foundry_account" {} +resource "azapi_resource" "project" {} +Microsoft.CognitiveServices/accounts +Microsoft.CognitiveServices/accounts/projects +`), 0600, + )) + owned, err = projectInfraHasEntrypoint( + dir, provisioning.TerraformProviderName, "main", + ) + require.NoError(t, err) + assert.True(t, owned) +} + +func TestPlanProjectInfraEjectMigratesExistingTerraform(t *testing.T) { + projectRoot := t.TempDir() + raw := []byte(`name: test +infra: + provider: terraform +services: + foundry: + host: azure.ai.project +`) + infraDir := filepath.Join(projectRoot, "infra") + require.NoError(t, os.MkdirAll(infraDir, 0750)) + require.NoError(t, os.WriteFile( + filepath.Join(infraDir, "main.tf"), + []byte("resource \"azurerm_resource_group\" \"app\" {}\n"), + 0600, + )) + + plan, err := planProjectInfraEject( + projectRoot, raw, provisioning.TerraformProviderName, + ) + require.NoError(t, err) + assert.True(t, plan.layer) + assert.False(t, plan.mergeExisting) + assert.Equal( + t, + filepath.Join(projectRoot, "infra", "foundry"), + plan.targetDir, + ) + assert.Contains(t, string(plan.updatedYAML), "name: foundry") +} + +func TestPlanProjectInfraEjectRejectsUnsafePaths(t *testing.T) { + projectRoot := t.TempDir() + outside := t.TempDir() + absolute := filepath.ToSlash(outside) + traversal, err := filepath.Rel(projectRoot, outside) + require.NoError(t, err) + + for _, path := range []string{".", traversal, absolute} { + t.Run(path, func(t *testing.T) { + raw := fmt.Appendf(nil, `name: test +infra: + layers: + - name: foundry + path: %s + provider: microsoft.foundry +services: + foundry: + host: azure.ai.project +`, path) + _, err := planProjectInfraEject(projectRoot, raw, "bicep") + require.Error(t, err) + assert.NoFileExists(t, filepath.Join(outside, "main.bicep")) + }) + } +} + +func TestPlanProjectInfraEjectRejectsSymlinkedTarget(t *testing.T) { + projectRoot := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0750)) + if err := os.Symlink( + outside, filepath.Join(projectRoot, "infra", "foundry"), + ); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + raw := []byte(`name: test +infra: + layers: + - name: app + path: infra/app + provider: bicep + - name: foundry + path: infra/foundry + provider: microsoft.foundry +services: + foundry: + host: azure.ai.project +`) + + _, err := planProjectInfraEject(projectRoot, raw, "bicep") + require.Error(t, err) + assert.NoFileExists(t, filepath.Join(outside, "main.bicep")) +} 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 index 665818ea56c..4681103a5d3 100644 --- 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 @@ -202,6 +202,58 @@ func (r *projectServiceReconciler) reconcileEndpoint( return service.Name, "updated", nil } +func (r *projectServiceReconciler) replaceDeployments( + ctx context.Context, + serviceName string, + deployments []delegatedDeployment, +) error { + service, _, err := r.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 updating deployments", + ) + } + if service.ServiceRef != "" { + return projectServiceRefError(service.Name, service.ServiceRef) + } + + values := make([]any, len(deployments)) + for i, deployment := range deployments { + values[i] = 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, + }, + } + } + value, err := structpb.NewValue(values) + if err != nil { + return fmt.Errorf("encode project deployments: %w", err) + } + if _, err := r.client.Project().SetServiceConfigValue( + ctx, + &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "deployments", + Value: value, + }, + ); err != nil { + return fmt.Errorf("replace project service %q deployments: %w", serviceName, err) + } + return nil +} + func setProjectServiceEndpoint( ctx context.Context, client *azdext.AzdClient, @@ -226,20 +278,25 @@ func legacyProjectServiceBody( raw map[string]any, endpoint string, ) (map[string]any, error) { - body, err := cloneMap(raw) + body := make(map[string]any, 3) + for _, key := range []string{"deployments", "network"} { + if value, ok := raw[key]; ok { + body[key] = value + } + } + cloned, err := cloneMap(body) if err != nil { return nil, err } - if body == nil { - body = map[string]any{} + if cloned == nil { + cloned = map[string]any{} } - delete(body, "host") if endpoint != "" { - body["endpoint"] = endpoint + cloned["endpoint"] = endpoint } else { - delete(body, "endpoint") + delete(cloned, "endpoint") } - return body, nil + return cloned, nil } func (r *projectServiceReconciler) addService( @@ -255,32 +312,20 @@ func (r *projectServiceReconciler) addService( 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, + Name: name, + Host: provisioning.FoundryProjectHost, + AdditionalProperties: properties, }, }) 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 } diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go index 9a58ae710f8..b28bbe0ee5b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -5,6 +5,11 @@ package exterrors const CodeCancelled = "cancelled" +const ( + CodeInfraEjectWriteFailed = "infra_eject_write_failed" + CodeInfraEjectMarkerInvalid = "infra_eject_marker_invalid" +) + // Error codes commonly used for validation errors. // // These are paired with [Validation] when user input or configuration values