ubitofu 0.1.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.
ubitofu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ # placeholder — package code lands via the implementation plan
ubitofu/cleaner.py ADDED
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class VarRef:
9
+ expr: str
10
+
11
+
12
+ # Value-pattern secret safety net (the WireGuard lesson): the provider can
13
+ # return secret material in PLAINTEXT with no schema `sensitive` flag (live
14
+ # WG server private keys did exactly that). Detect secret-shaped content in
15
+ # already-cleaned attrs so it never reaches emitted HCL.
16
+ _SECRET_NAME_RE = re.compile(r"private_key|passphrase|secret|token|password", re.IGNORECASE)
17
+ _PUBLIC_NAME_RE = re.compile(r"public", re.IGNORECASE)
18
+ # curve25519/WireGuard key shape: 44 chars of base64 ending in '='.
19
+ _B64_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{43}=$")
20
+
21
+
22
+ def _is_secret_shaped(name: str, value: object) -> bool:
23
+ if not isinstance(value, str) or not value:
24
+ return False
25
+ if _PUBLIC_NAME_RE.search(name):
26
+ # WG PUBLIC keys share the b64 shape but are not secrets (and may be
27
+ # required attrs, e.g. unifi_wireguard_peer.public_key).
28
+ return False
29
+ return bool(_SECRET_NAME_RE.search(name) or _B64_KEY_RE.match(value))
30
+
31
+
32
+ def strip_secret_shaped(attrs: dict, _prefix: str = "") -> list[str]: # type: ignore[type-arg]
33
+ """Remove secret-shaped string values from cleaned attrs (mutating).
34
+
35
+ A hit is (a) a string value whose attr NAME looks secret-bearing
36
+ (private_key/passphrase/secret/token/password, case-insensitive), or
37
+ (b) any string value shaped like a curve25519/WireGuard key. VarRef
38
+ values are already var-sourced and never match; attr names containing
39
+ "public" are exempt. Containers emptied by stripping are dropped too.
40
+
41
+ Returns the dotted paths of removed values, for reporting and for
42
+ adding their top-level attrs to lifecycle ignore_changes.
43
+ """
44
+ hits: list[str] = []
45
+ for name in list(attrs):
46
+ value = attrs[name]
47
+ path = f"{_prefix}{name}"
48
+ if isinstance(value, VarRef):
49
+ continue
50
+ if isinstance(value, dict):
51
+ hits.extend(strip_secret_shaped(value, f"{path}."))
52
+ if not value:
53
+ del attrs[name]
54
+ elif isinstance(value, list):
55
+ if any(isinstance(e, str) and _is_secret_shaped(name, e) for e in value):
56
+ hits.append(path)
57
+ del attrs[name]
58
+ continue
59
+ for i, entry in enumerate(value):
60
+ if isinstance(entry, dict):
61
+ hits.extend(strip_secret_shaped(entry, f"{path}[{i}]."))
62
+ remaining = [e for e in value if not (isinstance(e, dict) and not e)]
63
+ if remaining:
64
+ attrs[name] = remaining
65
+ else:
66
+ del attrs[name]
67
+ elif _is_secret_shaped(name, value):
68
+ hits.append(path)
69
+ del attrs[name]
70
+ return hits
71
+
72
+
73
+ def is_settable(attr_schema: dict) -> bool: # type: ignore[type-arg]
74
+ required = bool(attr_schema.get("required"))
75
+ optional = bool(attr_schema.get("optional"))
76
+ return required or optional
77
+
78
+
79
+ def is_empty(value: object) -> bool:
80
+ return value is None or value == "" or value == [] or value == {}
81
+
82
+
83
+ def normalize_emitted(resource_type: str, attrs: dict) -> dict: # type: ignore[type-arg]
84
+ """Fix up specific attribute values the provider rejects verbatim.
85
+
86
+ Small, explicit per-resource/attr normalizations applied to already-cleaned
87
+ attrs before rendering — NOT a blanket string replace.
88
+
89
+ - unifi_port_forward.wan.interface: the controller reports "all" for a
90
+ forward that applies to every WAN, but the provider validator accepts
91
+ only wan/wan2/both. With 2 WANs, "both" is the equivalent.
92
+ """
93
+ if resource_type == "unifi_port_forward":
94
+ wan = attrs.get("wan")
95
+ if isinstance(wan, dict) and wan.get("interface") == "all":
96
+ wan["interface"] = "both"
97
+ return attrs
98
+
99
+
100
+ def clean_resource(
101
+ values: dict, # type: ignore[type-arg]
102
+ resource_schema: dict, # type: ignore[type-arg]
103
+ sensitive: dict[str, VarRef] | None = None,
104
+ ) -> dict: # type: ignore[type-arg]
105
+ sensitive = sensitive or {}
106
+ block = resource_schema["block"]
107
+ attrs = block.get("attributes", {})
108
+ out: dict[str, object] = {}
109
+
110
+ for name, schema in attrs.items():
111
+ if name in sensitive:
112
+ out[name] = sensitive[name]
113
+ continue
114
+ if not is_settable(schema):
115
+ continue
116
+ value = values.get(name)
117
+ if is_empty(value):
118
+ continue
119
+ # Single/collection nested-object ATTRIBUTES live under `nested_type`
120
+ # (not block_types). generate-config-out emits every child verbatim,
121
+ # including null/empty and computed-only fields (e.g. firewall_policy
122
+ # source/destination) — recurse so the settable/drop-empty rules apply
123
+ # inside them too, else read-only children leak and break the plan.
124
+ nested = schema.get("nested_type")
125
+ if nested:
126
+ assert value is not None # is_empty guards None; narrows for mypy
127
+ sub_schema = {"block": {"attributes": nested["attributes"]}}
128
+ if nested.get("nesting_mode") == "single":
129
+ single = clean_resource(value, sub_schema)
130
+ if single:
131
+ out[name] = single
132
+ else: # list/set nesting -> a list of object entries
133
+ nested_entries: list[dict[str, object]] = (
134
+ value if isinstance(value, list) else [value])
135
+ nested_cleaned = [c for e in nested_entries
136
+ if (c := clean_resource(e, sub_schema))]
137
+ if nested_cleaned:
138
+ out[name] = nested_cleaned
139
+ continue
140
+ out[name] = value
141
+
142
+ # Repeated/nested config blocks live under block_types (not attributes).
143
+ # Previously these were silently dropped, making 0-change plans unreachable.
144
+ for name, bt in block.get("block_types", {}).items():
145
+ raw = values.get(name)
146
+ if is_empty(raw):
147
+ continue
148
+ assert raw is not None # is_empty guards None; assert narrows for mypy
149
+ entries: list[dict[str, object]] = raw if isinstance(raw, list) else [raw]
150
+ cleaned = [clean_resource(e, {"block": bt["block"]}) for e in entries]
151
+ cleaned = [c for c in cleaned if c] # drop wholly-empty entries
152
+ if cleaned:
153
+ out[name] = cleaned
154
+
155
+ return out
ubitofu/cli.py ADDED
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from typing import IO
7
+
8
+ from .config import Config, load_config, resolve_api_key
9
+ from .controller import Controller
10
+ from .enumerator import enumerate_controller
11
+ from .import_emitter import emit_import_blocks
12
+ from .reporter import format_gaps
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ p = argparse.ArgumentParser(
17
+ prog="ubitofu",
18
+ description="Plan-only UniFi -> OpenTofu importer.",
19
+ )
20
+ sub = p.add_subparsers(dest="command", required=True)
21
+ for name in ("enumerate", "generate", "verify"):
22
+ sp = sub.add_parser(name)
23
+ sp.add_argument("--config", required=True)
24
+ sp.add_argument("--controller-url")
25
+ sp.add_argument("--site")
26
+ sp.add_argument("--api-key-source", choices=["op", "env"])
27
+ if name != "verify":
28
+ sp.add_argument("--mode", choices=["bulk", "incremental"], default="bulk")
29
+ return p
30
+
31
+
32
+ def _controller(cfg: Config) -> Controller:
33
+ key = resolve_api_key(cfg, environ=os.environ)
34
+ return Controller(base_url=cfg.controller_url, site=cfg.site, api_key=key)
35
+
36
+
37
+ def cmd_enumerate(cfg: Config, mode: str, out: IO[str]) -> int:
38
+ res = enumerate_controller(_controller(cfg))
39
+ print(emit_import_blocks(res.targets), file=out)
40
+ print(format_gaps(res.gaps), file=out)
41
+ return 0
42
+
43
+
44
+ def cmd_generate(cfg: Config, mode: str, out: IO[str]) -> int:
45
+ # pipeline.run_generate wires Tasks 1-9 end-to-end (implemented in Task 12).
46
+ # Lazy import so cli is importable before Task 12 exists.
47
+ from .pipeline import run_generate # noqa: PLC0415
48
+
49
+ return run_generate(cfg, mode, out)
50
+
51
+
52
+ def cmd_verify(cfg: Config, out: IO[str]) -> int:
53
+ from .pipeline import run_verify # noqa: PLC0415
54
+
55
+ return run_verify(cfg, out)
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+ cfg = load_config(args.config)
61
+ # CLI flags override config-file values.
62
+ if args.controller_url:
63
+ cfg.controller_url = args.controller_url
64
+ if args.site:
65
+ cfg.site = args.site
66
+ if args.api_key_source:
67
+ cfg.api_key_source = args.api_key_source
68
+ if args.command == "enumerate":
69
+ return cmd_enumerate(cfg, args.mode, sys.stdout)
70
+ if args.command == "generate":
71
+ return cmd_generate(cfg, args.mode, sys.stdout)
72
+ return cmd_verify(cfg, sys.stdout)
ubitofu/config.py ADDED
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ import subprocess
4
+ import tomllib
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class Config:
11
+ controller_url: str
12
+ site: str
13
+ api_key_source: str
14
+ api_key_ref: str
15
+ op_vault: str # required: the operator's secret-manager vault, from config
16
+ workdir: str = "."
17
+
18
+
19
+ def load_config(path: str) -> Config:
20
+ with open(path, "rb") as fh:
21
+ data = tomllib.load(fh)
22
+ return Config(**data)
23
+
24
+
25
+ def _op_read(ref: str) -> str:
26
+ result = subprocess.run(["op", "read", ref], capture_output=True, text=True, check=True)
27
+ return result.stdout.strip()
28
+
29
+
30
+ def resolve_api_key(
31
+ cfg: Config,
32
+ environ: Mapping[str, str],
33
+ op_reader: Callable[[str], str] = _op_read,
34
+ ) -> str:
35
+ if cfg.api_key_source == "env":
36
+ return environ[cfg.api_key_ref]
37
+ if cfg.api_key_source == "op":
38
+ return op_reader(cfg.api_key_ref)
39
+ raise ValueError(f"unknown api_key_source: {cfg.api_key_source!r}")
ubitofu/controller.py ADDED
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ from dataclasses import dataclass, field
4
+
5
+ import httpx
6
+
7
+
8
+ @dataclass
9
+ class Controller:
10
+ base_url: str
11
+ site: str
12
+ api_key: str
13
+ verify_tls: bool = False
14
+ _http: httpx.Client = field(init=False, repr=False)
15
+
16
+ def __post_init__(self) -> None:
17
+ self._http = httpx.Client(base_url=self.base_url, verify=self.verify_tls)
18
+
19
+ def _resolve(self, endpoint: str) -> str:
20
+ endpoint = endpoint.replace("{site}", self.site)
21
+ if endpoint.startswith("v2/") or endpoint.startswith("api/self"):
22
+ return f"/proxy/network/{endpoint}"
23
+ return f"/proxy/network/api/s/{self.site}/{endpoint}"
24
+
25
+ def get(self, path: str) -> object:
26
+ resp = self._http.get(
27
+ self._resolve(path),
28
+ headers={"X-API-KEY": self.api_key, "Accept": "application/json"},
29
+ )
30
+ resp.raise_for_status()
31
+ return resp.json()
32
+
33
+ def collection(self, endpoint: str) -> list[dict]: # type: ignore[type-arg]
34
+ body = self.get(endpoint)
35
+ if isinstance(body, dict) and "data" in body:
36
+ return list(body["data"])
37
+ if isinstance(body, list):
38
+ return list(body)
39
+ return [body] if isinstance(body, dict) else []
ubitofu/enumerator.py ADDED
@@ -0,0 +1,185 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass, field
5
+
6
+ from .controller import Controller
7
+ from .manifest import MANIFEST, UNMAPPED_ENDPOINTS, ResourceSpec
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ImportTarget:
12
+ resource_type: str
13
+ name_hint: str
14
+ import_id: str
15
+
16
+
17
+ @dataclass
18
+ class EnumerationResult:
19
+ targets: list[ImportTarget] = field(default_factory=list)
20
+ gaps: list[str] = field(default_factory=list)
21
+
22
+
23
+ _ALIAS_SKIP = {"unifi_account"} # rest/account alias — unifi_radius_user wins
24
+
25
+ # Per-object skips: objects the provider cannot represent. Each maps to a
26
+ # coverage-gap label (rendered with the skipped count) instead of being emitted
27
+ # as invalid config. Keyed by the reason so counts aggregate across a run.
28
+ _SKIP_LABELS: dict[str, str] = {
29
+ "app_policy": "app-based firewall policy(ies) — unsupported matching_target=APP",
30
+ "radius_default": "default radius profile(s) — required auth_server.secret is "
31
+ "not sourceable; skipped",
32
+ "usergroup_default": "default client-QoS usergroup(s) — unmanageable default "
33
+ "(-1 sentinel rates); skipped",
34
+ }
35
+
36
+
37
+ def _is_app_policy(obj: dict[str, object]) -> bool:
38
+ """A firewall policy is app-based when source/destination match on APP.
39
+
40
+ The provider's matching_target validator accepts ANY/NETWORK/CLIENT/IP/
41
+ DEVICE/MAC/WEB but not APP (DPI / app_ids), so such policies cannot be
42
+ represented and must be skipped.
43
+ """
44
+ for side in ("source", "destination"):
45
+ sub = obj.get(side)
46
+ if isinstance(sub, dict) and str(sub.get("matching_target")).upper() == "APP":
47
+ return True
48
+ return False
49
+
50
+
51
+ def _skip_reason(spec: ResourceSpec, obj: dict[str, object]) -> str | None:
52
+ rt = spec.resource_type
53
+ if rt == "unifi_firewall_policy" and _is_app_policy(obj):
54
+ return "app_policy"
55
+ # attr_no_delete marks a built-in/default object (like firewall `predefined`).
56
+ # The lone default radius profile / QoS usergroup carry unsourceable secrets
57
+ # or -1 sentinel rates the provider rejects — skip rather than emit invalid.
58
+ if rt == "unifi_radius_profile" and obj.get("attr_no_delete"):
59
+ return "radius_default"
60
+ if rt == "unifi_client_qos_rate" and obj.get("attr_no_delete"):
61
+ return "usergroup_default"
62
+ return None
63
+
64
+
65
+ def matches(obj: dict[str, object], spec: ResourceSpec) -> bool:
66
+ if spec.discriminator:
67
+ for key, allowed in spec.discriminator.items():
68
+ if str(obj.get(key)) not in allowed.split("|"):
69
+ return False
70
+ if spec.include:
71
+ for key, want in spec.include.items():
72
+ if want == "__present__":
73
+ if not obj.get(key):
74
+ return False
75
+ elif obj.get(key) != want:
76
+ return False
77
+ return True
78
+
79
+
80
+ def extract_id(obj: dict[str, object], spec: ResourceSpec, site: str) -> str:
81
+ if spec.id_rule == "site":
82
+ return site
83
+ if spec.id_rule == "mac":
84
+ return str(obj["mac"])
85
+ if spec.id_rule == "mac_or_id":
86
+ return str(obj.get("mac") or obj["_id"])
87
+ if spec.id_rule == "site:_id":
88
+ return f"{site}:{obj['_id']}"
89
+ return str(obj["_id"])
90
+
91
+
92
+ def _name_hint(obj: dict[str, object], spec: ResourceSpec, site: str) -> str:
93
+ if spec.id_rule == "site":
94
+ return spec.resource_type.removeprefix("unifi_")
95
+ # `key`/`host_name` cover records that leave `name` null: unifi_dns_record
96
+ # (static-dns keys the hostname under `key`) and unifi_dynamic_dns
97
+ # (host_name) — so `to =` labels read as the hostname, not n_<hex>.
98
+ return str(obj.get("name") or obj.get("hostname") or obj.get("host_name")
99
+ or obj.get("key") or obj.get("_id") or site)
100
+
101
+
102
+ def enumerate_controller(
103
+ ctl: Controller, manifest: Iterable[ResourceSpec] = MANIFEST
104
+ ) -> EnumerationResult:
105
+ result = EnumerationResult()
106
+ specs = list(manifest)
107
+ skipped: dict[str, int] = {}
108
+ for spec in specs:
109
+ if spec.resource_type in _ALIAS_SKIP:
110
+ continue
111
+ if spec.id_rule == "site": # singleton — import by site, not enumerated
112
+ if spec.skip_if_empty and not ctl.collection(spec.endpoint):
113
+ result.gaps.append(
114
+ f"{spec.resource_type} skipped — not configured "
115
+ "(no remote object to import)")
116
+ continue
117
+ result.targets.append(
118
+ ImportTarget(spec.resource_type, _name_hint({}, spec, ctl.site), ctl.site))
119
+ continue
120
+ if spec.id_rule == "wg_two_level":
121
+ result.targets.extend(_enumerate_wireguard(ctl, spec))
122
+ continue
123
+ for obj in ctl.collection(spec.endpoint):
124
+ if not matches(obj, spec):
125
+ continue
126
+ reason = _skip_reason(spec, obj)
127
+ if reason is not None:
128
+ skipped[reason] = skipped.get(reason, 0) + 1
129
+ continue
130
+ result.targets.append(ImportTarget(
131
+ spec.resource_type,
132
+ _name_hint(obj, spec, ctl.site),
133
+ extract_id(obj, spec, ctl.site)))
134
+ for reason, count in skipped.items():
135
+ result.gaps.append(f"{count} {_SKIP_LABELS[reason]}")
136
+ result.gaps.extend(_guest_network_gaps(ctl, specs))
137
+ result.gaps.extend(_coverage_gaps(ctl))
138
+ return result
139
+
140
+
141
+ def _guest_network_gaps(
142
+ ctl: Controller, specs: list[ResourceSpec]
143
+ ) -> list[str]:
144
+ """Report guest networks (purpose="guest") — no provider resource exists.
145
+
146
+ They are already excluded by the unifi_network discriminator; this counts
147
+ and reports them so the coverage gap is explicit rather than silent.
148
+ """
149
+ if not any(s.endpoint == "rest/networkconf" for s in specs):
150
+ return []
151
+ n = sum(1 for net in ctl.collection("rest/networkconf")
152
+ if net.get("purpose") == "guest")
153
+ if not n:
154
+ return []
155
+ return [f"{n} guest network(s) — no provider resource; not imported"]
156
+
157
+
158
+ def _enumerate_wireguard(ctl: Controller, spec: ResourceSpec) -> list[ImportTarget]:
159
+ """First list WG-server networks, then GET each server's users.
160
+
161
+ Import id is "network_id:peer_id" (two-level enumeration).
162
+ """
163
+ targets: list[ImportTarget] = []
164
+ nets = [n for n in ctl.collection("rest/networkconf")
165
+ if n.get("vpn_type") == "wireguard-server"]
166
+ for net in nets:
167
+ nid = str(net["_id"])
168
+ for peer in ctl.collection(f"v2/api/site/{{site}}/wireguard/{nid}/users"):
169
+ targets.append(ImportTarget(
170
+ "unifi_wireguard_peer",
171
+ str(peer.get("name") or peer["_id"]),
172
+ f"{nid}:{peer['_id']}"))
173
+ return targets
174
+
175
+
176
+ def _coverage_gaps(ctl: Controller) -> list[str]:
177
+ """Flag any UNMAPPED_ENDPOINTS collection that is non-empty."""
178
+ gaps: list[str] = []
179
+ for endpoint, label in UNMAPPED_ENDPOINTS.items():
180
+ n = len(ctl.collection(endpoint))
181
+ if n:
182
+ gaps.append(
183
+ f"{n} objects found at {endpoint} ({label})"
184
+ " with no provider resource — not imported")
185
+ return gaps
ubitofu/hcl_writer.py ADDED
@@ -0,0 +1,179 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 James Braid
3
+ import json
4
+ import subprocess
5
+
6
+ import hcl2 # type: ignore[import-untyped]
7
+ from hcl2 import Builder
8
+
9
+ from .cleaner import VarRef
10
+
11
+
12
+ def _q(s: str) -> str:
13
+ """Pre-quote a string literal for python-hcl2.
14
+
15
+ python-hcl2 treats bare Python strings as raw HCL expressions. String
16
+ LITERALS must therefore arrive already surrounded by HCL double-quotes, with
17
+ internal special characters escaped:
18
+ - backslash first (avoid double-escaping later additions)
19
+ - double-quote
20
+ - HCL interpolation opener ${…} → $${…}
21
+ - HCL template opener %{…} → %%{…}
22
+ """
23
+ s = (s.replace("\\", "\\\\")
24
+ .replace('"', '\\"')
25
+ .replace("\n", "\\n")
26
+ .replace("\r", "\\r")
27
+ .replace("\t", "\\t")
28
+ .replace("${", "$${")
29
+ .replace("%{", "%%{"))
30
+ return f'"{s}"'
31
+
32
+
33
+ def hcl_literal(value: object) -> object:
34
+ """Prepare a Python value for hcl2.dumps.
35
+
36
+ python-hcl2 treats bare strings as raw HCL expressions, so string
37
+ LITERALS must be pre-quoted, while VarRef expressions pass through raw.
38
+ Dicts become nested object attrs; lists become HCL lists.
39
+ """
40
+ if isinstance(value, VarRef):
41
+ return value.expr
42
+ if isinstance(value, bool):
43
+ return value
44
+ if isinstance(value, str):
45
+ return _q(value)
46
+ if isinstance(value, dict):
47
+ return {k: hcl_literal(v) for k, v in value.items()}
48
+ if isinstance(value, list):
49
+ return [hcl_literal(v) for v in value]
50
+ return value # int/float/None — hcl2.dumps handles directly
51
+
52
+
53
+ def tofu_fmt(text: str, binary: str = "tofu") -> str:
54
+ """Run `tofu fmt -` on *text* and return the formatted result."""
55
+ proc = subprocess.run(
56
+ [binary, "fmt", "-"],
57
+ input=text,
58
+ capture_output=True,
59
+ text=True,
60
+ )
61
+ if proc.returncode != 0:
62
+ raise RuntimeError(proc.stderr.strip())
63
+ return proc.stdout
64
+
65
+
66
+ def _render_lifecycle_raw(lifecycle: dict[str, object]) -> str:
67
+ """Render a lifecycle block as a raw indented text block.
68
+
69
+ python-hcl2 expands list values to multi-line, but the test requires
70
+ ``ignore_changes = [attr_ref]`` on a single line (attr refs, not strings).
71
+ We therefore bypass Builder/dumps for this block and write it directly;
72
+ tofu fmt keeps single-line lists intact.
73
+ """
74
+ lines = [" lifecycle {"]
75
+ for k, v in lifecycle.items():
76
+ if isinstance(v, list):
77
+ refs = ", ".join(str(r) for r in v)
78
+ lines.append(f" {k} = [{refs}]")
79
+ lines.append(" }")
80
+ return "\n".join(lines)
81
+
82
+
83
+ def render_resource(
84
+ resource_type: str,
85
+ slug: str,
86
+ attrs: dict[str, object],
87
+ lifecycle: dict[str, object] | None = None,
88
+ block_attrs: tuple[str, ...] = (),
89
+ ) -> str:
90
+ """Render a single Terraform/OpenTofu resource block to formatted HCL.
91
+
92
+ Constructs handled:
93
+ - Scalar / string attributes (string literals pre-quoted via ``_q``).
94
+ - Nested object attribute: dict value → ``name = { … }`` HCL object.
95
+ - List-of-object attribute: list-of-dict → ``name = [ { … }, … ]``.
96
+ - Repeated nested blocks (``block_attrs``): each list entry → a separate
97
+ ``name { … }`` block rather than an ``= […]`` assignment.
98
+ - VarRef: rendered as a bare ``var.<name>`` traversal expression.
99
+ - lifecycle: rendered as raw text so ``ignore_changes`` refs stay unquoted.
100
+ """
101
+ builder = Builder()
102
+
103
+ # Scalar attrs and plain list/object attrs (everything except block_attrs).
104
+ scalar_attrs = {
105
+ k: hcl_literal(v)
106
+ for k, v in attrs.items()
107
+ if k not in block_attrs
108
+ }
109
+ block = builder.block("resource", [_q(resource_type), _q(slug)], **scalar_attrs)
110
+
111
+ # Repeated nested blocks (nesting_mode=set/list in provider schema).
112
+ for name in block_attrs:
113
+ entries = attrs.get(name)
114
+ if isinstance(entries, list):
115
+ for entry in entries:
116
+ if isinstance(entry, dict):
117
+ block.block(name, **{k: hcl_literal(v) for k, v in entry.items()})
118
+
119
+ # Convert to HCL text and apply tofu fmt.
120
+ hcl_text = tofu_fmt(hcl2.dumps(builder.build()))
121
+
122
+ if lifecycle:
123
+ # Splice the lifecycle block in before the closing `}` of the resource,
124
+ # then re-format. We build it as raw text to preserve bare identifier
125
+ # refs inside ignore_changes (python-hcl2 would expand the list and
126
+ # wrap refs in quotes, which is wrong).
127
+ body = hcl_text.rstrip() # strip trailing newline(s)
128
+ if not body.endswith("}"):
129
+ raise ValueError(f"unexpected hcl2.dumps tail: {body[-20:]!r}")
130
+ body = body[:-1].rstrip() # strip the closing "}"
131
+ lifecycle_txt = _render_lifecycle_raw(lifecycle)
132
+ combined = body + "\n\n" + lifecycle_txt + "\n}\n"
133
+ hcl_text = tofu_fmt(combined)
134
+
135
+ return hcl_text
136
+
137
+
138
+ def render_variables(var_names: list[str]) -> str:
139
+ """Render sensitive string variable declarations, sorted by name.
140
+
141
+ Only declarations — values come from the operator's secret manager
142
+ (TF_VAR_* / -var-file); no secret value is ever written to a file.
143
+ """
144
+ blocks = [
145
+ f'variable "{name}" {{\n type = string\n sensitive = true\n}}\n'
146
+ for name in sorted(set(var_names))
147
+ ]
148
+ return "\n".join(blocks)
149
+
150
+
151
+ def render_json_fallback(
152
+ resource_type: str,
153
+ slug: str,
154
+ attrs: dict[str, object],
155
+ ) -> str:
156
+ """Emit a ``.tf.json`` resource block as a fallback.
157
+
158
+ Used when python-hcl2 cannot render a construct correctly (e.g. for a
159
+ resource type whose schema requires a construct that Builder/dumps
160
+ mis-renders despite best efforts). VarRef values become ``${var.name}``
161
+ interpolation expressions, which are valid in .tf.json.
162
+ """
163
+ def _plain(v: object) -> object:
164
+ if isinstance(v, VarRef):
165
+ return f"${{{v.expr}}}"
166
+ if isinstance(v, dict):
167
+ return {k: _plain(x) for k, x in v.items()}
168
+ if isinstance(v, list):
169
+ return [_plain(x) for x in v]
170
+ return v
171
+
172
+ payload = {
173
+ "resource": {
174
+ resource_type: {
175
+ slug: {k: _plain(v) for k, v in attrs.items()}
176
+ }
177
+ }
178
+ }
179
+ return json.dumps(payload, indent=2) + "\n"