cloudmap 1.3.0__tar.gz → 1.3.2__tar.gz

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.
Files changed (81) hide show
  1. {cloudmap-1.3.0 → cloudmap-1.3.2}/PKG-INFO +4 -4
  2. {cloudmap-1.3.0 → cloudmap-1.3.2}/README.md +3 -3
  3. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/__init__.py +1 -1
  4. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/__init__.py +6 -5
  5. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/cli.py +47 -12
  6. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/graph.py +14 -3
  7. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ingest/azure.py +5 -2
  8. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/html.py +18 -5
  9. cloudmap-1.3.2/fixtures/contoso.json +216 -0
  10. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_ask.py +32 -5
  11. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_cli_exports.py +64 -1
  12. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_ingest_paging.py +24 -6
  13. {cloudmap-1.3.0 → cloudmap-1.3.2}/.github/workflows/ci.yml +0 -0
  14. {cloudmap-1.3.0 → cloudmap-1.3.2}/.github/workflows/publish.yml +0 -0
  15. {cloudmap-1.3.0 → cloudmap-1.3.2}/.gitignore +0 -0
  16. {cloudmap-1.3.0 → cloudmap-1.3.2}/ARCHITECTURE.md +0 -0
  17. {cloudmap-1.3.0 → cloudmap-1.3.2}/CONTRIBUTING.md +0 -0
  18. {cloudmap-1.3.0 → cloudmap-1.3.2}/FORMAT.md +0 -0
  19. {cloudmap-1.3.0 → cloudmap-1.3.2}/LICENSE +0 -0
  20. {cloudmap-1.3.0 → cloudmap-1.3.2}/PLAN.md +0 -0
  21. {cloudmap-1.3.0 → cloudmap-1.3.2}/SECURITY.md +0 -0
  22. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/__main__.py +0 -0
  23. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/adapters/__init__.py +0 -0
  24. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/intent.py +0 -0
  25. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/narration.py +0 -0
  26. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/queries.py +0 -0
  27. {cloudmap-1.3.0/fixtures → cloudmap-1.3.2/cloudmap/data}/contoso.json +0 -0
  28. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/extract/__init__.py +0 -0
  29. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/extract/extractors.py +0 -0
  30. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/extract/llm.py +0 -0
  31. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ingest/__init__.py +0 -0
  32. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ingest/fixture.py +0 -0
  33. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/interactive.py +0 -0
  34. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/local_model.py +0 -0
  35. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/model.py +0 -0
  36. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/__init__.py +0 -0
  37. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/azure_icons.py +0 -0
  38. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/csv_export.py +0 -0
  39. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/drawio.py +0 -0
  40. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/json_out.py +0 -0
  41. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/mermaid.py +0 -0
  42. {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/scrub.py +0 -0
  43. {cloudmap-1.3.0 → cloudmap-1.3.2}/docs/social-preview.html +0 -0
  44. {cloudmap-1.3.0 → cloudmap-1.3.2}/docs/social-preview.png +0 -0
  45. {cloudmap-1.3.0 → cloudmap-1.3.2}/estate-viewer.png +0 -0
  46. {cloudmap-1.3.0 → cloudmap-1.3.2}/fixtures/acme_orders.json +0 -0
  47. {cloudmap-1.3.0 → cloudmap-1.3.2}/fixtures/estate.json +0 -0
  48. {cloudmap-1.3.0 → cloudmap-1.3.2}/pyproject.toml +0 -0
  49. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/01_input_complex_random.json +0 -0
  50. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/04_scrubbed_output.json +0 -0
  51. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/06_trace_output.json +0 -0
  52. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/07_trace_output.html +0 -0
  53. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/08_trace_output.csv +0 -0
  54. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/09_trace_output.drawio +0 -0
  55. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/10_input_enterprise_architecture.json +0 -0
  56. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/12_enterprise_trace.html +0 -0
  57. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/13_enterprise_trace.json +0 -0
  58. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/14_enterprise_scrubbed.json +0 -0
  59. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/README.md +0 -0
  60. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/data/azure_resource_types.txt +0 -0
  61. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_adapters.py +0 -0
  62. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_arg_rows.py +0 -0
  63. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_azure.py +0 -0
  64. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_common_types.py +0 -0
  65. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_containerapps.py +0 -0
  66. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_containment_vs_association.py +0 -0
  67. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_drawio_xml.py +0 -0
  68. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_enrich.py +0 -0
  69. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_estate.py +0 -0
  70. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_fixtures_safe.py +0 -0
  71. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_golden_orders.py +0 -0
  72. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_graph.py +0 -0
  73. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_html.py +0 -0
  74. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_interactive_wizard.py +0 -0
  75. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_llm.py +0 -0
  76. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_local_model.py +0 -0
  77. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_render_text.py +0 -0
  78. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_scrub.py +0 -0
  79. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_trust.py +0 -0
  80. {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_type_agnostic.py +0 -0
  81. {cloudmap-1.3.0 → cloudmap-1.3.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudmap
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: Trace the blast radius of an Azure resource: one name in, a verified dependency graph out.
5
5
  Project-URL: Homepage, https://github.com/KatsaounisThanasis/cloudmap
6
6
  Project-URL: Repository, https://github.com/KatsaounisThanasis/cloudmap
@@ -92,10 +92,10 @@ pip install -e ".[dev]" && pytest
92
92
 
93
93
  ## 60-second demo
94
94
 
95
- No Azure account needed - the repo ships a synthetic estate:
95
+ No Azure account needed - a synthetic estate ships inside the package:
96
96
 
97
97
  ```
98
- cloudmap trace contoso-web --from fixtures/contoso.json -o contoso-web.drawio
98
+ cloudmap trace contoso-web --from demo -o contoso-web.drawio
99
99
  ```
100
100
 
101
101
  ```text
@@ -241,7 +241,7 @@ going outward, and their value is the reverse view (`--direction up`).
241
241
  ## Ask a map questions
242
242
 
243
243
  ```
244
- $ cloudmap trace contoso-web --from fixtures/contoso.json --level detail --json out.json
244
+ $ cloudmap trace contoso-web --from demo --level detail --json out.json
245
245
  $ cloudmap ask out.json "what breaks if I touch contoso-kv"
246
246
  Query: impact · subject: contoso-kv
247
247
  2 resource(s) depend on contoso-kv - changing it can break them.
@@ -58,10 +58,10 @@ pip install -e ".[dev]" && pytest
58
58
 
59
59
  ## 60-second demo
60
60
 
61
- No Azure account needed - the repo ships a synthetic estate:
61
+ No Azure account needed - a synthetic estate ships inside the package:
62
62
 
63
63
  ```
64
- cloudmap trace contoso-web --from fixtures/contoso.json -o contoso-web.drawio
64
+ cloudmap trace contoso-web --from demo -o contoso-web.drawio
65
65
  ```
66
66
 
67
67
  ```text
@@ -207,7 +207,7 @@ going outward, and their value is the reverse view (`--direction up`).
207
207
  ## Ask a map questions
208
208
 
209
209
  ```
210
- $ cloudmap trace contoso-web --from fixtures/contoso.json --level detail --json out.json
210
+ $ cloudmap trace contoso-web --from demo --level detail --json out.json
211
211
  $ cloudmap ask out.json "what breaks if I touch contoso-kv"
212
212
  Query: impact · subject: contoso-kv
213
213
  2 resource(s) depend on contoso-kv - changing it can break them.
@@ -1,3 +1,3 @@
1
1
  """cloudmap - trace an Azure resource's full dependency graph and export it."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.3.2"
@@ -25,7 +25,8 @@ def answer(graph, question, allow_llm_intent=False, max_hops=None, model=None):
25
25
  if plan is None and allow_llm_intent:
26
26
  plan = llm_parse(question, graph, model=model)
27
27
  if plan is None:
28
- result = _error("I could not turn that into a query over this map.")
28
+ result = _error("I could not turn that into a query over this map. "
29
+ "(Questions are matched in English.)")
29
30
  else:
30
31
  result = _run(graph, plan, max_hops=max_hops)
31
32
  result["question"] = question
@@ -67,10 +68,10 @@ def _no_subject(graph, default):
67
68
  return default
68
69
  groups = sorted({n.name for n in graph.nodes.values()
69
70
  if str(n.id).startswith(HIGH_LEVEL_PREFIXES)})
70
- return ("This map is the high-level view: resources are grouped into one box per "
71
- "type, so instance names do not exist in it. Ask about a group instead "
72
- f"({', '.join(groups[:6])}{', ...' if len(groups) > 6 else ''}), or re-run "
73
- "the trace with --level detail to keep instance names.")
71
+ return ("This map is the high-level view: types with several instances are "
72
+ "grouped into one box, so that name is not on it. Ask about a group "
73
+ f"instead ({', '.join(groups[:6])}{', ...' if len(groups) > 6 else ''}), "
74
+ "or re-run the trace with --level detail to keep every instance name.")
74
75
 
75
76
 
76
77
  def _error(message, query=None):
@@ -36,7 +36,7 @@ def main(argv=None):
36
36
  t = sub.add_parser("trace", help="trace a component's blast radius")
37
37
  t.add_argument("name", help="resource name (or substring) to seed from")
38
38
  src = t.add_mutually_exclusive_group(required=True)
39
- src.add_argument("--from", dest="fixture", help="path to a Resource Graph JSON fixture")
39
+ src.add_argument("--from", dest="fixture", help="path to a Resource Graph JSON fixture, or \"demo\" for the built-in synthetic estate")
40
40
  src.add_argument("--live", action="store_true", help="query live Azure (guarded, opt-in)")
41
41
  t.add_argument("--allow-live", action="store_true", help="required together with --live")
42
42
  t.add_argument("--single-sub", action="store_true",
@@ -125,7 +125,22 @@ def _cmd_trace(args):
125
125
  else:
126
126
  from .adapters import load_graph
127
127
  resources = []
128
- graph = load_graph(args.fixture) # auto-detects raw export vs neutral cloudmap graph
128
+ fixture = args.fixture
129
+ if fixture == "demo":
130
+ # the packaged synthetic estate, so `pip install cloudmap` can try the
131
+ # tool in one command without cloning the repo for fixtures/
132
+ import importlib.resources as ir
133
+ fixture = str(ir.files("cloudmap").joinpath("data/contoso.json"))
134
+ try:
135
+ graph = load_graph(fixture) # auto-detects raw export vs neutral cloudmap graph
136
+ except FileNotFoundError:
137
+ print(f"cloudmap: fixture '{args.fixture}' does not exist. Point --from at a "
138
+ f"capture/fixture file, or try the built-in demo estate: --from demo",
139
+ file=sys.stderr)
140
+ return 2
141
+ except ValueError as e: # json.JSONDecodeError is a ValueError
142
+ print(f"cloudmap: '{args.fixture}' is not valid JSON ({e}).", file=sys.stderr)
143
+ return 2
129
144
  # A re-traced capture keeps its own honesty flags: a scan that was
130
145
  # truncated at capture time must not become a map that claims
131
146
  # completeness just because it was re-read from disk.
@@ -135,12 +150,24 @@ def _cmd_trace(args):
135
150
 
136
151
  seeds = find_seeds(graph, args.name)
137
152
  if not seeds:
138
- print(f"No resource matched '{args.name}'.", file=sys.stderr)
153
+ scope = (("this subscription" if args.single_sub else "every enabled subscription")
154
+ if args.live else f"'{args.fixture}'")
155
+ print(f"No resource matched '{args.name}' among the {len(graph.nodes)} resources "
156
+ f"scanned in {scope}. Matching is case-insensitive and substrings count. "
157
+ + ("If it lives in another subscription, drop --single-sub."
158
+ if args.live and args.single_sub else ""), file=sys.stderr)
139
159
  return 2
140
160
  if len(seeds) > 1:
141
- print(f"'{args.name}' is ambiguous. Matches:", file=sys.stderr)
142
- for s in seeds:
143
- print(f" - {graph.nodes[s].name} ({graph.nodes[s].type})", file=sys.stderr)
161
+ # Same name, same type is the common case (one app per environment), so
162
+ # the name+type line was two identical rows: show what actually differs
163
+ # and say how to proceed.
164
+ print(f"'{args.name}' matches {len(seeds)} resources:", file=sys.stderr)
165
+ for sid in seeds:
166
+ n = graph.nodes[sid]
167
+ rg = f" rg: {n.resource_group}" if n.resource_group else ""
168
+ print(f" - {n.name} ({n.type}){rg}\n {sid}", file=sys.stderr)
169
+ print("Re-run with the full id as the seed to pick one, e.g.:\n"
170
+ f" cloudmap trace \"{seeds[0]}\" ...", file=sys.stderr)
144
171
  return 2
145
172
  seed = seeds[0]
146
173
 
@@ -206,10 +233,16 @@ def _export_outputs(sub, seed, args, meta):
206
233
  args.html_out = args.html_out or os.path.join(target_dir, f"{name}.html")
207
234
  args.csv_out = args.csv_out or os.path.join(target_dir, f"{name}.csv")
208
235
 
209
- out = args.out or f"{name}.blast.drawio"
210
- _ensure_parent(out)
211
- with open(out, "w", encoding="utf-8") as f:
212
- f.write(to_drawio(sub, seed))
236
+ # draw.io is the default artifact, not a mandatory one: if the user asked
237
+ # for specific format(s) and not for draw.io, do not surprise them with a
238
+ # stray .blast.drawio in the working directory.
239
+ other_asked = any([args.mermaid, args.json_out, args.html_out,
240
+ getattr(args, "csv_out", None)])
241
+ out = args.out or (None if other_asked else f"{name}.blast.drawio")
242
+ if out:
243
+ _ensure_parent(out)
244
+ with open(out, "w", encoding="utf-8") as f:
245
+ f.write(to_drawio(sub, seed))
213
246
  if args.mermaid:
214
247
  _ensure_parent(args.mermaid)
215
248
  with open(args.mermaid, "w", encoding="utf-8") as f:
@@ -627,7 +660,8 @@ def _print_summary(graph, seed, out, truncated=False, blind_spots=(), single_sub
627
660
  if up:
628
661
  console.print(Panel(_build_tree(seed, set(), upward=True),
629
662
  title=f"What depends on it ({len(up)})", border_style="magenta"))
630
- console.print(f"🔗 [bold]draw.io:[/bold] {out}\n")
663
+ if out:
664
+ console.print(f"🔗 [bold]draw.io:[/bold] {out}\n")
631
665
  else:
632
666
  print(f"Seed: {n.name} ({n.type})")
633
667
  print(f"Blast radius: {len(graph.nodes)} resources "
@@ -636,7 +670,8 @@ def _print_summary(graph, seed, out, truncated=False, blind_spots=(), single_sub
636
670
  print("INCOMPLETE: scan hit the pagination cap - some resources/edges are missing.")
637
671
  for spot in blind_spots:
638
672
  print(f"BLIND SPOT: {spot}")
639
- print(f"draw.io: {out}\n")
673
+ if out:
674
+ print(f"draw.io: {out}\n")
640
675
  for e in graph.edges:
641
676
  tgt = graph.nodes[e.target]
642
677
  tag = " [external]" if tgt.external else ""
@@ -101,14 +101,25 @@ def collapse_high_level(graph, seed_id):
101
101
  if gkey == seed_id:
102
102
  new_nodes[gkey] = rep
103
103
  else:
104
- label = rep.type.split("/", 1)[-1] if rep.external else friendly_type(rep.type)
105
- if len(members) > 1:
106
- label += f" ×{len(members)}"
104
+ role = rep.type.split("/", 1)[-1] if rep.external else friendly_type(rep.type)
105
+ # Grouping exists for readability, not anonymity: when a "group" has
106
+ # exactly one member, hiding its name buys nothing and costs the one
107
+ # thing the reader needs - WHICH vault, WHICH workspace this is.
108
+ if len(members) == 1:
109
+ label = rep.name or role
110
+ else:
111
+ label = f"{role} ×{len(members)}"
107
112
  # an external group keeps its members' reasons: "why is this unverified"
108
113
  # must not be lost to grouping either
109
114
  note = ""
110
115
  for m in members:
111
116
  note = _merge_semis(note, graph.nodes[m].note)
117
+ if len(members) > 1:
118
+ # the instance names survive collapsing - on hover in the HTML
119
+ # viewer, in the drawio tooltip and in `ask` answers
120
+ shown = sorted(graph.nodes[m].name for m in members)[:8]
121
+ more = "" if len(members) <= 8 else f", +{len(members) - 8} more"
122
+ note = _merge_semis(note, "instances: " + ", ".join(shown) + more)
112
123
  new_nodes[gkey] = Node(id=gkey, name=label, type=rep.type,
113
124
  external=rep.external, note=note)
114
125
  distances[gkey] = dist
@@ -31,8 +31,11 @@ def _az(args):
31
31
  # If the user pinned a specific subscription (e.g. cross-tenant QA sub),
32
32
  # force the CLI to use that context for every command to avoid "Given: ''" errors.
33
33
  pin = os.environ.get("CLOUDMAP_ALLOW_SUBSCRIPTION", "").strip()
34
- if pin and "--subscription" not in cmd and not (args[0] == "account" and len(args) > 1 and args[1] == "list"):
35
- # az account list does not accept --subscription
34
+ # `account list` does not accept --subscription, and `account show` must
35
+ # report the ACTUAL active subscription: injecting the pin there made the
36
+ # guard compare the pin to itself, and a mismatched pin surfaced as a bogus
37
+ # "you are not logged in" - the exact wrong diagnosis for a security knob.
38
+ if pin and "--subscription" not in cmd and args[0] != "account":
36
39
  cmd += ["--subscription", pin]
37
40
 
38
41
  try:
@@ -138,6 +138,9 @@ _TEMPLATE = r"""<!doctype html>
138
138
  .sw{width:22px;height:0;border-top:1.6px solid var(--edge)}
139
139
  .sw.model{border-top:2px dashed var(--model)}
140
140
  .sw.box{width:12px;height:12px;border:2px solid var(--seed);border-radius:3px}
141
+ .sw.observes{border-top:1.6px dashed #9aa4ae}
142
+ .sw.sec{border-top:1.6px solid #d18f00}
143
+ .sw.data{border-top:1.6px solid #1a7f37}
141
144
  aside{width:340px;background:var(--card);border-left:1px solid var(--line);background:var(--card);
142
145
  padding:18px;overflow:auto}
143
146
  aside.empty .detail{display:none}
@@ -236,11 +239,14 @@ _TEMPLATE = r"""<!doctype html>
236
239
  <div class="legend">
237
240
  <span class="lg"><span class="sw"></span> verified</span>
238
241
  <span class="lg"><span class="sw model"></span> model guess</span>
242
+ <span class="lg"><span class="sw observes"></span> observes</span>
243
+ <span class="lg"><span class="sw sec"></span> security</span>
244
+ <span class="lg"><span class="sw data"></span> data</span>
239
245
  <span class="lg"><span class="sw box"></span> seed</span>
240
246
  </div>
241
247
  </div>
242
248
  <aside id="panel" class="empty">
243
- <div class="hint">Click a resource to focus its blast radius. Scroll to zoom, drag to pan.</div>
249
+ <div class="hint">Click a resource to focus its blast radius - a grouped box (×N) lists its instances there. Scroll to zoom, drag to pan.</div>
244
250
  <div class="detail"></div>
245
251
  </aside>
246
252
  </div>
@@ -280,13 +286,20 @@ function catInfo(type){
280
286
 
281
287
  document.getElementById("seedName").textContent="— "+DATA.seedName;
282
288
  (function(){const m=DATA.meta,b=document.getElementById("badges");
283
- const add=(txt,cls,title)=>{const s=document.createElement("span");s.className="badge "+(cls||"");s.textContent=txt;if(title)s.title=title;b.appendChild(s);};
289
+ const add=(txt,cls,title,onclick)=>{const s=document.createElement("span");s.className="badge "+(cls||"");s.textContent=txt;if(title)s.title=title;if(onclick){s.style.cursor="pointer";s.onclick=onclick;}b.appendChild(s);return s;};
284
290
  add(m.nodes+" resources");add(m.edges+" dependencies");
285
291
  if(m.external)add(m.external+" external","warn");
286
292
  if(m.model_edges)add(m.model_edges+" model","warn");
287
- // why it is incomplete travels with the badge, so the reader never has to guess
293
+ // Why it is incomplete must be one click away, not hidden behind a hover
294
+ // nobody knows exists: the badge opens the explanation in the side panel.
288
295
  const why=[].concat(m.truncated?["scan hit the pagination cap"]:[],m.read_gaps||[],m.blind_spots||[]);
289
- add(m.complete?"complete":"INCOMPLETE",m.complete?"ok":"warn",why.join("\n\n"));
296
+ if(m.complete){add("complete","ok");}
297
+ else{add("INCOMPLETE ⓘ","warn",why.join("\n\n"),()=>{
298
+ document.getElementById("panel").innerHTML =
299
+ "<h3>Why this map is incomplete</h3>"+
300
+ why.map(w=>"<p>"+esc(w)+"</p>").join("")+
301
+ "<p class=\"hint\">Incomplete means known-missing, not wrong: these are the reads the scan could not or did not make.</p>";
302
+ });}
290
303
  })();
291
304
 
292
305
  // ---- layout: radial blast ----
@@ -395,7 +408,7 @@ DATA.nodes.forEach(n=>{
395
408
  if(icon){
396
409
  g.appendChild(icon);
397
410
  const t1=el("text",{x:NW/2,y:ICON+22,class:"nname","text-anchor":"middle"});
398
- t1.textContent=n.name;g.appendChild(t1);
411
+ t1.textContent=n.name.length>24?n.name.slice(0,23)+"…":n.name;g.appendChild(t1); // full name in the tooltip
399
412
  const t2=el("text",{x:NW/2,y:ICON+37,class:"ntype","text-anchor":"middle"});
400
413
  t2.textContent=shortType(n.type);g.appendChild(t2);
401
414
  }else{
@@ -0,0 +1,216 @@
1
+ {
2
+ "note": "100% synthetic fixture. Fake subscription/tenant/principal ids. No real cloud data. Mirrors `az graph query -q resources -o json` output shape.",
3
+ "count": 15,
4
+ "data": [
5
+ {
6
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-app/providers/Microsoft.Web/sites/contoso-web",
7
+ "name": "contoso-web",
8
+ "type": "microsoft.web/sites",
9
+ "resourceGroup": "rg-contoso-app",
10
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
11
+ "location": "westeurope",
12
+ "kind": "app,linux",
13
+ "identity": {"type": "SystemAssigned", "principalId": "10000000-0000-0000-0000-000000000001"},
14
+ "tags": {"app": "contoso", "env": "prod"},
15
+ "properties": {
16
+ "serverFarmId": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-app/providers/Microsoft.Web/serverfarms/contoso-plan",
17
+ "virtualNetworkSubnetId": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-app",
18
+ "defaultHostName": "contoso-web.azurewebsites.net",
19
+ "siteConfig": {
20
+ "appSettings": [
21
+ {"name": "KeyVaultSecret", "value": "@Microsoft.KeyVault(SecretUri=https://contoso-kv.vault.azure.net/secrets/db-password/)"},
22
+ {"name": "SqlConnection", "value": "Server=tcp:contoso-sql.database.windows.net,1433;Initial Catalog=appdb;Authentication=Active Directory Managed Identity;"},
23
+ {"name": "StorageConnection", "value": "BlobEndpoint=https://contosostg.blob.core.windows.net/;AccountName=contosostg;"},
24
+ {"name": "APPLICATIONINSIGHTS_CONNECTION_STRING", "value": "InstrumentationKey=aaaa1111-bbbb-2222-cccc-333344445555;IngestionEndpoint=https://westeurope-5.in.applicationinsights.azure.com/"}
25
+ ]
26
+ }
27
+ }
28
+ },
29
+ {
30
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-app/providers/Microsoft.Web/serverfarms/contoso-plan",
31
+ "name": "contoso-plan",
32
+ "type": "microsoft.web/serverfarms",
33
+ "resourceGroup": "rg-contoso-app",
34
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
35
+ "location": "westeurope",
36
+ "kind": "linux",
37
+ "properties": {"sku": {"name": "P1v3", "tier": "PremiumV3"}}
38
+ },
39
+ {
40
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet",
41
+ "name": "contoso-vnet",
42
+ "type": "microsoft.network/virtualNetworks",
43
+ "resourceGroup": "rg-contoso-network",
44
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
45
+ "location": "westeurope",
46
+ "properties": {
47
+ "addressSpace": {"addressPrefixes": ["10.10.0.0/16"]},
48
+ "subnets": [
49
+ {"name": "snet-app", "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-app"},
50
+ {"name": "snet-pe", "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-pe"},
51
+ {"name": "snet-aks", "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-aks"},
52
+ {"name": "snet-agw", "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-agw"}
53
+ ]
54
+ }
55
+ },
56
+ {
57
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.KeyVault/vaults/contoso-kv",
58
+ "name": "contoso-kv",
59
+ "type": "microsoft.keyvault/vaults",
60
+ "resourceGroup": "rg-contoso-shared",
61
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
62
+ "location": "westeurope",
63
+ "properties": {"vaultUri": "https://contoso-kv.vault.azure.net/", "enableRbacAuthorization": true}
64
+ },
65
+ {
66
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/privateEndpoints/pe-kv",
67
+ "name": "pe-kv",
68
+ "type": "microsoft.network/privateEndpoints",
69
+ "resourceGroup": "rg-contoso-network",
70
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
71
+ "location": "westeurope",
72
+ "properties": {
73
+ "subnet": {"id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-pe"},
74
+ "privateLinkServiceConnections": [
75
+ {"name": "kvconn", "properties": {"privateLinkServiceId": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.KeyVault/vaults/contoso-kv", "groupIds": ["vault"]}}
76
+ ]
77
+ }
78
+ },
79
+ {
80
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-data/providers/Microsoft.Sql/servers/contoso-sql",
81
+ "name": "contoso-sql",
82
+ "type": "microsoft.sql/servers",
83
+ "resourceGroup": "rg-contoso-data",
84
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
85
+ "location": "westeurope",
86
+ "properties": {"fullyQualifiedDomainName": "contoso-sql.database.windows.net", "version": "12.0"}
87
+ },
88
+ {
89
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/privateEndpoints/pe-sql",
90
+ "name": "pe-sql",
91
+ "type": "microsoft.network/privateEndpoints",
92
+ "resourceGroup": "rg-contoso-network",
93
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
94
+ "location": "westeurope",
95
+ "properties": {
96
+ "subnet": {"id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-pe"},
97
+ "privateLinkServiceConnections": [
98
+ {"name": "sqlconn", "properties": {"privateLinkServiceId": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-data/providers/Microsoft.Sql/servers/contoso-sql", "groupIds": ["sqlServer"]}}
99
+ ]
100
+ }
101
+ },
102
+ {
103
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-data/providers/Microsoft.Storage/storageAccounts/contosostg",
104
+ "name": "contosostg",
105
+ "type": "microsoft.storage/storageAccounts",
106
+ "resourceGroup": "rg-contoso-data",
107
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
108
+ "location": "westeurope",
109
+ "kind": "StorageV2",
110
+ "properties": {"primaryEndpoints": {"blob": "https://contosostg.blob.core.windows.net/"}}
111
+ },
112
+ {
113
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/privateEndpoints/pe-stg",
114
+ "name": "pe-stg",
115
+ "type": "microsoft.network/privateEndpoints",
116
+ "resourceGroup": "rg-contoso-network",
117
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
118
+ "location": "westeurope",
119
+ "properties": {
120
+ "subnet": {"id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-pe"},
121
+ "privateLinkServiceConnections": [
122
+ {"name": "stgconn", "properties": {"privateLinkServiceId": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-data/providers/Microsoft.Storage/storageAccounts/contosostg", "groupIds": ["blob"]}}
123
+ ]
124
+ }
125
+ },
126
+ {
127
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.Insights/components/contoso-appi",
128
+ "name": "contoso-appi",
129
+ "type": "microsoft.insights/components",
130
+ "resourceGroup": "rg-contoso-shared",
131
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
132
+ "location": "westeurope",
133
+ "kind": "web",
134
+ "properties": {
135
+ "InstrumentationKey": "aaaa1111-bbbb-2222-cccc-333344445555",
136
+ "WorkspaceResourceId": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.OperationalInsights/workspaces/contoso-law"
137
+ }
138
+ },
139
+ {
140
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.OperationalInsights/workspaces/contoso-law",
141
+ "name": "contoso-law",
142
+ "type": "microsoft.operationalinsights/workspaces",
143
+ "resourceGroup": "rg-contoso-shared",
144
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
145
+ "location": "westeurope",
146
+ "properties": {"sku": {"name": "PerGB2018"}}
147
+ },
148
+ {
149
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/applicationGateways/contoso-agw",
150
+ "name": "contoso-agw",
151
+ "type": "microsoft.network/applicationGateways",
152
+ "resourceGroup": "rg-contoso-network",
153
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
154
+ "location": "westeurope",
155
+ "properties": {
156
+ "gatewayIPConfigurations": [
157
+ {"name": "agwip", "properties": {"subnet": {"id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-agw"}}}
158
+ ],
159
+ "backendAddressPools": [
160
+ {"name": "webpool", "properties": {"backendAddresses": [{"fqdn": "contoso-web.azurewebsites.net"}]}}
161
+ ]
162
+ }
163
+ },
164
+ {
165
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-app/providers/Microsoft.ContainerService/managedClusters/contoso-aks",
166
+ "name": "contoso-aks",
167
+ "type": "microsoft.containerservice/managedClusters",
168
+ "resourceGroup": "rg-contoso-app",
169
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
170
+ "location": "westeurope",
171
+ "identity": {"type": "SystemAssigned", "principalId": "10000000-0000-0000-0000-000000000002"},
172
+ "properties": {
173
+ "nodeResourceGroup": "mc_rg-contoso-app_contoso-aks_westeurope",
174
+ "agentPoolProfiles": [
175
+ {"name": "system", "vnetSubnetID": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-network/providers/Microsoft.Network/virtualNetworks/contoso-vnet/subnets/snet-aks"}
176
+ ],
177
+ "addonProfiles": {
178
+ "omsagent": {"enabled": true, "config": {"logAnalyticsWorkspaceResourceID": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.OperationalInsights/workspaces/contoso-law"}}
179
+ }
180
+ }
181
+ },
182
+ {
183
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.ContainerRegistry/registries/contosoacr",
184
+ "name": "contosoacr",
185
+ "type": "microsoft.containerregistry/registries",
186
+ "resourceGroup": "rg-contoso-shared",
187
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
188
+ "location": "westeurope",
189
+ "properties": {"loginServer": "contosoacr.azurecr.io"}
190
+ },
191
+ {
192
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.Authorization/roleAssignments/ra-web-kv",
193
+ "name": "ra-web-kv",
194
+ "type": "microsoft.authorization/roleAssignments",
195
+ "resourceGroup": "rg-contoso-shared",
196
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
197
+ "properties": {
198
+ "principalId": "10000000-0000-0000-0000-000000000001",
199
+ "roleDefinitionId": "/subscriptions/11111111-1111-1111-1111-111111111111/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
200
+ "scope": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.KeyVault/vaults/contoso-kv"
201
+ }
202
+ },
203
+ {
204
+ "id": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.Authorization/roleAssignments/ra-aks-acr",
205
+ "name": "ra-aks-acr",
206
+ "type": "microsoft.authorization/roleAssignments",
207
+ "resourceGroup": "rg-contoso-shared",
208
+ "subscriptionId": "11111111-1111-1111-1111-111111111111",
209
+ "properties": {
210
+ "principalId": "10000000-0000-0000-0000-000000000002",
211
+ "roleDefinitionId": "/subscriptions/11111111-1111-1111-1111-111111111111/providers/Microsoft.Authorization/roleDefinitions/7f951dda-4ed3-4680-a7ca-43fe172d538d",
212
+ "scope": "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-contoso-shared/providers/Microsoft.ContainerRegistry/registries/contosoacr"
213
+ }
214
+ }
215
+ ]
216
+ }
@@ -343,18 +343,45 @@ def test_warnings_stay_quiet_on_a_clean_map():
343
343
  assert warnings(graph) == []
344
344
 
345
345
 
346
- def test_high_level_map_explains_itself_instead_of_stonewalling():
347
- # The default trace writes the collapsed view, so this is the error people
348
- # actually hit: the vault IS on their diagram, inside a "Key Vault" box, and
349
- # "name a resource from this map" sends them hunting for a typo.
346
+ def test_a_singleton_group_keeps_its_name_so_asking_about_it_just_works():
347
+ # Grouping is for readability, not anonymity: with one vault in the map,
348
+ # the box carries its real name and the question answers directly.
350
349
  graph, seed = _real()
351
350
  collapsed = collapse_high_level(blast_radius(graph, seed), seed)
352
351
  res = answer(collapsed, "what breaks if I touch kv-orders-dev")
353
352
 
353
+ assert not res.get("error")
354
+ assert res["subject_name"] == "kv-orders-dev"
355
+
356
+
357
+ def test_high_level_map_explains_itself_instead_of_stonewalling():
358
+ # With SEVERAL vaults the box really is anonymous ("Key Vault ×2"), so this
359
+ # is the error people actually hit: the vault IS on their diagram, inside
360
+ # the group box, and "name a resource" sends them hunting for a typo.
361
+ S = "/subscriptions/s/resourcegroups/rg/providers"
362
+ rows = [
363
+ {"id": f"{S}/microsoft.web/sites/web", "name": "web", "type": "microsoft.web/sites",
364
+ "properties": {"siteConfig": {"appSettings": [
365
+ {"name": "A", "value": "https://kv-orders-dev.vault.azure.net/"},
366
+ {"name": "B", "value": "https://kv-payments-dev.vault.azure.net/"}]}}},
367
+ {"id": f"{S}/microsoft.keyvault/vaults/kv-orders-dev", "name": "kv-orders-dev",
368
+ "type": "microsoft.keyvault/vaults",
369
+ "properties": {"vaultUri": "https://kv-orders-dev.vault.azure.net/"}},
370
+ {"id": f"{S}/microsoft.keyvault/vaults/kv-payments-dev", "name": "kv-payments-dev",
371
+ "type": "microsoft.keyvault/vaults",
372
+ "properties": {"vaultUri": "https://kv-payments-dev.vault.azure.net/"}},
373
+ ]
374
+ graph = build_graph(rows)
375
+ seed = find_seeds(graph, "web")[0]
376
+ collapsed = collapse_high_level(blast_radius(graph, seed), seed)
377
+ res = answer(collapsed, "what breaks if I touch kv-orders-dev")
378
+
354
379
  assert res["error"]
355
380
  assert "high-level view" in res["error"]
356
381
  assert "--level detail" in res["error"]
357
- assert "Key Vault" in res["error"] # names a group they can actually ask about
382
+ assert "Key Vault ×2" in res["error"] # names a group they can actually ask about
383
+ grouped = next(n for n in collapsed.nodes.values() if "×2" in n.name)
384
+ assert "kv-orders-dev" in grouped.note # the names survive in the note
358
385
 
359
386
 
360
387
  def test_high_level_map_warns_that_counts_are_per_type():
@@ -142,7 +142,9 @@ def test_a_live_trace_seeded_by_arm_id_renders_valid_xml(tmp_path, monkeypatch):
142
142
  labels = {o.get("label") for o in ET.parse(tmp_path / "map.drawio").getroot().iter("object")}
143
143
 
144
144
  assert "web (sites)" in labels
145
- assert "Key Vault (vaults)" in labels # default view groups by type
145
+ # the default view groups by type, but a group of ONE keeps its name -
146
+ # a box saying just "Key Vault" answers the wrong question (which one?)
147
+ assert "kv-1 (vaults)" in labels
146
148
 
147
149
 
148
150
  def _capture_stubs(monkeypatch, rows, truncated=False):
@@ -326,3 +328,64 @@ def test_an_observer_is_labelled_as_observing_rather_than_depending(tmp_path, ca
326
328
  out = _summary_of(rows, "st", tmp_path, capsys)
327
329
 
328
330
  assert "observes" in out and "alert" in out
331
+
332
+
333
+ # --- first-hour UX: the answers a new user actually hits --------------------------
334
+
335
+ def test_the_built_in_demo_estate_traces_without_the_repo(tmp_path, capsys):
336
+ # `pip install cloudmap` ships no fixtures/ directory, so the README's
337
+ # 60-second demo used to end in a raw FileNotFoundError traceback. The
338
+ # packaged estate makes the first command work from anywhere.
339
+ rc = cli.main(["trace", "contoso-web", "--from", "demo",
340
+ "-o", str(tmp_path / "demo.drawio")])
341
+
342
+ assert rc == 0
343
+ assert (tmp_path / "demo.drawio").exists()
344
+
345
+
346
+ def test_a_missing_fixture_is_a_message_not_a_traceback(capsys):
347
+ rc = cli.main(["trace", "x", "--from", "/nowhere/at/all.json"])
348
+ err = capsys.readouterr().err
349
+
350
+ assert rc == 2
351
+ assert "does not exist" in err
352
+ assert "--from demo" in err # and it offers the working alternative
353
+
354
+
355
+ def test_asking_for_one_format_yields_exactly_that_format(tmp_path, monkeypatch):
356
+ # --json alone used to also drop a stray <name>.blast.drawio into the cwd.
357
+ monkeypatch.chdir(tmp_path)
358
+ rc = cli.main(["trace", "contoso-web", "--from", FIX,
359
+ "--json", str(tmp_path / "only.json")])
360
+
361
+ assert rc == 0
362
+ assert [p.name for p in tmp_path.iterdir()] == ["only.json"]
363
+
364
+
365
+ def test_an_ambiguous_name_shows_what_differs_and_how_to_proceed(tmp_path, capsys):
366
+ # Two apps named "app" printed two IDENTICAL "app (microsoft.web/sites)"
367
+ # lines - nothing to choose between and no way forward.
368
+ fixture = tmp_path / "amb.json"
369
+ fixture.write_text(json.dumps({"data": [
370
+ {"id": "/subscriptions/s/resourcegroups/rg1/providers/microsoft.web/sites/app",
371
+ "name": "app", "type": "microsoft.web/sites", "resourceGroup": "rg1"},
372
+ {"id": "/subscriptions/s/resourcegroups/rg2/providers/microsoft.web/sites/app",
373
+ "name": "app", "type": "microsoft.web/sites", "resourceGroup": "rg2"},
374
+ ]}), encoding="utf-8")
375
+
376
+ rc = cli.main(["trace", "app", "--from", str(fixture)])
377
+ err = capsys.readouterr().err
378
+
379
+ assert rc == 2
380
+ assert "rg1" in err and "rg2" in err # the rows are now tellable apart
381
+ assert "/resourcegroups/rg1/" in err # the full id is on screen
382
+ assert "full id" in err # and the way forward is stated
383
+
384
+
385
+ def test_a_no_match_answer_says_what_was_searched(capsys):
386
+ rc = cli.main(["trace", "this-does-not-exist", "--from", FIX])
387
+ err = capsys.readouterr().err
388
+
389
+ assert rc == 2
390
+ assert "resources scanned" in err
391
+ assert "case-insensitive" in err
@@ -146,22 +146,40 @@ def test_a_failing_az_call_raises_with_its_stderr_attached(monkeypatch):
146
146
  assert "account show" in str(err.value)
147
147
 
148
148
 
149
- def test_the_subscription_pin_is_forced_onto_every_command_except_account_list(monkeypatch):
150
- # The pin is what makes a cross-tenant subscription readable: `az account show`
151
- # must be evaluated IN the pinned context, while `az account list` rejects the
152
- # flag outright, so it is the one command that must not receive it.
149
+ def test_the_subscription_pin_is_forced_onto_every_command_except_account_ones(monkeypatch):
150
+ # `account list` rejects --subscription outright, and `account show` must
151
+ # report the ACTUAL active subscription: injecting the pin there made the
152
+ # guard compare the pin to itself, so a mismatched pin surfaced as a bogus
153
+ # "you are not logged in" instead of "refusing the mismatch".
153
154
  monkeypatch.setenv("CLOUDMAP_ALLOW_SUBSCRIPTION", "PINNED-SUB")
154
155
  seen = _record_commands(monkeypatch)
155
156
 
156
157
  azure._az(["account", "list", "-o", "json"])
157
158
  azure._az(["account", "show", "-o", "json"])
158
- azure._az(["graph", "query", "-q", "resources", "--subscriptions", "PINNED-SUB"])
159
+ azure._az(["webapp", "show", "-g", "rg", "-n", "app", "-o", "json"])
159
160
 
160
161
  assert "--subscription" not in seen[0]
161
- assert seen[1][-2:] == ["--subscription", "PINNED-SUB"]
162
+ assert "--subscription" not in seen[1]
162
163
  assert seen[2][-2:] == ["--subscription", "PINNED-SUB"]
163
164
 
164
165
 
166
+ def test_a_mismatched_pin_reports_the_mismatch_not_a_login_failure(monkeypatch):
167
+ # The end-to-end version of the above: the user pointed the pin at one
168
+ # subscription while az is on another. The old injection made this print
169
+ # "You are not logged in. Run az login" - the exact wrong advice.
170
+ import json as _json
171
+
172
+ monkeypatch.setenv("CLOUDMAP_ALLOW_SUBSCRIPTION", "SUB-PINNED")
173
+ monkeypatch.setattr(azure, "_az",
174
+ lambda args: _json.dumps({"id": "SUB-ACTIVE", "tenantId": "t",
175
+ "name": "dev"}))
176
+ with pytest.raises(SystemExit) as e:
177
+ azure._guard()
178
+
179
+ assert "Refusing the mismatch" in str(e.value)
180
+ assert "logged in" not in str(e.value)
181
+
182
+
165
183
  def test_an_unpinned_run_adds_no_subscription_flag(monkeypatch):
166
184
  seen = _record_commands(monkeypatch)
167
185
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes