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.
- {cloudmap-1.3.0 → cloudmap-1.3.2}/PKG-INFO +4 -4
- {cloudmap-1.3.0 → cloudmap-1.3.2}/README.md +3 -3
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/__init__.py +1 -1
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/__init__.py +6 -5
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/cli.py +47 -12
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/graph.py +14 -3
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ingest/azure.py +5 -2
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/html.py +18 -5
- cloudmap-1.3.2/fixtures/contoso.json +216 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_ask.py +32 -5
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_cli_exports.py +64 -1
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_ingest_paging.py +24 -6
- {cloudmap-1.3.0 → cloudmap-1.3.2}/.github/workflows/ci.yml +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/.github/workflows/publish.yml +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/.gitignore +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/ARCHITECTURE.md +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/CONTRIBUTING.md +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/FORMAT.md +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/LICENSE +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/PLAN.md +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/SECURITY.md +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/__main__.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/adapters/__init__.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/intent.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/narration.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ask/queries.py +0 -0
- {cloudmap-1.3.0/fixtures → cloudmap-1.3.2/cloudmap/data}/contoso.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/extract/__init__.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/extract/extractors.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/extract/llm.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ingest/__init__.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/ingest/fixture.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/interactive.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/local_model.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/model.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/__init__.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/azure_icons.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/csv_export.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/drawio.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/json_out.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/render/mermaid.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/cloudmap/scrub.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/docs/social-preview.html +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/docs/social-preview.png +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/estate-viewer.png +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/fixtures/acme_orders.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/fixtures/estate.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/pyproject.toml +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/01_input_complex_random.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/04_scrubbed_output.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/06_trace_output.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/07_trace_output.html +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/08_trace_output.csv +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/09_trace_output.drawio +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/10_input_enterprise_architecture.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/12_enterprise_trace.html +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/13_enterprise_trace.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/14_enterprise_scrubbed.json +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/README.md +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/data/azure_resource_types.txt +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_adapters.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_arg_rows.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_azure.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_common_types.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_containerapps.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_containment_vs_association.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_drawio_xml.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_enrich.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_estate.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_fixtures_safe.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_golden_orders.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_graph.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_html.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_interactive_wizard.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_llm.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_local_model.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_render_text.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_scrub.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_trust.py +0 -0
- {cloudmap-1.3.0 → cloudmap-1.3.2}/tests/test_type_agnostic.py +0 -0
- {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.
|
|
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 -
|
|
95
|
+
No Azure account needed - a synthetic estate ships inside the package:
|
|
96
96
|
|
|
97
97
|
```
|
|
98
|
-
cloudmap trace contoso-web --from
|
|
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
|
|
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 -
|
|
61
|
+
No Azure account needed - a synthetic estate ships inside the package:
|
|
62
62
|
|
|
63
63
|
```
|
|
64
|
-
cloudmap trace contoso-web --from
|
|
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
|
|
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.
|
|
@@ -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:
|
|
71
|
-
"
|
|
72
|
-
f"({', '.join(groups[:6])}{', ...' if len(groups) > 6 else ''}),
|
|
73
|
-
"the trace with --level detail to keep instance
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
347
|
-
#
|
|
348
|
-
#
|
|
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"]
|
|
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
|
-
|
|
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
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
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(["
|
|
159
|
+
azure._az(["webapp", "show", "-g", "rg", "-n", "app", "-o", "json"])
|
|
159
160
|
|
|
160
161
|
assert "--subscription" not in seen[0]
|
|
161
|
-
assert
|
|
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
|
|
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
|
{cloudmap-1.3.0 → cloudmap-1.3.2}/tests/complex_mock_demo/10_input_enterprise_architecture.json
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|