cloudmap 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cloudmap/cli.py ADDED
@@ -0,0 +1,534 @@
1
+ """cloudmap command-line interface."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from .graph import blast_radius, build_graph, collapse_high_level, find_seeds, node_from_id
8
+ from .model import Edge
9
+ from .render.drawio import to_drawio
10
+ from .render.html import to_html
11
+ from .render.json_out import to_json
12
+ from .render.mermaid import to_mermaid
13
+
14
+
15
+ def main(argv=None):
16
+ # Interactive mode if no arguments are provided
17
+ if (argv is None and len(sys.argv) == 1) or (argv is not None and len(argv) == 0):
18
+ from .interactive import interactive_main
19
+ return interactive_main()
20
+
21
+ parser = argparse.ArgumentParser(
22
+ prog="cloudmap",
23
+ description="Trace an Azure resource's full dependency graph (blast radius) "
24
+ "and export it to draw.io / Mermaid / JSON.",
25
+ )
26
+ sub = parser.add_subparsers(dest="cmd", required=True)
27
+
28
+ t = sub.add_parser("trace", help="trace a component's blast radius")
29
+ t.add_argument("name", help="resource name (or substring) to seed from")
30
+ src = t.add_mutually_exclusive_group(required=True)
31
+ src.add_argument("--from", dest="fixture", help="path to a Resource Graph JSON fixture")
32
+ src.add_argument("--live", action="store_true", help="query live Azure (guarded, opt-in)")
33
+ t.add_argument("--allow-live", action="store_true", help="required together with --live")
34
+ t.add_argument("--single-sub", action="store_true",
35
+ help="live: query only the active subscription "
36
+ "(default: every enabled subscription in the tenant)")
37
+ t.add_argument("--resolve-secrets", action="store_true",
38
+ help="live: read KV secret values in-memory to see through KV-backed "
39
+ "connection strings (never printed or written)")
40
+ t.add_argument("--llm", action="store_true",
41
+ help="also let a LOCAL model (ollama) propose edges from the seed's JSON; "
42
+ "each proposal is verified against scanned resources (nothing leaves the machine)")
43
+ t.add_argument("--enrich", choices=["auto", "seed", "all", "none"], default="auto",
44
+ help="live: which web apps to deep-enrich for the dependencies that live "
45
+ "in app config (Key Vault refs, connection strings, RBAC). "
46
+ "auto = the seed alone when the seed is a web app, every app in scope "
47
+ "when it is not (only other apps' config can reveal what depends on a "
48
+ "shared resource); all = every app in scope; none = ARM topology only")
49
+ t.add_argument("--level", choices=["high", "detail"], default="high",
50
+ help="high = architecture view grouped by resource type (default); "
51
+ "detail = every instance with its real name")
52
+ t.add_argument("--direction", choices=["both", "down", "up"], default="both",
53
+ help="both = full blast radius (default); down = dependencies; up = dependents")
54
+ t.add_argument("--max-hops", type=int, default=None, help="limit traversal depth")
55
+ t.add_argument("-o", "--out", default=None, help="draw.io output file")
56
+ t.add_argument("--mermaid", default=None, help="also write a Mermaid file")
57
+ t.add_argument("--json", dest="json_out", default=None, help="also write the graph JSON")
58
+ t.add_argument("--html", dest="html_out", default=None,
59
+ help="also write a self-contained interactive HTML viewer (open in a browser)")
60
+ t.add_argument("--csv", dest="csv_out", default=None, help="also write the graph as CSV")
61
+ t.add_argument("-d", "--out-dir", dest="out_dir", default=None,
62
+ help="write ALL formats into this directory, under a folder named after the resource")
63
+
64
+ c = sub.add_parser("capture", help="save a raw live export so it can become a fixture")
65
+ c.add_argument("-o", "--out", required=True, help="where to write the export")
66
+ c.add_argument("--allow-live", action="store_true", help="required: this reads live Azure")
67
+ c.add_argument("--single-sub", action="store_true",
68
+ help="capture only the active subscription "
69
+ "(default: every enabled subscription in the tenant)")
70
+ c.add_argument("--enrich", choices=["all", "none"], default="all",
71
+ help="all (default) also captures web app config, which is where the "
72
+ "interesting dependencies live; none captures ARM topology only")
73
+ c.add_argument("--resolve-secrets", action="store_true",
74
+ help="resolve Key Vault references while capturing (values are redacted "
75
+ "by the scrub pass, but see --no-scrub)")
76
+ c.add_argument("--no-scrub", action="store_true",
77
+ help="write the export UNSCRUBBED - real names, hosts and app settings, "
78
+ "i.e. credentials on disk. Never commit that file")
79
+
80
+ s = sub.add_parser("scrub", help="pseudonymise a raw export so it can be committed")
81
+ s.add_argument("input", help="a raw export (from `capture --no-scrub` or `az graph query`)")
82
+ s.add_argument("-o", "--out", required=True, help="where to write the scrubbed export")
83
+
84
+ a = sub.add_parser("ask", help="ask a question about a map you already produced")
85
+ a.add_argument("map", help="a cloudmap graph JSON (written by `trace --json`) or a raw export")
86
+ a.add_argument("question", help='e.g. "what breaks if I touch kv-orders-dev"')
87
+ a.add_argument("--explain", action="store_true",
88
+ help="also narrate the answer with a LOCAL model (ollama); the computed "
89
+ "answer is printed either way")
90
+ a.add_argument("--llm", action="store_true",
91
+ help="if no built-in rule understands the question, let a LOCAL model pick "
92
+ "the query (its choice is validated against the map)")
93
+ a.add_argument("--max-hops", type=int, default=None, help="limit traversal depth")
94
+ a.add_argument("--json", dest="json_out", action="store_true",
95
+ help="print the answer as JSON instead of text (for scripting)")
96
+
97
+ args = parser.parse_args(argv)
98
+ if args.cmd == "trace":
99
+ return _cmd_trace(args)
100
+ if args.cmd == "capture":
101
+ return _cmd_capture(args)
102
+ if args.cmd == "scrub":
103
+ return _cmd_scrub(args)
104
+ if args.cmd == "ask":
105
+ return _cmd_ask(args)
106
+ return 1
107
+
108
+
109
+ def _cmd_trace(args):
110
+ read_gaps, blind_spots = [], []
111
+ if args.live:
112
+ from .ingest.azure import query_live
113
+ resources, truncated = query_live(allow_live=args.allow_live, tenant_wide=not args.single_sub)
114
+ graph = build_graph(resources)
115
+ else:
116
+ from .adapters import load_graph
117
+ resources, truncated = [], False
118
+ graph = load_graph(args.fixture) # auto-detects raw export vs neutral cloudmap graph
119
+
120
+ seeds = find_seeds(graph, args.name)
121
+ if not seeds:
122
+ print(f"No resource matched '{args.name}'.", file=sys.stderr)
123
+ return 2
124
+ if len(seeds) > 1:
125
+ print(f"'{args.name}' is ambiguous. Matches:", file=sys.stderr)
126
+ for s in seeds:
127
+ print(f" - {graph.nodes[s].name} ({graph.nodes[s].type})", file=sys.stderr)
128
+ return 2
129
+ seed = seeds[0]
130
+
131
+ # Live: Resource Graph omits config/RBAC/diagnostics, so deep-enrich web apps
132
+ # and re-extract, then report whatever stayed invisible.
133
+ if args.live:
134
+ graph, read_gaps, blind_spots = _enrich_live(args, graph, seed, resources)
135
+
136
+ sub = blast_radius(graph, seed, direction=args.direction, max_hops=args.max_hops)
137
+
138
+ # LLM-assisted extraction (Phase 3): local model proposes edges for resources
139
+ # with no hand-written rules. It runs on the initial deterministic blast radius
140
+ # to find hidden links, verifies them against the tenant, and re-computes the radius.
141
+ if args.llm:
142
+ from rich.console import Console
143
+ from rich.progress import Progress, SpinnerColumn, TextColumn
144
+ console = Console(stderr=True)
145
+ console.print("[bold yellow]Running local model (ollama) on blast radius nodes to propose hidden edges...[/bold yellow]")
146
+ from .extract.extractors import Resolver, merge_model_edges
147
+ from .extract.llm import llm_edges_for_seed
148
+
149
+ resolver = Resolver(graph.nodes)
150
+ ledges = []
151
+
152
+ with Progress(
153
+ SpinnerColumn(),
154
+ TextColumn("[progress.description]{task.description}"),
155
+ console=console
156
+ ) as progress:
157
+ task = progress.add_task(f"Asking AI about {len(sub.nodes)} resources...", total=len(sub.nodes))
158
+ for n_id in list(sub.nodes.keys()):
159
+ node_name = graph.nodes[n_id].name
160
+ progress.update(task, description=f"AI analyzing: [cyan]{node_name}[/cyan] ({graph.nodes[n_id].type})")
161
+ lext, ledges_n = llm_edges_for_seed(graph.nodes[n_id], resolver)
162
+ for nd in lext:
163
+ graph.nodes.setdefault(nd.id, nd)
164
+ ledges.extend(ledges_n)
165
+ progress.advance(task)
166
+
167
+ # model edges may only add new targets, never override deterministic ones
168
+ graph.edges = merge_model_edges(graph.edges, ledges)
169
+
170
+ # Re-compute blast radius now that we have LLM-proposed edges
171
+ sub = blast_radius(graph, seed, direction=args.direction, max_hops=args.max_hops)
172
+
173
+
174
+ if args.level == "high":
175
+ sub = collapse_high_level(sub, seed)
176
+
177
+ meta = {"truncated": truncated, "read_gaps": read_gaps, "blind_spots": blind_spots}
178
+
179
+ _export_outputs(sub, seed, args, meta)
180
+ return 0
181
+
182
+
183
+ def _export_outputs(sub, seed, args, meta):
184
+ name = sub.nodes[seed].name
185
+ if getattr(args, "out_dir", None):
186
+ target_dir = os.path.join(args.out_dir, name)
187
+ args.out = args.out or os.path.join(target_dir, f"{name}.drawio")
188
+ args.mermaid = args.mermaid or os.path.join(target_dir, f"{name}.mmd")
189
+ args.json_out = args.json_out or os.path.join(target_dir, f"{name}.json")
190
+ args.html_out = args.html_out or os.path.join(target_dir, f"{name}.html")
191
+ args.csv_out = args.csv_out or os.path.join(target_dir, f"{name}.csv")
192
+
193
+ out = args.out or f"{name}.blast.drawio"
194
+ _ensure_parent(out)
195
+ with open(out, "w", encoding="utf-8") as f:
196
+ f.write(to_drawio(sub, seed))
197
+ if args.mermaid:
198
+ _ensure_parent(args.mermaid)
199
+ with open(args.mermaid, "w", encoding="utf-8") as f:
200
+ f.write(to_mermaid(sub, seed))
201
+ if args.json_out:
202
+ _ensure_parent(args.json_out)
203
+ with open(args.json_out, "w", encoding="utf-8") as f:
204
+ f.write(to_json(sub, seed, meta=meta))
205
+ if args.html_out:
206
+ _ensure_parent(args.html_out)
207
+ with open(args.html_out, "w", encoding="utf-8") as f:
208
+ f.write(to_html(sub, seed, meta=meta))
209
+ if args.csv_out:
210
+ _ensure_parent(args.csv_out)
211
+ from .render.csv_export import to_csv
212
+ with open(args.csv_out, "w", encoding="utf-8") as f:
213
+ f.write(to_csv(sub, seed, meta=meta))
214
+
215
+ _print_summary(sub, seed, out, truncated=meta.get("truncated", False), blind_spots=meta.get("blind_spots", []))
216
+
217
+
218
+ _WEBAPP = "microsoft.web/sites"
219
+ _AKS = "microsoft.containerservice/managedclusters"
220
+ # Workloads whose dependencies live in free-text config, so an unresolved
221
+ # reference is worth resurfacing as an explicit external node. Container apps are
222
+ # here but not in the enrichment list: Resource Graph already returns their
223
+ # template, so there is nothing extra to fetch.
224
+ _CONFIG_WORKLOADS = (_WEBAPP, "microsoft.app/containerapps", _AKS)
225
+
226
+
227
+ def _enrichment_targets(graph, seed, mode, direction):
228
+ """Which workloads to deep-enrich, and which stay a blind spot.
229
+
230
+ An app's config-level dependencies exist nowhere until that app is enriched,
231
+ but enriching a whole tenant on every trace costs an `az` round-trip per app.
232
+ So `auto` spends the calls where the answer actually needs them: a web-app
233
+ seed's own config already yields its downward view, whereas a shared-resource
234
+ seed (Key Vault, database, plan) can only learn its dependents from the
235
+ config of the apps pointing at it.
236
+ """
237
+ workloads = [n for n in graph.nodes.values() if n.type in (_WEBAPP, _AKS)]
238
+ seed_only = [n for n in workloads if n.id == seed]
239
+
240
+ if mode == "none":
241
+ chosen = []
242
+ elif mode == "all":
243
+ chosen = workloads
244
+ elif mode == "seed" or seed_only and direction != "up":
245
+ chosen = seed_only
246
+ else:
247
+ chosen = workloads
248
+
249
+ picked = {n.id for n in chosen}
250
+ return chosen, [n for n in workloads if n.id not in picked]
251
+
252
+
253
+ def _enrich_live(args, graph, seed, resources):
254
+ """Deep-enrich workloads, re-extract, and name what stayed invisible.
255
+ Returns (graph, read_gaps, blind_spots) - `resources` is extended in place."""
256
+ from .extract.extractors import Resolver, _dedupe, seed_external_dependencies
257
+ from .ingest.azure import enrich_aks_clusters, enrich_webapps
258
+
259
+ targets, skipped = _enrichment_targets(graph, seed, args.enrich, args.direction)
260
+ read_gaps, blind_spots = [], []
261
+
262
+ if targets:
263
+ apps = [n for n in targets if n.type == _WEBAPP]
264
+ akss = [n for n in targets if n.type == _AKS]
265
+
266
+ diagnostics = []
267
+ if apps:
268
+ print(f"Deep-enriching {len(apps)} web app(s) - app settings, connection "
269
+ f"strings, RBAC, diagnostics...", file=sys.stderr)
270
+ enr = enrich_webapps([n.raw for n in apps], resolve_secrets=args.resolve_secrets)
271
+ read_gaps.extend(enr["errors"])
272
+ resources.extend(enr["role_assignments"])
273
+ diagnostics.extend(enr["diagnostics"])
274
+
275
+ if akss:
276
+ print(f"Deep-enriching {len(akss)} AKS cluster(s) - reading Kubernetes manifests...", file=sys.stderr)
277
+ enr_aks = enrich_aks_clusters([n.raw for n in akss], resolve_secrets=args.resolve_secrets)
278
+ read_gaps.extend(enr_aks["errors"])
279
+
280
+ if read_gaps:
281
+ print("Read gaps while enriching (edges below may be INCOMPLETE):", file=sys.stderr)
282
+ for msg in read_gaps:
283
+ print(f" ! could not read {msg}", file=sys.stderr)
284
+
285
+ graph = build_graph(resources) # re-extract, now that config is present
286
+ resolver = Resolver(graph.nodes)
287
+
288
+ # Unresolved references are resurfaced as external nodes for the seed only:
289
+ # doing it for every enriched app would bury the map in tenant-wide noise.
290
+ if graph.nodes[seed].type in _CONFIG_WORKLOADS:
291
+ ext_nodes, ext_edges = seed_external_dependencies(graph.nodes[seed], resolver)
292
+ for nd in ext_nodes:
293
+ graph.nodes.setdefault(nd.id, nd)
294
+ graph.edges.extend(ext_edges)
295
+
296
+ for app_id, tid in diagnostics:
297
+ if app_id not in graph.nodes:
298
+ continue
299
+ target = resolver.by_resource_id(tid)
300
+ if not target:
301
+ nd = node_from_id(tid, note="diagnostic target (outside scanned scope)")
302
+ graph.nodes.setdefault(nd.id, nd)
303
+ target = nd.id
304
+ graph.edges.append(Edge(app_id, target, "sends-logs-to"))
305
+
306
+ graph.edges = _dedupe(graph.edges)
307
+
308
+ # An un-enriched app is a known class of missing edge - say so rather than let
309
+ # an empty upward view read as "nothing depends on this".
310
+ if skipped and args.direction != "down":
311
+ blind_spots.append(
312
+ f"{len(skipped)} workload(s) in scope were not deep-enriched, so anything that "
313
+ f"depends on this resource through config (Key Vault references, connection "
314
+ f"strings, hostnames) cannot appear as an inbound edge. "
315
+ f"Re-run with --enrich all to close this gap."
316
+ )
317
+ return graph, read_gaps, blind_spots
318
+
319
+
320
+ def _cmd_capture(args):
321
+ """Save what the cloud actually returned, before any interpretation.
322
+
323
+ A fixture we invented can only confirm what we already believe; a captured
324
+ export can contradict us, which is the only way a test earns trust. Scrubbed
325
+ by default, because the interesting half of a capture is app config and app
326
+ config is full of credentials."""
327
+ from .ingest.azure import enrich_aks_clusters, enrich_webapps, query_live
328
+
329
+ resources, truncated = query_live(allow_live=args.allow_live,
330
+ tenant_wide=not args.single_sub)
331
+ if args.enrich == "all":
332
+ apps = [r for r in resources if str(r.get("type", "")).lower() == _WEBAPP]
333
+ akss = [r for r in resources if str(r.get("type", "")).lower() == _AKS]
334
+ if apps:
335
+ print(f"Deep-enriching {len(apps)} web app(s) so the capture includes the "
336
+ f"dependencies that only exist in app config...", file=sys.stderr)
337
+ enr = enrich_webapps(apps, resolve_secrets=args.resolve_secrets)
338
+ for msg in enr["errors"]:
339
+ print(f" ! could not read {msg}", file=sys.stderr)
340
+ resources.extend(enr["role_assignments"])
341
+ if akss:
342
+ print(f"Deep-enriching {len(akss)} AKS cluster(s) to capture Kubernetes manifests...", file=sys.stderr)
343
+ enr_aks = enrich_aks_clusters(akss, resolve_secrets=args.resolve_secrets)
344
+ for msg in enr_aks["errors"]:
345
+ print(f" ! could not read {msg}", file=sys.stderr)
346
+
347
+ stats = None
348
+ if args.no_scrub:
349
+ print("WARNING: writing an UNSCRUBBED export - real resource names, hostnames "
350
+ "and app settings (i.e. credentials) are about to be written to disk.\n"
351
+ " Do not commit or share this file; run `cloudmap scrub` on it first.",
352
+ file=sys.stderr)
353
+ else:
354
+ from .scrub import scrub
355
+ resources, stats = scrub(resources)
356
+
357
+ _write_export(args.out, resources, scrubbed=not args.no_scrub,
358
+ meta={"truncated": truncated, "enriched": args.enrich == "all"})
359
+ print(f"Captured {len(resources)} rows -> {args.out}")
360
+ _print_scrub_stats(stats)
361
+ return 0
362
+
363
+
364
+ def _cmd_scrub(args):
365
+ import json as _json
366
+
367
+ from .scrub import scrub
368
+
369
+ with open(args.input, encoding="utf-8") as f:
370
+ data = _json.load(f)
371
+ resources = data.get("data", data) if isinstance(data, dict) else data
372
+ meta = dict((data.get("meta") or {}) if isinstance(data, dict) else {})
373
+
374
+ scrubbed, stats = scrub(resources)
375
+ _write_export(args.out, scrubbed, scrubbed=True, meta=meta)
376
+ print(f"Scrubbed {len(scrubbed)} rows -> {args.out}")
377
+ _print_scrub_stats(stats)
378
+ return 0
379
+
380
+
381
+ def _write_export(path, resources, scrubbed, meta=None):
382
+ import json as _json
383
+
384
+ _ensure_parent(path)
385
+ doc = {"meta": dict(meta or {}, scrubbed=scrubbed), "data": resources}
386
+ with open(path, "w", encoding="utf-8") as f:
387
+ _json.dump(doc, f, indent=2)
388
+
389
+
390
+ def _print_scrub_stats(stats):
391
+ """Counts only. The mapping itself is the re-identification key and is never
392
+ printed or written."""
393
+ if not stats:
394
+ return
395
+ print(f"Scrub: {stats['tokens']} identifier(s) pseudonymised, "
396
+ f"{stats['redactions']} credential fragment(s) redacted.")
397
+ if stats["short_tokens_left_alone"]:
398
+ print(f" ! left alone (too short to replace safely): "
399
+ f"{', '.join(stats['short_tokens_left_alone'])}")
400
+ print(" Review the output before committing it - a scrubber is not a proof.")
401
+
402
+
403
+ def _cmd_ask(args):
404
+ import json as _json
405
+
406
+ from .adapters import load_graph
407
+ from .ask import answer, narrate
408
+
409
+ graph = load_graph(args.map)
410
+ result = answer(graph, args.question, allow_llm_intent=args.llm, max_hops=args.max_hops)
411
+ if args.explain:
412
+ result["narration"] = narrate(result)
413
+
414
+ if args.json_out:
415
+ print(_json.dumps(result, indent=2))
416
+ return 0 if not result.get("error") else 2
417
+
418
+ _print_answer(result)
419
+ return 0 if not result.get("error") else 2
420
+
421
+
422
+ def _print_answer(result):
423
+ if result.get("error"):
424
+ print(result["error"], file=sys.stderr)
425
+ print("\nQuestions this map can answer:", file=sys.stderr)
426
+ for line in result.get("supported", []):
427
+ print(f" {line}", file=sys.stderr)
428
+ return
429
+
430
+ subject = result.get("subject_name")
431
+ print(f"Query: {result['query']}" + (f" Β· subject: {subject}" if subject else ""))
432
+ print(result["headline"])
433
+
434
+ if result.get("hint"):
435
+ print(f" -> {result['hint']}")
436
+ for w in result.get("warnings", []):
437
+ print(f" ! {w}")
438
+
439
+ print()
440
+ for f in result["findings"]:
441
+ flag = " [GUESS]" if f["trust"] != "verified" else (
442
+ " [unverified target]" if f.get("external") else "")
443
+ metric = f" {f['metric']}" if f.get("metric") else ""
444
+ print(f" {f['name']} ({f['type']}){metric}{flag}")
445
+ if f.get("why"):
446
+ print(f" why unverified: {f['why']}")
447
+ for hop in f.get("path", []):
448
+ print(f" {hop['source']} --{hop['kind']}--> {hop['target']}")
449
+ if hop.get("evidence"):
450
+ print(f" proof: {hop['evidence']}")
451
+ for dep in f.get("dependents", []):
452
+ print(f" depended on by: {dep}")
453
+
454
+ if result.get("narration"):
455
+ print("\nNarration (local model, from the facts above - not a source of facts):")
456
+ print(f" {result['narration']}")
457
+
458
+
459
+ def _ensure_parent(path):
460
+ parent = os.path.dirname(path)
461
+ if parent:
462
+ os.makedirs(parent, exist_ok=True)
463
+
464
+
465
+ def _print_summary(graph, seed, out, truncated=False, blind_spots=()):
466
+ try:
467
+ from rich.console import Console
468
+ from rich.panel import Panel
469
+ from rich.tree import Tree
470
+ except ImportError:
471
+ Console, Tree, Panel = None, None, None
472
+
473
+ n = graph.nodes[seed]
474
+ ext = sum(1 for x in graph.nodes.values() if x.external)
475
+
476
+ if Console:
477
+ console = Console()
478
+ console.print(f"\n[bold green]Blast radius:[/bold green] {len(graph.nodes)} resources ({ext} external), {len(graph.edges)} dependencies")
479
+ if truncated:
480
+ console.print("[bold red]INCOMPLETE:[/bold red] scan hit the pagination cap - some resources/edges are missing.")
481
+ for spot in blind_spots:
482
+ console.print(f"[bold yellow]BLIND SPOT:[/bold yellow] {spot}")
483
+
484
+ def _get_icon(t):
485
+ t = (t or "").lower()
486
+ if "sites" in t: return "🌐"
487
+ if "clusters" in t: return "☸️"
488
+ if "database" in t or "sql" in t or "redis" in t or "cosmos" in t: return "πŸ—„οΈ"
489
+ if "vault" in t: return "πŸ”"
490
+ return "πŸ“¦"
491
+
492
+ def _build_tree(node_id, seen):
493
+ node = graph.nodes[node_id]
494
+ color = "cyan" if not node.external else "dim white"
495
+ txt = f"[{color}]{_get_icon(node.type)} {node.name}[/{color}]"
496
+ if node.external: txt += " [italic dim](external)[/italic dim]"
497
+
498
+ tree = Tree(txt)
499
+ seen.add(node_id)
500
+
501
+ for e in graph.edges:
502
+ if e.source == node_id:
503
+ kind_color = "blue"
504
+ if "secret" in e.kind: kind_color = "yellow"
505
+ elif "connects" in e.kind: kind_color = "green"
506
+ elif "auth" in e.kind: kind_color = "magenta"
507
+
508
+ lbl = f"[{kind_color}]--{e.kind}-->[/{kind_color}]"
509
+
510
+ if e.target in seen:
511
+ tgt = graph.nodes[e.target]
512
+ tree.add(f"{lbl} [dim]{tgt.name} (cycle)[/dim]")
513
+ else:
514
+ branch = _build_tree(e.target, set(seen))
515
+ branch.label = f"{lbl} " + str(branch.label)
516
+ tree.add(branch)
517
+ return tree
518
+
519
+ tree = _build_tree(seed, set())
520
+ console.print(Panel(tree, title="Dependency Graph", border_style="blue"))
521
+ console.print(f"πŸ”— [bold]draw.io:[/bold] {out}\n")
522
+ else:
523
+ print(f"Seed: {n.name} ({n.type})")
524
+ print(f"Blast radius: {len(graph.nodes)} resources "
525
+ f"({ext} external/unverified), {len(graph.edges)} dependencies")
526
+ if truncated:
527
+ print("INCOMPLETE: scan hit the pagination cap - some resources/edges are missing.")
528
+ for spot in blind_spots:
529
+ print(f"BLIND SPOT: {spot}")
530
+ print(f"draw.io: {out}\n")
531
+ for e in graph.edges:
532
+ tgt = graph.nodes[e.target]
533
+ tag = " [external]" if tgt.external else ""
534
+ print(f" {graph.nodes[e.source].name} --{e.kind}--> {tgt.name}{tag}")
File without changes