Skip to content

Commit 3c13565

Browse files
jongioCopilot
andcommitted
feat: add bicep snapshot as primary RG classification source
Implements @vhvb1989's proposal to use bicep snapshot predictedResources as the primary classification mechanism for resource group ownership. Snapshot path (when available): - RG in predictedResources → owned (template creates it) - RG NOT in predictedResources → external (uses existing keyword) - Tier 4 (locks/foreign resources) runs as defense-in-depth Graceful fallback to existing Tier 1-4 pipeline when snapshot is unavailable (older Bicep CLI, non-bicepparam mode, errors). Changes: - Add SnapshotPredictedRGs field to ClassifyOptions - Add classifyFromSnapshot() with Tier 4 defense-in-depth - Add getSnapshotPredictedRGs() to BicepProvider for snapshot extraction - Add 8 unit tests for snapshot classification - Update architecture.md with snapshot-primary data flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c4b7a94 commit 3c13565

File tree

4 files changed

+473
-4
lines changed

4 files changed

+473
-4
lines changed

cli/azd/pkg/azapi/resource_group_classifier.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,25 @@ type ManagementLock struct {
4242

4343
// ClassifyOptions configures the classification pipeline.
4444
type ClassifyOptions struct {
45+
// SnapshotPredictedRGs is the set of resource group names (lowercased) that the
46+
// Bicep template declares as created resources (not 'existing' references).
47+
// Populated from `bicep snapshot` → predictedResources filtered by RG type.
48+
//
49+
// When non-nil, snapshot-based classification replaces Tiers 1-3:
50+
// - RG in set → owned (template creates it)
51+
// - RG not in set → external (template references it as existing)
52+
// - Tier 4 still runs on all owned candidates (defense-in-depth)
53+
//
54+
// When nil, the full Tier 1-4 pipeline runs as fallback (older Bicep CLI,
55+
// non-bicepparam mode, or snapshot failure).
56+
SnapshotPredictedRGs map[string]bool
57+
4558
// ForceMode runs only Tier 1 (zero API calls). External RGs identified by
4659
// deployment operations are still protected; unknown RGs are treated as owned.
4760
// Tier 2/3/4 callbacks are not invoked.
61+
//
62+
// When combined with SnapshotPredictedRGs, snapshot classification is used
63+
// (deterministic, zero API calls) and Tier 4 is skipped.
4864
ForceMode bool
4965
// Interactive enables per-RG prompts for unknown and foreign-resource RGs.
5066
// When false, unknown/unverified RGs are always skipped without deletion.
@@ -120,6 +136,12 @@ func ClassifyResourceGroups(
120136

121137
result := &ClassifyResult{}
122138

139+
// --- Snapshot path: when predictedResources are available, use them as primary signal ---
140+
// This replaces Tiers 1-3 with a deterministic, offline classification from bicep snapshot.
141+
if opts.SnapshotPredictedRGs != nil {
142+
return classifyFromSnapshot(ctx, rgNames, opts, result)
143+
}
144+
123145
// --- Tier 1: classify all RGs from deployment operations (zero extra API calls) ---
124146
owned, unknown := classifyTier1(operations, rgNames, result)
125147

@@ -276,6 +298,126 @@ func ClassifyResourceGroups(
276298
return result, nil
277299
}
278300

301+
// classifyFromSnapshot uses the Bicep snapshot predictedResources to classify RGs.
302+
// RGs whose names appear in the predicted set are owned (the template creates them).
303+
// RGs not in the predicted set are external (referenced via the `existing` keyword).
304+
//
305+
// Tier 4 (lock + foreign-resource veto) still runs on owned candidates unless ForceMode
306+
// is active, providing defense-in-depth even when snapshot says "owned."
307+
func classifyFromSnapshot(
308+
ctx context.Context,
309+
rgNames []string,
310+
opts ClassifyOptions,
311+
result *ClassifyResult,
312+
) (*ClassifyResult, error) {
313+
var owned []string
314+
for _, rg := range rgNames {
315+
if opts.SnapshotPredictedRGs[strings.ToLower(rg)] {
316+
owned = append(owned, rg)
317+
} else {
318+
result.Skipped = append(result.Skipped, ClassifiedSkip{
319+
Name: rg,
320+
Reason: "external (snapshot: not in predictedResources)",
321+
})
322+
}
323+
}
324+
325+
// ForceMode + snapshot: deterministic classification, zero API calls, no Tier 4.
326+
if opts.ForceMode {
327+
result.Owned = owned
328+
return result, nil
329+
}
330+
331+
// --- Tier 4: veto checks on all snapshot-owned candidates (defense-in-depth) ---
332+
// Same logic as the tier pipeline path. Even if the snapshot says "owned," a
333+
// management lock or foreign resources should still prevent deletion.
334+
type veto struct {
335+
rg string
336+
reason string
337+
}
338+
type pendingPrompt struct {
339+
rg string
340+
reason string
341+
}
342+
vetoCh := make(chan veto, len(owned))
343+
promptCh := make(chan pendingPrompt, len(owned))
344+
sem := make(chan struct{}, cTier4Parallelism)
345+
var wg sync.WaitGroup
346+
for _, rg := range owned {
347+
select {
348+
case sem <- struct{}{}:
349+
if ctx.Err() != nil {
350+
<-sem
351+
vetoCh <- veto{
352+
rg: rg,
353+
reason: "error during safety check: " + ctx.Err().Error(),
354+
}
355+
continue
356+
}
357+
case <-ctx.Done():
358+
vetoCh <- veto{
359+
rg: rg,
360+
reason: "error during safety check: " + ctx.Err().Error(),
361+
}
362+
continue
363+
}
364+
wg.Go(func() {
365+
defer func() { <-sem }()
366+
reason, vetoed, needsPrompt, err := classifyTier4(ctx, rg, opts)
367+
if err != nil {
368+
log.Printf(
369+
"ERROR: classify rg=%s tier=4: safety check failed: %v (treating as veto)",
370+
rg, err,
371+
)
372+
vetoCh <- veto{
373+
rg: rg,
374+
reason: fmt.Sprintf("error during safety check: %s", err.Error()),
375+
}
376+
return
377+
}
378+
if needsPrompt {
379+
promptCh <- pendingPrompt{rg: rg, reason: reason}
380+
return
381+
}
382+
if vetoed {
383+
vetoCh <- veto{rg: rg, reason: reason}
384+
}
385+
})
386+
}
387+
wg.Wait()
388+
close(vetoCh)
389+
close(promptCh)
390+
391+
vetoedSet := make(map[string]string, len(owned))
392+
for v := range vetoCh {
393+
vetoedSet[v.rg] = v.reason
394+
}
395+
396+
for p := range promptCh {
397+
if opts.Interactive && opts.Prompter != nil {
398+
accept, err := opts.Prompter(p.rg, p.reason)
399+
if err != nil {
400+
return nil, fmt.Errorf("classify rg=%s tier=4 prompt: %w", p.rg, err)
401+
}
402+
if !accept {
403+
vetoedSet[p.rg] = p.reason
404+
}
405+
} else {
406+
vetoedSet[p.rg] = p.reason
407+
}
408+
}
409+
410+
for _, rg := range owned {
411+
if reason, vetoed := vetoedSet[rg]; vetoed {
412+
result.Skipped = append(result.Skipped, ClassifiedSkip{Name: rg, Reason: reason})
413+
} else {
414+
result.Owned = append(result.Owned, rg)
415+
}
416+
}
417+
418+
return result, nil
419+
}
420+
279421
// classifyTier1 uses deployment operations to classify RGs with zero extra API calls.
280422
// Returns (owned, unknown) slices. External RGs are appended directly to result.Skipped.
281423
func classifyTier1(

cli/azd/pkg/azapi/resource_group_classifier_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,4 +1265,178 @@ func TestClassifyResourceGroups_ForceMode(t *testing.T) {
12651265
require.Len(t, res.Skipped, 1)
12661266
assert.Equal(t, rgExternal, res.Skipped[0].Name)
12671267
})
1268+
1269+
}
1270+
1271+
func TestClassifyResourceGroups_Snapshot(t *testing.T) {
1272+
t.Parallel()
1273+
1274+
const (
1275+
rgA = "rg-alpha"
1276+
rgB = "rg-beta"
1277+
rgC = "rg-gamma"
1278+
envName = "myenv"
1279+
)
1280+
1281+
rgOp := "Microsoft.Resources/resourceGroups"
1282+
1283+
t.Run("owned and external", func(t *testing.T) {
1284+
t.Parallel()
1285+
predicted := map[string]bool{
1286+
"rg-alpha": true,
1287+
"rg-beta": true,
1288+
}
1289+
opts := ClassifyOptions{
1290+
EnvName: envName,
1291+
SnapshotPredictedRGs: predicted,
1292+
}
1293+
// rgC is NOT in the predicted set → external
1294+
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA, rgB, rgC}, opts)
1295+
require.NoError(t, err)
1296+
assert.ElementsMatch(t, []string{rgA, rgB}, res.Owned)
1297+
require.Len(t, res.Skipped, 1)
1298+
assert.Equal(t, rgC, res.Skipped[0].Name)
1299+
assert.Contains(t, res.Skipped[0].Reason, "snapshot")
1300+
})
1301+
1302+
t.Run("case insensitive matching", func(t *testing.T) {
1303+
t.Parallel()
1304+
predicted := map[string]bool{
1305+
"rg-alpha": true, // lowercased in the map
1306+
}
1307+
opts := ClassifyOptions{
1308+
EnvName: envName,
1309+
SnapshotPredictedRGs: predicted,
1310+
}
1311+
// "RG-Alpha" should match "rg-alpha" via ToLower
1312+
res, err := ClassifyResourceGroups(t.Context(), nil, []string{"RG-Alpha"}, opts)
1313+
require.NoError(t, err)
1314+
assert.Equal(t, []string{"RG-Alpha"}, res.Owned)
1315+
assert.Empty(t, res.Skipped)
1316+
})
1317+
1318+
t.Run("all external", func(t *testing.T) {
1319+
t.Parallel()
1320+
predicted := map[string]bool{
1321+
"rg-unrelated": true, // no overlap with test RGs
1322+
}
1323+
opts := ClassifyOptions{
1324+
EnvName: envName,
1325+
SnapshotPredictedRGs: predicted,
1326+
}
1327+
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA, rgB}, opts)
1328+
require.NoError(t, err)
1329+
assert.Empty(t, res.Owned)
1330+
assert.Len(t, res.Skipped, 2)
1331+
})
1332+
1333+
t.Run("ForceMode skips Tier4", func(t *testing.T) {
1334+
t.Parallel()
1335+
predicted := map[string]bool{
1336+
"rg-alpha": true,
1337+
}
1338+
var tier4Called bool
1339+
opts := ClassifyOptions{
1340+
EnvName: envName,
1341+
ForceMode: true,
1342+
SnapshotPredictedRGs: predicted,
1343+
ListResourceGroupLocks: func(_ context.Context, _ string) ([]*ManagementLock, error) {
1344+
tier4Called = true
1345+
return nil, nil
1346+
},
1347+
}
1348+
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA, rgB}, opts)
1349+
require.NoError(t, err)
1350+
assert.False(t, tier4Called, "Tier 4 should not run when ForceMode + snapshot")
1351+
assert.Equal(t, []string{rgA}, res.Owned)
1352+
require.Len(t, res.Skipped, 1)
1353+
assert.Equal(t, rgB, res.Skipped[0].Name)
1354+
})
1355+
1356+
t.Run("Tier4 lock veto", func(t *testing.T) {
1357+
t.Parallel()
1358+
predicted := map[string]bool{
1359+
"rg-alpha": true,
1360+
"rg-beta": true,
1361+
}
1362+
opts := ClassifyOptions{
1363+
EnvName: envName,
1364+
SnapshotPredictedRGs: predicted,
1365+
ListResourceGroupLocks: func(_ context.Context, rgName string) ([]*ManagementLock, error) {
1366+
if rgName == rgA {
1367+
return []*ManagementLock{{Name: "mylock", LockType: "CanNotDelete"}}, nil
1368+
}
1369+
return nil, nil
1370+
},
1371+
}
1372+
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA, rgB}, opts)
1373+
require.NoError(t, err)
1374+
// rgA is snapshot-owned but vetoed by lock
1375+
assert.Equal(t, []string{rgB}, res.Owned)
1376+
require.Len(t, res.Skipped, 1)
1377+
assert.Equal(t, rgA, res.Skipped[0].Name)
1378+
assert.Contains(t, res.Skipped[0].Reason, "lock")
1379+
})
1380+
1381+
t.Run("Tier4 foreign resource veto", func(t *testing.T) {
1382+
t.Parallel()
1383+
predicted := map[string]bool{
1384+
"rg-alpha": true,
1385+
}
1386+
opts := ClassifyOptions{
1387+
EnvName: envName,
1388+
Interactive: false,
1389+
SnapshotPredictedRGs: predicted,
1390+
ListResourceGroupResources: func(_ context.Context, _ string) ([]*ResourceWithTags, error) {
1391+
return []*ResourceWithTags{
1392+
{Name: "foreign-vm", Type: "Microsoft.Compute/virtualMachines", Tags: map[string]*string{
1393+
"azd-env-name": strPtr("otherenv"),
1394+
}},
1395+
}, nil
1396+
},
1397+
}
1398+
res, err := ClassifyResourceGroups(t.Context(), nil, []string{rgA}, opts)
1399+
require.NoError(t, err)
1400+
assert.Empty(t, res.Owned)
1401+
require.Len(t, res.Skipped, 1)
1402+
assert.Contains(t, res.Skipped[0].Reason, "foreign")
1403+
})
1404+
1405+
t.Run("nil falls back to tier pipeline", func(t *testing.T) {
1406+
t.Parallel()
1407+
// SnapshotPredictedRGs is nil → should use Tier 1 pipeline
1408+
ops := []*armresources.DeploymentOperation{
1409+
makeOperation("Create", rgOp, rgA),
1410+
}
1411+
opts := ClassifyOptions{
1412+
EnvName: envName,
1413+
SnapshotPredictedRGs: nil, // explicitly nil
1414+
}
1415+
res, err := ClassifyResourceGroups(t.Context(), ops, []string{rgA, rgB}, opts)
1416+
require.NoError(t, err)
1417+
// rgA is owned via Tier 1 Create, rgB is unknown → skipped (no Tier 2/3 callbacks)
1418+
assert.Equal(t, []string{rgA}, res.Owned)
1419+
require.Len(t, res.Skipped, 1)
1420+
assert.Equal(t, rgB, res.Skipped[0].Name)
1421+
})
1422+
1423+
t.Run("overrides deployment operations", func(t *testing.T) {
1424+
t.Parallel()
1425+
// Even though operations say rgA is "Read" (external), snapshot says it's owned.
1426+
// Snapshot should take precedence when available.
1427+
ops := []*armresources.DeploymentOperation{
1428+
makeOperation("Read", rgOp, rgA),
1429+
}
1430+
predicted := map[string]bool{
1431+
"rg-alpha": true,
1432+
}
1433+
opts := ClassifyOptions{
1434+
EnvName: envName,
1435+
SnapshotPredictedRGs: predicted,
1436+
}
1437+
res, err := ClassifyResourceGroups(t.Context(), ops, []string{rgA}, opts)
1438+
require.NoError(t, err)
1439+
assert.Equal(t, []string{rgA}, res.Owned)
1440+
assert.Empty(t, res.Skipped)
1441+
})
12681442
}

0 commit comments

Comments
 (0)