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..a4418d88e59 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "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_]*)(:-[^}]*)?\}`) + +var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) + +var azureYamlEnvironmentReferencePaths = map[string][][]string{ + "azure.ai.agent": { + {"environmentVariables", "*", "value"}, + }, + "azure.ai.connection": { + {"target"}, + {"credentials"}, + {"metadata"}, + }, + "azure.ai.project": { + {"network", "agentSubnet", "vnet"}, + {"network", "peSubnet", "vnet"}, + {"network", "dns", "subscription"}, + }, + "azure.ai.routine": { + {"action", "input"}, + }, + "azure.ai.toolbox": { + {"endpoint"}, + {"tools"}, + }, + "microsoft.foundry": { + {"network", "agentSubnet", "vnet"}, + {"network", "peSubnet", "vnet"}, + {"network", "dns", "subscription"}, + }, +} + +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 { + if noPrompt { + return nil + } + + 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 { + 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 + } + _, ok = azureYamlEnvironmentReferencePaths[host] + if !ok { + 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) + } + + referencePaths := activeAzureYamlEnvironmentReferencePaths(host, &resolvedService) + for _, referencePath := range referencePaths { + collectAzureYamlEnvironmentReferencesAtPath( + &resolvedService, + referencePath, + []string{"services", serviceName}, + &references, + indexByName, + ) + } + } + 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 activeAzureYamlEnvironmentReferencePaths(host string, service *yaml.Node) [][]string { + referencePaths := azureYamlEnvironmentReferencePaths[host] + + switch host { + case "azure.ai.agent": + if hasNonEmptyAzureYamlString(service, "kind") { + return referencePaths + } + config := yamlMappingValue(service, "config") + if hasNonEmptyAzureYamlString(config, "kind") { + return prefixAzureYamlEnvironmentReferencePaths("config", referencePaths) + } + return nil + case "azure.ai.routine", "azure.ai.toolbox": + if hasAzureYamlInlineProperties(service) { + return referencePaths + } + if yamlMappingValue(service, "config") != nil { + return prefixAzureYamlEnvironmentReferencePaths("config", referencePaths) + } + return nil + default: + return referencePaths + } +} + +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 prefixAzureYamlEnvironmentReferencePaths(prefix string, paths [][]string) [][]string { + prefixed := make([][]string, len(paths)) + for i, path := range paths { + prefixed[i] = append([]string{prefix}, path...) + } + return prefixed +} + +func collectAzureYamlEnvironmentReferencesAtPath( + node *yaml.Node, + referencePath []string, + path []string, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + if node == nil { + return + } + if node.Kind == yaml.AliasNode { + node = node.Alias + } + if len(referencePath) == 0 { + collectAzureYamlEnvironmentReferences(node, path, references, indexByName) + return + } + + segment := referencePath[0] + if segment == "*" { + if node.Kind != yaml.SequenceNode { + return + } + for _, child := range node.Content { + collectAzureYamlEnvironmentReferencesAtPath( + child, + referencePath[1:], + append(slices.Clone(path), segment), + references, + indexByName, + ) + } + return + } + + child := yamlMappingValue(node, segment) + if child == nil { + return + } + collectAzureYamlEnvironmentReferencesAtPath( + child, + referencePath[1:], + append(slices.Clone(path), segment), + references, + indexByName, + ) +} + +func collectAzureYamlEnvironmentReferences( + node *yaml.Node, + path []string, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + if node == nil { + return + } + + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + collectAzureYamlEnvironmentReferences(child, path, references, indexByName) + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + value := node.Content[i+1] + childPath := append(slices.Clone(path), key.Value) + collectAzureYamlEnvironmentReferences(value, childPath, references, indexByName) + } + case yaml.AliasNode: + collectAzureYamlEnvironmentReferences(node.Alias, path, references, indexByName) + case yaml.ScalarNode: + value := foundryTemplateSpanPattern.ReplaceAllStringFunc(node.Value, func(span string) string { + return strings.Repeat(" ", len(span)) + }) + for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) { + if isEscapedAzureYamlEnvironmentReference(value, match[0]) { + continue + } + if match[4] != -1 { + continue + } + + name := value[match[2]:match[3]] + secret := isSecretAzureYamlEnvironmentReference(path) + 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, + }) + } + } +} + +func isEscapedAzureYamlEnvironmentReference(value string, start int) bool { + precedingDollars := 0 + for i := start - 1; i >= 0 && value[i] == '$'; i-- { + precedingDollars++ + } + return precedingDollars%2 == 1 +} + +// Secret masking is based on explicit configuration structure. Environment +// variable names are user-defined and are not a reliable sensitivity signal. +func isSecretAzureYamlEnvironmentReference(path []string) bool { + for _, segment := range path { + switch strings.ToLower(segment) { + 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..2a6152b62bd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -0,0 +1,598 @@ +// 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} +`, + want: []azureYamlEnvironmentReference{ + {Name: "EXPANDED_AFTER_LITERAL_DOLLAR"}, + }, + }, + { + 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: "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.Parallel() + + 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) { + 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.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{} + 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_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]) +}