133 lines
5.5 KiB
Python
133 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Draw the cloud resources ACTUALLY deployed, from `terraform show -json`.
|
|
|
|
Where tfimports.py reads the *declared* config (`.tf` files), this reads the
|
|
*real* state: what Terraform recorded as provisioned. It is provider-agnostic
|
|
(the JSON is uniform across AWS / Azure / GCP), expands `count`/`for_each` into
|
|
their real instances, keeps module nesting, and reuses tfimports' icon resolver
|
|
so every resource shows its official cloud icon. The output feeds autolayout.py:
|
|
|
|
terraform show -json | python3 tfstate.py - -o graph.json
|
|
python3 autolayout.py graph.json -o deployed.drawio
|
|
|
|
Input is the JSON `terraform show -json` prints — from live state (no argument)
|
|
or a saved plan (`terraform show -json plan.tfplan`) — as a file path or `-` for
|
|
stdin. Nodes are the managed resource instances (data sources are ignored);
|
|
edges come from the dependencies Terraform recorded in state (`depends_on`).
|
|
`--group` boxes resources by their module; `--no-icons` forces plain boxes.
|
|
|
|
Usage: terraform show -json | python3 tfstate.py - [-o graph.json]
|
|
[--direction TB|LR] [--group] [--no-reduce] [--no-icons]
|
|
"""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
|
|
def load_tfimports():
|
|
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tfimports.py")
|
|
spec = importlib.util.spec_from_file_location("tfimports", path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def walk_module(mod, out):
|
|
"""Collect (address, type, name, index, module_path, depends_on) for every
|
|
managed resource, recursing into child modules."""
|
|
addr = mod.get("address", "") # "" for root, else module.x[...]
|
|
for r in mod.get("resources") or []:
|
|
if r.get("mode") == "data":
|
|
continue
|
|
out.append((r.get("address"), r.get("type"), r.get("name"),
|
|
r.get("index"), addr, r.get("depends_on") or []))
|
|
for child in mod.get("child_modules") or []:
|
|
walk_module(child, out)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="`terraform show -json` -> autolayout graph JSON.")
|
|
ap.add_argument("input", help="`terraform show -json` output file, or - for stdin")
|
|
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
|
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
ap.add_argument("--group", action="store_true",
|
|
help="group resources into containers by module")
|
|
ap.add_argument("--no-reduce", action="store_true",
|
|
help="keep every edge (skip transitive reduction)")
|
|
ap.add_argument("--no-icons", action="store_true",
|
|
help="plain boxes instead of official cloud icons")
|
|
args = ap.parse_args()
|
|
|
|
if args.input == "-":
|
|
text = sys.stdin.read()
|
|
else:
|
|
with open(args.input, encoding="utf-8") as f:
|
|
text = f.read()
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
sys.exit(f"error: input is not valid JSON ({exc}) — feed `terraform show -json`")
|
|
# State: top-level "values"; saved plan: "planned_values".
|
|
root = ((data.get("values") or data.get("planned_values") or {}).get("root_module")) or {}
|
|
resources = []
|
|
walk_module(root, resources)
|
|
if not resources:
|
|
sys.exit("error: no managed resources found in the Terraform state/plan")
|
|
|
|
addresses = {r[0] for r in resources}
|
|
|
|
def targets(dep):
|
|
"""Instance addresses a depends_on entry names. State records the
|
|
un-indexed address (`aws_subnet.this`) for a resource with several
|
|
instances (`aws_subnet.this[0]`), so expand by prefix too."""
|
|
if dep in addresses:
|
|
return {dep}
|
|
return {a for a in addresses if a.startswith(dep + "[")}
|
|
|
|
edges = sorted({(addr, t) for addr, _, _, _, _, deps in resources
|
|
for dep in deps for t in targets(dep) if t != addr})
|
|
|
|
tf = load_tfimports()
|
|
raw = len(edges)
|
|
if not args.no_reduce and edges:
|
|
edges = tf.transitive_reduce(list(addresses), edges)
|
|
|
|
resolver = None if args.no_icons else tf.IconResolver()
|
|
unmatched, nodes = [], []
|
|
for addr, rtype, name, index, mpath, _ in resources:
|
|
label = name if index is None else f"{name}[{index}]"
|
|
node = {"id": addr, "label": label}
|
|
icon = resolver.resolve(rtype) if resolver and rtype else None
|
|
if icon:
|
|
node.update(style=icon["style"], width=icon["w"], height=icon["h"])
|
|
else:
|
|
node["label"] = f"{label}\n{rtype}" if rtype else label
|
|
if rtype:
|
|
unmatched.append(rtype)
|
|
if args.group and mpath:
|
|
node["group"] = mpath
|
|
nodes.append(node)
|
|
|
|
graph = {"direction": args.direction, "nodes": nodes,
|
|
"edges": [{"source": s, "target": t} for s, t in edges]}
|
|
if resolver:
|
|
# Icon labels render below the shape — reserve extra layout spacing.
|
|
graph.update(ranksep=0.7, nodesep=0.6)
|
|
out = json.dumps(graph, indent=2)
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(out)
|
|
sys.stderr.write(f"wrote {args.output}\n")
|
|
else:
|
|
sys.stdout.write(out)
|
|
note = "" if args.no_reduce else f" (reduced from {raw})"
|
|
sys.stderr.write(f"{len(nodes)} resources, {len(edges)} edges{note}\n")
|
|
if unmatched:
|
|
sys.stderr.write("no icon for: " + ", ".join(sorted(set(unmatched))) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|