ign8inventory 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.
- ign8inventory/__init__.py +0 -0
- ign8inventory/cli.py +603 -0
- ign8inventory/config.py +33 -0
- ign8inventory/dns/__init__.py +0 -0
- ign8inventory/dns/cloudflare.py +37 -0
- ign8inventory/infra/__init__.py +0 -0
- ign8inventory/infra/hetzner.py +109 -0
- ign8inventory/keys.py +48 -0
- ign8inventory/setup/__init__.py +0 -0
- ign8inventory/setup/netbox.py +308 -0
- ign8inventory/setup/server.py +361 -0
- ign8inventory/vault.py +152 -0
- ign8inventory-0.1.0.dist-info/METADATA +136 -0
- ign8inventory-0.1.0.dist-info/RECORD +17 -0
- ign8inventory-0.1.0.dist-info/WHEEL +4 -0
- ign8inventory-0.1.0.dist-info/entry_points.txt +3 -0
- ign8inventory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
ign8inventory/cli.py
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""CLI entry point: `ign8inventory up` provisions NetBox on Hetzner."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ign8inventory.config import Config
|
|
12
|
+
from ign8inventory.dns.cloudflare import CloudflareDNS
|
|
13
|
+
from ign8inventory.infra import hetzner
|
|
14
|
+
from ign8inventory.keys import save_keypair
|
|
15
|
+
from ign8inventory.setup.netbox import NetboxBootstrap
|
|
16
|
+
from ign8inventory.setup.server import NetboxSetup
|
|
17
|
+
from ign8inventory import vault
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="ign8inventory",
|
|
21
|
+
help="Ignite a NetBox inventory environment in minutes.",
|
|
22
|
+
invoke_without_command=True,
|
|
23
|
+
)
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback(invoke_without_command=True)
|
|
28
|
+
def _main(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
env_file: Path = typer.Option(Path(".env"), "--env-file", hidden=True),
|
|
31
|
+
) -> None:
|
|
32
|
+
if env_file.exists():
|
|
33
|
+
import os
|
|
34
|
+
for line in env_file.read_text().splitlines():
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
37
|
+
continue
|
|
38
|
+
k, _, v = line.partition("=")
|
|
39
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
40
|
+
for bare, prefixed in (("VAULT_ADDR", "IGN8_VAULT_ADDR"), ("VAULT_TOKEN", "IGN8_VAULT_TOKEN")):
|
|
41
|
+
if bare not in os.environ and prefixed in os.environ:
|
|
42
|
+
os.environ[bare] = os.environ[prefixed]
|
|
43
|
+
if ctx.invoked_subcommand is None:
|
|
44
|
+
_print_intro()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def up(
|
|
49
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
50
|
+
admin_email: str = typer.Option(..., envvar="IGN8_ADMIN_EMAIL"),
|
|
51
|
+
hetzner_token: str = typer.Option(..., envvar="IGN8_HETZNER_TOKEN"),
|
|
52
|
+
cloudflare_token: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_TOKEN"),
|
|
53
|
+
cloudflare_zone_id: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_ZONE_ID"),
|
|
54
|
+
cloudflare_email: str = typer.Option("", envvar="IGN8_CLOUDFLARE_EMAIL"),
|
|
55
|
+
netbox_superuser: str = typer.Option("admin", envvar="IGN8_NETBOX_SUPERUSER"),
|
|
56
|
+
netbox_password: str = typer.Option(..., envvar="IGN8_NETBOX_PASSWORD"),
|
|
57
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Provision NetBox: Hetzner VM + Cloudflare DNS + TLS + NetBox install."""
|
|
60
|
+
|
|
61
|
+
cfg = Config(
|
|
62
|
+
domain=domain,
|
|
63
|
+
admin_email=admin_email,
|
|
64
|
+
hetzner_token=hetzner_token,
|
|
65
|
+
cloudflare_token=cloudflare_token,
|
|
66
|
+
cloudflare_zone_id=cloudflare_zone_id,
|
|
67
|
+
cloudflare_email=cloudflare_email,
|
|
68
|
+
netbox_superuser=netbox_superuser,
|
|
69
|
+
netbox_password=netbox_password,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
console.print(Panel(f"[bold]ign8inventory[/bold] — igniting [cyan]inventory.{cfg.domain}[/cyan]"))
|
|
73
|
+
|
|
74
|
+
console.print("[1/5] Generating SSH keypair...")
|
|
75
|
+
keys_dir = work_dir / "keys"
|
|
76
|
+
priv_path, pub_path = save_keypair(keys_dir)
|
|
77
|
+
public_key_openssh = pub_path.read_text().strip()
|
|
78
|
+
|
|
79
|
+
console.print("[2/5] Provisioning Hetzner server...")
|
|
80
|
+
outputs = hetzner.provision(cfg, public_key_openssh, work_dir / "state.json")
|
|
81
|
+
ipv4: str = outputs["ipv4"]
|
|
82
|
+
ipv6: str = outputs["ipv6"]
|
|
83
|
+
console.print(f" Server IPv4: [green]{ipv4}[/green]")
|
|
84
|
+
if ipv6:
|
|
85
|
+
console.print(f" Server IPv6: [green]{ipv6}[/green]")
|
|
86
|
+
|
|
87
|
+
console.print("[3/5] Creating Cloudflare DNS records...")
|
|
88
|
+
dns = CloudflareDNS(cfg.cloudflare_token, cfg.cloudflare_zone_id, cfg.domain, cfg.cloudflare_email)
|
|
89
|
+
dns.provision(ipv4, ipv6)
|
|
90
|
+
console.print(f" inventory.{cfg.domain} → {ipv4}")
|
|
91
|
+
|
|
92
|
+
console.print("[4/5] Installing NetBox (may take 5–10 minutes)...")
|
|
93
|
+
console.print(" Connecting via SSH...")
|
|
94
|
+
setup = NetboxSetup(cfg, ipv4, str(priv_path))
|
|
95
|
+
setup.connect(retries=30, delay=10)
|
|
96
|
+
try:
|
|
97
|
+
init_data = setup.run()
|
|
98
|
+
finally:
|
|
99
|
+
setup.disconnect()
|
|
100
|
+
|
|
101
|
+
console.print("[5/5] Saving credentials...")
|
|
102
|
+
init_path = work_dir / "netbox-init.json"
|
|
103
|
+
saved = {
|
|
104
|
+
"netbox_url": f"https://inventory.{cfg.domain}",
|
|
105
|
+
"api_token": init_data["api_token"],
|
|
106
|
+
"db_password": init_data["db_password"],
|
|
107
|
+
}
|
|
108
|
+
init_path.write_text(json.dumps(saved, indent=2))
|
|
109
|
+
init_path.chmod(0o600)
|
|
110
|
+
|
|
111
|
+
_print_summary(cfg, saved)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def bootstrap(
|
|
116
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
117
|
+
netbox_token: str = typer.Option("", envvar="IGN8_NETBOX_TOKEN", help="NetBox API token"),
|
|
118
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Populate NetBox with the default regions, sites, and locations."""
|
|
121
|
+
|
|
122
|
+
netbox_token = _resolve_token(netbox_token, work_dir)
|
|
123
|
+
netbox_url = f"https://inventory.{domain}"
|
|
124
|
+
console.print(Panel(f"[bold]ign8inventory bootstrap[/bold] — populating [cyan]{netbox_url}[/cyan]"))
|
|
125
|
+
|
|
126
|
+
nb = NetboxBootstrap(netbox_url, netbox_token)
|
|
127
|
+
result = nb.run()
|
|
128
|
+
|
|
129
|
+
table = Table(show_header=True, header_style="bold")
|
|
130
|
+
table.add_column("Type")
|
|
131
|
+
table.add_column("Created")
|
|
132
|
+
|
|
133
|
+
table.add_row("Regions", ", ".join(result["regions"]))
|
|
134
|
+
table.add_row("Sites", ", ".join(result["sites"]))
|
|
135
|
+
table.add_row("Locations", ", ".join(result["locations"]))
|
|
136
|
+
|
|
137
|
+
console.print(table)
|
|
138
|
+
console.print("\n[green]Bootstrap complete.[/green]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def show_users(
|
|
143
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
144
|
+
netbox_token: str = typer.Option("", envvar="IGN8_NETBOX_TOKEN"),
|
|
145
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""List all NetBox users."""
|
|
148
|
+
netbox_token = _resolve_token(netbox_token, work_dir)
|
|
149
|
+
nb = NetboxBootstrap(f"https://inventory.{domain}", netbox_token)
|
|
150
|
+
users = nb.list_users()
|
|
151
|
+
|
|
152
|
+
if not users:
|
|
153
|
+
console.print("[dim]No users found.[/dim]")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
table = Table(header_style="bold")
|
|
157
|
+
table.add_column("ID", style="dim", width=6)
|
|
158
|
+
table.add_column("Username", min_width=16)
|
|
159
|
+
table.add_column("Email")
|
|
160
|
+
table.add_column("Active", width=8)
|
|
161
|
+
table.add_column("Staff", width=8)
|
|
162
|
+
|
|
163
|
+
for u in users:
|
|
164
|
+
table.add_row(
|
|
165
|
+
str(u.get("id", "")),
|
|
166
|
+
u.get("username", ""),
|
|
167
|
+
u.get("email", "") or "[dim]—[/dim]",
|
|
168
|
+
"[green]yes[/green]" if u.get("is_active") else "[red]no[/red]",
|
|
169
|
+
"[yellow]yes[/yellow]" if u.get("is_staff") else "no",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
console.print(table)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def add_user(
|
|
177
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
178
|
+
netbox_token: str = typer.Option("", envvar="IGN8_NETBOX_TOKEN"),
|
|
179
|
+
user: str = typer.Option(..., "--user", help="Username to create"),
|
|
180
|
+
password: str = typer.Option(..., "--password", help="Initial password"),
|
|
181
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Create a new NetBox user."""
|
|
184
|
+
netbox_token = _resolve_token(netbox_token, work_dir)
|
|
185
|
+
nb = NetboxBootstrap(f"https://inventory.{domain}", netbox_token)
|
|
186
|
+
created = nb.add_user(user, password)
|
|
187
|
+
|
|
188
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
189
|
+
table.add_row("[dim]ID[/dim]", str(created.get("id", "")))
|
|
190
|
+
table.add_row("[dim]Username[/dim]", f"[cyan]{created.get('username', '')}[/cyan]")
|
|
191
|
+
table.add_row("[dim]Active[/dim]", "[green]yes[/green]" if created.get("is_active") else "no")
|
|
192
|
+
console.print(Panel("[bold green]User created[/bold green]"))
|
|
193
|
+
console.print(table)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command()
|
|
197
|
+
def seed(
|
|
198
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
199
|
+
netbox_token: str = typer.Option("", envvar="IGN8_NETBOX_TOKEN"),
|
|
200
|
+
infra_file: Path = typer.Option(Path("infrastructure.yml"), "--file", "-f"),
|
|
201
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Query live providers from infrastructure.yml and seed NetBox with running servers."""
|
|
204
|
+
import yaml
|
|
205
|
+
from hcloud import Client
|
|
206
|
+
|
|
207
|
+
if not infra_file.exists():
|
|
208
|
+
console.print(f"[red]Not found: {infra_file}[/red]")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
raw = yaml.safe_load(infra_file.read_text())
|
|
212
|
+
config = vault.resolve(raw)
|
|
213
|
+
|
|
214
|
+
netbox_token = _resolve_token(netbox_token, work_dir)
|
|
215
|
+
netbox_url = f"https://inventory.{domain}"
|
|
216
|
+
nb = NetboxBootstrap(netbox_url, netbox_token)
|
|
217
|
+
|
|
218
|
+
console.print(Panel(
|
|
219
|
+
f"[bold]ign8inventory seed[/bold]\n\n"
|
|
220
|
+
f"Querying providers in [bold]{infra_file}[/bold]\n"
|
|
221
|
+
f"Seeding → [cyan]{netbox_url}[/cyan]"
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
providers = config.get("providers", {})
|
|
225
|
+
total_vms = 0
|
|
226
|
+
|
|
227
|
+
for project in providers.get("hetzner", []):
|
|
228
|
+
name = project["name"]
|
|
229
|
+
console.print(f"\n[bold]Hetzner[/bold] / [cyan]{name}[/cyan]")
|
|
230
|
+
|
|
231
|
+
client = Client(token=project["api_key"])
|
|
232
|
+
servers = client.servers.get_all()
|
|
233
|
+
console.print(f" Found [yellow]{len(servers)}[/yellow] server(s)")
|
|
234
|
+
|
|
235
|
+
if not servers:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
result = nb.seed_hetzner_project(name, servers)
|
|
239
|
+
|
|
240
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
241
|
+
table.add_column("VM")
|
|
242
|
+
table.add_column("Type")
|
|
243
|
+
table.add_column("Location")
|
|
244
|
+
table.add_column("Status")
|
|
245
|
+
table.add_column("IPv4")
|
|
246
|
+
|
|
247
|
+
for server in servers:
|
|
248
|
+
status_color = "green" if server.status == "running" else "dim"
|
|
249
|
+
table.add_row(
|
|
250
|
+
server.name,
|
|
251
|
+
server.server_type.name,
|
|
252
|
+
server.location.name,
|
|
253
|
+
f"[{status_color}]{server.status}[/{status_color}]",
|
|
254
|
+
server.public_net.ipv4.ip if server.public_net.ipv4 else "—",
|
|
255
|
+
)
|
|
256
|
+
console.print(table)
|
|
257
|
+
total_vms += len(result["vms"])
|
|
258
|
+
|
|
259
|
+
console.print(f"\n[green]Done — {total_vms} VM(s) seeded into NetBox.[/green]")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.command("vault-put")
|
|
263
|
+
def vault_put(
|
|
264
|
+
infra_file: Path = typer.Option(Path("infrastructure.yml"), "--file", "-f", help="Infrastructure definition"),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Read infrastructure.yml, prompt for each vault: secret, and store in Vault."""
|
|
267
|
+
import yaml # pyyaml — only needed for this command
|
|
268
|
+
|
|
269
|
+
if not infra_file.exists():
|
|
270
|
+
console.print(f"[red]Not found: {infra_file}[/red]")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
raw = yaml.safe_load(infra_file.read_text())
|
|
274
|
+
refs = vault.collect_refs(raw)
|
|
275
|
+
|
|
276
|
+
if not refs:
|
|
277
|
+
console.print("[yellow]No vault: references found in infrastructure.yml[/yellow]")
|
|
278
|
+
raise typer.Exit(0)
|
|
279
|
+
|
|
280
|
+
console.print(Panel(
|
|
281
|
+
f"[bold]ign8inventory vault-put[/bold]\n\n"
|
|
282
|
+
f"Found [cyan]{len(refs)}[/cyan] secret path(s) in [bold]{infra_file}[/bold].\n"
|
|
283
|
+
f"Enter values for each field. Press Enter to keep an existing value.",
|
|
284
|
+
style="bold cyan",
|
|
285
|
+
))
|
|
286
|
+
|
|
287
|
+
# Fetch existing values so the user can confirm/update them
|
|
288
|
+
existing: dict[str, dict[str, str]] = {}
|
|
289
|
+
for path in refs:
|
|
290
|
+
try:
|
|
291
|
+
existing[path] = vault.get_secret(path) or {}
|
|
292
|
+
except Exception:
|
|
293
|
+
existing[path] = {}
|
|
294
|
+
|
|
295
|
+
for path, fields in sorted(refs.items()):
|
|
296
|
+
console.rule(f"[dim]{path}[/dim]")
|
|
297
|
+
values: dict[str, str] = {}
|
|
298
|
+
for field in sorted(fields):
|
|
299
|
+
current = existing.get(path, {}).get(field, "")
|
|
300
|
+
display = "(set)" if current else ""
|
|
301
|
+
prompt_text = f" {field}" + (f" [{display}]" if display else "")
|
|
302
|
+
while True:
|
|
303
|
+
entered = typer.prompt(prompt_text, default=current, show_default=False).strip()
|
|
304
|
+
if entered:
|
|
305
|
+
values[field] = entered
|
|
306
|
+
break
|
|
307
|
+
console.print(" [red]Required — cannot be empty.[/red]")
|
|
308
|
+
|
|
309
|
+
vault.put_secret(path, values)
|
|
310
|
+
console.print(f" [green]stored {len(values)} field(s)[/green]")
|
|
311
|
+
|
|
312
|
+
console.print(f"\n[green]Done — {len(refs)} path(s) written to Vault.[/green]")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _resolve_token(token: str, work_dir: Path) -> str:
|
|
316
|
+
"""Fall back to token in netbox-init.json if envvar is empty."""
|
|
317
|
+
if token:
|
|
318
|
+
return token
|
|
319
|
+
init_path = work_dir / "netbox-init.json"
|
|
320
|
+
if init_path.exists():
|
|
321
|
+
return json.loads(init_path.read_text()).get("api_token", "")
|
|
322
|
+
console.print("[red]No NetBox API token. Set IGN8_NETBOX_TOKEN or run ign8inventory up first.[/red]")
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command()
|
|
327
|
+
def backup(
|
|
328
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
329
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
330
|
+
output: Path = typer.Option(Path("backup/netbox.sql"), "--output", "-o"),
|
|
331
|
+
commit: bool = typer.Option(True, "--commit/--no-commit", help="Git-commit the dump after saving"),
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Export the NetBox PostgreSQL database and commit it to the repository."""
|
|
334
|
+
import subprocess
|
|
335
|
+
|
|
336
|
+
state_path = work_dir / "state.json"
|
|
337
|
+
if not state_path.exists():
|
|
338
|
+
console.print("[red]No state found. Run ign8inventory up first.[/red]")
|
|
339
|
+
raise typer.Exit(1)
|
|
340
|
+
|
|
341
|
+
state = json.loads(state_path.read_text())
|
|
342
|
+
ipv4: str = state.get("ipv4", "")
|
|
343
|
+
if not ipv4:
|
|
344
|
+
console.print("[red]No server IP in state.json.[/red]")
|
|
345
|
+
raise typer.Exit(1)
|
|
346
|
+
|
|
347
|
+
priv_key = work_dir / "keys" / "ign8inventory"
|
|
348
|
+
if not priv_key.exists():
|
|
349
|
+
console.print("[red]SSH key not found. Run ign8inventory up first.[/red]")
|
|
350
|
+
raise typer.Exit(1)
|
|
351
|
+
|
|
352
|
+
console.print(Panel(f"[bold]ign8inventory backup[/bold] — dumping [cyan]inventory.{domain}[/cyan]"))
|
|
353
|
+
|
|
354
|
+
console.print(f"[1/2] Connecting to [green]{ipv4}[/green] via SSH...")
|
|
355
|
+
from ign8inventory.setup.server import NetboxSetup
|
|
356
|
+
from ign8inventory.config import Config
|
|
357
|
+
|
|
358
|
+
cfg = Config(
|
|
359
|
+
domain=domain,
|
|
360
|
+
admin_email="",
|
|
361
|
+
hetzner_token="",
|
|
362
|
+
cloudflare_token="",
|
|
363
|
+
cloudflare_zone_id="",
|
|
364
|
+
)
|
|
365
|
+
setup = NetboxSetup(cfg, ipv4, str(priv_key))
|
|
366
|
+
setup.connect(retries=3, delay=5)
|
|
367
|
+
try:
|
|
368
|
+
sql_bytes = setup.dump_database()
|
|
369
|
+
finally:
|
|
370
|
+
setup.disconnect()
|
|
371
|
+
|
|
372
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
373
|
+
output.write_bytes(sql_bytes)
|
|
374
|
+
size_kb = len(sql_bytes) // 1024
|
|
375
|
+
console.print(f"[2/2] Saved [green]{output}[/green] ({size_kb} KB)")
|
|
376
|
+
|
|
377
|
+
if commit:
|
|
378
|
+
result = subprocess.run(
|
|
379
|
+
["git", "add", str(output)],
|
|
380
|
+
capture_output=True, text=True,
|
|
381
|
+
)
|
|
382
|
+
if result.returncode != 0:
|
|
383
|
+
console.print(f"[yellow]git add failed: {result.stderr.strip()}[/yellow]")
|
|
384
|
+
else:
|
|
385
|
+
result = subprocess.run(
|
|
386
|
+
["git", "commit", "-m", f"backup: netbox database export ({domain})"],
|
|
387
|
+
capture_output=True, text=True,
|
|
388
|
+
)
|
|
389
|
+
if result.returncode == 0:
|
|
390
|
+
console.print("[green]Committed to git.[/green]")
|
|
391
|
+
elif "nothing to commit" in result.stdout or "nothing to commit" in result.stderr:
|
|
392
|
+
console.print("[dim]No changes since last backup — nothing committed.[/dim]")
|
|
393
|
+
else:
|
|
394
|
+
console.print(f"[yellow]git commit: {result.stderr.strip()}[/yellow]")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command()
|
|
398
|
+
def destroy(
|
|
399
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN"),
|
|
400
|
+
hetzner_token: str = typer.Option(..., envvar="IGN8_HETZNER_TOKEN"),
|
|
401
|
+
cloudflare_token: str = typer.Option("", envvar="IGN8_CLOUDFLARE_TOKEN"),
|
|
402
|
+
cloudflare_zone_id: str = typer.Option("", envvar="IGN8_CLOUDFLARE_ZONE_ID"),
|
|
403
|
+
cloudflare_email: str = typer.Option("", envvar="IGN8_CLOUDFLARE_EMAIL"),
|
|
404
|
+
work_dir: Path = typer.Option(Path(".ign8inventory")),
|
|
405
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Tear down the NetBox server and DNS records."""
|
|
408
|
+
if not yes:
|
|
409
|
+
typer.confirm(f"Destroy all ign8inventory resources for {domain}?", abort=True)
|
|
410
|
+
|
|
411
|
+
console.print(Panel(f"[bold red]ign8inventory destroy[/bold red] — tearing down [cyan]{domain}[/cyan]"))
|
|
412
|
+
|
|
413
|
+
console.print("[1/2] Deleting Hetzner server...")
|
|
414
|
+
hetzner.destroy(work_dir / "state.json", hetzner_token)
|
|
415
|
+
|
|
416
|
+
if cloudflare_token and cloudflare_zone_id:
|
|
417
|
+
console.print("[2/2] Removing DNS records...")
|
|
418
|
+
dns = CloudflareDNS(cloudflare_token, cloudflare_zone_id, domain, cloudflare_email)
|
|
419
|
+
dns.destroy()
|
|
420
|
+
else:
|
|
421
|
+
console.print("[2/2] Skipping DNS cleanup (no Cloudflare credentials)")
|
|
422
|
+
|
|
423
|
+
console.print("[green]Done.[/green]")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.command()
|
|
427
|
+
def setenv(
|
|
428
|
+
env_file: Path = typer.Option(Path(".env")),
|
|
429
|
+
) -> None:
|
|
430
|
+
"""Interactive setup: collect all credentials and write to .env."""
|
|
431
|
+
import os
|
|
432
|
+
|
|
433
|
+
existing: dict[str, str] = {}
|
|
434
|
+
if env_file.exists():
|
|
435
|
+
for line in env_file.read_text().splitlines():
|
|
436
|
+
line = line.strip()
|
|
437
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
438
|
+
continue
|
|
439
|
+
k, _, v = line.partition("=")
|
|
440
|
+
existing[k.strip()] = v.strip()
|
|
441
|
+
|
|
442
|
+
def _current(key: str) -> str:
|
|
443
|
+
return existing.get(key) or os.environ.get(key, "")
|
|
444
|
+
|
|
445
|
+
def _ask(label: str, key: str, hint: str, required: bool = True, secret: bool = False) -> str:
|
|
446
|
+
console.print(f"\n[bold]{label}[/bold]")
|
|
447
|
+
console.print(f" [dim]{hint}[/dim]")
|
|
448
|
+
default = _current(key)
|
|
449
|
+
display_default = ("(set)" if default else "") if secret else default
|
|
450
|
+
prompt_text = f" {key}" + (f" [{display_default}]" if display_default else "")
|
|
451
|
+
while True:
|
|
452
|
+
value = typer.prompt(prompt_text, default=default, show_default=False).strip()
|
|
453
|
+
if value or not required:
|
|
454
|
+
return value
|
|
455
|
+
console.print(" [red]Required — cannot be empty.[/red]")
|
|
456
|
+
|
|
457
|
+
console.print(Panel("[bold]ign8inventory — interactive setup[/bold]", style="bold cyan"))
|
|
458
|
+
console.print("\nPress Enter to keep an existing value. Leave optional fields blank to skip.\n")
|
|
459
|
+
|
|
460
|
+
results: list[tuple[str, str]] = []
|
|
461
|
+
|
|
462
|
+
console.rule("[dim]Provisioning[/dim]")
|
|
463
|
+
|
|
464
|
+
results.append(("IGN8_DOMAIN", _ask(
|
|
465
|
+
"Domain", "IGN8_DOMAIN",
|
|
466
|
+
"Base domain — inventory.DOMAIN will be created (e.g. example.com)",
|
|
467
|
+
)))
|
|
468
|
+
results.append(("IGN8_ADMIN_EMAIL", _ask(
|
|
469
|
+
"Admin email", "IGN8_ADMIN_EMAIL",
|
|
470
|
+
"Contact email for Let's Encrypt TLS certificates",
|
|
471
|
+
)))
|
|
472
|
+
results.append(("IGN8_HETZNER_TOKEN", _ask(
|
|
473
|
+
"Hetzner Cloud API token", "IGN8_HETZNER_TOKEN",
|
|
474
|
+
"console.hetzner.cloud → Project → Security → API Tokens → Generate API Token",
|
|
475
|
+
secret=True,
|
|
476
|
+
)))
|
|
477
|
+
results.append(("IGN8_CLOUDFLARE_TOKEN", _ask(
|
|
478
|
+
"Cloudflare API token", "IGN8_CLOUDFLARE_TOKEN",
|
|
479
|
+
"dash.cloudflare.com → Profile → API Tokens → Create Token\n"
|
|
480
|
+
" Permission needed: Zone → DNS → Edit",
|
|
481
|
+
secret=True,
|
|
482
|
+
)))
|
|
483
|
+
results.append(("IGN8_CLOUDFLARE_ZONE_ID", _ask(
|
|
484
|
+
"Cloudflare Zone ID", "IGN8_CLOUDFLARE_ZONE_ID",
|
|
485
|
+
"dash.cloudflare.com → select your domain → Overview → right sidebar → Zone ID",
|
|
486
|
+
)))
|
|
487
|
+
|
|
488
|
+
console.rule("[dim]NetBox superuser[/dim]")
|
|
489
|
+
|
|
490
|
+
results.append(("IGN8_NETBOX_SUPERUSER", _ask(
|
|
491
|
+
"NetBox admin username", "IGN8_NETBOX_SUPERUSER",
|
|
492
|
+
"Username for the NetBox superuser account (default: admin)",
|
|
493
|
+
)))
|
|
494
|
+
results.append(("IGN8_NETBOX_PASSWORD", _ask(
|
|
495
|
+
"NetBox admin password", "IGN8_NETBOX_PASSWORD",
|
|
496
|
+
"Password for the NetBox superuser account",
|
|
497
|
+
secret=True,
|
|
498
|
+
)))
|
|
499
|
+
|
|
500
|
+
console.rule("[dim]NetBox API token (fill in after ign8inventory up)[/dim]")
|
|
501
|
+
|
|
502
|
+
results.append(("IGN8_NETBOX_TOKEN", _ask(
|
|
503
|
+
"NetBox API token", "IGN8_NETBOX_TOKEN",
|
|
504
|
+
"API token from .ign8inventory/netbox-init.json — leave blank now",
|
|
505
|
+
required=False,
|
|
506
|
+
secret=True,
|
|
507
|
+
)))
|
|
508
|
+
|
|
509
|
+
# Write / upsert .env
|
|
510
|
+
lines = env_file.read_text().splitlines() if env_file.exists() else []
|
|
511
|
+
written: set[str] = set()
|
|
512
|
+
new_lines: list[str] = []
|
|
513
|
+
for line in lines:
|
|
514
|
+
stripped = line.strip()
|
|
515
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
516
|
+
new_lines.append(line)
|
|
517
|
+
continue
|
|
518
|
+
key = stripped.partition("=")[0].strip()
|
|
519
|
+
match = next(((k, v) for k, v in results if k == key), None)
|
|
520
|
+
if match:
|
|
521
|
+
k, v = match
|
|
522
|
+
new_lines.append(f"{k}={v}" if v else f"# {k}=")
|
|
523
|
+
written.add(k)
|
|
524
|
+
else:
|
|
525
|
+
new_lines.append(line)
|
|
526
|
+
for k, v in results:
|
|
527
|
+
if k not in written:
|
|
528
|
+
new_lines.append(f"{k}={v}" if v else f"# {k}=")
|
|
529
|
+
|
|
530
|
+
env_file.write_text("\n".join(new_lines) + "\n")
|
|
531
|
+
env_file.chmod(0o600)
|
|
532
|
+
console.print(f"\n[green]Written to {env_file}[/green]")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@app.command()
|
|
536
|
+
def quickstart() -> None:
|
|
537
|
+
"""Print a step-by-step quickstart guide."""
|
|
538
|
+
console.print()
|
|
539
|
+
console.print(Panel("[bold]ign8inventory — Quickstart[/bold]", style="bold cyan"))
|
|
540
|
+
|
|
541
|
+
steps = [
|
|
542
|
+
("1. Install",
|
|
543
|
+
"[dim]$ [/dim]pipx install ign8inventory"),
|
|
544
|
+
("2. Configure",
|
|
545
|
+
"[dim]$ [/dim][cyan]ign8inventory setenv[/cyan]\n\n"
|
|
546
|
+
" Walks you through every credential. Writes a [bold].env[/bold] file.\n"
|
|
547
|
+
" Re-run after provisioning to fill in IGN8_NETBOX_TOKEN."),
|
|
548
|
+
("3. Provision",
|
|
549
|
+
"[dim]$ [/dim][cyan]ign8inventory up[/cyan]\n\n"
|
|
550
|
+
" Creates a Hetzner VPS, Cloudflare DNS, installs NetBox + TLS.\n"
|
|
551
|
+
" Saves credentials to [bold].ign8inventory/netbox-init.json[/bold]."),
|
|
552
|
+
("4. Bootstrap",
|
|
553
|
+
"[dim]$ [/dim][cyan]ign8inventory bootstrap[/cyan]\n\n"
|
|
554
|
+
" Populates NetBox with the default layout:\n"
|
|
555
|
+
" Regions → EU, US, APAC\n"
|
|
556
|
+
" Sites → nbg1, fsn1, hel1, ash1, hil1, sin1\n"
|
|
557
|
+
" Zones → zone-a, zone-b per site"),
|
|
558
|
+
("5. Backup",
|
|
559
|
+
"[dim]$ [/dim][cyan]ign8inventory backup[/cyan]\n\n"
|
|
560
|
+
" Exports the NetBox PostgreSQL database via SSH.\n"
|
|
561
|
+
" Saves to [bold]backup/netbox.sql[/bold] and commits it to git.\n"
|
|
562
|
+
" Run regularly to keep an auditable snapshot in the repository."),
|
|
563
|
+
("Tear down",
|
|
564
|
+
"[dim]$ [/dim][cyan]ign8inventory destroy[/cyan]"),
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
for title, body in steps:
|
|
568
|
+
console.print(f"\n[bold]{title}[/bold]")
|
|
569
|
+
console.print(f" {body}")
|
|
570
|
+
|
|
571
|
+
console.print()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _print_summary(cfg: Config, saved: dict[str, str]) -> None:
|
|
575
|
+
console.print()
|
|
576
|
+
console.print(Panel("[bold green]NetBox is live![/bold green]"))
|
|
577
|
+
|
|
578
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
579
|
+
table.add_row("[dim]URL[/dim]", f"[cyan]{saved['netbox_url']}[/cyan]")
|
|
580
|
+
table.add_row("[dim]API token[/dim]", f"[yellow]{saved['api_token']}[/yellow]")
|
|
581
|
+
console.print(table)
|
|
582
|
+
console.print()
|
|
583
|
+
console.print(
|
|
584
|
+
"[dim]Credentials saved to[/dim] [bold].ign8inventory/netbox-init.json[/bold]\n"
|
|
585
|
+
"[dim]Run [cyan]ign8inventory bootstrap[/cyan] to populate regions, sites, and locations.[/dim]"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _print_intro() -> None:
|
|
590
|
+
console.print(Panel(
|
|
591
|
+
"[bold]ign8inventory[/bold] — NetBox on Hetzner\n\n"
|
|
592
|
+
" [cyan]ign8inventory up[/cyan] provision server + install NetBox\n"
|
|
593
|
+
" [cyan]ign8inventory bootstrap[/cyan] populate regions / sites / locations\n"
|
|
594
|
+
" [cyan]ign8inventory seed[/cyan] query live providers → seed NetBox\n"
|
|
595
|
+
" [cyan]ign8inventory backup[/cyan] export database → backup/netbox.sql + git commit\n"
|
|
596
|
+
" [cyan]ign8inventory vault-put[/cyan] push infrastructure.yml secrets to Vault\n"
|
|
597
|
+
" [cyan]ign8inventory show-users[/cyan] list NetBox users\n"
|
|
598
|
+
" [cyan]ign8inventory add-user[/cyan] create a NetBox user\n"
|
|
599
|
+
" [cyan]ign8inventory setenv[/cyan] interactive credential setup\n"
|
|
600
|
+
" [cyan]ign8inventory quickstart[/cyan] step-by-step guide\n"
|
|
601
|
+
" [cyan]ign8inventory destroy[/cyan] tear it all down",
|
|
602
|
+
title="ign8inventory",
|
|
603
|
+
))
|
ign8inventory/config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pydantic import field_validator
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Config(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(env_prefix="IGN8_", env_file=".env", extra="ignore")
|
|
7
|
+
|
|
8
|
+
# Required
|
|
9
|
+
domain: str
|
|
10
|
+
admin_email: str
|
|
11
|
+
hetzner_token: str
|
|
12
|
+
cloudflare_token: str
|
|
13
|
+
cloudflare_zone_id: str
|
|
14
|
+
cloudflare_email: str = ""
|
|
15
|
+
|
|
16
|
+
# Vault (auto-discovered from ign8vault's vault-init.json if not set)
|
|
17
|
+
vault_addr: str = ""
|
|
18
|
+
vault_token: str = ""
|
|
19
|
+
|
|
20
|
+
# NetBox superuser
|
|
21
|
+
netbox_superuser: str = "admin"
|
|
22
|
+
netbox_password: str = ""
|
|
23
|
+
netbox_token: str = "" # filled in after provisioning
|
|
24
|
+
|
|
25
|
+
# Server
|
|
26
|
+
server_location: str = "nbg1"
|
|
27
|
+
server_type: str = "cpx21"
|
|
28
|
+
server_image: str = "ubuntu-24.04"
|
|
29
|
+
|
|
30
|
+
@field_validator("domain")
|
|
31
|
+
@classmethod
|
|
32
|
+
def strip_trailing_dot(cls, v: str) -> str:
|
|
33
|
+
return v.rstrip(".")
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Create DNS record for inventory.DOMAIN."""
|
|
2
|
+
|
|
3
|
+
import cloudflare
|
|
4
|
+
from cloudflare.types.dns import RecordCreateParams
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CloudflareDNS:
|
|
8
|
+
def __init__(self, token: str, zone_id: str, domain: str, email: str = "") -> None:
|
|
9
|
+
if email:
|
|
10
|
+
self._cf = cloudflare.Cloudflare(api_email=email, api_key=token)
|
|
11
|
+
else:
|
|
12
|
+
self._cf = cloudflare.Cloudflare(api_token=token)
|
|
13
|
+
self._zone = zone_id
|
|
14
|
+
self._domain = domain
|
|
15
|
+
|
|
16
|
+
def provision(self, ipv4: str, ipv6: str) -> None:
|
|
17
|
+
records: list[RecordCreateParams] = [
|
|
18
|
+
{"type": "A", "name": f"inventory.{self._domain}", "content": ipv4, "proxied": False, "ttl": 1},
|
|
19
|
+
]
|
|
20
|
+
if ipv6:
|
|
21
|
+
records.append(
|
|
22
|
+
{"type": "AAAA", "name": f"inventory.{self._domain}", "content": ipv6, "proxied": False, "ttl": 1},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
names = {r["name"] for r in records} # type: ignore[index]
|
|
26
|
+
for existing in self._cf.dns.records.list(zone_id=self._zone).result or []:
|
|
27
|
+
if existing.name in names:
|
|
28
|
+
self._cf.dns.records.delete(existing.id, zone_id=self._zone)
|
|
29
|
+
|
|
30
|
+
for record in records:
|
|
31
|
+
self._cf.dns.records.create(zone_id=self._zone, **record) # type: ignore[arg-type]
|
|
32
|
+
|
|
33
|
+
def destroy(self) -> None:
|
|
34
|
+
targets = {f"inventory.{self._domain}"}
|
|
35
|
+
for existing in self._cf.dns.records.list(zone_id=self._zone).result or []:
|
|
36
|
+
if existing.name in targets:
|
|
37
|
+
self._cf.dns.records.delete(existing.id, zone_id=self._zone)
|
|
File without changes
|