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 +3 -0
- ubitofu/cleaner.py +155 -0
- ubitofu/cli.py +72 -0
- ubitofu/config.py +39 -0
- ubitofu/controller.py +39 -0
- ubitofu/enumerator.py +185 -0
- ubitofu/hcl_writer.py +179 -0
- ubitofu/import_emitter.py +32 -0
- ubitofu/manifest.py +88 -0
- ubitofu/pipeline.py +223 -0
- ubitofu/py.typed +0 -0
- ubitofu/reporter.py +77 -0
- ubitofu/secrets.py +129 -0
- ubitofu/tofu_runner.py +97 -0
- ubitofu-0.1.0.dist-info/METADATA +109 -0
- ubitofu-0.1.0.dist-info/RECORD +20 -0
- ubitofu-0.1.0.dist-info/WHEEL +5 -0
- ubitofu-0.1.0.dist-info/entry_points.txt +2 -0
- ubitofu-0.1.0.dist-info/licenses/LICENSE +674 -0
- ubitofu-0.1.0.dist-info/top_level.txt +1 -0
ubitofu/__init__.py
ADDED
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"
|