From a487286a83b25b6348e47a4ad0f943d235132b6e Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 3 Jun 2026 11:16:38 -0400 Subject: [PATCH 1/3] Enhance resource group naming with salt to avoid collisions and add tests for new functionality, fixes #8528 --- .../azure.ai.agents/internal/cmd/init.go | 94 +++++++++++-- .../azure.ai.agents/internal/cmd/init_test.go | 133 ++++++++++++++++++ 2 files changed, 218 insertions(+), 9 deletions(-) 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..269cfb8d596 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,45 @@ 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" +// resourceGroupEnvKey is the standard azd env var consumed by Bicep's +// main.parameters.json (`"resourceGroupName": "${AZURE_RESOURCE_GROUP}"`). +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 +2757,64 @@ 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) but that path is not +// exercised by ensureResourceGroupName, which short-circuits in that case. +func composeSaltedResourceGroupName(envName, salt string) string { + const prefix = "rg-" + suffixLen := 0 + if salt != "" { + suffixLen = 1 + len(salt) // joiner "-" + salt + } + maxEnvName := maxResourceGroupNameLen - len(prefix) - suffixLen + if maxEnvName < 0 { + maxEnvName = 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") +} From f30d8d99102e252eb24eca49b6f095705713c63f Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 3 Jun 2026 11:27:55 -0400 Subject: [PATCH 2/3] Apply go fix: use max() builtin in composeSaltedResourceGroupName Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 269cfb8d596..3d0334e199a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2800,10 +2800,7 @@ func composeSaltedResourceGroupName(envName, salt string) string { if salt != "" { suffixLen = 1 + len(salt) // joiner "-" + salt } - maxEnvName := maxResourceGroupNameLen - len(prefix) - suffixLen - if maxEnvName < 0 { - maxEnvName = 0 - } + maxEnvName := max(maxResourceGroupNameLen-len(prefix)-suffixLen, 0) truncated := envName if len(truncated) > maxEnvName { truncated = truncated[:maxEnvName] From d6049c6d77d0fbbfb10855bd1dad6fae634e0f9a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 4 Jun 2026 12:03:20 -0400 Subject: [PATCH 3/3] address Kristen's comments --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 2 ++ cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index a52946615c8..ba6e6884749 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,7 @@ # Release History +- [[#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) - [[#8512]](https://github.com/Azure/azure-dev/pull/8512) Normalize connection auth `AgenticIdentity` values to the ARM-required `AgenticIdentityToken`. 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 3d0334e199a..35e28a61260 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2707,8 +2707,7 @@ 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" -// resourceGroupEnvKey is the standard azd env var consumed by Bicep's -// main.parameters.json (`"resourceGroupName": "${AZURE_RESOURCE_GROUP}"`). +// 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 @@ -2792,8 +2791,7 @@ func ensureResourceGroupName(ctx context.Context, azdClient *azdext.AzdClient, e // ".-" 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) but that path is not -// exercised by ensureResourceGroupName, which short-circuits in that case. +// a valid name if salt is empty (no trailing dash). func composeSaltedResourceGroupName(envName, salt string) string { const prefix = "rg-" suffixLen := 0