|
Bogdan Timofte
authored
2 weeks ago
|
1
|
#!/usr/bin/env python3
|
|
|
2
|
import argparse
|
|
|
3
|
import fnmatch
|
|
|
4
|
from pathlib import Path
|
|
|
5
|
|
|
|
6
|
import yaml
|
|
|
7
|
|
|
|
8
|
|
|
|
9
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
10
|
|
|
|
11
|
|
|
|
12
|
def load_inventory(path: Path) -> dict:
|
|
|
13
|
with path.open("r", encoding="utf-8") as handle:
|
|
|
14
|
data = yaml.safe_load(handle)
|
|
|
15
|
if data.get("version") != 1:
|
|
|
16
|
raise SystemExit("unsupported inventory version")
|
|
|
17
|
return data
|
|
|
18
|
|
|
|
19
|
|
|
Bogdan Timofte
authored
2 weeks ago
|
20
|
def merge_inventories(paths: list) -> dict:
|
|
|
21
|
"""Merge multiple inventory files, with later ones overriding earlier ones."""
|
|
|
22
|
merged = {}
|
|
|
23
|
for path in paths:
|
|
|
24
|
data = load_inventory(path)
|
|
|
25
|
for key in ("facts", "ssh_options", "defaults", "entrypoints", "jumps", "groups", "company_managed", "access_policies"):
|
|
|
26
|
if key not in data:
|
|
|
27
|
continue
|
|
|
28
|
if key not in merged:
|
|
|
29
|
merged[key] = {}
|
|
|
30
|
if key in ("facts", "defaults", "company_managed"):
|
|
|
31
|
merged[key].update(data[key])
|
|
|
32
|
elif key in ("ssh_options", "entrypoints", "jumps", "groups", "access_policies"):
|
|
|
33
|
merged[key].update(data[key])
|
|
|
34
|
if "version" not in merged:
|
|
|
35
|
merged["version"] = 1
|
|
|
36
|
return merged
|
|
|
37
|
|
|
|
38
|
|
|
Bogdan Timofte
authored
2 weeks ago
|
39
|
def fmt_bool(value: bool) -> str:
|
|
|
40
|
return "yes" if value else "no"
|
|
|
41
|
|
|
|
42
|
|
|
|
43
|
def fmt_option(value) -> str:
|
|
|
44
|
if isinstance(value, bool):
|
|
|
45
|
return fmt_bool(value)
|
|
|
46
|
return str(value)
|
|
|
47
|
|
|
|
48
|
|
|
|
49
|
def aliases_match_rule(aliases, rule):
|
|
|
50
|
patterns = rule.get("patterns", [])
|
|
|
51
|
if not patterns:
|
|
|
52
|
return False
|
|
|
53
|
return all(any(fnmatch.fnmatch(str(alias), pattern) for pattern in patterns) for alias in aliases)
|
|
|
54
|
|
|
|
55
|
|
|
|
56
|
def company_managed_rule(data, target, aliases, user, port):
|
|
|
57
|
managed = data.get("company_managed", {}).get("jump_hosts", {})
|
|
|
58
|
if target not in managed.get("inherit_globals_on_targets", []):
|
|
|
59
|
return None
|
|
|
60
|
|
|
|
61
|
for rule in managed.get("match_defaults", []):
|
|
|
62
|
if rule.get("user") != user or rule.get("port") != port:
|
|
|
63
|
continue
|
|
|
64
|
if aliases_match_rule(aliases, rule):
|
|
|
65
|
return rule
|
|
|
66
|
return None
|
|
|
67
|
|
|
|
68
|
|
|
|
69
|
def aliases_for_host(host):
|
|
|
70
|
aliases = [str(alias) for alias in host["aliases"]]
|
|
|
71
|
if host["hostname"] not in aliases:
|
|
|
72
|
aliases.append(host["hostname"])
|
|
|
73
|
return aliases
|
|
|
74
|
|
|
|
75
|
|
|
|
76
|
def host_block(aliases, hostname, user=None, port=None, extra=None):
|
|
|
77
|
lines = [f"Host {' '.join(str(alias) for alias in aliases)}", f" HostName {hostname}"]
|
|
|
78
|
if user:
|
|
|
79
|
lines.append(f" User {user}")
|
|
|
80
|
if port:
|
|
|
81
|
lines.append(f" Port {port}")
|
|
|
82
|
auth = (extra or {}).pop("auth", None)
|
|
Bogdan Timofte
authored
2 weeks ago
|
83
|
proxy_jump = (extra or {}).pop("proxy_jump", None)
|
|
|
84
|
route = (extra or {}).pop("route", None)
|
|
Bogdan Timofte
authored
2 weeks ago
|
85
|
identity_file = (extra or {}).pop("identity_file", None)
|
|
Bogdan Timofte
authored
2 weeks ago
|
86
|
|
|
|
87
|
if route:
|
|
|
88
|
lines.append(f" SetEnv SSH_ROUTE={route}")
|
|
Bogdan Timofte
authored
2 weeks ago
|
89
|
if identity_file:
|
|
|
90
|
lines.append(f" IdentityFile {identity_file}")
|
|
Bogdan Timofte
authored
2 weeks ago
|
91
|
if auth == "password_interactive":
|
|
|
92
|
lines.append(" SetEnv NG_SSH_AUTH=password-interactive")
|
|
|
93
|
lines.append(" BatchMode no")
|
|
|
94
|
lines.append(" PreferredAuthentications keyboard-interactive,password")
|
|
|
95
|
lines.append(" PubkeyAuthentication no")
|
|
Bogdan Timofte
authored
2 weeks ago
|
96
|
if proxy_jump and proxy_jump != "none":
|
|
|
97
|
lines.append(f" ProxyJump {proxy_jump}")
|
|
Bogdan Timofte
authored
2 weeks ago
|
98
|
for key, value in (extra or {}).items():
|
|
|
99
|
lines.append(f" {key} {value}")
|
|
|
100
|
lines.append("")
|
|
|
101
|
return lines
|
|
|
102
|
|
|
|
103
|
|
|
|
104
|
def pattern_block(pattern, options):
|
|
|
105
|
lines = [f"Host {pattern}"]
|
|
|
106
|
if "connect_timeout" in options:
|
|
|
107
|
lines.append(f" ConnectTimeout {options['connect_timeout']}")
|
|
|
108
|
if "connection_attempts" in options:
|
|
|
109
|
lines.append(f" ConnectionAttempts {options['connection_attempts']}")
|
|
|
110
|
lines.append("")
|
|
|
111
|
return lines
|
|
|
112
|
|
|
|
113
|
|
|
|
114
|
def generated_header(target, include_comments=True):
|
|
|
115
|
if not include_comments:
|
|
|
116
|
return []
|
|
|
117
|
return [
|
|
|
118
|
"# Generated by tools/generate-configs.py.",
|
|
|
119
|
"# Do not edit this file directly; edit inventory/hosts.yaml.",
|
|
|
120
|
f"# Target: {target}",
|
|
|
121
|
"",
|
|
|
122
|
]
|
|
|
123
|
|
|
|
124
|
|
|
|
125
|
def emit_global_options(data, include_comments=True):
|
|
|
126
|
blocks = data.get("ssh_options", {})
|
|
|
127
|
if not blocks:
|
|
|
128
|
return []
|
|
|
129
|
|
|
|
130
|
lines = []
|
|
|
131
|
if include_comments:
|
|
|
132
|
lines.extend(["# Global SSH compatibility options", ""])
|
|
|
133
|
for name, block in blocks.items():
|
|
|
134
|
if include_comments:
|
|
|
135
|
lines.append(f"# {name}: {block.get('description', '')}")
|
|
|
136
|
lines.append("Host *")
|
|
|
137
|
for key, value in block.get("options", {}).items():
|
|
|
138
|
lines.append(f" {key} {fmt_option(value)}")
|
|
|
139
|
lines.append("")
|
|
|
140
|
return lines
|
|
|
141
|
|
|
|
142
|
|
|
|
143
|
def inherit_globals(data, target):
|
|
|
144
|
managed = data.get("company_managed", {}).get("jump_hosts", {})
|
|
|
145
|
return target in managed.get("inherit_globals_on_targets", [])
|
|
|
146
|
|
|
|
147
|
|
|
|
148
|
def merged(defaults, group_defaults, host):
|
|
|
149
|
result = dict(defaults)
|
|
|
150
|
result.update(group_defaults or {})
|
|
|
151
|
result.update(host)
|
|
|
152
|
return result
|
|
|
153
|
|
|
|
154
|
|
|
|
155
|
def host_differs_from_defaults(host, defaults):
|
|
|
156
|
for key in ("user", "port", "auth"):
|
|
|
157
|
if key in host and host[key] != defaults.get(key):
|
|
|
158
|
return True
|
|
|
159
|
return False
|
|
|
160
|
|
|
|
161
|
|
|
|
162
|
def should_emit_host_on_target(data, target, group_defaults, host):
|
|
|
163
|
if target not in ("j1", "j2"):
|
|
|
164
|
return True
|
|
|
165
|
|
|
|
166
|
baseline = merged(data["defaults"]["final_host"], group_defaults, {})
|
|
|
167
|
return host_differs_from_defaults(host, baseline)
|
|
|
168
|
|
|
|
169
|
|
|
|
170
|
def emit_entrypoints(data, include_comments=True):
|
|
|
171
|
lines = ["# Entrypoints", ""] if include_comments else []
|
|
|
172
|
for host in data["entrypoints"].values():
|
|
|
173
|
extra = {}
|
|
|
174
|
if host.get("identity_file"):
|
|
|
175
|
extra["IdentityFile"] = host["identity_file"]
|
|
|
176
|
if "identities_only" in host:
|
|
|
177
|
extra["IdentitiesOnly"] = fmt_bool(host["identities_only"])
|
|
|
178
|
lines.extend(host_block(aliases_for_host(host), host["hostname"], host.get("user"), host.get("port"), extra))
|
|
|
179
|
return lines
|
|
|
180
|
|
|
|
181
|
|
|
|
182
|
def emit_jumps(data, include_comments=True):
|
|
|
183
|
lines = ["# Jump hosts", ""] if include_comments else []
|
|
|
184
|
defaults = data["defaults"]["jump"]
|
|
|
185
|
for jump in data["jumps"].values():
|
|
|
186
|
item = merged(defaults, {}, jump)
|
|
|
187
|
lines.extend(host_block(aliases_for_host(item), item["hostname"], item.get("user"), item.get("port")))
|
|
|
188
|
return lines
|
|
|
189
|
|
|
|
190
|
|
|
|
191
|
def emit_hosts_for_group(data, group, target, defaults):
|
|
|
192
|
group_defaults = group.get("defaults", {})
|
|
|
193
|
lines = []
|
|
|
194
|
for host in group.get("hosts", {}).values():
|
|
|
195
|
if not should_emit_host_on_target(data, target, group_defaults, host):
|
|
|
196
|
continue
|
|
|
197
|
item = merged(defaults, group_defaults, host)
|
|
|
198
|
aliases = aliases_for_host(item)
|
|
|
199
|
extra = {}
|
|
|
200
|
if item.get("auth"):
|
|
|
201
|
extra["auth"] = item["auth"]
|
|
Bogdan Timofte
authored
2 weeks ago
|
202
|
if item.get("route"):
|
|
|
203
|
extra["route"] = item["route"]
|
|
Bogdan Timofte
authored
2 weeks ago
|
204
|
if item.get("identity_file"):
|
|
|
205
|
extra["identity_file"] = item["identity_file"]
|
|
Bogdan Timofte
authored
2 weeks ago
|
206
|
user = item.get("user")
|
|
|
207
|
port = item.get("port")
|
|
|
208
|
if company_managed_rule(data, target, aliases, user, port):
|
|
|
209
|
user = None
|
|
|
210
|
port = None
|
|
|
211
|
lines.extend(host_block(aliases, item["hostname"], user, port, extra))
|
|
|
212
|
for pattern, options in group.get("patterns", {}).items():
|
|
|
213
|
lines.extend(pattern_block(pattern, options))
|
|
|
214
|
return lines
|
|
|
215
|
|
|
|
216
|
|
|
|
217
|
def emit_groups(data, target=None, include_comments=True):
|
|
|
218
|
lines = []
|
|
|
219
|
defaults = data["defaults"]["final_host"]
|
|
|
220
|
metadata_keys = {"description", "default_jump", "defaults", "patterns", "hosts"}
|
|
|
221
|
queue = [(name, group) for name, group in data["groups"].items()]
|
|
|
222
|
|
|
|
223
|
while queue:
|
|
|
224
|
group_name, group = queue.pop(0)
|
|
|
225
|
group_lines = emit_hosts_for_group(data, group, target, defaults)
|
|
|
226
|
if group_lines:
|
|
|
227
|
if include_comments:
|
|
|
228
|
lines.extend([f"# Group: {group_name}", f"# Description: {group.get('description', '')}", ""])
|
|
|
229
|
lines.extend(group_lines)
|
|
|
230
|
|
|
|
231
|
for child_name, child in group.items():
|
|
|
232
|
if child_name in metadata_keys or not isinstance(child, dict):
|
|
|
233
|
continue
|
|
|
234
|
if "hosts" in child:
|
|
|
235
|
queue.append((f"{group_name}.{child_name}", child))
|
|
|
236
|
return lines
|
|
|
237
|
|
|
|
238
|
|
|
|
239
|
def write(path: Path, lines):
|
|
|
240
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
241
|
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
|
242
|
|
|
|
243
|
|
|
|
244
|
def generate(data, output_dir: Path):
|
|
|
245
|
final_groups = emit_groups(data)
|
|
|
246
|
|
|
|
247
|
client = generated_header("client")
|
|
|
248
|
client.extend(emit_global_options(data))
|
|
|
249
|
client.extend(emit_entrypoints(data))
|
|
|
250
|
client.extend(emit_jumps(data))
|
|
|
251
|
client.extend(final_groups)
|
|
|
252
|
write(output_dir / "client.conf", client)
|
|
|
253
|
|
|
|
254
|
is_jumper = generated_header("is-jumper")
|
|
|
255
|
is_jumper.extend(emit_global_options(data))
|
|
|
256
|
is_jumper.extend(emit_jumps(data))
|
|
|
257
|
write(output_dir / "is-jumper.conf", is_jumper)
|
|
|
258
|
|
|
|
259
|
for target in ("j1", "j2"):
|
|
|
260
|
lines = generated_header(target, include_comments=False)
|
|
|
261
|
if inherit_globals(data, target):
|
|
|
262
|
pass
|
|
|
263
|
else:
|
|
|
264
|
lines.extend(emit_global_options(data, include_comments=False))
|
|
|
265
|
lines.extend(emit_groups(data, target, include_comments=False))
|
|
|
266
|
write(output_dir / f"{target}.conf", lines)
|
|
|
267
|
|
|
|
268
|
|
|
|
269
|
def main():
|
|
|
270
|
parser = argparse.ArgumentParser()
|
|
Bogdan Timofte
authored
2 weeks ago
|
271
|
parser.add_argument("--inventory", action="append", type=Path, help="Inventory file(s) to load (can be used multiple times)")
|
|
Bogdan Timofte
authored
2 weeks ago
|
272
|
parser.add_argument("--output-dir", default=ROOT / "generated", type=Path)
|
|
|
273
|
args = parser.parse_args()
|
|
|
274
|
|
|
Bogdan Timofte
authored
2 weeks ago
|
275
|
inventories = args.inventory if args.inventory else [ROOT / "inventory" / "hosts.yaml"]
|
|
|
276
|
local_inventory = ROOT / "inventory" / "hosts-local.yaml"
|
|
|
277
|
if local_inventory.exists() and local_inventory not in inventories:
|
|
|
278
|
inventories.append(local_inventory)
|
|
|
279
|
|
|
|
280
|
data = merge_inventories(inventories) if len(inventories) > 1 else load_inventory(inventories[0])
|
|
|
281
|
generate(data, args.output_dir)
|
|
Bogdan Timofte
authored
2 weeks ago
|
282
|
|
|
|
283
|
|
|
|
284
|
if __name__ == "__main__":
|
|
|
285
|
main()
|