Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
Expand Down Expand Up @@ -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 <value>` 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 <value>`, 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading