diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index d07f9cc4f36..8dad44784c8 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Add support for a generic `policies` list in `agent.yaml` to attach governance policies to hosted agents. Each entry has a `type` discriminator; `type: rai_policy` attaches a Responsible AI (content safety) guardrail via `rai_policy_name`, the full ARM resource ID of the RAI policy (for example, `/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//raiPolicies/`). `azd deploy` forwards it to the Foundry data plane as `rai_config.rai_policy_name`. +- [[#8528]](https://github.com/Azure/azure-dev/issues/8528) .env will now contain a salted RG name after init, where before it was empty (Bicep filled in the default). Anyone scripting against AZURE_RESOURCE_GROUP right after init will see something different. ## 0.1.37-preview (2026-06-01) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 89b2c270dcb..35e28a61260 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1540,7 +1540,13 @@ func ensureProject( // Best-effort: generate a salt so uniqueString()-based resource names // differ across project recreations. If anything fails the Bicep // templates fall back to the original deterministic hash. - ensureResourceTokenSalt(ctx, azdClient, envName) + salt := ensureResourceTokenSalt(ctx, azdClient, envName) + + // Best-effort: write a salted AZURE_RESOURCE_GROUP so recreated + // projects get a fresh RG (avoiding collisions with leftovers from + // a prior teardown). Skipped when salt generation failed or when + // AZURE_RESOURCE_GROUP is already set. + ensureResourceGroupName(ctx, azdClient, envName, salt) // Sync the extension process into the new project directory so that // subsequent local file operations see the scaffolded project. @@ -2701,33 +2707,44 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa //nolint:gosec // env var key name, not a credential const resourceTokenSaltKey = "AZD_RESOURCE_TOKEN_SALT" +// read by azd's Bicep provider to scope resource-group deployments to a unique name per environment +const resourceGroupEnvKey = "AZURE_RESOURCE_GROUP" + +// maxResourceGroupNameLen is the Azure resource group name length limit +// (Microsoft.Resources/resourceGroups: 1-90 chars). +const maxResourceGroupNameLen = 90 + // ensureResourceTokenSalt checks whether the current azd environment already -// has a resource token salt. If not, it generates and stores one. -// Failures are silently ignored so the Bicep templates fall back to the -// original deterministic uniqueString() hash. -func ensureResourceTokenSalt(ctx context.Context, azdClient *azdext.AzdClient, envName string) { +// has a resource token salt. If not, it generates and stores one. Returns the +// persisted salt value (existing or newly-generated), or an empty string if +// no salt could be persisted (failures are silently ignored so the Bicep +// templates fall back to the original deterministic uniqueString() hash). +func ensureResourceTokenSalt(ctx context.Context, azdClient *azdext.AzdClient, envName string) string { // Already have a salt from a previous init — keep it so resource names stay stable. existing, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ EnvName: envName, Key: resourceTokenSaltKey, }) if err == nil && existing.Value != "" { - return + return existing.Value } // Generate a random salt; if entropy fails, fall back to deterministic naming. salt, err := generateResourceTokenSalt() if err != nil { - return + return "" } // Persist the salt into the azd environment; if storage fails, provision // will still work with the original deterministic resource names. - _, _ = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + if _, err := azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: envName, Key: resourceTokenSaltKey, Value: salt, - }) + }); err != nil { + return "" + } + return salt } // generateResourceTokenSalt returns a random 8-character hex string. @@ -2739,6 +2756,60 @@ func generateResourceTokenSalt() (string, error) { return hex.EncodeToString(b), nil } +// ensureResourceGroupName writes a salted AZURE_RESOURCE_GROUP value to the +// azd environment when scaffolding a new project, so that recreating a +// project with the same environment name produces a fresh resource group +// (avoiding collisions with any leftover resources from a prior teardown). +// +// Best-effort: skipped when salt is empty or AZURE_RESOURCE_GROUP is +// already set (preserving BYO / previously-provisioned values). Storage +// failures are silently ignored so Bicep's default `rg-${environmentName}` +// continues to work. +func ensureResourceGroupName(ctx context.Context, azdClient *azdext.AzdClient, envName, salt string) { + if salt == "" { + return + } + existing, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: resourceGroupEnvKey, + }) + if err == nil && existing.Value != "" { + return + } + name := composeSaltedResourceGroupName(envName, salt) + _, _ = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: resourceGroupEnvKey, + Value: name, + }) +} + +// composeSaltedResourceGroupName returns `rg-{envName}-{salt}` with envName +// truncated first so the salt is always appended and the final name fits +// inside Azure's 90-char RG limit. Trailing "-" and "." characters are +// trimmed off the truncated envName so the join doesn't produce "--" / +// ".-" and the final name doesn't end with "." (which Azure disallows). +// +// Caller is expected to pass a non-empty salt; the function still produces +// a valid name if salt is empty (no trailing dash). +func composeSaltedResourceGroupName(envName, salt string) string { + const prefix = "rg-" + suffixLen := 0 + if salt != "" { + suffixLen = 1 + len(salt) // joiner "-" + salt + } + maxEnvName := max(maxResourceGroupNameLen-len(prefix)-suffixLen, 0) + truncated := envName + if len(truncated) > maxEnvName { + truncated = truncated[:maxEnvName] + } + truncated = strings.TrimRight(truncated, "-.") + if salt == "" { + return prefix + truncated + } + return prefix + truncated + "-" + salt +} + // resolveCollisions checks whether the auto-computed target directory or // service name already exist. When a collision is detected, the user is // prompted for a new name (or a numeric suffix is appended in no-prompt diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index e60ac017b9c..6b58aac60c8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -3039,3 +3039,136 @@ func TestGenerateResourceTokenSalt_Unique(t *testing.T) { seen[salt] = true } } + +func TestComposeSaltedResourceGroupName(t *testing.T) { + t.Parallel() + + const salt = "deadbeef" // 8 hex chars, matches generateResourceTokenSalt + // Build a long env name once for the truncation cases. + longEnvName := strings.Repeat("a", 100) + // 78 chars is the boundary: 90 - len("rg-") - 1 - len(salt) = 78 + atBoundary := strings.Repeat("a", 78) + // Env name that, once truncated to 78, ends in a "-" we want to trim. + trailingDashEnv := strings.Repeat("a", 77) + "-" + strings.Repeat("b", 30) + // Env name that, once truncated to 78, ends in a "." we want to trim. + trailingDotEnv := strings.Repeat("a", 77) + "." + strings.Repeat("b", 30) + + cases := []struct { + name string + envName string + salt string + expected string + }{ + { + name: "short env name", + envName: "myapp-dev", + salt: salt, + expected: "rg-myapp-dev-" + salt, + }, + { + name: "env name at boundary", + envName: atBoundary, + salt: salt, + expected: "rg-" + atBoundary + "-" + salt, + }, + { + name: "env name over boundary is truncated, salt preserved", + envName: longEnvName, + salt: salt, + expected: "rg-" + strings.Repeat("a", 78) + "-" + salt, + }, + { + name: "trailing dash after truncation is trimmed", + envName: trailingDashEnv, + salt: salt, + expected: "rg-" + strings.Repeat("a", 77) + "-" + salt, + }, + { + name: "trailing dot after truncation is trimmed", + envName: trailingDotEnv, + salt: salt, + expected: "rg-" + strings.Repeat("a", 77) + "-" + salt, + }, + { + name: "empty salt returns name without trailing dash", + envName: "myapp-dev", + salt: "", + expected: "rg-myapp-dev", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := composeSaltedResourceGroupName(tc.envName, tc.salt) + require.Equal(t, tc.expected, got) + require.LessOrEqual(t, len(got), maxResourceGroupNameLen, + "composed name must not exceed Azure RG length limit") + if tc.salt != "" { + require.True(t, strings.HasSuffix(got, "-"+tc.salt), + "salt suffix must always be appended last, got %q", got) + } + require.False(t, strings.HasSuffix(got, "."), + "composed name must not end with '.'") + }) + } +} + +func TestEnsureResourceTokenSaltReturnsPersistedSalt(t *testing.T) { + envServer := &testEnvironmentServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + const envName = "myapp-dev" + + first := ensureResourceTokenSalt(t.Context(), azdClient, envName) + require.Len(t, first, 8, "newly generated salt should be 8 hex chars") + _, err := hex.DecodeString(first) + require.NoError(t, err, "salt should be valid hex") + require.Equal(t, first, envServer.values[envName][resourceTokenSaltKey], + "salt should be persisted to the env under AZD_RESOURCE_TOKEN_SALT") + + // Idempotent: a second call returns the same persisted salt. + second := ensureResourceTokenSalt(t.Context(), azdClient, envName) + require.Equal(t, first, second, "second call should return the existing salt unchanged") +} + +func TestEnsureResourceGroupNameWritesSaltedName(t *testing.T) { + envServer := &testEnvironmentServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + const envName = "myapp-dev" + const salt = "deadbeef" + + ensureResourceGroupName(t.Context(), azdClient, envName, salt) + + got := envServer.values[envName][resourceGroupEnvKey] + require.Equal(t, "rg-myapp-dev-"+salt, got, + "AZURE_RESOURCE_GROUP should be written with the salt appended last") +} + +func TestEnsureResourceGroupNameSkipsWhenAlreadySet(t *testing.T) { + const envName = "myapp-dev" + const existing = "rg-pre-existing" + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + envName: {resourceGroupEnvKey: existing}, + }, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + ensureResourceGroupName(t.Context(), azdClient, envName, "deadbeef") + + require.Equal(t, existing, envServer.values[envName][resourceGroupEnvKey], + "existing AZURE_RESOURCE_GROUP must not be overwritten") +} + +func TestEnsureResourceGroupNameSkipsWhenSaltEmpty(t *testing.T) { + envServer := &testEnvironmentServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) + + ensureResourceGroupName(t.Context(), azdClient, "myapp-dev", "") + + _, ok := envServer.values["myapp-dev"][resourceGroupEnvKey] + require.False(t, ok, "no AZURE_RESOURCE_GROUP should be written when salt is empty") +}