diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index f71af284c78..37a613633b4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -981,6 +981,17 @@ func runInitFromAzureYaml( } } + // scaffoldProject changes the extension process into the adopted project root. + if err := configureAzureYamlEnvironmentVariables( + ctx, + azdClient, + env.Name, + ".", + flags.noPrompt, + ); err != nil { + return err + } + fmt.Printf( "\nAdopted the sample's azure.yaml as the project manifest at %s.\n", output.WithHighLightFormat("azure.yaml"), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go new file mode 100644 index 00000000000..019a8ac0945 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -0,0 +1,619 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "gopkg.in/yaml.v3" +) + +// azureYamlEnvRefPattern matches bare ${VAR} references and references with a +// fallback. Group 2 is non-empty for ${VAR:-default}, which does not require an +// environment value because the runtime expander supplies the fallback. +var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) + +// Escape handling must match the expander that owns each field. +// foundry.ExpandEnv treats an odd leading '$' as an escape, while the project +// synthesizers' resolveVars helper expands every ${VAR} match regardless of +// a preceding '$'. +const ( + honorAzureYamlEnvironmentEscaping = true + ignoreAzureYamlEnvironmentEscaping = false +) + +// These types mirror only the fields each Foundry provider expands from the +// azd environment. The owning provider types are unexported or live in sibling +// extension modules, so init keeps small typed views instead of string paths. +// +// Keep these views aligned with: +// - agent environmentVariables values +// - connection target, credentials, and metadata +// - project network VNet IDs and DNS subscription +// - routine action input +// - toolbox endpoint and tools +type azureYamlAgentEnvironmentConfig struct { + Kind string `yaml:"kind,omitempty"` + EnvironmentVariables []azureYamlEnvironmentVariable `yaml:"environmentVariables,omitempty"` +} + +type azureYamlEnvironmentVariable struct { + Value string `yaml:"value,omitempty"` +} + +type azureYamlConnectionEnvironmentConfig struct { + Target string `yaml:"target,omitempty"` + Credentials map[string]any `yaml:"credentials,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty"` +} + +type azureYamlProjectEnvironmentConfig struct { + Network *azureYamlNetworkEnvironmentConfig `yaml:"network,omitempty"` +} + +type azureYamlNetworkEnvironmentConfig struct { + AgentSubnet *azureYamlSubnetEnvironmentConfig `yaml:"agentSubnet,omitempty"` + PESubnet *azureYamlSubnetEnvironmentConfig `yaml:"peSubnet,omitempty"` + DNS *azureYamlDNSEnvironmentConfig `yaml:"dns,omitempty"` +} + +type azureYamlSubnetEnvironmentConfig struct { + VNet string `yaml:"vnet,omitempty"` +} + +type azureYamlDNSEnvironmentConfig struct { + Subscription string `yaml:"subscription,omitempty"` +} + +type azureYamlRoutineEnvironmentConfig struct { + Action *azureYamlRoutineActionEnvironmentConfig `yaml:"action,omitempty"` +} + +type azureYamlRoutineActionEnvironmentConfig struct { + Input any `yaml:"input,omitempty"` +} + +type azureYamlToolboxEnvironmentConfig struct { + Endpoint string `yaml:"endpoint,omitempty"` + Tools []map[string]any `yaml:"tools,omitempty"` +} + +var azureYamlCoreServiceFields = map[string]struct{}{ + "apiVersion": {}, + "condition": {}, + "config": {}, + "dist": {}, + "docker": {}, + "env": {}, + "hooks": {}, + "host": {}, + "image": {}, + "infra": {}, + "k8s": {}, + "language": {}, + "module": {}, + "project": {}, + "remoteBuild": {}, + "resourceGroup": {}, + "resourceName": {}, + "uses": {}, +} + +type azureYamlEnvironmentReference struct { + Name string + Secret bool +} + +// configureAzureYamlEnvironmentVariables prompts for unset environment values +// referenced by the adopted azure.yaml and persists them in the active azd +// environment. +func configureAzureYamlEnvironmentVariables( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + projectDir string, + noPrompt bool, +) error { + manifestPath := filepath.Join(projectDir, "azure.yaml") + //nolint:gosec // projectDir is the user-selected project root created by init + content, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("reading adopted azure.yaml: %w", err) + } + + references, err := findAzureYamlEnvironmentReferences(content, projectDir) + if err != nil { + return fmt.Errorf("finding environment variable references in azure.yaml: %w", err) + } + if len(references) == 0 { + return nil + } + + envResp, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envName, + }) + if err != nil { + return fmt.Errorf("reading azd environment %q: %w", envName, err) + } + + existing := make(map[string]string, len(envResp.KeyValues)) + for _, keyValue := range envResp.KeyValues { + existing[keyValue.Key] = keyValue.Value + } + + missing := make([]azureYamlEnvironmentReference, 0, len(references)) + for _, reference := range references { + if value, ok := existing[reference.Name]; ok { + if value == "" { + missing = append(missing, reference) + } + continue + } + + if value, ok := os.LookupEnv(reference.Name); ok && value != "" { + if err := setEnvValue(ctx, azdClient, envName, reference.Name, value); err != nil { + return err + } + existing[reference.Name] = value + continue + } + + missing = append(missing, reference) + } + if len(missing) == 0 || noPrompt { + return nil + } + + fmt.Println() + fmt.Println("azure.yaml references environment variables that need to be configured:") + fmt.Println() + + for _, reference := range missing { + promptResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf("Enter a value for %s", reference.Name), + HelpMessage: "The value will be stored in the current azd environment.", + Required: true, + RequiredMessage: fmt.Sprintf("A value is required for %s.", reference.Name), + IgnoreHintKeys: true, + Secret: reference.Secret, + ClearOnCompletion: reference.Secret, + }, + }) + if err != nil { + return exterrors.FromPrompt( + err, + fmt.Sprintf("failed to prompt for environment variable %s", reference.Name), + ) + } + if promptResp == nil || promptResp.Value == "" { + return fmt.Errorf("no value provided for environment variable %s", reference.Name) + } + + if err := setEnvValue(ctx, azdClient, envName, reference.Name, promptResp.Value); err != nil { + return err + } + } + + return nil +} + +func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]azureYamlEnvironmentReference, error) { + var document yaml.Node + if err := yaml.Unmarshal(content, &document); err != nil { + return nil, fmt.Errorf("parsing azure.yaml: %w", err) + } + + if len(document.Content) == 0 { + return nil, nil + } + + services := yamlMappingValue(document.Content[0], "services") + if services == nil || services.Kind != yaml.MappingNode { + return nil, nil + } + + var references []azureYamlEnvironmentReference + indexByName := make(map[string]int) + for i := 0; i+1 < len(services.Content); i += 2 { + serviceName := services.Content[i].Value + service := services.Content[i+1] + host, ok := foundryAzureYamlServiceHost(service) + if !ok { + continue + } + if !supportsAzureYamlEnvironmentReferences(host) { + continue + } + + var raw map[string]any + if err := service.Decode(&raw); err != nil { + return nil, fmt.Errorf("decoding service %q: %w", serviceName, err) + } + resolved, err := foundry.ResolveFileRefs(raw, projectDir) + if err != nil { + return nil, fmt.Errorf("resolving $ref includes for service %q: %w", serviceName, err) + } + var resolvedService yaml.Node + if err := resolvedService.Encode(resolved); err != nil { + return nil, fmt.Errorf("encoding resolved service %q: %w", serviceName, err) + } + + if err := collectAzureYamlServiceEnvironmentReferences( + host, + serviceName, + &resolvedService, + &references, + indexByName, + ); err != nil { + return nil, err + } + } + return references, nil +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func foundryAzureYamlServiceHost(service *yaml.Node) (string, bool) { + host := yamlMappingValue(service, "host") + if host == nil || host.Kind != yaml.ScalarNode { + return "", false + } + + _, knownHost := foundryServiceHosts[host.Value] + if !knownHost && !strings.HasPrefix(host.Value, "azure.ai.") { + return "", false + } + return host.Value, true +} + +func supportsAzureYamlEnvironmentReferences(host string) bool { + switch host { + case "azure.ai.agent", + "azure.ai.connection", + "azure.ai.project", + "azure.ai.routine", + "azure.ai.toolbox", + "microsoft.foundry": + return true + default: + return false + } +} + +func activeAzureYamlServiceConfiguration(host string, service *yaml.Node) *yaml.Node { + switch host { + case "azure.ai.agent": + if hasNonEmptyAzureYamlString(service, "kind") { + return service + } + config := yamlMappingValue(service, "config") + if hasNonEmptyAzureYamlString(config, "kind") { + return config + } + return nil + case "azure.ai.routine", "azure.ai.toolbox": + if hasAzureYamlInlineProperties(service) { + return service + } + return yamlMappingValue(service, "config") + default: + return service + } +} + +func hasNonEmptyAzureYamlString(node *yaml.Node, key string) bool { + value := yamlMappingValue(node, key) + return value != nil && value.Kind == yaml.ScalarNode && value.Value != "" +} + +func hasAzureYamlInlineProperties(service *yaml.Node) bool { + if service == nil || service.Kind != yaml.MappingNode { + return false + } + + for i := 0; i+1 < len(service.Content); i += 2 { + if _, isCoreField := azureYamlCoreServiceFields[service.Content[i].Value]; !isCoreField { + return true + } + } + return false +} + +func collectAzureYamlServiceEnvironmentReferences( + host string, + serviceName string, + service *yaml.Node, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) error { + active := activeAzureYamlServiceConfiguration(host, service) + if active == nil { + return nil + } + + switch host { + case "azure.ai.agent": + var config azureYamlAgentEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding agent service %q: %w", serviceName, err) + } + for _, variable := range config.EnvironmentVariables { + collectAzureYamlEnvironmentReferences( + variable.Value, + false, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + } + case "azure.ai.connection": + var config azureYamlConnectionEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding connection service %q: %w", serviceName, err) + } + collectAzureYamlEnvironmentReferences( + config.Target, + false, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Credentials, + true, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning connection service %q credentials: %w", serviceName, err) + } + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Metadata, + false, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning connection service %q metadata: %w", serviceName, err) + } + case "azure.ai.project", "microsoft.foundry": + var config azureYamlProjectEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding project service %q: %w", serviceName, err) + } + if config.Network != nil { + if config.Network.AgentSubnet != nil { + collectAzureYamlEnvironmentReferences( + config.Network.AgentSubnet.VNet, + false, + ignoreAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + } + if config.Network.PESubnet != nil { + collectAzureYamlEnvironmentReferences( + config.Network.PESubnet.VNet, + false, + ignoreAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + } + if config.Network.DNS != nil { + collectAzureYamlEnvironmentReferences( + config.Network.DNS.Subscription, + false, + ignoreAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + } + } + case "azure.ai.routine": + var config azureYamlRoutineEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding routine service %q: %w", serviceName, err) + } + if config.Action != nil { + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Action.Input, + false, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning routine service %q action input: %w", serviceName, err) + } + } + case "azure.ai.toolbox": + var config azureYamlToolboxEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding toolbox service %q: %w", serviceName, err) + } + collectAzureYamlEnvironmentReferences( + config.Endpoint, + false, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Tools, + false, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning toolbox service %q tools: %w", serviceName, err) + } + } + + return nil +} + +func collectAzureYamlEnvironmentReferencesFromValue( + value any, + secret bool, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) error { + if value == nil { + return nil + } + + var node yaml.Node + if err := node.Encode(value); err != nil { + return fmt.Errorf("encoding value: %w", err) + } + collectAzureYamlEnvironmentReferencesFromNode(&node, secret, references, indexByName) + return nil +} + +func collectAzureYamlEnvironmentReferencesFromNode( + node *yaml.Node, + secret bool, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + if node == nil { + return + } + + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + collectAzureYamlEnvironmentReferencesFromNode(child, secret, references, indexByName) + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + value := node.Content[i+1] + childSecret := secret || isSecretAzureYamlEnvironmentKey(key.Value) + collectAzureYamlEnvironmentReferencesFromNode(value, childSecret, references, indexByName) + } + case yaml.AliasNode: + collectAzureYamlEnvironmentReferencesFromNode(node.Alias, secret, references, indexByName) + case yaml.ScalarNode: + collectAzureYamlEnvironmentReferences( + node.Value, + secret, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) + } +} + +func collectAzureYamlEnvironmentReferences( + value string, + secret bool, + honorEscaping bool, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + matches := azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) + protected := protectedAzureYamlEnvironmentReferenceOccurrences(value, matches, honorEscaping) + for i, match := range matches { + if honorEscaping && isEscapedAzureYamlEnvironmentReference(value, match[0]) { + continue + } + if protected[i] { + continue + } + if match[4] != -1 { + continue + } + + name := value[match[2]:match[3]] + if index, ok := indexByName[name]; ok { + if secret { + (*references)[index].Secret = true + } + continue + } + + indexByName[name] = len(*references) + *references = append(*references, azureYamlEnvironmentReference{ + Name: name, + Secret: secret, + }) + } +} + +// protectedAzureYamlEnvironmentReferenceOccurrences reports which candidate +// references are inside server-side ${{...}} spans. Each candidate is replaced +// with a unique probe before running [foundry.ExpandEnv]; probes left verbatim +// are protected by the shared expander. This keeps discovery linked to the +// owning implementation without ambiguous name-based occurrence counting. +func protectedAzureYamlEnvironmentReferenceOccurrences( + value string, + matches [][]int, + honorEscaping bool, +) []bool { + protected := make([]bool, len(matches)) + if !honorEscaping || len(matches) == 0 { + return protected + } + + probePrefix := "AZD_ENV_REFERENCE_PROBE_" + for strings.Contains(value, probePrefix) { + probePrefix += "_" + } + + probeRefs := make([]string, len(matches)) + var probed strings.Builder + last := 0 + for i, match := range matches { + probed.WriteString(value[last:match[0]]) + probeRefs[i] = fmt.Sprintf("${%s%d}", probePrefix, i) + probed.WriteString(probeRefs[i]) + last = match[1] + } + probed.WriteString(value[last:]) + + expanded, err := foundry.ExpandEnv(probed.String(), func(name string) string { + return "expanded_" + name + }) + if err != nil { + return protected + } + for i, probeRef := range probeRefs { + protected[i] = strings.Contains(expanded, probeRef) + } + return protected +} + +func isEscapedAzureYamlEnvironmentReference(value string, start int) bool { + precedingDollars := 0 + for i := start - 1; i >= 0 && value[i] == '$'; i-- { + precedingDollars++ + } + return precedingDollars%2 == 1 +} + +func isSecretAzureYamlEnvironmentKey(key string) bool { + switch strings.ToLower(key) { + case "credential", "credentials", "secret", "secrets": + return true + } + return false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go new file mode 100644 index 00000000000..41018fcb2df --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -0,0 +1,650 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindAzureYamlEnvironmentReferences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want []azureYamlEnvironmentReference + wantErr bool + }{ + { + name: "finds issue references and classifies credential as secret", + content: `name: playwright-agent +services: + playwright: + host: azure.ai.connection + credentials: + key: '${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}' + connectionString: '${DATABASE_CONNECTION_STRING}' + metadata: + resourceId: '${PLAYWRIGHT_SERVICE_RESOURCE_ID}' +`, + want: []azureYamlEnvironmentReference{ + {Name: "DATABASE_CONNECTION_STRING", Secret: true}, + {Name: "PLAYWRIGHT_SERVICE_ACCESS_TOKEN", Secret: true}, + {Name: "PLAYWRIGHT_SERVICE_RESOURCE_ID"}, + }, + }, + { + name: "deduplicates and upgrades secret classification", + content: `name: sample +services: + connection: + host: azure.ai.connection + metadata: + resourceId: ${SHARED_VALUE} + credentials: + key: ${SHARED_VALUE} +`, + want: []azureYamlEnvironmentReference{ + {Name: "SHARED_VALUE", Secret: true}, + }, + }, + { + name: "ignores defaults foundry templates comments and escaped references", + content: `name: sample +# ${COMMENT_ONLY} +services: + agent: + host: azure.ai.agent + kind: hosted + environmentVariables: + - name: DEFAULTED + value: ${DEFAULTED:-fallback} + - name: FOUNDRY + value: ${{connections.search.credentials.key}} + - name: MULTILINE_FOUNDRY + value: | + ${{ event.body + ?? '${FOUNDRY_INNER_VALUE}' + }} + - name: ESCAPED + value: $${ESCAPED} + - name: EXPANDED_AFTER_LITERAL_DOLLAR + value: $$${EXPANDED_AFTER_LITERAL_DOLLAR} + - name: MIXED_ESCAPE + value: ${MIXED_REF} literal-$${MIXED_REF} + - name: MIXED_SERVER + value: ${SERVER_SHARED} ${{ event.body ?? '${SERVER_SHARED}' }} +`, + want: []azureYamlEnvironmentReference{ + {Name: "EXPANDED_AFTER_LITERAL_DOLLAR"}, + {Name: "MIXED_REF"}, + {Name: "SERVER_SHARED"}, + }, + }, + { + name: "does not infer secrets from variable names outside credential paths", + content: `name: sample +services: + agent: + host: azure.ai.agent + kind: hosted + environmentVariables: + - name: API_TOKEN + value: ${SERVICE_API_TOKEN} + - name: DATABASE_CONNECTION_STRING + value: ${DATABASE_CONNECTION_STRING} + - name: ENDPOINT + value: ${SERVICE_ENDPOINT} +`, + want: []azureYamlEnvironmentReference{ + {Name: "SERVICE_API_TOKEN"}, + {Name: "DATABASE_CONNECTION_STRING"}, + {Name: "SERVICE_ENDPOINT"}, + }, + }, + { + name: "ignores project hooks service hooks and unrelated services", + content: `name: sample +hooks: + preprovision: + shell: sh + run: echo ${PROJECT_HOOK_VALUE} +services: + web: + host: containerapp + env: + WEB_VALUE: ${WEB_VALUE} + agent: + host: azure.ai.agent + kind: hosted + hooks: + predeploy: + shell: sh + run: echo ${SERVICE_HOOK_VALUE} + environmentVariables: + - name: AGENT_VALUE + value: ${AGENT_VALUE} +`, + want: []azureYamlEnvironmentReference{ + {Name: "AGENT_VALUE"}, + }, + }, + { + name: "project scans only expanded network fields", + content: `name: sample +services: + project: + host: azure.ai.project + endpoint: ${RAW_PROJECT_ENDPOINT} + deployments: + - name: ${RAW_DEPLOYMENT_NAME} + model: + name: ${RAW_MODEL_NAME} + sku: + name: ${RAW_SKU_NAME} + network: + agentSubnet: + vnet: $${AGENT_VNET_ID} + peSubnet: + vnet: $${PE_VNET_ID} + dns: + subscription: ${DNS_SUBSCRIPTION_ID} +`, + want: []azureYamlEnvironmentReference{ + {Name: "AGENT_VNET_ID"}, + {Name: "PE_VNET_ID"}, + {Name: "DNS_SUBSCRIPTION_ID"}, + }, + }, + { + name: "connection scans only target credentials and metadata", + content: `name: sample +services: + connection: + host: azure.ai.connection + category: ${RAW_CATEGORY} + authType: ${RAW_AUTH_TYPE} + target: ${CONNECTION_TARGET} + credentials: + key: ${CONNECTION_KEY} + metadata: + resourceId: ${CONNECTION_RESOURCE_ID} +`, + want: []azureYamlEnvironmentReference{ + {Name: "CONNECTION_TARGET"}, + {Name: "CONNECTION_KEY", Secret: true}, + {Name: "CONNECTION_RESOURCE_ID"}, + }, + }, + { + name: "toolbox scans endpoint and tools but not description", + content: `name: sample +services: + toolbox: + host: azure.ai.toolbox + description: ${RAW_TOOLBOX_DESCRIPTION} + endpoint: ${TOOLBOX_ENDPOINT} + tools: + - name: search + configuration: + key: ${TOOLBOX_KEY} +`, + want: []azureYamlEnvironmentReference{ + {Name: "TOOLBOX_ENDPOINT"}, + {Name: "TOOLBOX_KEY"}, + }, + }, + { + name: "routine scans action input but not triggers or description", + content: `name: sample +services: + routine: + host: azure.ai.routine + description: ${RAW_ROUTINE_DESCRIPTION} + triggers: + - type: ${RAW_TRIGGER_TYPE} + action: + input: + value: ${ROUTINE_INPUT} +`, + want: []azureYamlEnvironmentReference{ + {Name: "ROUTINE_INPUT"}, + }, + }, + { + name: "skill fields are not expanded", + content: `name: sample +services: + skill: + host: azure.ai.skill + description: ${RAW_SKILL_DESCRIPTION} + instructions: ${RAW_SKILL_INSTRUCTIONS} +`, + want: nil, + }, + { + name: "unsupported host refs are ignored before resolution", + content: `name: sample +services: + skill: + host: azure.ai.skill + $ref: ./missing-skill.yaml +`, + want: nil, + }, + { + name: "deprecated toolbox and routine config fields are scanned", + content: `name: sample +services: + agent: + host: azure.ai.agent + config: + kind: hosted + environmentVariables: + - name: LEGACY_AGENT_VALUE + value: ${LEGACY_AGENT_VALUE} + routine: + host: azure.ai.routine + config: + action: + input: + value: ${LEGACY_ROUTINE_INPUT} + toolbox: + host: azure.ai.toolbox + config: + endpoint: ${LEGACY_TOOLBOX_ENDPOINT} + tools: + - name: legacy + configuration: + key: ${LEGACY_TOOLBOX_KEY} +`, + want: []azureYamlEnvironmentReference{ + {Name: "LEGACY_AGENT_VALUE"}, + {Name: "LEGACY_ROUTINE_INPUT"}, + {Name: "LEGACY_TOOLBOX_ENDPOINT"}, + {Name: "LEGACY_TOOLBOX_KEY"}, + }, + }, + { + name: "inline properties take precedence over stale config fields", + content: `name: sample +services: + agent: + host: azure.ai.agent + kind: hosted + environmentVariables: + - name: INLINE_AGENT_VALUE + value: ${INLINE_AGENT_VALUE} + config: + kind: hosted + environmentVariables: + - name: STALE_AGENT_VALUE + value: ${STALE_AGENT_VALUE} + routine: + host: azure.ai.routine + description: inline routine + action: + input: + value: ${INLINE_ROUTINE_INPUT} + config: + action: + input: + value: ${STALE_ROUTINE_INPUT} + toolbox: + host: azure.ai.toolbox + description: inline toolbox + endpoint: ${INLINE_TOOLBOX_ENDPOINT} + config: + endpoint: ${STALE_TOOLBOX_ENDPOINT} + tools: + - name: stale + configuration: + key: ${STALE_TOOLBOX_KEY} +`, + want: []azureYamlEnvironmentReference{ + {Name: "INLINE_AGENT_VALUE"}, + {Name: "INLINE_ROUTINE_INPUT"}, + {Name: "INLINE_TOOLBOX_ENDPOINT"}, + }, + }, + { + name: "malformed yaml", + content: "name: [unterminated", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := findAzureYamlEnvironmentReferences([]byte(tt.content), ".") + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestConfigureAzureYamlEnvironmentVariables_ResolvesServiceRefs(t *testing.T) { + t.Setenv("REFERENCED_API_TOKEN", "") + t.Setenv("ROOT_RESOURCE_ID", "") + + projectDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(projectDir, "services"), 0700)) + require.NoError(t, os.WriteFile( + filepath.Join(projectDir, "services", "connection.yaml"), + []byte(`credentials: + key: ${REFERENCED_API_TOKEN} +metadata: + resourceId: ${OVERRIDDEN_RESOURCE_ID} +`), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(projectDir, "azure.yaml"), + []byte(`name: sample +services: + connection: + host: azure.ai.connection + $ref: ./services/connection.yaml + metadata: + resourceId: ${ROOT_RESOURCE_ID} +`), + 0600, + )) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{ + promptResponses: []string{"api-token", "resource-id"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Equal(t, "api-token", envServer.values["dev"]["REFERENCED_API_TOKEN"]) + require.Equal(t, "resource-id", envServer.values["dev"]["ROOT_RESOURCE_ID"]) + require.NotContains(t, envServer.values["dev"], "OVERRIDDEN_RESOURCE_ID") + require.Len(t, promptServer.promptRequests, 2) + require.True(t, promptServer.promptRequests[0].Options.Secret) +} + +func TestConfigureAzureYamlEnvironmentVariables_PromptsAndPersistsMissingValues(t *testing.T) { + t.Setenv("PLAYWRIGHT_SERVICE_ACCESS_TOKEN", "") + t.Setenv("PLAYWRIGHT_SERVICE_RESOURCE_ID", "") + + projectDir := t.TempDir() + content := `name: playwright-agent +services: + playwright: + host: azure.ai.connection + credentials: + key: '${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}' + metadata: + resourceId: '${PLAYWRIGHT_SERVICE_RESOURCE_ID}' + existing: '${ALREADY_SET}' +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + t.Chdir(projectDir) + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + "dev": { + "ALREADY_SET": "existing-value", + }, + }, + } + promptServer := &testPromptServiceServer{ + promptResponses: []string{"access-token", "/subscriptions/sub/resourceGroups/rg/providers/test"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + ".", + false, + ) + require.NoError(t, err) + + require.Equal(t, "access-token", envServer.values["dev"]["PLAYWRIGHT_SERVICE_ACCESS_TOKEN"]) + require.Equal( + t, + "/subscriptions/sub/resourceGroups/rg/providers/test", + envServer.values["dev"]["PLAYWRIGHT_SERVICE_RESOURCE_ID"], + ) + require.Equal(t, "existing-value", envServer.values["dev"]["ALREADY_SET"]) + + require.Len(t, promptServer.promptRequests, 2) + require.Equal( + t, + "Enter a value for PLAYWRIGHT_SERVICE_ACCESS_TOKEN", + promptServer.promptRequests[0].Options.Message, + ) + require.True(t, promptServer.promptRequests[0].Options.Required) + require.True(t, promptServer.promptRequests[0].Options.Secret) + require.True(t, promptServer.promptRequests[0].Options.ClearOnCompletion) + + require.Equal( + t, + "Enter a value for PLAYWRIGHT_SERVICE_RESOURCE_ID", + promptServer.promptRequests[1].Options.Message, + ) + require.True(t, promptServer.promptRequests[1].Options.Required) + require.False(t, promptServer.promptRequests[1].Options.Secret) + require.False(t, promptServer.promptRequests[1].Options.ClearOnCompletion) +} + +func TestConfigureAzureYamlEnvironmentVariables_NoPromptSkipsPrompts(t *testing.T) { + t.Setenv("API_TOKEN", "") + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + credentials: + key: ${API_TOKEN} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + true, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Empty(t, envServer.values) +} + +func TestConfigureAzureYamlEnvironmentVariables_NoPromptPersistsProcessEnvironmentFallback(t *testing.T) { + const envVarName = "AZD_TEST_INIT_NO_PROMPT_PROCESS_VALUE" + t.Setenv(envVarName, "from-process") + + projectDir := t.TempDir() + content := `name: sample +services: + toolbox: + host: azure.ai.toolbox + tools: + - name: process-value + configuration: + value: ${AZD_TEST_INIT_NO_PROMPT_PROCESS_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + true, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Equal(t, "from-process", envServer.values["dev"][envVarName]) +} + +func TestConfigureAzureYamlEnvironmentVariables_SkipsConfiguredValues(t *testing.T) { + t.Parallel() + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + credentials: + key: ${API_TOKEN} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + "dev": { + "API_TOKEN": "configured", + }, + }, + } + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Equal(t, "configured", envServer.values["dev"]["API_TOKEN"]) +} + +func TestConfigureAzureYamlEnvironmentVariables_PersistsProcessEnvironmentFallback(t *testing.T) { + const envVarName = "AZD_TEST_INIT_PROCESS_ONLY_VALUE" + t.Setenv(envVarName, "from-process") + + projectDir := t.TempDir() + content := `name: sample +services: + toolbox: + host: azure.ai.toolbox + tools: + - name: process-value + configuration: + value: ${AZD_TEST_INIT_PROCESS_ONLY_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Equal(t, "from-process", envServer.values["dev"][envVarName]) +} + +func TestConfigureAzureYamlEnvironmentVariables_EmptyAzdValueBlocksProcessFallback(t *testing.T) { + const envVarName = "AZD_TEST_INIT_EXPLICIT_EMPTY_VALUE" + t.Setenv(envVarName, "from-process") + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + metadata: + explicitEmpty: ${AZD_TEST_INIT_EXPLICIT_EMPTY_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + "dev": { + envVarName: "", + }, + }, + } + promptServer := &testPromptServiceServer{ + promptResponses: []string{"prompted-value"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Len(t, promptServer.promptRequests, 1) + require.Equal(t, "prompted-value", envServer.values["dev"][envVarName]) +} + +func TestConfigureAzureYamlEnvironmentVariables_EmptyProcessValuePrompts(t *testing.T) { + const envVarName = "AZD_TEST_INIT_EMPTY_PROCESS_VALUE" + t.Setenv(envVarName, "") + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + metadata: + emptyProcess: ${AZD_TEST_INIT_EMPTY_PROCESS_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{ + promptResponses: []string{"prompted-value"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Len(t, promptServer.promptRequests, 1) + require.Equal(t, "prompted-value", envServer.values["dev"][envVarName]) +}