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/__init__.py +3 -0
- cloudmap/__main__.py +6 -0
- cloudmap/adapters/__init__.py +91 -0
- cloudmap/ask/__init__.py +111 -0
- cloudmap/ask/intent.py +133 -0
- cloudmap/ask/narration.py +54 -0
- cloudmap/ask/queries.py +296 -0
- cloudmap/cli.py +534 -0
- cloudmap/extract/__init__.py +0 -0
- cloudmap/extract/extractors.py +653 -0
- cloudmap/extract/llm.py +89 -0
- cloudmap/graph.py +208 -0
- cloudmap/ingest/__init__.py +0 -0
- cloudmap/ingest/azure.py +380 -0
- cloudmap/ingest/fixture.py +15 -0
- cloudmap/interactive.py +227 -0
- cloudmap/local_model.py +49 -0
- cloudmap/model.py +43 -0
- cloudmap/render/__init__.py +0 -0
- cloudmap/render/azure_icons.py +72 -0
- cloudmap/render/csv_export.py +47 -0
- cloudmap/render/drawio.py +149 -0
- cloudmap/render/html.py +579 -0
- cloudmap/render/json_out.py +52 -0
- cloudmap/render/mermaid.py +25 -0
- cloudmap/scrub.py +300 -0
- cloudmap-1.0.0.dist-info/METADATA +340 -0
- cloudmap-1.0.0.dist-info/RECORD +31 -0
- cloudmap-1.0.0.dist-info/WHEEL +4 -0
- cloudmap-1.0.0.dist-info/entry_points.txt +2 -0
- cloudmap-1.0.0.dist-info/licenses/LICENSE +21 -0
cloudmap/ask/queries.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The queries an answer is actually made of - every one computed from the graph.
|
|
2
|
+
|
|
3
|
+
Nothing in this module asks a model anything. "What breaks if I touch X" is not a
|
|
4
|
+
language problem, it is a traversal: walk the edges that point AT X. Computing it
|
|
5
|
+
here rather than prompting for it is what makes the answer worth trusting, and it
|
|
6
|
+
is why a model can never promote a guess to a fact in the Ask layer - it is never
|
|
7
|
+
the thing producing the facts.
|
|
8
|
+
|
|
9
|
+
Trust aggregation: a conclusion is only as good as the weakest hop behind it. A
|
|
10
|
+
path is `verified` only when EVERY edge on it came from a deterministic rule and
|
|
11
|
+
no node along the way is an unverified external reference. One model-proposed hop
|
|
12
|
+
makes the whole conclusion a guess, and it is labelled as one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ..graph import friendly_type
|
|
16
|
+
|
|
17
|
+
QUERIES = ("impact", "depends", "paths", "shared", "guesses", "summary")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _adjacency(graph):
|
|
21
|
+
"""edges indexed by source (downstream) and by target (upstream)."""
|
|
22
|
+
down, up = {}, {}
|
|
23
|
+
for e in graph.edges:
|
|
24
|
+
down.setdefault(e.source, []).append(e)
|
|
25
|
+
up.setdefault(e.target, []).append(e)
|
|
26
|
+
return down, up
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _name(graph, nid):
|
|
30
|
+
node = graph.nodes.get(nid)
|
|
31
|
+
return (node.name if node and node.name else nid)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hop(graph, edge):
|
|
35
|
+
"""One step, carrying its own proof so the reader can audit instead of trust."""
|
|
36
|
+
return {
|
|
37
|
+
"source": _name(graph, edge.source),
|
|
38
|
+
"target": _name(graph, edge.target),
|
|
39
|
+
"kind": edge.kind,
|
|
40
|
+
"origin": edge.origin,
|
|
41
|
+
"evidence": edge.evidence,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _chain(subject, path):
|
|
46
|
+
"""The ordered node ids along a path, whichever direction it was walked in."""
|
|
47
|
+
chain = [subject]
|
|
48
|
+
for e in path:
|
|
49
|
+
chain.append(e.target if e.source == chain[-1] else e.source)
|
|
50
|
+
return chain
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _trust(graph, path, subject):
|
|
54
|
+
"""Grade a path: verified, or a guess with the reason it is one.
|
|
55
|
+
|
|
56
|
+
Only the hops in BETWEEN count as crossings. That an endpoint is an unverified
|
|
57
|
+
external reference is already reported on the finding itself (`external`), and
|
|
58
|
+
the edge reaching it is real - the app genuinely names that host. Passing
|
|
59
|
+
*through* something unverified is different: the conclusion then rests on a
|
|
60
|
+
resource nobody confirmed exists, so the whole path becomes a guess.
|
|
61
|
+
"""
|
|
62
|
+
for e in path:
|
|
63
|
+
if e.origin != "extracted":
|
|
64
|
+
return "unverified", (f"a model proposed the hop {_name(graph, e.source)} "
|
|
65
|
+
f"--{e.kind}--> {_name(graph, e.target)}")
|
|
66
|
+
for nid in _chain(subject, path)[1:-1]:
|
|
67
|
+
node = graph.nodes.get(nid)
|
|
68
|
+
if node is not None and node.external:
|
|
69
|
+
return "unverified", (f"the path passes through {node.name}, which was referenced "
|
|
70
|
+
"but never verified as a real resource")
|
|
71
|
+
return "verified", ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _walk(graph, start, adjacency, pick, max_hops=None):
|
|
75
|
+
"""BFS over ONE direction, keeping the shortest path (as edges) to each node.
|
|
76
|
+
|
|
77
|
+
One direction only, never reversing - the same rule blast_radius uses, so a
|
|
78
|
+
shared resource (plan, vault, vnet) cannot bridge the subject to resources it
|
|
79
|
+
has nothing to do with."""
|
|
80
|
+
paths, queue, order = {start: []}, [(start, 0)], []
|
|
81
|
+
while queue:
|
|
82
|
+
cur, dist = queue.pop(0)
|
|
83
|
+
if max_hops is not None and dist >= max_hops:
|
|
84
|
+
continue
|
|
85
|
+
for e in adjacency.get(cur, []):
|
|
86
|
+
nxt = pick(e)
|
|
87
|
+
if nxt in paths or nxt not in graph.nodes:
|
|
88
|
+
continue
|
|
89
|
+
paths[nxt] = paths[cur] + [e]
|
|
90
|
+
order.append(nxt)
|
|
91
|
+
queue.append((nxt, dist + 1))
|
|
92
|
+
return paths, order
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _findings(graph, subject, paths, order):
|
|
96
|
+
out = []
|
|
97
|
+
for nid in order:
|
|
98
|
+
path = paths[nid]
|
|
99
|
+
node = graph.nodes[nid]
|
|
100
|
+
trust, why = _trust(graph, path, subject)
|
|
101
|
+
out.append({
|
|
102
|
+
"id": nid,
|
|
103
|
+
"name": node.name,
|
|
104
|
+
"type": friendly_type(node.type),
|
|
105
|
+
"hops": len(path),
|
|
106
|
+
# what the number MEANS for this query - the renderer should not have to
|
|
107
|
+
# guess whether a count is hops, steps or dependents.
|
|
108
|
+
"metric": f"{len(path)} hop(s)",
|
|
109
|
+
"trust": trust,
|
|
110
|
+
"why": why,
|
|
111
|
+
"external": node.external,
|
|
112
|
+
"path": [_hop(graph, e) for e in path],
|
|
113
|
+
})
|
|
114
|
+
out.sort(key=lambda f: (f["hops"], f["name"]))
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _tally(findings):
|
|
119
|
+
return {
|
|
120
|
+
"total": len(findings),
|
|
121
|
+
"verified": sum(1 for f in findings if f["trust"] == "verified"),
|
|
122
|
+
"unverified": sum(1 for f in findings if f["trust"] != "verified"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def impact(graph, subject, max_hops=None):
|
|
127
|
+
"""What breaks if `subject` changes: everything that depends ON it (upstream)."""
|
|
128
|
+
down, up = _adjacency(graph)
|
|
129
|
+
paths, order = _walk(graph, subject, up, lambda e: e.source, max_hops=max_hops)
|
|
130
|
+
findings = _findings(graph, subject, paths, order)
|
|
131
|
+
name = _name(graph, subject)
|
|
132
|
+
headline = (f"Nothing in this map depends on {name}."
|
|
133
|
+
if not findings else
|
|
134
|
+
f"{len(findings)} resource(s) depend on {name} - changing it can break them.")
|
|
135
|
+
result = {"query": "impact", "subject": subject, "subject_name": name,
|
|
136
|
+
"headline": headline, "findings": findings, "facts": _tally(findings)}
|
|
137
|
+
# An empty answer is easy to misread as "nothing to see here" when the map was
|
|
138
|
+
# simply walked the other way round. Say what IS there - a fact, not a guess.
|
|
139
|
+
if not findings and down.get(subject):
|
|
140
|
+
result["hint"] = (f"{name} does have {len(down[subject])} dependency(ies) of its own - "
|
|
141
|
+
f"ask what {name} depends on.")
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def depends(graph, subject, max_hops=None):
|
|
146
|
+
"""What `subject` itself needs to work (downstream)."""
|
|
147
|
+
down, up = _adjacency(graph)
|
|
148
|
+
paths, order = _walk(graph, subject, down, lambda e: e.target, max_hops=max_hops)
|
|
149
|
+
findings = _findings(graph, subject, paths, order)
|
|
150
|
+
name = _name(graph, subject)
|
|
151
|
+
headline = (f"{name} has no dependencies in this map."
|
|
152
|
+
if not findings else
|
|
153
|
+
f"{name} depends on {len(findings)} resource(s).")
|
|
154
|
+
result = {"query": "depends", "subject": subject, "subject_name": name,
|
|
155
|
+
"headline": headline, "findings": findings, "facts": _tally(findings)}
|
|
156
|
+
if not findings and up.get(subject):
|
|
157
|
+
result["hint"] = (f"{len(up[subject])} resource(s) do depend on {name} - "
|
|
158
|
+
f"ask what breaks if you touch {name}.")
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def paths(graph, subject, target, max_paths=25, max_depth=8):
|
|
163
|
+
"""Every way `subject` reaches `target`, following dependency direction."""
|
|
164
|
+
down, _up = _adjacency(graph)
|
|
165
|
+
found, stack = [], [(subject, [], {subject})]
|
|
166
|
+
truncated = False
|
|
167
|
+
while stack:
|
|
168
|
+
cur, path, seen = stack.pop()
|
|
169
|
+
if len(path) >= max_depth:
|
|
170
|
+
continue
|
|
171
|
+
for e in down.get(cur, []):
|
|
172
|
+
if e.target in seen:
|
|
173
|
+
continue
|
|
174
|
+
if e.target == target:
|
|
175
|
+
found.append(path + [e])
|
|
176
|
+
if len(found) >= max_paths:
|
|
177
|
+
truncated = True
|
|
178
|
+
stack = []
|
|
179
|
+
break
|
|
180
|
+
else:
|
|
181
|
+
stack.append((e.target, path + [e], seen | {e.target}))
|
|
182
|
+
|
|
183
|
+
findings = []
|
|
184
|
+
for path in sorted(found, key=len):
|
|
185
|
+
trust, why = _trust(graph, path, subject)
|
|
186
|
+
findings.append({
|
|
187
|
+
"id": None, "name": " -> ".join([_name(graph, subject)]
|
|
188
|
+
+ [_name(graph, e.target) for e in path]),
|
|
189
|
+
"type": "path", "hops": len(path), "metric": f"{len(path)} step(s)",
|
|
190
|
+
"trust": trust, "why": why,
|
|
191
|
+
"external": False, "path": [_hop(graph, e) for e in path],
|
|
192
|
+
})
|
|
193
|
+
a, b = _name(graph, subject), _name(graph, target)
|
|
194
|
+
headline = (f"No path from {a} to {b} in this map."
|
|
195
|
+
if not findings else
|
|
196
|
+
f"{len(findings)} path(s) from {a} to {b}"
|
|
197
|
+
f"{' (list capped)' if truncated else ''}.")
|
|
198
|
+
facts = _tally(findings)
|
|
199
|
+
facts["capped"] = truncated
|
|
200
|
+
return {"query": "paths", "subject": subject, "subject_name": a, "target": target,
|
|
201
|
+
"target_name": b, "headline": headline, "findings": findings, "facts": facts}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def shared(graph, min_dependents=2):
|
|
205
|
+
"""Resources several others depend on - where one change hits more than one team."""
|
|
206
|
+
_down, up = _adjacency(graph)
|
|
207
|
+
findings = []
|
|
208
|
+
for nid, edges in up.items():
|
|
209
|
+
node = graph.nodes.get(nid)
|
|
210
|
+
if node is None:
|
|
211
|
+
continue
|
|
212
|
+
dependents = sorted({f"{_name(graph, e.source)} ({e.kind})" for e in edges})
|
|
213
|
+
if len({e.source for e in edges}) < min_dependents:
|
|
214
|
+
continue
|
|
215
|
+
unproven = [e for e in edges if e.origin != "extracted"]
|
|
216
|
+
findings.append({
|
|
217
|
+
"id": nid, "name": node.name, "type": friendly_type(node.type),
|
|
218
|
+
"hops": 0, "metric": f"{len({e.source for e in edges})} dependents",
|
|
219
|
+
"external": node.external,
|
|
220
|
+
"trust": "unverified" if unproven else "verified",
|
|
221
|
+
"why": (f"{len(unproven)} of the incoming dependencies are model-proposed"
|
|
222
|
+
if unproven else ""),
|
|
223
|
+
"dependents": dependents,
|
|
224
|
+
# the dependents ARE the finding here, so listing them twice (once as a
|
|
225
|
+
# path, once as a name) would just be noise.
|
|
226
|
+
"path": [],
|
|
227
|
+
})
|
|
228
|
+
findings.sort(key=lambda f: (-len(f["dependents"]), f["name"]))
|
|
229
|
+
headline = ("Nothing in this map is depended on by more than one resource."
|
|
230
|
+
if not findings else
|
|
231
|
+
f"{len(findings)} shared resource(s): one change there affects several "
|
|
232
|
+
"dependents.")
|
|
233
|
+
return {"query": "shared", "subject": None, "subject_name": None,
|
|
234
|
+
"headline": headline, "findings": findings, "facts": _tally(findings)}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def guesses(graph):
|
|
238
|
+
"""What NOT to trust in this map: model-proposed edges and unverified references."""
|
|
239
|
+
findings = []
|
|
240
|
+
for e in graph.edges:
|
|
241
|
+
if e.origin == "extracted":
|
|
242
|
+
continue
|
|
243
|
+
findings.append({
|
|
244
|
+
"id": None, "name": f"{_name(graph, e.source)} --{e.kind}--> {_name(graph, e.target)}",
|
|
245
|
+
"type": "model-proposed edge", "hops": 1, "metric": "", "trust": "unverified",
|
|
246
|
+
"external": False,
|
|
247
|
+
"why": e.evidence or "proposed by the local model, no deterministic proof",
|
|
248
|
+
"path": [_hop(graph, e)],
|
|
249
|
+
})
|
|
250
|
+
for node in graph.nodes.values():
|
|
251
|
+
if not node.external:
|
|
252
|
+
continue
|
|
253
|
+
findings.append({
|
|
254
|
+
"id": node.id, "name": node.name,
|
|
255
|
+
"type": f"unverified reference ({node.type or 'unknown type'})",
|
|
256
|
+
"hops": 0, "metric": "", "trust": "unverified", "external": True,
|
|
257
|
+
"why": node.note or "referenced by a resource but not found in the scanned scope",
|
|
258
|
+
"path": [],
|
|
259
|
+
})
|
|
260
|
+
headline = ("Every edge in this map was verified by a deterministic rule, and every "
|
|
261
|
+
"node was found in the scanned scope."
|
|
262
|
+
if not findings else
|
|
263
|
+
f"{len(findings)} item(s) in this map are NOT proven and should be treated "
|
|
264
|
+
"as guesses.")
|
|
265
|
+
return {"query": "guesses", "subject": None, "subject_name": None,
|
|
266
|
+
"headline": headline, "findings": findings, "facts": _tally(findings)}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def summary(graph):
|
|
270
|
+
"""Explain the map itself: what is in it, and how much of it is proven."""
|
|
271
|
+
by_type = {}
|
|
272
|
+
for node in graph.nodes.values():
|
|
273
|
+
by_type[friendly_type(node.type)] = by_type.get(friendly_type(node.type), 0) + 1
|
|
274
|
+
model_edges = sum(1 for e in graph.edges if e.origin != "extracted")
|
|
275
|
+
external = sum(1 for n in graph.nodes.values() if n.external)
|
|
276
|
+
meta = getattr(graph, "meta", None) or {}
|
|
277
|
+
seed = meta.get("seed")
|
|
278
|
+
findings = [
|
|
279
|
+
{"id": None, "name": f"{count} x {label}", "type": "resource type", "hops": 0,
|
|
280
|
+
"metric": "", "trust": "verified", "why": "", "external": False, "path": []}
|
|
281
|
+
for label, count in sorted(by_type.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
282
|
+
]
|
|
283
|
+
headline = (f"{len(graph.nodes)} resource(s) and {len(graph.edges)} dependency edge(s)"
|
|
284
|
+
+ (f", traced from {_name(graph, seed)}" if seed else "") + ".")
|
|
285
|
+
return {
|
|
286
|
+
"query": "summary", "subject": seed, "subject_name": _name(graph, seed) if seed else None,
|
|
287
|
+
"headline": headline, "findings": findings,
|
|
288
|
+
"facts": {
|
|
289
|
+
"resources": len(graph.nodes),
|
|
290
|
+
"edges": len(graph.edges),
|
|
291
|
+
"verified_edges": len(graph.edges) - model_edges,
|
|
292
|
+
"model_edges": model_edges,
|
|
293
|
+
"external_unverified": external,
|
|
294
|
+
"complete": meta.get("complete", True),
|
|
295
|
+
},
|
|
296
|
+
}
|