diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index f650bca3e72..749707dbd9f 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features Added +- [[#9025]](https://github.com/Azure/azure-dev/pull/9025) `azd provision` now prompts for unresolved `${VAR}` references in a Foundry service's `network:` block (for example `peSubnet.vnet` or `dns.subscription`) instead of failing, then persists the value to the azd environment so later runs and `azd env get-values` see it. Resolution order is azd environment, then the process environment, then a prompt; under `--no-prompt` it returns an actionable error naming the variable so CI/CD stays deterministic. - [[#8989]](https://github.com/Azure/azure-dev/pull/8989) Add `a2a` protocol support to `azd ai agent invoke`. A plain message is wrapped in a JSON-RPC 2.0 `message/send` request, `--input-file` sends a complete JSON-RPC request, and `--output raw` dumps the response verbatim. A2A is remote-only (not available with `--local`). ### Bugs Fixed diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go index b6e9c9202d7..82ed7e96ab7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go @@ -155,13 +155,7 @@ func (p *FoundryProvisioningProvider) Initialize( return p.resolveEnv(ctx) } - res, err := synthesis.Synthesize(synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: p.networkEnvMap(ctx), - ProjectRoot: projectPath, - }) + res, err := p.synthesizeWithEnvPrompt(ctx, rawYAML, svcName, projectPath) switch { case errors.Is(err, synthesis.ErrEndpointBrownfield): // endpoint: reuse — connect to the existing project, skip provisioning. @@ -179,6 +173,13 @@ func (p *FoundryProvisioningProvider) Initialize( fmt.Sprintf("add a service with `host: %s` to azure.yaml", FoundryProjectHost), ) case err != nil: + // Cancellation and --no-prompt guidance from the env-var + // prompt are already actionable LocalErrors; surface them + // as-is rather than rewrapping in a generic azure.yaml error. + var localErr *azdext.LocalError + if errors.As(err, &localErr) { + return err + } return exterrors.Validation( exterrors.CodeInvalidAzureYaml, fmt.Sprintf("synthesize foundry project service %q: %s", svcName, err), @@ -240,7 +241,144 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str return out } -// warnNetworkIgnoredInBrownfield logs a warning when a service declares both +// maxEnvVarPrompts bounds the synthesize/prompt retry loop so a +// reference that stays unresolved cannot spin forever. Each pass +// resolves at most one variable; this ceiling comfortably covers +// the few ${VAR} refs a network block can carry (two subnets plus +// dns.subscription). +const maxEnvVarPrompts = 16 + +// synthesizeWithEnvPrompt runs the synthesizer and, when a network +// ${VAR} reference cannot be resolved from the azd environment or +// the process environment, prompts for the value, persists it to +// the azd environment, and retries. Under --no-prompt the azd host +// reports "prompt required", which is surfaced as an actionable +// error naming the variable so CI/CD stays deterministic. Other +// synthesize errors (brownfield, service-not-found, validation) are +// returned unchanged for the caller's switch. +func (p *FoundryProvisioningProvider) synthesizeWithEnvPrompt( + ctx context.Context, + rawYAML []byte, + svcName string, + projectPath string, +) (*synthesis.Result, error) { + // networkEnvMap may return nil; copy into a mutable map we can + // augment as the user supplies values across retries. + env := p.networkEnvMap(ctx) + if env == nil { + env = map[string]string{} + } + + prompted := map[string]struct{}{} + + for range maxEnvVarPrompts { + res, err := synthesis.Synthesize(synthesis.Input{ + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: env, + ProjectRoot: projectPath, + }) + if err == nil { + return res, nil + } + + var unresolved *synthesis.UnresolvedEnvVarError + if !errors.As(err, &unresolved) { + // Brownfield, service-not-found, or a validation error: + // hand it back to the caller unchanged. + return nil, err + } + + // A variable still unresolved after we set it signals a + // value we cannot honor; fail with the original error. + if _, seen := prompted[unresolved.Name]; seen { + return nil, err + } + prompted[unresolved.Name] = struct{}{} + + value, perr := p.promptNetworkEnvVar(ctx, unresolved.Name) + if perr != nil { + return nil, perr + } + env[unresolved.Name] = value + } + + return nil, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("too many unresolved environment variables while synthesizing service %q", svcName), + "resolve the ${VAR} references under the service's network: block, then re-run provision", + ) +} + +// promptNetworkEnvVar asks the user for the value of a ${VAR} +// reference that the network: block uses but the environment does +// not define, then persists it to the azd environment so later runs +// and `azd env get-values` see it. Cancellation and --no-prompt are +// handled like promptSubscription: interactive callers get a prompt, +// headless callers get an actionable error. +func (p *FoundryProvisioningProvider) promptNetworkEnvVar(ctx context.Context, name string) (string, error) { + if p.azdClient == nil { + return "", exterrors.Dependency( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("environment variable %s referenced in azure.yaml is not set", name), + fmt.Sprintf("run `azd env set %s ` and re-run provision", name), + ) + } + if err := p.ensureEnvName(ctx); err != nil { + return "", err + } + + resp, err := p.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf("Enter value for environment variable %s", name), + HelpMessage: fmt.Sprintf( + "azure.yaml references ${%s} in the service network configuration, "+ + "but it is not set in the azd environment or the shell.", name), + IgnoreHintKeys: true, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled(fmt.Sprintf("entry for %s was cancelled", name)) + } + if exterrors.IsPromptRequired(err) { + return "", exterrors.Dependency( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("environment variable %s is referenced in azure.yaml but not set", name), + fmt.Sprintf("run `azd env set %s `, or run interactively to provide it", name), + ) + } + return "", exterrors.FromPrompt(err, fmt.Sprintf("failed to prompt for environment variable %s", name)) + } + + value := strings.TrimSpace(resp.Value) + if err := p.setEnv(ctx, name, value); err != nil { + return "", err + } + return value, nil +} + +// ensureEnvName populates p.envName from the current azd environment +// when it has not been resolved yet. Synthesis (and any env-var +// prompt it triggers) runs before resolveEnv, so the name may be +// unset at prompt time; setEnv needs it to persist the value. +func (p *FoundryProvisioningProvider) ensureEnvName(ctx context.Context) error { + if p.envName != "" { + return nil + } + curr, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || curr.GetEnvironment() == nil { + return exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + fmt.Sprintf("get current azd environment: %v", err), + "run 'azd env new' to create an environment", + ) + } + p.envName = curr.GetEnvironment().GetName() + return nil +} + // endpoint: (brownfield) and network:. The account's network posture is fixed // by whoever created it, so the network: block has no effect. func warnNetworkIgnoredInBrownfield(rawYAML []byte, svcName string) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go index f8047a023d9..9200065523f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go @@ -627,6 +627,33 @@ func TestFindFoundryProjectService_DependencyCategory(t *testing.T) { "missing foundry project service is a Dependency, not a Validation") } +func TestSynthesizeWithEnvPrompt_NoClientSurfacesActionableError(t *testing.T) { + // Without an azd client (which also stands in for the + // --no-prompt path), an unresolved network ${VAR} cannot be + // prompted for and must surface as an actionable dependency + // error naming the variable, not a raw synthesizer failure. + // The variable name is one the process environment does not set. + const varName = "AZURE_DEV_TEST_UNSET_VNET_ID" + require.Empty(t, os.Getenv(varName), "test precondition: %s must be unset", varName) + + yaml := ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: "${` + varName + `}", name: pe} +` + p := &FoundryProvisioningProvider{} // azdClient intentionally nil + _, err := p.synthesizeWithEnvPrompt(t.Context(), []byte(yaml), "my-project", "") + require.Error(t, err) + + var local *azdext.LocalError + require.True(t, errors.As(err, &local), "want *azdext.LocalError, got %T", err) + assert.Equal(t, azdext.LocalErrorCategoryDependency, local.Category) + assert.Contains(t, local.Message, varName) + assert.Contains(t, local.Suggestion, "azd env set") +} + func TestOnDiskTemplatePresent(t *testing.T) { t.Parallel() // Empty project root: no infra/, so on-disk template absent. diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 45dc1ecd2c0..1e1293ca216 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -38,6 +38,22 @@ var ( ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") ) +// UnresolvedEnvVarError reports a ${VAR} reference in a network +// field that could not be resolved from the supplied env map or +// the process environment. It is a typed error (not a plain +// string) so the provisioning provider can detect it with +// errors.As, prompt for the value, and retry — or fail with +// actionable guidance under --no-prompt. Callers wrap it with +// field context via %w, so the wrapped chain still matches. +type UnresolvedEnvVarError struct { + // Name is the referenced variable, e.g. AZURE_VNET_ID. + Name string +} + +func (e *UnresolvedEnvVarError) Error() string { + return fmt.Sprintf("unresolved environment variable ${%s}", e.Name) +} + // Input is the synthesizer's view of azure.yaml. type Input struct { // RawAzureYAML is the full bytes of azure.yaml. @@ -603,7 +619,7 @@ func resolveVars(s string, env map[string]string) (string, error) { return match }) if unresolved != "" { - return "", fmt.Errorf("unresolved environment variable ${%s}", unresolved) + return "", &UnresolvedEnvVarError{Name: unresolved} } return out, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 1755d30d783..32f120e66c3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -615,6 +615,30 @@ services: assert.Contains(t, err.Error(), "not a well-formed") } +func TestSynthesize_UnresolvedVarIsTyped(t *testing.T) { + // The provision path (resolve on) must surface a typed + // UnresolvedEnvVarError naming the variable so the provider + // can prompt for it. Field context is still wrapped via %w. + yaml := ` +services: + my-project: + host: azure.ai.project + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe} +` + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + }) + require.Error(t, err) + + var unresolved *UnresolvedEnvVarError + require.True(t, errors.As(err, &unresolved), "want *UnresolvedEnvVarError, got %T", err) + assert.Equal(t, "AZURE_VNET_ID", unresolved.Name) + assert.Contains(t, err.Error(), "services.my-project.network") +} + func TestSynthesize_ResolvesDeploymentRef(t *testing.T) { // A deployment item authored as a $ref must be loaded so synthesis sees the // real deployment, not a zero-valued {"$ref": ...} placeholder.