diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 78453895d53..d7f8a573c55 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -17,6 +17,7 @@ require ( github.com/google/uuid v1.6.0 github.com/mark3labs/mcp-go v0.41.1 github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 go.yaml.in/yaml/v3 v3.0.4 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 @@ -75,7 +76,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index deddeec82e7..d2d1876b13f 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -15,8 +15,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontai github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers v1.1.0/go.mod h1:qV+BWew22CAalRTwJEAHs+aSLP49k/csNlspqhMIDRU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 h1:qiir/pptnHqp6hV8QwV+IExYIf6cPsXBfUDUXQ27t2Y= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 h1:ZMGAqCZov8+7iFUPWKVcTaLgNXUeTlz20sIuWkQWNfg= diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 9c195b9c932..0837c27f3c2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -67,7 +67,9 @@ func newListenCommand() *cobra.Command { WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { return predeployHandler(ctx, azdClient, projectParser, args) }). - WithProjectEventHandler("postdeploy", projectParser.CoboPostDeploy) + WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return postdeployHandler(ctx, projectParser, args) + }) // Start listening for events // This is a blocking call and will not return until the server connection is closed. @@ -81,10 +83,6 @@ func newListenCommand() *cobra.Command { } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, projectParser *project.FoundryParser, args *azdext.ProjectEventArgs) error { - if err := projectParser.SetIdentity(ctx, args); err != nil { - return fmt.Errorf("failed to set identity: %w", err) - } - for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: @@ -95,7 +93,7 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, proje return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } case ContainerAppHost: - if err := containerAgentHandling(ctx, azdClient, args.Project, svc); err != nil { + if err := containerAgentHandling(ctx, azdClient, projectParser, svc, args.Project.Path); err != nil { return fmt.Errorf("failed to handle container agent for service %q: %w", svc.Name, err) } } @@ -105,10 +103,6 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, proje } func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, projectParser *project.FoundryParser, args *azdext.ProjectEventArgs) error { - if err := projectParser.SetIdentity(ctx, args); err != nil { - return fmt.Errorf("failed to set identity: %w", err) - } - for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: @@ -118,6 +112,29 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, projectP if err := envUpdate(ctx, azdClient, args.Project, svc); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } + case ContainerAppHost: + if err := containerAgentHandling(ctx, azdClient, projectParser, svc, args.Project.Path); err != nil { + return fmt.Errorf("failed to handle container agent for service %q: %w", svc.Name, err) + } + } + } + + return nil +} + +func postdeployHandler(ctx context.Context, projectParser *project.FoundryParser, args *azdext.ProjectEventArgs) error { + for _, svc := range args.Project.Services { + switch svc.Host { + case ContainerAppHost: + isContainerAgent, err := projectParser.IsContainerAgent(svc, args.Project.Path) + if err != nil { + return fmt.Errorf("failed to determine if extension should attach: %w", err) + } + if !isContainerAgent { + return nil + } + + return projectParser.CoboPostDeploy(ctx, args) } } @@ -219,19 +236,13 @@ func resourcesEnvUpdate(ctx context.Context, resources []project.Resource, azdCl return setEnvVar(ctx, azdClient, envName, "AI_PROJECT_DEPENDENT_RESOURCES", escapedJsonString) } -func containerAgentHandling(ctx context.Context, azdClient *azdext.AzdClient, project *azdext.ProjectConfig, svc *azdext.ServiceConfig) error { - servicePath := svc.RelativePath - fullPath := filepath.Join(project.Path, servicePath) - agentYamlPath := filepath.Join(fullPath, "agent.yaml") - - data, err := os.ReadFile(agentYamlPath) +func containerAgentHandling(ctx context.Context, azdClient *azdext.AzdClient, projectParser *project.FoundryParser, service *azdext.ServiceConfig, projectPath string) error { + isContainerAgent, err := projectParser.IsContainerAgent(service, projectPath) if err != nil { - return nil + return fmt.Errorf("failed to determine if extension should attach: %w", err) } - - var agentDef agent_yaml.AgentDefinition - if err := yaml.Unmarshal(data, &agentDef); err != nil { - return fmt.Errorf("YAML content is not valid: %w", err) + if !isContainerAgent { + return nil } // If there is an agent.yaml in the project, and it can be properly parsed into an agent definition, add the env var to enable container agents diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/parser.go b/cli/azd/extensions/azure.ai.agents/internal/project/parser.go index 4fc1c2ba9b5..39003f57669 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/parser.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/parser.go @@ -25,7 +25,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/azure/azure-dev/cli/azd/pkg/graphsdk" "github.com/braydonk/yaml" "github.com/google/uuid" ) @@ -35,201 +34,48 @@ type FoundryParser struct { } // Check if there is a service using containerapp host and contains agent.yaml file in the service path -func shouldRun(ctx context.Context, project *azdext.ProjectConfig) (bool, error) { - projectPath := project.Path - for _, service := range project.Services { - if service.Host == "containerapp" { - servicePath := filepath.Join(projectPath, service.RelativePath) - - agentYamlPath := filepath.Join(servicePath, "agent.yaml") - agentYmlPath := filepath.Join(servicePath, "agent.yml") - agentPath := "" - - if _, err := os.Stat(agentYamlPath); err == nil { - agentPath = agentYamlPath - } - - if _, err := os.Stat(agentYmlPath); err == nil { - agentPath = agentYmlPath - } - if agentPath != "" { - // read the file content into bytes and close the file - content, err := os.ReadFile(agentPath) - if err != nil { - return false, fmt.Errorf("failed to read agent yaml file: %w", err) - } - - err = agent_yaml.ValidateAgentDefinition(content) - if err != nil { - return false, fmt.Errorf("agent.yaml is not valid to run: %w", err) - } - - var genericTemplate map[string]interface{} - if err := yaml.Unmarshal(content, &genericTemplate); err != nil { - return false, fmt.Errorf("YAML content is not valid to run: %w", err) - } +func (p *FoundryParser) IsContainerAgent(service *azdext.ServiceConfig, projectPath string) (bool, error) { + if service.Host == "containerapp" { + servicePath := filepath.Join(projectPath, service.RelativePath) - kind, ok := genericTemplate["kind"].(string) - if !ok { - return false, fmt.Errorf("kind field is not a valid string to check should run: %w", err) - } + agentYamlPath := filepath.Join(servicePath, "agent.yaml") + agentYmlPath := filepath.Join(servicePath, "agent.yml") + agentPath := "" - return kind == string(agent_yaml.AgentKindHosted), nil - } + if _, err := os.Stat(agentYamlPath); err == nil { + agentPath = agentYamlPath } - } - - return false, nil -} -func (p *FoundryParser) SetIdentity(ctx context.Context, args *azdext.ProjectEventArgs) error { - shouldRun, err := shouldRun(ctx, args.Project) - if err != nil { - return fmt.Errorf("failed to determine if extension should attach: %w", err) - } - if !shouldRun { - return nil - } - - // Get aiFoundryProjectResourceId from environment config - azdEnvClient := p.AzdClient.Environment() - response, err := azdEnvClient.GetConfigString(ctx, &azdext.GetConfigStringRequest{ - Path: "infra.parameters.aiFoundryProjectResourceId", - }) - if err != nil { - return fmt.Errorf("failed to get environment config: %w", err) - } - aiFoundryProjectResourceID := response.Value - fmt.Println("✓ Retrieved aiFoundryProjectResourceId") - - // Extract subscription ID from resource ID - parts := strings.Split(aiFoundryProjectResourceID, "/") - if len(parts) < 3 { - return fmt.Errorf("invalid resource ID format: %s", aiFoundryProjectResourceID) - } - - // Find subscription ID - var subscriptionID string - for i, part := range parts { - if part == "subscriptions" && i+1 < len(parts) { - subscriptionID = parts[i+1] - break + if _, err := os.Stat(agentYmlPath); err == nil { + agentPath = agentYmlPath } - } - - if subscriptionID == "" { - return fmt.Errorf("subscription ID not found in resource ID: %s", aiFoundryProjectResourceID) - } - - // Get the tenant ID - tenantResponse, err := p.AzdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: subscriptionID, - }) - if err != nil { - return fmt.Errorf("failed to get tenant ID: %w", err) - } - - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResponse.TenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return fmt.Errorf("failed to create Azure credential: %w", err) - } - - // Get Microsoft Foundry Project's managed identity - fmt.Println("Retrieving Microsoft Foundry Project identity...") - projectPrincipalID, err := getProjectPrincipalID(ctx, cred, aiFoundryProjectResourceID, subscriptionID) - if err != nil { - return fmt.Errorf("failed to get Project principal ID: %w", err) - } - fmt.Printf("Principal ID: %s\n", projectPrincipalID) - - // Get Application ID from Principal ID - fmt.Println("Retrieving Application ID...") - projectClientID, err := getApplicationID(context.Background(), cred, projectPrincipalID) - if err != nil { - return fmt.Errorf("failed to get Application ID: %w", err) - } - - fmt.Printf("Application ID: %s\n", projectClientID) - - // Save to environment - cResponse, err := azdEnvClient.GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil { - return fmt.Errorf("failed to get current environment: %w", err) - } - - _, err = azdEnvClient.SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: cResponse.Environment.Name, - Key: "AZURE_AI_PROJECT_PRINCIPAL_ID", - Value: projectClientID, - }) - if err != nil { - return fmt.Errorf("failed to set AZURE_AI_PROJECT_PRINCIPAL_ID in environment: %w", err) - } - - fmt.Println("✓ Application ID saved to environment") - - return nil -} - -// getProjectPrincipalID retrieves the principal ID from the Microsoft Foundry Project using Azure SDK -func getProjectPrincipalID(ctx context.Context, cred *azidentity.AzureDeveloperCLICredential, resourceID, subscriptionID string) (string, error) { - // Create resources client - client, err := armresources.NewClient(subscriptionID, cred, nil) - if err != nil { - return "", fmt.Errorf("failed to create resources client: %w", err) - } - - // Get the resource - // API version for Microsoft Foundry projects (Machine Learning workspaces) - apiVersion := "2025-06-01" - resp, err := client.GetByID(ctx, resourceID, apiVersion, nil) - if err != nil { - return "", fmt.Errorf("failed to retrieve resource: %w", err) - } - - // Extract principal ID from identity - if resp.Identity == nil { - return "", fmt.Errorf("resource does not have an identity") - } - - if resp.Identity.PrincipalID == nil { - return "", fmt.Errorf("resource identity does not have a principal ID") - } - - principalID := *resp.Identity.PrincipalID - if principalID == "" { - return "", fmt.Errorf("principal ID is empty") - } - - return principalID, nil -} + if agentPath != "" { + // read the file content into bytes and close the file + content, err := os.ReadFile(agentPath) + if err != nil { + return false, fmt.Errorf("failed to read agent yaml file: %w", err) + } -// getApplicationID retrieves the application ID from the principal ID using Microsoft Graph API -func getApplicationID(ctx context.Context, cred *azidentity.AzureDeveloperCLICredential, principalID string) (string, error) { - // Create Graph client - graphClient, err := graphsdk.NewGraphClient(cred, nil) - if err != nil { - return "", fmt.Errorf("failed to create Graph client: %w", err) - } + err = agent_yaml.ValidateAgentDefinition(content) + if err != nil { + return false, fmt.Errorf("agent.yaml is not valid to run: %w", err) + } - // Get service principal directly by object ID (principal ID) - servicePrincipal, err := graphClient. - ServicePrincipalById(principalID). - Get(ctx) + var genericTemplate map[string]interface{} + if err := yaml.Unmarshal(content, &genericTemplate); err != nil { + return false, fmt.Errorf("YAML content is not valid to run: %w", err) + } - if err != nil { - return "", fmt.Errorf("failed to retrieve service principal with principal ID '%s': %w", principalID, err) - } + kind, ok := genericTemplate["kind"].(string) + if !ok { + return false, fmt.Errorf("kind field is not a valid string to check should run: %w", err) + } - appID := servicePrincipal.AppId - if appID == "" { - return "", fmt.Errorf("application ID is empty") + return kind == string(agent_yaml.AgentKindHosted), nil + } } - return appID, nil + return false, nil } // getCognitiveServicesAccountLocation retrieves the location of a Cognitive Services account using Azure SDK @@ -259,8 +105,6 @@ func getCognitiveServicesAccountLocation(ctx context.Context, cred *azidentity.A return location, nil } -///////////////////////////////////////////////////////////////////////////// - // Config structures for JSON parsing type AgentRegistrationPayload struct { Description string `json:"description"` @@ -300,14 +144,6 @@ type DataPlaneResponse struct { } func (p *FoundryParser) CoboPostDeploy(ctx context.Context, args *azdext.ProjectEventArgs) error { - shouldRun, err := shouldRun(ctx, args.Project) - if err != nil { - return fmt.Errorf("failed to determine if extension should attach: %w", err) - } - if !shouldRun { - return nil - } - azdEnvClient := p.AzdClient.Environment() cEnvResponse, err := azdEnvClient.GetCurrent(ctx, &azdext.EmptyRequest{}) if err != nil { @@ -326,9 +162,8 @@ func (p *FoundryParser) CoboPostDeploy(ctx context.Context, args *azdext.Project } // Get required values from azd environment - containerAppPrincipalID := azdEnv["SERVICE_API_IDENTITY_PRINCIPAL_ID"] + containerAppPrincipalID := azdEnv["COBO_ACA_IDENTITY_PRINCIPAL_ID"] aiFoundryProjectResourceID := azdEnv["AZURE_AI_PROJECT_ID"] - deploymentName := azdEnv["DEPLOYMENT_NAME"] resourceID := azdEnv["SERVICE_API_RESOURCE_ID"] agentName := azdEnv["AGENT_NAME"] //aiProjectEndpoint := azdEnv["AI_PROJECT_ENDPOINT"] @@ -352,10 +187,7 @@ func (p *FoundryParser) CoboPostDeploy(ctx context.Context, args *azdext.Project if err := validateRequired("AZURE_AI_PROJECT_ID", aiFoundryProjectResourceID); err != nil { return err } - if err := validateRequired("SERVICE_API_IDENTITY_PRINCIPAL_ID", containerAppPrincipalID); err != nil { - return err - } - if err := validateRequired("DEPLOYMENT_NAME", deploymentName); err != nil { + if err := validateRequired("COBO_ACA_IDENTITY_PRINCIPAL_ID", containerAppPrincipalID); err != nil { return err } if err := validateRequired("AGENT_NAME", agentName); err != nil { @@ -391,7 +223,6 @@ func (p *FoundryParser) CoboPostDeploy(ctx context.Context, args *azdext.Project fmt.Printf("Microsoft Foundry region: %s\n", aiFoundryRegion) fmt.Printf("Project: %s\n", projectName) - fmt.Printf("Deployment: %s\n", deploymentName) fmt.Printf("Agent: %s\n", agentName) // Assign Azure AI User role