Skip to content

Commit 6ae0bcc

Browse files
jongioCopilot
andcommitted
quality: apply max-quality wave 1-3 fixes
- Pre-lowercase extensionResourceTypePrefixes for O(1) lookup - Add trust-boundary comment at Tier 1 entry - Correct goroutine invariant comment (sends at most once) - Log foreign resource names in Tier 4 veto - Add hash case-sensitivity comment - Improve Interactive field doc comment - Use atomic.Bool/Int32 for test concurrency counters - Remove duplicate 404 test, add non-azcore error test - Modernize map key collection with slices.Collect(maps.Keys) - Improve getResourceGroupTags doc (error-handling asymmetry) - Guard nil env tag pointer in standard_deployments.go - Fix architecture doc evaluation order - Add diagnosticsettings to cspell dictionary - Promote armlocks to direct dependency (go mod tidy) - Apply gofmt to all changed files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d1a56ac commit 6ae0bcc

File tree

8 files changed

+59
-42
lines changed

8 files changed

+59
-42
lines changed

cli/azd/.vscode/cspell.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ words:
2222
- cooldown
2323
- customtype
2424
- devcontainers
25+
- diagnosticsettings
2526
- errgroup
2627
- errorhandler
2728
- extendee

cli/azd/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2
2323
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0
2424
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeploymentstacks v1.0.1
25+
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0
2526
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0
2627
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0
2728
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7
@@ -93,7 +94,6 @@ require (
9394

9495
require (
9596
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
96-
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0 // indirect
9797
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
9898
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
9999
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect

cli/azd/pkg/azapi/resource_group_classifier.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ type ClassifyOptions struct {
4545
// ForceMode runs only Tier 1 (zero API calls). External RGs identified by
4646
// deployment operations are still protected; unknown RGs are treated as owned.
4747
// Tier 2/3/4 callbacks are not invoked.
48-
ForceMode bool
49-
Interactive bool // Whether to prompt for unknown RGs
48+
ForceMode bool
49+
// Interactive enables per-RG prompts for unknown and foreign-resource RGs.
50+
// When false, unknown/unverified RGs are always skipped without deletion.
51+
Interactive bool
5052
EnvName string // Current azd environment name for tag matching
5153

5254
// ExpectedProvisionParamHash is the expected value of the azd-provision-param-hash tag.
@@ -183,6 +185,10 @@ func ClassifyResourceGroups(
183185
rg string
184186
reason string
185187
}
188+
// Tier 4 goroutine invariant: every RG either (a) enters wg.Go — which
189+
// sends at most once to vetoCh or promptCh (clean RGs send to neither) —
190+
// or (b) sends to vetoCh directly (cancelled context). Both channels
191+
// are buffered to len(owned) so sends never block and goroutines never leak.
186192
vetoCh := make(chan veto, len(owned))
187193
promptCh := make(chan pendingPrompt, len(owned))
188194
sem := make(chan struct{}, cTier4Parallelism)
@@ -282,6 +288,9 @@ func classifyTier1(
282288
tier1[rg] = tier1Info{result: tier1Unknown}
283289
}
284290
for _, op := range operations {
291+
// TRUST ASSUMPTION: ARM ProvisioningOperation=Create is only emitted for RGs
292+
// that were actually created by this deployment, never for `existing` references.
293+
// Tier 4 (locks + foreign resources) provides defense-in-depth for all owned RGs.
285294
if name, ok := operationTargetsRG(op, cProvisionOpCreate); ok {
286295
if _, tracked := tier1[name]; tracked {
287296
tier1[name] = tier1Info{result: tier1Owned}
@@ -365,6 +374,8 @@ func classifyTier2(ctx context.Context, rgName string, opts ClassifyOptions) (*C
365374
hashTag := tagValue(tags, cAzdProvisionHashTag)
366375
if envTag != "" && hashTag != "" && strings.EqualFold(envTag, opts.EnvName) {
367376
// If an expected hash is provided, verify it matches.
377+
// Case-sensitive comparison is intentional — hash values must match exactly.
378+
// Mismatch falls safely to Tier 3 (more scrutiny, not less).
368379
// If not provided, presence of both tags is sufficient (backward compat).
369380
if opts.ExpectedProvisionParamHash != "" &&
370381
hashTag != opts.ExpectedProvisionParamHash {
@@ -434,6 +445,7 @@ func classifyTier4(ctx context.Context, rgName string, opts ClassifyOptions) (st
434445
reason := fmt.Sprintf(
435446
"vetoed (Tier 4: %d foreign resource(s) without azd-env-name=%q)", len(foreign), opts.EnvName,
436447
)
448+
log.Printf("classify rg=%s tier=4: foreign resources: %v", rgName, foreign)
437449
return reason, true, true, nil
438450
}
439451
}
@@ -512,18 +524,19 @@ func tagValue(tags map[string]*string, key string) string {
512524
// resources that don't support tags. These are skipped during Tier 4
513525
// foreign-resource detection to avoid false-positive vetoes on resources
514526
// commonly created by azd scaffold templates.
527+
// All values are pre-lowercased for efficient case-insensitive comparison.
515528
var extensionResourceTypePrefixes = []string{
516-
"Microsoft.Authorization/",
517-
"Microsoft.Insights/diagnosticSettings",
518-
"Microsoft.Resources/links",
529+
"microsoft.authorization/",
530+
"microsoft.insights/diagnosticsettings",
531+
"microsoft.resources/links",
519532
}
520533

521534
// isExtensionResourceType returns true if the given ARM resource type is a
522535
// known extension resource that does not support tags.
523536
func isExtensionResourceType(resourceType string) bool {
524537
lower := strings.ToLower(resourceType)
525538
for _, prefix := range extensionResourceTypePrefixes {
526-
if strings.HasPrefix(lower, strings.ToLower(prefix)) {
539+
if strings.HasPrefix(lower, prefix) {
527540
return true
528541
}
529542
}

cli/azd/pkg/azapi/resource_group_classifier_test.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -213,21 +213,6 @@ func TestClassifyResourceGroups(t *testing.T) {
213213
assert.Contains(t, res.Skipped[0].Reason, "Tier 3")
214214
})
215215

216-
t.Run("Tier2 tag fetch 404 — already deleted skip", func(t *testing.T) {
217-
t.Parallel()
218-
opts := ClassifyOptions{
219-
EnvName: envName,
220-
GetResourceGroupTags: func(_ context.Context, _ string) (map[string]*string, error) {
221-
return nil, makeResponseError(http.StatusNotFound)
222-
},
223-
}
224-
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA}, opts)
225-
require.NoError(t, err)
226-
assert.Empty(t, res.Owned)
227-
require.Len(t, res.Skipped, 1)
228-
assert.Contains(t, res.Skipped[0].Reason, "already deleted")
229-
})
230-
231216
t.Run("Tier2 tag fetch 403 — falls to Tier3 non-interactive skip", func(t *testing.T) {
232217
t.Parallel()
233218
opts := ClassifyOptions{
@@ -368,23 +353,23 @@ func TestClassifyResourceGroups(t *testing.T) {
368353

369354
t.Run("Tier3 non-interactive — unknown skipped without prompt", func(t *testing.T) {
370355
t.Parallel()
371-
prompted := false
356+
var prompted atomic.Bool
372357
opts := ClassifyOptions{
373358
EnvName: envName,
374359
Interactive: false,
375360
GetResourceGroupTags: func(_ context.Context, _ string) (map[string]*string, error) {
376361
return nil, nil
377362
},
378363
Prompter: func(_, _ string) (bool, error) {
379-
prompted = true
364+
prompted.Store(true)
380365
return true, nil
381366
},
382367
}
383368
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA}, opts)
384369
require.NoError(t, err)
385370
assert.Empty(t, res.Owned)
386371
require.Len(t, res.Skipped, 1)
387-
assert.False(t, prompted, "prompter should not be called in non-interactive mode")
372+
assert.False(t, prompted.Load(), "prompter should not be called in non-interactive mode")
388373
})
389374

390375
t.Run("multiple RGs — mix of owned, external, unknown", func(t *testing.T) {
@@ -563,7 +548,7 @@ func TestClassifyResourceGroups(t *testing.T) {
563548
t.Run("Tier4 foreign resources sequential prompt (not concurrent)", func(t *testing.T) {
564549
t.Parallel()
565550
rgOp := "Microsoft.Resources/resourceGroups"
566-
promptCount := 0
551+
var promptCount atomic.Int32
567552
opts := ClassifyOptions{
568553
EnvName: envName,
569554
Interactive: true,
@@ -573,7 +558,7 @@ func TestClassifyResourceGroups(t *testing.T) {
573558
}, nil
574559
},
575560
Prompter: func(_, _ string) (bool, error) {
576-
promptCount++
561+
promptCount.Add(1)
577562
return false, nil // deny all
578563
},
579564
}
@@ -584,7 +569,7 @@ func TestClassifyResourceGroups(t *testing.T) {
584569
res, err := ClassifyResourceGroups(t.Context(), ops, []string{rgA, rgB}, opts)
585570
require.NoError(t, err)
586571
assert.Empty(t, res.Owned)
587-
assert.Equal(t, 2, promptCount, "both RGs should be prompted sequentially")
572+
assert.Equal(t, int32(2), promptCount.Load(), "both RGs should be prompted sequentially")
588573
})
589574

590575
t.Run("Tier4 500 error treated as veto (fail-safe)", func(t *testing.T) {
@@ -1001,6 +986,22 @@ func TestClassifyResourceGroups(t *testing.T) {
1001986
assert.Contains(t, res.Skipped[0].Reason, "error during safety check")
1002987
})
1003988

989+
t.Run("Tier4 non-azcore network error on resource listing treated as veto (fail-safe)", func(t *testing.T) {
990+
t.Parallel()
991+
opts := ClassifyOptions{
992+
EnvName: envName,
993+
ListResourceGroupResources: func(_ context.Context, _ string) ([]*ResourceWithTags, error) {
994+
return nil, fmt.Errorf("dial tcp: connection refused")
995+
},
996+
}
997+
ops := []*armresources.DeploymentOperation{makeOperation("Create", rgOp, rgA)}
998+
res, err := ClassifyResourceGroups(t.Context(), ops, []string{rgA}, opts)
999+
require.NoError(t, err)
1000+
assert.Empty(t, res.Owned, "non-azcore error on resource listing should veto")
1001+
require.Len(t, res.Skipped, 1)
1002+
assert.Contains(t, res.Skipped[0].Reason, "error during safety check")
1003+
})
1004+
10041005
t.Run("Tier4 extension resource types skipped in foreign check", func(t *testing.T) {
10051006
t.Parallel()
10061007
opts := ClassifyOptions{

cli/azd/pkg/azapi/standard_deployments.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ func (ds *StandardDeployments) voidSubscriptionDeploymentState(
508508
}
509509

510510
envName, has := deployment.Tags[azure.TagKeyAzdEnvName]
511-
if has {
511+
if has && envName != nil && *envName != "" {
512512
var emptyTemplate json.RawMessage = []byte(emptySubscriptionArmTemplate)
513513
emptyDeploymentName := ds.GenerateDeploymentName(*envName)
514514
tags := map[string]*string{

cli/azd/pkg/infra/provisioning/bicep/bicep_destroy.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"errors"
99
"fmt"
1010
"log"
11+
"maps"
12+
"slices"
1113
"strings"
1214

1315
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
@@ -75,10 +77,7 @@ func (p *BicepProvider) classifyResourceGroups(
7577
options provisioning.DestroyOptions,
7678
) (owned []string, skipped []azapi.ClassifiedSkip, err error) {
7779
// Extract RG names from the grouped resources map.
78-
rgNames := make([]string, 0, len(groupedResources))
79-
for rgName := range groupedResources {
80-
rgNames = append(rgNames, rgName)
81-
}
80+
rgNames := slices.Collect(maps.Keys(groupedResources))
8281

8382
// Get deployment info for classification (used for logging and hash derivation).
8483
deploymentInfo, deployInfoErr := deployment.Get(ctx)
@@ -116,7 +115,7 @@ func (p *BicepProvider) classifyResourceGroups(
116115
subscriptionId := deployment.SubscriptionId()
117116
classifyOpts := azapi.ClassifyOptions{
118117
Interactive: !p.console.IsNoPromptMode(),
119-
ForceMode: options.Force(),
118+
ForceMode: options.Force(),
120119
EnvName: p.env.Name(),
121120
ExpectedProvisionParamHash: expectedHash,
122121
}
@@ -230,7 +229,10 @@ func (p *BicepProvider) deleteRGList(
230229
// getResourceGroupTags retrieves the tags for a resource group using the ARM API.
231230
// It uses the service locator to resolve the credential provider and ARM client options.
232231
// Returns nil tags (no error) as a graceful fallback if dependencies cannot be resolved,
233-
// which causes the classifier to fall back to Tier 2/3.
232+
// which causes the classifier to fall to Tier 3 (more scrutiny — safe direction).
233+
// This differs from listResourceGroupLocks/listResourceGroupResourcesWithTags which
234+
// return errors → fail-safe veto. The asymmetry is intentional: missing tags means
235+
// "try harder to verify," while missing lock/resource data means "don't delete."
234236
func (p *BicepProvider) getResourceGroupTags(
235237
ctx context.Context,
236238
subscriptionId string,

cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,9 +1362,9 @@ func httpRespondFn(request *http.Request) (*http.Response, error) {
13621362

13631363
// classifyMockCfg configures a multi-RG destroy test scenario.
13641364
type classifyMockCfg struct {
1365-
rgNames []string // RG names referenced in the deployment
1366-
operations []*armresources.DeploymentOperation // Tier 1 classification operations
1367-
withPurgeResources bool // adds a KeyVault to each RG for purge testing
1365+
rgNames []string // RG names referenced in the deployment
1366+
operations []*armresources.DeploymentOperation // Tier 1 classification operations
1367+
withPurgeResources bool // adds a KeyVault to each RG for purge testing
13681368
rgLocks map[string][]*armlocks.ManagementLockObject // per-RG locks (nil key = empty locks)
13691369
}
13701370

docs/azd-down-resource-group-safety/architecture.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,10 @@ By layering signals, the system tolerates any single signal being unavailable
304304
or compromised. The key insight is that each tier's failure mode is "skip"
305305
(safe) not "delete" (unsafe).
306306

307-
**Evaluation order**: Tier 4 (always-on vetoes) runs first because it can
308-
immediately exclude RGs regardless of what other tiers say. Then Tier 1
309-
(highest confidence) through Tier 3 (lowest confidence) run in sequence,
310-
stopping at the first tier that produces a definitive answer.
307+
**Evaluation order**: Tier 1 (highest confidence, zero API calls) through Tier 3
308+
(lowest confidence) run in sequence, stopping at the first tier that produces a
309+
definitive answer. Tier 4 (always-on vetoes) then runs on ALL deletion candidates
310+
to apply lock and foreign-resource checks regardless of which tier classified them.
311311

312312
### Decision 2: Deployment Operations as Primary Signal (Tier 1)
313313

0 commit comments

Comments
 (0)