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 ADDED
@@ -0,0 +1,3 @@
1
+ """cloudmap - trace an Azure resource's full dependency graph and export it."""
2
+
3
+ __version__ = "1.0.0"
cloudmap/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,91 @@
1
+ """Adapters: turn a raw export into the neutral cloudmap Graph.
2
+
3
+ The rest of the tool never learns which cloud (or file shape) the data came from
4
+ - that knowledge is quarantined here. Adding a cloud = adding one adapter.
5
+ The neutral shape and accepted inputs are documented in FORMAT.md.
6
+
7
+ Today:
8
+ - AzureAdapter raw Azure Resource Graph -> Graph (runs extraction rules)
9
+ - neutral graph JSON a file cloudmap wrote -> Graph (loaded back, no re-extract)
10
+ """
11
+
12
+ import json
13
+
14
+ from ..graph import build_graph
15
+ from ..model import Edge, Graph, Node
16
+
17
+
18
+ class AzureAdapter:
19
+ """Raw Azure Resource Graph (a list of resources, each with `properties`) ->
20
+ neutral Graph. The Azure-specific rules live in extract/extractors.py; this
21
+ just names the seam and delegates."""
22
+
23
+ name = "azure"
24
+
25
+ @staticmethod
26
+ def matches(data):
27
+ resources = _azure_resources(data)
28
+ return bool(resources) and any(
29
+ str(r.get("type", "")).lower().startswith("microsoft.") for r in resources
30
+ )
31
+
32
+ @staticmethod
33
+ def to_graph(data):
34
+ return build_graph(_azure_resources(data))
35
+
36
+
37
+ def _azure_resources(data):
38
+ if isinstance(data, dict):
39
+ return data.get("data") or data.get("resources") or []
40
+ if isinstance(data, list):
41
+ return data
42
+ return []
43
+
44
+
45
+ def _looks_neutral(data):
46
+ """A graph cloudmap itself wrote: a dict carrying both `nodes` and `edges`."""
47
+ return isinstance(data, dict) and isinstance(data.get("nodes"), list) \
48
+ and isinstance(data.get("edges"), list)
49
+
50
+
51
+ def graph_from_neutral(data):
52
+ """Reconstruct a Graph from cloudmap's own JSON output (post-extraction, so no
53
+ rules run). Provenance (origin/evidence), hop distances and the map's own meta
54
+ (seed, complete, read_gaps) are preserved - a reloaded map keeps its caveats."""
55
+ nodes = {}
56
+ for n in data.get("nodes", []):
57
+ nodes[n["id"]] = Node(
58
+ id=n["id"],
59
+ name=n.get("name", ""),
60
+ type=n.get("type", ""),
61
+ resource_group=n.get("resourceGroup", ""),
62
+ location=n.get("location", ""),
63
+ external=bool(n.get("external")),
64
+ note=n.get("note", ""),
65
+ )
66
+ edges = [
67
+ Edge(
68
+ source=e["source"],
69
+ target=e["target"],
70
+ kind=e.get("kind", ""),
71
+ origin=e.get("origin", "extracted"),
72
+ evidence=e.get("evidence", ""),
73
+ )
74
+ for e in data.get("edges", [])
75
+ ]
76
+ distances = {n["id"]: n["hops"] for n in data.get("nodes", []) if n.get("hops") is not None}
77
+ meta = dict(data.get("meta") or {})
78
+ if data.get("seed"):
79
+ meta.setdefault("seed", data["seed"])
80
+ return Graph(nodes=nodes, edges=edges, distances=distances, meta=meta)
81
+
82
+
83
+ def load_graph(path):
84
+ """Read a file and return a neutral Graph, auto-detecting the input shape:
85
+ a neutral cloudmap graph is loaded as-is; anything else is treated as a raw
86
+ cloud export and sent through the matching adapter (Azure today)."""
87
+ with open(path, encoding="utf-8") as f:
88
+ data = json.load(f)
89
+ if _looks_neutral(data):
90
+ return graph_from_neutral(data)
91
+ return AzureAdapter.to_graph(data)
@@ -0,0 +1,111 @@
1
+ """Ask: answer questions about a map that has already been verified.
2
+
3
+ Read-only by construction, and that is this layer's whole trust story. The facts
4
+ in an answer are computed from the graph (`queries.py`); a local model may at most
5
+ route an unusual phrasing (`intent.py`) or put the computed facts into prose
6
+ (`narration.py`). Because the model never produces the facts, it cannot turn a guess
7
+ into a fact - the worst it can do is choose the wrong query, which the printed
8
+ query name and subject make immediately obvious.
9
+
10
+ An answer also inherits the map's honesty: if the artifact says it is incomplete,
11
+ every answer drawn from it carries that warning.
12
+ """
13
+
14
+ from ..graph import HIGH_LEVEL_PREFIXES, is_high_level
15
+ from . import queries
16
+ from .intent import SUPPORTED, llm_parse, parse
17
+ from .narration import narrate
18
+
19
+ __all__ = ["SUPPORTED", "answer", "narrate", "warnings"]
20
+
21
+
22
+ def answer(graph, question, allow_llm_intent=False, max_hops=None, model=None):
23
+ """Route `question` to one deterministic query and run it against `graph`."""
24
+ plan = parse(question, graph)
25
+ if plan is None and allow_llm_intent:
26
+ plan = llm_parse(question, graph, model=model)
27
+ if plan is None:
28
+ result = _error("I could not turn that into a query over this map.")
29
+ else:
30
+ result = _run(graph, plan, max_hops=max_hops)
31
+ result["question"] = question
32
+ result["warnings"] = warnings(graph)
33
+ return result
34
+
35
+
36
+ def _run(graph, plan, max_hops=None):
37
+ query = plan.get("query")
38
+ subject, target = plan.get("subject"), plan.get("target")
39
+
40
+ if query in ("impact", "depends") and not subject:
41
+ return _error(_no_subject(graph, "Name a resource from this map in the question."),
42
+ query=query)
43
+ if query == "paths" and not (subject and target):
44
+ return _error(_no_subject(graph, "Name two resources: 'how does <resource> reach "
45
+ "<other>'."), query=query)
46
+
47
+ if query == "impact":
48
+ return queries.impact(graph, subject, max_hops=max_hops)
49
+ if query == "depends":
50
+ return queries.depends(graph, subject, max_hops=max_hops)
51
+ if query == "paths":
52
+ return queries.paths(graph, subject, target)
53
+ if query == "shared":
54
+ return queries.shared(graph)
55
+ if query == "guesses":
56
+ return queries.guesses(graph)
57
+ if query == "summary":
58
+ return queries.summary(graph)
59
+ return _error(f"Unknown query '{query}'.")
60
+
61
+
62
+ def _no_subject(graph, default):
63
+ """Why the resource was not found matters. In a collapsed map the instance is
64
+ usually right there on the diagram, inside a group box - saying "name a
65
+ resource" sends the reader looking for a typo that does not exist."""
66
+ if not is_high_level(graph):
67
+ return default
68
+ groups = sorted({n.name for n in graph.nodes.values()
69
+ 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.")
74
+
75
+
76
+ def _error(message, query=None):
77
+ return {"query": query, "subject": None, "subject_name": None, "error": message,
78
+ "headline": message, "findings": [], "facts": {}, "supported": list(SUPPORTED)}
79
+
80
+
81
+ def warnings(graph):
82
+ """What the reader must know before believing the answer: the map's own limits."""
83
+ meta = getattr(graph, "meta", None) or {}
84
+ out = []
85
+ if meta.get("complete") is False:
86
+ out.append("This map is marked INCOMPLETE - resources and edges may be missing, "
87
+ "so this answer can be missing them too.")
88
+ if meta.get("truncated"):
89
+ out.append("The scan behind this map hit its pagination cap.")
90
+ for gap in meta.get("read_gaps") or []:
91
+ out.append(f"read gap in the source scan: {gap}")
92
+ # a class of edge the scan never went looking for: an empty answer here means
93
+ # "not looked for", not "nothing found"
94
+ for spot in meta.get("blind_spots") or []:
95
+ out.append(f"blind spot in the source scan: {spot}")
96
+
97
+ grouped = sum(1 for nid in graph.nodes if str(nid).startswith(HIGH_LEVEL_PREFIXES))
98
+ if grouped:
99
+ out.append(f"This is the high-level view: {grouped} box(es) each stand for every "
100
+ "instance of a resource type, so counts and names here are per type, "
101
+ "not per instance.")
102
+
103
+ model_edges = sum(1 for e in graph.edges if e.origin != "extracted")
104
+ if model_edges:
105
+ out.append(f"{model_edges} edge(s) in this map are model-proposed guesses; findings "
106
+ "that rely on them are marked unverified.")
107
+ external = sum(1 for n in graph.nodes.values() if n.external)
108
+ if external:
109
+ out.append(f"{external} node(s) are referenced but were never verified as real "
110
+ "resources.")
111
+ return out
cloudmap/ask/intent.py ADDED
@@ -0,0 +1,133 @@
1
+ """Turn a developer's question into ONE deterministic query.
2
+
3
+ Rules first, deliberately: the phrasings people actually type are keyword-shaped,
4
+ a rule match costs nothing, and it works on a machine with no model installed. A
5
+ local model is only a FALLBACK for phrasings the rules miss (opt-in, `--llm`), and
6
+ even then it may do exactly two things - name a query from a fixed list and name a
7
+ resource - both of which are validated against this graph before anything runs.
8
+ The model picks a route; it never supplies an answer. A wrong route is visible,
9
+ because the query and subject it chose are printed with the answer.
10
+ """
11
+
12
+ import re
13
+
14
+ from ..local_model import generate_json
15
+ from .queries import QUERIES
16
+
17
+ SUPPORTED = (
18
+ "what breaks if I touch <resource> - what depends on it (blast radius)",
19
+ "what does <resource> depend on - what it needs to work",
20
+ "how does <resource> reach <other> - the paths between two resources",
21
+ "what is shared in this map - resources several others depend on",
22
+ "what should I not trust - the model-proposed guesses",
23
+ "explain this map - a summary of what was traced",
24
+ )
25
+
26
+ # Checked in order: the specific, whole-map questions first, so "which paths cross
27
+ # a shared vault" is understood as a question about sharing, not as a path lookup.
28
+ _RULES = (
29
+ ("guesses", (r"(not trust|untrusted|unverified|guess|how sure|confidence|"
30
+ r"model[- ]proposed|reliable)")),
31
+ ("shared", (r"(shared|in common|common dependenc|several (apps|resources|teams)|"
32
+ r"more than one (app|resource|team))")),
33
+ ("impact", (r"(what breaks|breaks if|what depends|who depends|dependents|blast radius|"
34
+ r"impact|affected|if i (touch|change|delete|remove|restart|move|redeploy|"
35
+ r"rotate|break))")),
36
+ # The verbs of "I am about to change this": whatever the sentence around them
37
+ # looks like, the question is always who else feels it. Catching them with a
38
+ # rule keeps the common real-world phrasings off the model entirely.
39
+ ("impact", (r"\b(rotat\w*|restart\w*|delet\w*|decommission\w*|redeploy\w*|resiz\w*|"
40
+ r"migrat\w*|patch\w*|upgrad\w*|drain\w*|scal\w*|reboot\w*)\b")),
41
+ ("depends", r"(depends? on|dependenc(y|ies)|what does .*(need|use)|needs|uses|downstream)"),
42
+ ("paths", r"(paths?|reach|route|how does .*(talk|connect|get) )"),
43
+ ("summary", r"(explain|summar|overview|what is (this|in this)|describe|shape of)"),
44
+ )
45
+
46
+ _NEEDS_SUBJECT = ("impact", "depends")
47
+
48
+
49
+ def parse(question, graph):
50
+ """Rule-based routing. Returns a plan dict, or None if no rule recognised it."""
51
+ text = (question or "").strip()
52
+ if not text:
53
+ return None
54
+ subjects = match_nodes(graph, text)
55
+ for query, pattern in _RULES:
56
+ if re.search(pattern, text, re.I):
57
+ return _plan(query, subjects)
58
+ return None
59
+
60
+
61
+ def _plan(query, subjects):
62
+ plan = {"query": query}
63
+ if query == "paths":
64
+ if subjects:
65
+ plan["subject"] = subjects[0]
66
+ if len(subjects) > 1:
67
+ plan["target"] = subjects[1]
68
+ elif query in _NEEDS_SUBJECT and subjects:
69
+ plan["subject"] = subjects[0]
70
+ return plan
71
+
72
+
73
+ def match_nodes(graph, text):
74
+ """Resource names mentioned in `text`, in the order they appear.
75
+
76
+ Longest name wins on overlap, so a question about `myapi` is not read as a
77
+ question about `api` - the same label-boundary discipline the extractors use.
78
+ """
79
+ low = text.lower()
80
+ hits = []
81
+ for nid, node in graph.nodes.items():
82
+ name = (node.name or "").lower()
83
+ if len(name) < 3 or name not in low:
84
+ continue
85
+ hits.append((low.index(name), len(name), nid))
86
+
87
+ hits.sort(key=lambda h: (-h[1], h[0]))
88
+ claimed, chosen = [], []
89
+ for pos, length, nid in hits:
90
+ span = (pos, pos + length)
91
+ if any(span[0] >= c[0] and span[1] <= c[1] for c in claimed):
92
+ continue
93
+ claimed.append(span)
94
+ chosen.append((pos, nid))
95
+ chosen.sort()
96
+ return [nid for _pos, nid in chosen]
97
+
98
+
99
+ _PROMPT = """You route a question to ONE query over a cloud dependency map.
100
+ Output ONLY JSON: {{"query":"<name>","subject":"<resource name or empty>","target":"<resource name or empty>"}}
101
+ Allowed query names and what they mean:
102
+ - impact: what breaks if the subject changes (what depends on it)
103
+ - depends: what the subject itself depends on
104
+ - paths: how subject reaches target
105
+ - shared: resources that several others depend on
106
+ - guesses: which parts of the map are unverified
107
+ - summary: explain the whole map
108
+ Use ONLY resource names from this list, copied exactly: {names}
109
+ Question: {question}
110
+ """
111
+
112
+
113
+ def llm_parse(question, graph, model=None, timeout=120):
114
+ """Fallback routing by the local model. Anything it returns is validated: an
115
+ unknown query name is refused, and a resource it invents is dropped because it
116
+ resolves against this graph or not at all."""
117
+ names = sorted({n.name for n in graph.nodes.values() if n.name})
118
+ out = generate_json(
119
+ _PROMPT.format(names=", ".join(names[:200]), question=question),
120
+ model=model, timeout=timeout)
121
+
122
+ query = str(out.get("query", "")).strip().lower()
123
+ if query not in QUERIES:
124
+ return None
125
+ plan = {"query": query}
126
+ for key in ("subject", "target"):
127
+ hint = str(out.get(key) or "").strip()
128
+ if not hint:
129
+ continue
130
+ resolved = match_nodes(graph, hint)
131
+ if resolved:
132
+ plan[key] = resolved[0]
133
+ return plan
@@ -0,0 +1,54 @@
1
+ """Optional prose over an answer the graph already produced.
2
+
3
+ The model is handed the computed FACTS only - names, relationships, hop counts,
4
+ trust labels - and told to add nothing. The deterministic answer is printed above
5
+ the narration either way, so if the prose drifts, what the reader sees is a
6
+ narration disagreeing with the facts printed right above it, not a wrong answer.
7
+
8
+ Empty string on any failure: no ollama means no prose, never a missing answer.
9
+ """
10
+
11
+ import json
12
+
13
+ from ..local_model import generate
14
+
15
+ _PROMPT = """You explain a cloud dependency answer to a developer, in 2-4 sentences.
16
+ Rules:
17
+ - Use ONLY the facts in the JSON. Never add a resource, relationship or cause that is not there.
18
+ - Say plainly which findings are unverified guesses, if any are.
19
+ - No headings, no bullet lists, no restating the JSON field names.
20
+
21
+ Question: {question}
22
+
23
+ Facts:
24
+ {facts}
25
+ """
26
+
27
+
28
+ def narrate(result, model=None, timeout=120, max_findings=20):
29
+ """Prose for a deterministic answer. Returns "" if there is nothing to narrate
30
+ or the local model is unavailable."""
31
+ if not result or result.get("error"):
32
+ return ""
33
+
34
+ findings = [
35
+ {
36
+ "name": f.get("name"),
37
+ "type": f.get("type"),
38
+ "hops": f.get("hops"),
39
+ "trust": f.get("trust"),
40
+ "relationships": [h.get("kind") for h in (f.get("path") or [])],
41
+ }
42
+ for f in (result.get("findings") or [])[:max_findings]
43
+ ]
44
+ facts = {
45
+ "query": result.get("query"),
46
+ "subject": result.get("subject_name"),
47
+ "headline": result.get("headline"),
48
+ "counts": result.get("facts"),
49
+ "findings": findings,
50
+ "caveats": result.get("warnings") or [],
51
+ }
52
+ prompt = _PROMPT.format(question=result.get("question", ""),
53
+ facts=json.dumps(facts, indent=2))
54
+ return generate(prompt, model=model, timeout=timeout, json_format=False).strip()