Skip to content
Merged
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 @@ -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/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>/raiPolicies/<policyName>`). `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)

Expand Down
89 changes: 80 additions & 9 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
133 changes: 133 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading