devnomads-cli 0.3.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.
dncli.py ADDED
@@ -0,0 +1,3608 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "typer>=0.12",
6
+ # "httpx>=0.27",
7
+ # "rich>=13",
8
+ # "cryptography>=42",
9
+ # "devnomads>=0.1",
10
+ # ]
11
+ # ///
12
+ """dncli - manage DevNomads services from the command line.
13
+
14
+ Single-file CLI for the DevNomads public API (https://api.devnomads.nl).
15
+ Create an API key in the control panel, run `dncli configure`, and go.
16
+ See PLAN.md in the repository for the design.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import configparser
23
+ import hashlib
24
+ import hmac
25
+ import io
26
+ import json
27
+ import os
28
+ import secrets
29
+ import stat
30
+ import sys
31
+ import time
32
+ import urllib.parse
33
+ from contextlib import nullcontext
34
+ from dataclasses import dataclass
35
+ from enum import Enum
36
+ from pathlib import Path
37
+ from typing import Annotated, Any, ContextManager, Iterable, Protocol
38
+
39
+ import httpx
40
+ import typer
41
+ from devnomads.api import ApiError, AuthError
42
+ from devnomads.api import Client as ApiClient
43
+ from devnomads.api import DevNomadsError
44
+ from devnomads.api.client import _unwrap as _lib_unwrap
45
+ from devnomads.dns import Dns, challenge_name
46
+ from rich.console import Console
47
+ from rich.table import Table
48
+ from typer.core import TyperGroup
49
+
50
+ __version__ = "0.3.0"
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Constants
54
+
55
+ DEFAULT_API_URL = "https://api.devnomads.nl"
56
+ DEFAULT_PROFILE = "default"
57
+ ENV_API_KEY = "DN_API_KEY"
58
+ ENV_PROFILE = "DN_PROFILE"
59
+ ENV_CONFIG_DIR = "DN_CONFIG_DIR"
60
+ PROVIDERS = ("auroradns", "transip")
61
+ REQUEST_TIMEOUT = 30.0
62
+ RECORD_COLUMNS = ["name", "type", "ttl", "content", "disabled"]
63
+
64
+ # Data on stdout, everything meant for humans on stderr.
65
+ out_console = Console()
66
+ err_console = Console(stderr=True)
67
+
68
+
69
+ class CliError(typer.Exit):
70
+ """Fatal user-facing error: one line on stderr, exit code 1."""
71
+
72
+ def __init__(self, message: str) -> None:
73
+ # soft_wrap keeps the error a single grep-able line
74
+ err_console.print(f"[red]error:[/] {message}", soft_wrap=True)
75
+ self.message = message
76
+ super().__init__(code=1)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Config layer
81
+
82
+
83
+ def config_dir() -> Path:
84
+ if env_dir := os.environ.get(ENV_CONFIG_DIR):
85
+ return Path(env_dir)
86
+ xdg = os.environ.get("XDG_CONFIG_HOME")
87
+ base = Path(xdg) if xdg else Path.home() / ".config"
88
+ return base / "dnctl"
89
+
90
+
91
+ def credentials_path() -> Path:
92
+ return config_dir() / "credentials"
93
+
94
+
95
+ def load_credentials() -> configparser.ConfigParser:
96
+ parser = configparser.ConfigParser()
97
+ path = credentials_path()
98
+ if path.exists():
99
+ mode = stat.S_IMODE(path.stat().st_mode)
100
+ if mode & 0o077:
101
+ err_console.print(
102
+ f"[yellow]warning:[/] {path} is readable by others "
103
+ f"(mode {mode:03o}); run: chmod 600 {path}"
104
+ )
105
+ parser.read(path)
106
+ return parser
107
+
108
+
109
+ def _write_private(path: Path, content: str) -> None:
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ path.parent.chmod(0o700)
112
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
113
+ with os.fdopen(fd, "w") as fh:
114
+ fh.write(content)
115
+ path.chmod(0o600)
116
+
117
+
118
+ def save_credentials(parser: configparser.ConfigParser) -> None:
119
+ buffer = io.StringIO()
120
+ parser.write(buffer)
121
+ _write_private(credentials_path(), buffer.getvalue())
122
+
123
+
124
+ def devnomads_profiles(parser: configparser.ConfigParser) -> list[str]:
125
+ # Plain sections are DevNomads accounts; "provider:name" sections
126
+ # belong to transfer source drivers.
127
+ return [section for section in parser.sections() if ":" not in section]
128
+
129
+
130
+ def resolve_credentials(state: AppState) -> tuple[str, str]:
131
+ """Resolve (api_key, api_url): flag > env > profile > [default]."""
132
+
133
+ parser = load_credentials()
134
+ profile = state.profile or os.environ.get(ENV_PROFILE) or DEFAULT_PROFILE
135
+ explicit = bool(state.profile or os.environ.get(ENV_PROFILE))
136
+ section = parser[profile] if parser.has_section(profile) else None
137
+ api_url = section.get("api_url", DEFAULT_API_URL) if section else DEFAULT_API_URL
138
+ api_key = (
139
+ state.api_key
140
+ or os.environ.get(ENV_API_KEY)
141
+ or (section.get("api_key") if section else None)
142
+ )
143
+ if not api_key:
144
+ if explicit and section is None:
145
+ available = ", ".join(devnomads_profiles(parser)) or "none"
146
+ raise CliError(
147
+ f"profile '{profile}' not found in {credentials_path()} "
148
+ f"(available: {available})"
149
+ )
150
+ raise CliError(f"no API key found; run `dncli configure` or set {ENV_API_KEY}")
151
+ return api_key, api_url
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Output
156
+
157
+
158
+ class OutputFormat(str, Enum):
159
+ table = "table"
160
+ json = "json"
161
+
162
+
163
+ @dataclass
164
+ class AppState:
165
+ profile: str | None = None
166
+ api_key: str | None = None
167
+ output: OutputFormat | None = None
168
+ debug: bool = False
169
+ client: DevNomadsClient | None = None
170
+
171
+
172
+ def state_from(ctx: typer.Context, output: OutputFormat | None = None) -> AppState:
173
+ state = ctx.obj
174
+ if not isinstance(state, AppState): # direct invocation in tests
175
+ state = AppState()
176
+ ctx.obj = state
177
+ if output is not None: # per-command --output overrides the global one
178
+ state.output = output
179
+ return state
180
+
181
+
182
+ def resolve_format(state: AppState) -> OutputFormat:
183
+ if state.output:
184
+ return state.output
185
+ return OutputFormat.table if sys.stdout.isatty() else OutputFormat.json
186
+
187
+
188
+ def render(
189
+ state: AppState,
190
+ data: Any,
191
+ *,
192
+ columns: list[str] | None = None,
193
+ title: str | None = None,
194
+ ) -> None:
195
+ """Render data on stdout: JSON for machines, a rich table for humans."""
196
+
197
+ if resolve_format(state) is OutputFormat.json:
198
+ sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
199
+ return
200
+ if isinstance(data, dict):
201
+ _render_kv(data, title)
202
+ elif isinstance(data, list):
203
+ _render_rows(data, columns, title)
204
+ else:
205
+ out_console.print(str(data), soft_wrap=True)
206
+
207
+
208
+ def _cell(value: Any) -> str:
209
+ if value is None:
210
+ return ""
211
+ if isinstance(value, bool):
212
+ return "yes" if value else "no"
213
+ if isinstance(value, list) and not any(
214
+ isinstance(item, (dict, list)) for item in value
215
+ ):
216
+ return ", ".join(str(item) for item in value)
217
+ if isinstance(value, (dict, list)):
218
+ return json.dumps(value)
219
+ return str(value)
220
+
221
+
222
+ def _flatten_row(row: dict[str, Any]) -> dict[str, Any]:
223
+ """Merge one level of nested objects into the row, so type-specific
224
+ details (e.g. the "proxy" object on proxy services) become real
225
+ columns. On a name collision the nested key gets a parent_ prefix."""
226
+
227
+ flat: dict[str, Any] = {}
228
+ nested: list[tuple[str, dict[str, Any]]] = []
229
+ for key, value in row.items():
230
+ if isinstance(value, dict):
231
+ nested.append((key, value))
232
+ else:
233
+ flat[key] = value
234
+ for parent, obj in nested:
235
+ for key, value in obj.items():
236
+ column = key if key not in flat else f"{parent}_{key}"
237
+ flat[column] = value
238
+ return flat
239
+
240
+
241
+ def _render_kv(data: dict[str, Any], title: str | None) -> None:
242
+ table = Table(title=title, show_header=False)
243
+ table.add_column(style="bold")
244
+ table.add_column()
245
+ for key, value in _flatten_row(data).items():
246
+ table.add_row(key, _cell(value))
247
+ out_console.print(table)
248
+
249
+
250
+ def _render_rows(
251
+ rows: list[dict[str, Any]], columns: list[str] | None, title: str | None
252
+ ) -> None:
253
+ if not rows:
254
+ err_console.print("[dim]no results[/]")
255
+ return
256
+ rows = [_flatten_row(row) if isinstance(row, dict) else row for row in rows]
257
+ cols = columns or list(rows[0].keys())
258
+ table = Table(title=title)
259
+ for col in cols:
260
+ table.add_column(col)
261
+ for row in rows:
262
+ table.add_row(*(_cell(row.get(col)) for col in cols))
263
+ out_console.print(table)
264
+
265
+
266
+ def sort_rows(rows: list[dict[str, Any]], sort: str | None) -> list[dict[str, Any]]:
267
+ """Sort rows by a field; a leading '-' reverses. None sorts last."""
268
+
269
+ if not sort or not rows:
270
+ return rows
271
+ reverse = sort.startswith("-")
272
+ field = sort.lstrip("-")
273
+ if not any(field in row for row in rows):
274
+ available = ", ".join(rows[0].keys())
275
+ raise CliError(f"unknown sort field '{field}' (available: {available})")
276
+ values = [row.get(field) for row in rows]
277
+ numeric = all(
278
+ isinstance(value, (int, float)) and not isinstance(value, bool)
279
+ for value in values
280
+ if value is not None
281
+ )
282
+
283
+ def key(row: dict[str, Any]) -> tuple[int, Any]:
284
+ value = row.get(field)
285
+ if value is None:
286
+ return (1, 0 if numeric else "")
287
+ return (0, value if numeric else str(value).lower())
288
+
289
+ return sorted(rows, key=key, reverse=reverse)
290
+
291
+
292
+ def _confirm(question: str, yes: bool) -> None:
293
+ if yes:
294
+ return
295
+ if not sys.stdin.isatty():
296
+ raise CliError("confirmation required; pass --yes to confirm non-interactively")
297
+ if not typer.confirm(question, err=True):
298
+ raise typer.Abort()
299
+
300
+
301
+ def _mask(secret: str) -> str:
302
+ if not secret:
303
+ return ""
304
+ if len(secret) <= 8:
305
+ return "****"
306
+ return f"{secret[:4]}...{secret[-4:]}"
307
+
308
+
309
+ def working(message: str) -> ContextManager[Any]:
310
+ """Spinner on stderr while a request is in flight. Inert when stderr
311
+ is not a terminal (pipelines, CI, redirects), so output stays clean.
312
+ Never nest two of these: rich allows one live display at a time."""
313
+
314
+ if err_console.is_terminal:
315
+ return err_console.status(message, spinner="dots")
316
+ return nullcontext()
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # API client
321
+
322
+
323
+ class DevNomadsClient:
324
+ """Thin wrapper over the shared :class:`devnomads.api.Client`.
325
+
326
+ The library owns auth, retries, the Laravel envelope, and the HTTP
327
+ transport; this wrapper keeps dncli's UX: the Rich spinner while a
328
+ request is in flight, and library errors re-raised as :class:`CliError`
329
+ with dncli's own message wording. Commands never touch httpx directly.
330
+ """
331
+
332
+ def __init__(self, api_url: str, api_key: str, *, debug: bool = False) -> None:
333
+ self.debug = debug
334
+ self._client = ApiClient(
335
+ api_url,
336
+ api_key,
337
+ user_agent=f"dncli/{__version__}",
338
+ timeout=REQUEST_TIMEOUT,
339
+ )
340
+
341
+ @property
342
+ def api(self) -> ApiClient:
343
+ """The underlying shared transport, for the dns/acme helpers."""
344
+
345
+ return self._client
346
+
347
+ def request(
348
+ self,
349
+ method: str,
350
+ path: str,
351
+ *,
352
+ params: dict[str, Any] | None = None,
353
+ json_body: Any = None,
354
+ ) -> Any:
355
+ if self.debug:
356
+ suffix = f" {json.dumps(json_body)}" if json_body is not None else ""
357
+ err_console.print(f"[dim]> {method} {path}{suffix}[/]")
358
+ with working(f"{method} {path}"):
359
+ try:
360
+ return self._client.request(
361
+ method, path, params=params, json_body=json_body
362
+ )
363
+ except (AuthError, ApiError) as exc:
364
+ raise CliError(_error_message(exc.status, exc.detail)) from exc
365
+ except DevNomadsError as exc:
366
+ raise CliError(str(exc)) from exc
367
+
368
+
369
+ def _unwrap(body: Any) -> Any:
370
+ """Strip the Laravel API resource envelope ({"data": ...}, optionally
371
+ with links/meta). The transport already unwraps responses; this thin
372
+ re-export keeps the helper available to callers and tests."""
373
+
374
+ return _lib_unwrap(body)
375
+
376
+
377
+ def _error_message(status: int, detail: str) -> str:
378
+ message = f"API error {status}"
379
+ if detail:
380
+ message += f": {detail}"
381
+ if status == 401:
382
+ message += " - check your API key (run `dncli configure`)"
383
+ return message
384
+
385
+
386
+ def get_client(state: AppState) -> DevNomadsClient:
387
+ if state.client is None:
388
+ api_key, api_url = resolve_credentials(state)
389
+ state.client = DevNomadsClient(api_url, api_key, debug=state.debug)
390
+ return state.client
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # DNS record helpers (PowerDNS rrsets)
395
+
396
+
397
+ def zone_id(zone: str) -> str:
398
+ """PowerDNS zone ids carry a trailing dot; accept names without it."""
399
+
400
+ return zone if zone.endswith(".") else f"{zone}."
401
+
402
+
403
+ def fqdn(name: str, zone: str) -> str:
404
+ """Absolute record name with trailing dot, PowerDNS style."""
405
+
406
+ zone_root = zone.rstrip(".")
407
+ relative = name.rstrip(".")
408
+ if relative in ("@", ""):
409
+ return f"{zone_root}."
410
+ if relative == zone_root or relative.endswith(f".{zone_root}"):
411
+ return f"{relative}."
412
+ return f"{relative}.{zone_root}."
413
+
414
+
415
+ def flatten_rrsets(rrsets: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
416
+ """One row per record value, for display and `records list`."""
417
+
418
+ rows = []
419
+ for rrset in rrsets or []:
420
+ for record in rrset.get("records", []):
421
+ rows.append(
422
+ {
423
+ "name": rrset.get("name"),
424
+ "type": rrset.get("type"),
425
+ "ttl": rrset.get("ttl"),
426
+ "content": record.get("content"),
427
+ "disabled": record.get("disabled", False),
428
+ }
429
+ )
430
+ return sorted(rows, key=lambda row: (row["name"] or "", row["type"] or ""))
431
+
432
+
433
+ def build_rrset(
434
+ zone: str,
435
+ name: str,
436
+ rtype: str,
437
+ *,
438
+ changetype: str,
439
+ ttl: int | None = None,
440
+ contents: Iterable[str] = (),
441
+ ) -> dict[str, Any]:
442
+ rrset: dict[str, Any] = {
443
+ "name": fqdn(name, zone),
444
+ "type": rtype.upper(),
445
+ "changetype": changetype,
446
+ }
447
+ if changetype == "REPLACE":
448
+ rrset["ttl"] = ttl
449
+ rrset["records"] = [
450
+ {"content": content, "disabled": False} for content in contents
451
+ ]
452
+ return rrset
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # DNS transfer: pull a zone from another provider into DevNomads.
457
+ # Drivers are read-only by design; writing happens exclusively on the
458
+ # DevNomads side through the PowerDNS rrsets PATCH.
459
+
460
+
461
+ @dataclass(frozen=True)
462
+ class TransferRecord:
463
+ """Provider-neutral record: relative name ("@" for the apex), TTL in
464
+ seconds, and content in PowerDNS style (MX/SRV priority embedded)."""
465
+
466
+ name: str
467
+ type: str
468
+ content: str
469
+ ttl: int
470
+
471
+
472
+ class DnsSource(Protocol):
473
+ name: str
474
+
475
+ def get_records(self, zone: str) -> list[TransferRecord]: ...
476
+
477
+
478
+ HOST_CONTENT_TYPES = {"CNAME", "NS", "PTR", "ALIAS", "MX", "SRV"}
479
+
480
+
481
+ def normalize_content(rtype: str, content: str) -> str:
482
+ """Canonicalize record content the PowerDNS way, so source and target
483
+ records compare equal: hostname targets get a trailing dot, TXT values
484
+ get quoted. Idempotent on already-canonical content."""
485
+
486
+ content = content.strip()
487
+ if rtype == "TXT":
488
+ return content if content.startswith('"') else f'"{content}"'
489
+ if rtype in HOST_CONTENT_TYPES:
490
+ parts = content.split()
491
+ if parts and not parts[-1].endswith("."):
492
+ parts[-1] += "."
493
+ return " ".join(parts)
494
+ return content
495
+
496
+
497
+ def _transfer_skip(name: str, rtype: str, zone: str) -> bool:
498
+ # the target zone owns its SOA and apex NS records
499
+ if rtype == "SOA":
500
+ return True
501
+ return rtype == "NS" and name == f"{zone.rstrip('.')}."
502
+
503
+
504
+ def desired_rrsets(
505
+ records: Iterable[TransferRecord], zone: str
506
+ ) -> dict[tuple[str, str], dict[str, Any]]:
507
+ """Group normalized source records into PowerDNS-comparable rrsets."""
508
+
509
+ out: dict[tuple[str, str], dict[str, Any]] = {}
510
+ for record in records:
511
+ rtype = record.type.upper()
512
+ name = fqdn(record.name, zone)
513
+ if _transfer_skip(name, rtype, zone):
514
+ continue
515
+ entry = out.setdefault((name, rtype), {"ttl": record.ttl, "contents": set()})
516
+ entry["ttl"] = min(entry["ttl"], record.ttl)
517
+ entry["contents"].add(normalize_content(rtype, record.content))
518
+ return out
519
+
520
+
521
+ def current_rrsets(
522
+ rrsets: Iterable[dict[str, Any]], zone: str
523
+ ) -> dict[tuple[str, str], dict[str, Any]]:
524
+ """The same comparable shape, from a DevNomads (PowerDNS) zone."""
525
+
526
+ out: dict[tuple[str, str], dict[str, Any]] = {}
527
+ for rrset in rrsets or []:
528
+ rtype = str(rrset.get("type", "")).upper()
529
+ name = str(rrset.get("name", ""))
530
+ if _transfer_skip(name, rtype, zone):
531
+ continue
532
+ out[(name, rtype)] = {
533
+ "ttl": rrset.get("ttl"),
534
+ "contents": {
535
+ normalize_content(rtype, record["content"])
536
+ for record in rrset.get("records", [])
537
+ },
538
+ }
539
+ return out
540
+
541
+
542
+ def diff_rrsets(
543
+ current: dict[tuple[str, str], dict[str, Any]],
544
+ desired: dict[tuple[str, str], dict[str, Any]],
545
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
546
+ """Compute (display rows, PATCH rrsets) to turn current into desired."""
547
+
548
+ changes: list[dict[str, Any]] = []
549
+ patch: list[dict[str, Any]] = []
550
+ for key in sorted(set(current) | set(desired)):
551
+ old, new = current.get(key), desired.get(key)
552
+ if old == new:
553
+ continue
554
+ name, rtype = key
555
+ if new is None:
556
+ if old is None:
557
+ continue
558
+ changes.append(
559
+ {
560
+ "action": "delete",
561
+ "name": name,
562
+ "type": rtype,
563
+ "ttl": old["ttl"],
564
+ "content": "; ".join(sorted(old["contents"])),
565
+ }
566
+ )
567
+ patch.append({"name": name, "type": rtype, "changetype": "DELETE"})
568
+ continue
569
+ changes.append(
570
+ {
571
+ "action": "create" if old is None else "update",
572
+ "name": name,
573
+ "type": rtype,
574
+ "ttl": new["ttl"],
575
+ "content": "; ".join(sorted(new["contents"])),
576
+ }
577
+ )
578
+ patch.append(
579
+ {
580
+ "name": name,
581
+ "type": rtype,
582
+ "ttl": new["ttl"],
583
+ "changetype": "REPLACE",
584
+ "records": [
585
+ {"content": content, "disabled": False}
586
+ for content in sorted(new["contents"])
587
+ ],
588
+ }
589
+ )
590
+ return changes, patch
591
+
592
+
593
+ class TransipSource:
594
+ """TransIP REST API v6: RSA-SHA512 signed auth request for a read-only
595
+ JWT, then plain bearer requests."""
596
+
597
+ name = "transip"
598
+
599
+ def __init__(
600
+ self, login: str, private_key_pem: str, api_url: str | None = None
601
+ ) -> None:
602
+ self.login = login
603
+ self.private_key_pem = private_key_pem
604
+ self._http = httpx.Client(
605
+ base_url=api_url or "https://api.transip.nl/v6",
606
+ headers={"User-Agent": f"dncli/{__version__}"},
607
+ timeout=REQUEST_TIMEOUT,
608
+ )
609
+ self._authenticated = False
610
+
611
+ def _authenticate(self) -> None:
612
+ from cryptography.hazmat.primitives import hashes, serialization
613
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
614
+
615
+ try:
616
+ key = serialization.load_pem_private_key(
617
+ self.private_key_pem.encode(), password=None
618
+ )
619
+ except ValueError as exc:
620
+ raise CliError(f"cannot load TransIP private key: {exc}") from exc
621
+ if not isinstance(key, rsa.RSAPrivateKey):
622
+ raise CliError("the TransIP private key must be an RSA key")
623
+ nonce = secrets.token_hex(16)
624
+ body = json.dumps(
625
+ {
626
+ "login": self.login,
627
+ "nonce": nonce,
628
+ "read_only": True,
629
+ "expiration_time": "30 minutes",
630
+ "label": f"dncli-{nonce[:10]}",
631
+ "global_key": True,
632
+ }
633
+ )
634
+ signature = base64.b64encode(
635
+ key.sign(body.encode(), padding.PKCS1v15(), hashes.SHA512())
636
+ ).decode()
637
+ with working("authenticating with TransIP"):
638
+ response = self._http.post(
639
+ "/auth",
640
+ content=body,
641
+ headers={"Signature": signature, "Content-Type": "application/json"},
642
+ )
643
+ if response.status_code >= 400:
644
+ raise CliError(
645
+ f"TransIP authentication failed ({response.status_code}): "
646
+ f"{_provider_error(response)} - check the login name and private "
647
+ "key, and that non-whitelisted API keys are allowed in the "
648
+ "TransIP control panel"
649
+ )
650
+ self._http.headers["Authorization"] = f"Bearer {response.json()['token']}"
651
+ self._authenticated = True
652
+
653
+ def get_records(self, zone: str) -> list[TransferRecord]:
654
+ if not self._authenticated:
655
+ self._authenticate()
656
+ with working(f"TransIP: GET /domains/{zone}/dns"):
657
+ response = self._http.get(f"/domains/{zone}/dns")
658
+ if response.status_code >= 400:
659
+ raise CliError(
660
+ f"TransIP returned {response.status_code} for zone '{zone}': "
661
+ f"{_provider_error(response)}"
662
+ )
663
+ return [
664
+ TransferRecord(
665
+ name=str(entry["name"]),
666
+ type=str(entry["type"]).upper(),
667
+ content=str(entry["content"]),
668
+ ttl=int(entry["expire"]),
669
+ )
670
+ for entry in response.json().get("dnsEntries", [])
671
+ ]
672
+
673
+
674
+ class AuroraDnsSource:
675
+ """AuroraDNS (PCextreme): HMAC-SHA256 signed requests, protocol as
676
+ implemented in Apache Libcloud's auroradns driver."""
677
+
678
+ name = "auroradns"
679
+
680
+ def __init__(
681
+ self, api_key: str, secret_key: str, api_url: str | None = None
682
+ ) -> None:
683
+ self.api_key = api_key
684
+ self.secret_key = secret_key
685
+ self._http = httpx.Client(
686
+ base_url=api_url or "https://api.auroradns.eu",
687
+ headers={"User-Agent": f"dncli/{__version__}"},
688
+ timeout=REQUEST_TIMEOUT,
689
+ )
690
+
691
+ def _headers(self, method: str, path: str) -> dict[str, str]:
692
+ timestamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
693
+ signature = base64.b64encode(
694
+ hmac.new(
695
+ self.secret_key.encode(),
696
+ f"{method}{path}{timestamp}".encode(),
697
+ hashlib.sha256,
698
+ ).digest()
699
+ ).decode()
700
+ token = base64.b64encode(f"{self.api_key}:{signature}".encode()).decode()
701
+ return {
702
+ "X-AuroraDNS-Date": timestamp,
703
+ "Authorization": f"AuroraDNSv1 {token}",
704
+ }
705
+
706
+ def _get(self, path: str) -> Any:
707
+ with working(f"AuroraDNS: GET {path}"):
708
+ response = self._http.get(path, headers=self._headers("GET", path))
709
+ if response.status_code >= 400:
710
+ raise CliError(
711
+ f"AuroraDNS returned {response.status_code} for {path}: "
712
+ f"{_provider_error(response)}"
713
+ )
714
+ return response.json()
715
+
716
+ def get_records(self, zone: str) -> list[TransferRecord]:
717
+ zones = self._get("/zones")
718
+ zone_id = next(
719
+ (
720
+ item["id"]
721
+ for item in zones
722
+ if str(item.get("name", "")).rstrip(".") == zone.rstrip(".")
723
+ ),
724
+ None,
725
+ )
726
+ if zone_id is None:
727
+ raise CliError(f"zone '{zone}' not found in this AuroraDNS account")
728
+ records = []
729
+ for record in self._get(f"/zones/{zone_id}/records"):
730
+ rtype = str(record["type"]).upper()
731
+ content = str(record["content"])
732
+ prio = record.get("prio")
733
+ if rtype in ("MX", "SRV") and prio is not None:
734
+ content = f"{prio} {content}"
735
+ records.append(
736
+ TransferRecord(
737
+ name=str(record.get("name") or "@"),
738
+ type=rtype,
739
+ content=content,
740
+ ttl=int(record["ttl"]),
741
+ )
742
+ )
743
+ return records
744
+
745
+
746
+ def _provider_error(response: httpx.Response) -> str:
747
+ try:
748
+ body = response.json()
749
+ except ValueError:
750
+ return response.text[:200]
751
+ if isinstance(body, dict):
752
+ return str(body.get("error") or body.get("errormsg") or body)
753
+ return str(body)
754
+
755
+
756
+ def _source_value(
757
+ stored: dict[str, str], key: str, env_var: str, label: str, *, hide: bool = False
758
+ ) -> str:
759
+ value = stored.get(key) or os.environ.get(env_var)
760
+ if value:
761
+ return value
762
+ if not sys.stdin.isatty():
763
+ raise CliError(
764
+ f"missing source credential '{label}'; store it with "
765
+ f"`dncli configure --provider <driver>` or set {env_var}"
766
+ )
767
+ return typer.prompt(label, hide_input=hide, err=True)
768
+
769
+
770
+ def build_source(driver: str, source_profile: str | None) -> DnsSource:
771
+ if driver not in PROVIDERS:
772
+ raise CliError(f"unknown driver '{driver}' (available: {', '.join(PROVIDERS)})")
773
+ parser = load_credentials()
774
+ section = f"{driver}:{source_profile or DEFAULT_PROFILE}"
775
+ stored = dict(parser[section]) if parser.has_section(section) else {}
776
+ if source_profile and not stored:
777
+ raise CliError(
778
+ f"profile '{section}' not found; run: "
779
+ f"dncli configure --provider {driver} --profile {source_profile}"
780
+ )
781
+ api_url = stored.get("api_url") # testing escape hatch, like the main client
782
+ if driver == "transip":
783
+ login = _source_value(stored, "login", "DN_TRANSIP_LOGIN", "TransIP login name")
784
+ key_file = _source_value(
785
+ stored,
786
+ "private_key_file",
787
+ "DN_TRANSIP_PRIVATE_KEY_FILE",
788
+ "Path to TransIP private key (PEM)",
789
+ )
790
+ try:
791
+ pem = Path(key_file).expanduser().read_text()
792
+ except OSError as exc:
793
+ raise CliError(f"cannot read TransIP private key: {exc}") from exc
794
+ return TransipSource(login, pem, api_url)
795
+ api_key = _source_value(
796
+ stored, "api_key", "DN_AURORADNS_API_KEY", "AuroraDNS API key", hide=True
797
+ )
798
+ secret_key = _source_value(
799
+ stored,
800
+ "secret_key",
801
+ "DN_AURORADNS_SECRET_KEY",
802
+ "AuroraDNS secret key",
803
+ hide=True,
804
+ )
805
+ return AuroraDnsSource(api_key, secret_key, api_url)
806
+
807
+
808
+ # ---------------------------------------------------------------------------
809
+ # CLI
810
+
811
+
812
+ class PrefixGroup(TyperGroup):
813
+ """Resolve unique command prefixes: `dncli e l` runs `dncli emails list`.
814
+
815
+ Exact names always win; an ambiguous prefix fails listing the matches."""
816
+
817
+ # typer vendors click, so the context/command annotations stay loose
818
+ def get_command(self, ctx: Any, cmd_name: str) -> Any:
819
+ command = super().get_command(ctx, cmd_name)
820
+ if command is not None:
821
+ return command
822
+ matches = [
823
+ name for name in self.list_commands(ctx) if name.startswith(cmd_name)
824
+ ]
825
+ if len(matches) == 1:
826
+ return super().get_command(ctx, matches[0])
827
+ if matches:
828
+ ctx.fail(f"'{cmd_name}' is ambiguous: {', '.join(sorted(matches))}")
829
+ return None
830
+
831
+ def resolve_command(self, ctx: Any, args: Any) -> Any:
832
+ # report the resolved full command name, not the typed prefix
833
+ _, command, remaining = super().resolve_command(ctx, args)
834
+ return command.name if command else None, command, remaining
835
+
836
+
837
+ app = typer.Typer(
838
+ name="dncli",
839
+ cls=PrefixGroup,
840
+ help="Manage your DevNomads services from the command line.",
841
+ no_args_is_help=True,
842
+ )
843
+ configure_app = typer.Typer(cls=PrefixGroup, help="Manage stored credential profiles.")
844
+ services_app = typer.Typer(
845
+ cls=PrefixGroup, help="Your DevNomads services.", no_args_is_help=True
846
+ )
847
+ dns_app = typer.Typer(
848
+ cls=PrefixGroup, help="DNS zones and records.", no_args_is_help=True
849
+ )
850
+ zones_app = typer.Typer(cls=PrefixGroup, help="DNS zones.", no_args_is_help=True)
851
+ records_app = typer.Typer(cls=PrefixGroup, help="DNS records.", no_args_is_help=True)
852
+
853
+ app.add_typer(configure_app, name="configure")
854
+ app.add_typer(services_app, name="services")
855
+ app.add_typer(dns_app, name="dns")
856
+ dns_app.add_typer(zones_app, name="zones")
857
+ dns_app.add_typer(records_app, name="records")
858
+
859
+
860
+ SortOption = Annotated[
861
+ str | None,
862
+ typer.Option(
863
+ "--sort",
864
+ help="Sort by field; prefix with - for descending (e.g. --sort -ttl).",
865
+ ),
866
+ ]
867
+ OutputOption = Annotated[
868
+ OutputFormat | None,
869
+ typer.Option("--output", "-o", help="Output format (table or json)."),
870
+ ]
871
+
872
+
873
+ def _version_callback(value: bool) -> None:
874
+ if value:
875
+ sys.stdout.write(f"dncli {__version__}\n")
876
+ raise typer.Exit()
877
+
878
+
879
+ @app.callback()
880
+ def main(
881
+ ctx: typer.Context,
882
+ profile: Annotated[
883
+ str | None,
884
+ typer.Option(
885
+ "--profile", "-p", help=f"Credentials profile (env: {ENV_PROFILE})."
886
+ ),
887
+ ] = None,
888
+ api_key: Annotated[
889
+ str | None,
890
+ typer.Option(
891
+ "--api-key", help=f"API key, beats any profile (env: {ENV_API_KEY})."
892
+ ),
893
+ ] = None,
894
+ output: Annotated[
895
+ OutputFormat | None,
896
+ typer.Option(
897
+ "--output",
898
+ "-o",
899
+ help="Output format; default: table on a TTY, json when piped.",
900
+ ),
901
+ ] = None,
902
+ debug: Annotated[
903
+ bool, typer.Option("--debug", help="Log the HTTP exchange to stderr.")
904
+ ] = False,
905
+ version: Annotated[
906
+ bool,
907
+ typer.Option(
908
+ "--version",
909
+ callback=_version_callback,
910
+ is_eager=True,
911
+ help="Print version and exit.",
912
+ ),
913
+ ] = False,
914
+ ) -> None:
915
+ ctx.obj = AppState(profile=profile, api_key=api_key, output=output, debug=debug)
916
+
917
+
918
+ # --- configure -------------------------------------------------------------
919
+
920
+
921
+ @configure_app.callback(invoke_without_command=True)
922
+ def configure(
923
+ ctx: typer.Context,
924
+ profile: Annotated[
925
+ str, typer.Option(help="Profile name to create or update.")
926
+ ] = DEFAULT_PROFILE,
927
+ provider: Annotated[
928
+ str | None,
929
+ typer.Option(
930
+ help="Configure a DNS transfer source profile "
931
+ f"({', '.join(PROVIDERS)}) instead of a DevNomads profile."
932
+ ),
933
+ ] = None,
934
+ ) -> None:
935
+ """Interactively store credentials in your home directory."""
936
+
937
+ if ctx.invoked_subcommand:
938
+ return
939
+ if provider is None:
940
+ section = profile
941
+ values = {
942
+ "api_key": typer.prompt("DevNomads API key", hide_input=True, err=True)
943
+ }
944
+ elif provider == "auroradns":
945
+ section = f"auroradns:{profile}"
946
+ values = {
947
+ "api_key": typer.prompt("AuroraDNS API key", hide_input=True, err=True),
948
+ "secret_key": typer.prompt(
949
+ "AuroraDNS secret key", hide_input=True, err=True
950
+ ),
951
+ }
952
+ elif provider == "transip":
953
+ section = f"transip:{profile}"
954
+ login = typer.prompt("TransIP login name", err=True)
955
+ key_source = typer.prompt("Path to TransIP private key (PEM)", err=True)
956
+ key_path = _import_private_key(Path(key_source).expanduser(), profile)
957
+ values = {"login": login, "private_key_file": str(key_path)}
958
+ else:
959
+ raise CliError(
960
+ f"unknown provider '{provider}' (available: {', '.join(PROVIDERS)})"
961
+ )
962
+ parser = load_credentials()
963
+ if not parser.has_section(section):
964
+ parser.add_section(section)
965
+ for key, value in values.items():
966
+ parser.set(section, key, value)
967
+ save_credentials(parser)
968
+ err_console.print(f"profile '{section}' written to {credentials_path()}")
969
+
970
+
971
+ def _import_private_key(source: Path, profile: str) -> Path:
972
+ try:
973
+ pem = source.read_text()
974
+ except OSError as exc:
975
+ raise CliError(f"cannot read private key: {exc}") from exc
976
+ if "PRIVATE KEY" not in pem:
977
+ raise CliError(f"{source} does not look like a PEM private key")
978
+ target = config_dir() / f"transip-{profile}.pem"
979
+ _write_private(target, pem)
980
+ return target
981
+
982
+
983
+ @configure_app.command("list")
984
+ def configure_list(
985
+ ctx: typer.Context, sort: SortOption = None, output: OutputOption = None
986
+ ) -> None:
987
+ """List stored profiles (secrets masked)."""
988
+
989
+ parser = load_credentials()
990
+ rows = []
991
+ for section in parser.sections():
992
+ provider, _, name = section.partition(":")
993
+ values = parser[section]
994
+ secret = values.get("api_key") or values.get("secret_key") or ""
995
+ rows.append(
996
+ {
997
+ "profile": section,
998
+ "type": provider if name else "devnomads",
999
+ "api_key": _mask(secret) or values.get("login", ""),
1000
+ }
1001
+ )
1002
+ render(
1003
+ state_from(ctx, output),
1004
+ sort_rows(rows, sort),
1005
+ columns=["profile", "type", "api_key"],
1006
+ title="Profiles",
1007
+ )
1008
+
1009
+
1010
+ # --- services ----------------------------------------------------------------
1011
+
1012
+
1013
+ @services_app.command("list")
1014
+ def services_list(
1015
+ ctx: typer.Context, sort: SortOption = None, output: OutputOption = None
1016
+ ) -> None:
1017
+ """List all your services."""
1018
+
1019
+ state = state_from(ctx, output)
1020
+ data = get_client(state).request("GET", "/services")
1021
+ if isinstance(data, list):
1022
+ data = sort_rows(data, sort)
1023
+ render(
1024
+ state,
1025
+ data,
1026
+ columns=["service_id", "type", "entity", "started_at", "ended_at"],
1027
+ title="Services",
1028
+ )
1029
+
1030
+
1031
+ @services_app.command("show")
1032
+ def services_show(
1033
+ ctx: typer.Context,
1034
+ service_id: Annotated[int, typer.Argument(help="Service ID.")],
1035
+ output: OutputOption = None,
1036
+ ) -> None:
1037
+ """Show one service."""
1038
+
1039
+ state = state_from(ctx, output)
1040
+ data = get_client(state).request("GET", f"/services/{service_id}")
1041
+ render(state, data, title=f"Service {service_id}")
1042
+
1043
+
1044
+ # --- dns zones ---------------------------------------------------------------
1045
+
1046
+
1047
+ @zones_app.command("list")
1048
+ def zones_list(
1049
+ ctx: typer.Context, sort: SortOption = None, output: OutputOption = None
1050
+ ) -> None:
1051
+ """List your DNS zones."""
1052
+
1053
+ state = state_from(ctx, output)
1054
+ data = get_client(state).request("GET", "/services/dns/zones")
1055
+ if isinstance(data, list):
1056
+ data = sort_rows(data, sort)
1057
+ render(
1058
+ state,
1059
+ data,
1060
+ columns=["name", "kind", "serial", "dnssec"],
1061
+ title="DNS zones",
1062
+ )
1063
+
1064
+
1065
+ @zones_app.command("show")
1066
+ def zones_show(
1067
+ ctx: typer.Context,
1068
+ zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1069
+ output: OutputOption = None,
1070
+ ) -> None:
1071
+ """Show a DNS zone including its records."""
1072
+
1073
+ state = state_from(ctx, output)
1074
+ data = get_client(state).request("GET", f"/services/dns/zones/{zone_id(zone)}")
1075
+ if resolve_format(state) is OutputFormat.json or not isinstance(data, dict):
1076
+ render(state, data)
1077
+ return
1078
+ meta = {key: value for key, value in data.items() if key != "rrsets"}
1079
+ render(state, meta, title=zone)
1080
+ render(
1081
+ state,
1082
+ flatten_rrsets(data.get("rrsets", [])),
1083
+ columns=RECORD_COLUMNS,
1084
+ title="Records",
1085
+ )
1086
+
1087
+
1088
+ # --- dns records -------------------------------------------------------------
1089
+
1090
+
1091
+ @records_app.command("list")
1092
+ def records_list(
1093
+ ctx: typer.Context,
1094
+ zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1095
+ sort: SortOption = None,
1096
+ output: OutputOption = None,
1097
+ ) -> None:
1098
+ """List the records in a zone, one row per value."""
1099
+
1100
+ state = state_from(ctx, output)
1101
+ data = get_client(state).request("GET", f"/services/dns/zones/{zone_id(zone)}")
1102
+ rrsets = data.get("rrsets", []) if isinstance(data, dict) else []
1103
+ render(
1104
+ state,
1105
+ sort_rows(flatten_rrsets(rrsets), sort),
1106
+ columns=RECORD_COLUMNS,
1107
+ title=f"Records in {zone}",
1108
+ )
1109
+
1110
+
1111
+ @records_app.command("set")
1112
+ def records_set(
1113
+ ctx: typer.Context,
1114
+ zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1115
+ name: Annotated[
1116
+ str, typer.Argument(help="Record name relative to the zone; @ for the apex.")
1117
+ ],
1118
+ rtype: Annotated[
1119
+ str, typer.Argument(metavar="TYPE", help="Record type, e.g. A, MX, TXT.")
1120
+ ],
1121
+ content: Annotated[
1122
+ list[str],
1123
+ typer.Argument(
1124
+ help="One or more record values; replaces the whole record set."
1125
+ ),
1126
+ ],
1127
+ ttl: Annotated[int, typer.Option(help="TTL in seconds.")] = 3600,
1128
+ ) -> None:
1129
+ """Create or replace a record set (PowerDNS REPLACE semantics)."""
1130
+
1131
+ state = state_from(ctx)
1132
+ rrset = build_rrset(
1133
+ zone, name, rtype, changetype="REPLACE", ttl=ttl, contents=content
1134
+ )
1135
+ payload = {"rrsets": [rrset]}
1136
+ get_client(state).request(
1137
+ "PATCH", f"/services/dns/zones/{zone_id(zone)}", json_body=payload
1138
+ )
1139
+ err_console.print(
1140
+ f"set {rrset['name']} {rrset['type']} "
1141
+ f"({len(rrset['records'])} value(s), ttl {ttl})"
1142
+ )
1143
+
1144
+
1145
+ @records_app.command("delete")
1146
+ def records_delete(
1147
+ ctx: typer.Context,
1148
+ zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1149
+ name: Annotated[
1150
+ str, typer.Argument(help="Record name relative to the zone; @ for the apex.")
1151
+ ],
1152
+ rtype: Annotated[
1153
+ str, typer.Argument(metavar="TYPE", help="Record type, e.g. A, MX, TXT.")
1154
+ ],
1155
+ yes: Annotated[
1156
+ bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")
1157
+ ] = False,
1158
+ ) -> None:
1159
+ """Delete a whole record set (all values for name + type)."""
1160
+
1161
+ state = state_from(ctx)
1162
+ rrset = build_rrset(zone, name, rtype, changetype="DELETE")
1163
+ _confirm(f"Delete all {rrset['type']} records for {rrset['name']}?", yes)
1164
+ payload = {"rrsets": [rrset]}
1165
+ get_client(state).request(
1166
+ "PATCH", f"/services/dns/zones/{zone_id(zone)}", json_body=payload
1167
+ )
1168
+ err_console.print(f"deleted {rrset['name']} {rrset['type']}")
1169
+
1170
+
1171
+ @dns_app.command("transfer")
1172
+ def dns_transfer(
1173
+ ctx: typer.Context,
1174
+ from_: Annotated[
1175
+ str,
1176
+ typer.Option(
1177
+ "--from",
1178
+ metavar="DRIVER",
1179
+ help=f"Source provider ({', '.join(PROVIDERS)}).",
1180
+ ),
1181
+ ],
1182
+ zone: Annotated[
1183
+ str, typer.Option("--zone", help="Zone name on both sides, e.g. example.com.")
1184
+ ],
1185
+ source_profile: Annotated[
1186
+ str | None,
1187
+ typer.Option(
1188
+ "--source-profile",
1189
+ help="Stored provider profile to read credentials from "
1190
+ "(default: the provider's 'default' profile).",
1191
+ ),
1192
+ ] = None,
1193
+ dry_run: Annotated[
1194
+ bool, typer.Option("--dry-run", help="Show the changes without applying them.")
1195
+ ] = False,
1196
+ yes: Annotated[
1197
+ bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")
1198
+ ] = False,
1199
+ output: OutputOption = None,
1200
+ ) -> None:
1201
+ """Copy a DNS zone from another provider into DevNomads.
1202
+
1203
+ Fetches the records at the source (read-only), diffs them against the
1204
+ DevNomads zone, shows the changes, and applies them after confirmation.
1205
+ SOA and apex NS records stay untouched - the platform owns those.
1206
+ """
1207
+
1208
+ state = state_from(ctx, output)
1209
+ client = get_client(state) # resolve DevNomads credentials first: fail fast
1210
+ source = build_source(from_, source_profile)
1211
+ zones = client.request("GET", "/services/dns/zones")
1212
+ known = {str(item.get("name", "")).rstrip(".") for item in zones or []}
1213
+ if zone.rstrip(".") not in known:
1214
+ raise CliError(
1215
+ f"zone '{zone}' does not exist at DevNomads; register or transfer "
1216
+ "the domain first (dncli dns zones list shows your zones)"
1217
+ )
1218
+ err_console.print(f"fetching {zone} from {source.name}")
1219
+ records = source.get_records(zone)
1220
+ data = client.request("GET", f"/services/dns/zones/{zone_id(zone)}")
1221
+ rrsets = data.get("rrsets", []) if isinstance(data, dict) else []
1222
+ changes, patch = diff_rrsets(
1223
+ current_rrsets(rrsets, zone), desired_rrsets(records, zone)
1224
+ )
1225
+ if not changes:
1226
+ err_console.print(f"{zone} is already in sync with {source.name}")
1227
+ return
1228
+ render(
1229
+ state,
1230
+ changes,
1231
+ columns=["action", "name", "type", "ttl", "content"],
1232
+ title=f"Transfer {zone} from {source.name}",
1233
+ )
1234
+ if dry_run:
1235
+ err_console.print(f"dry run: {len(changes)} change(s) not applied")
1236
+ return
1237
+ _confirm(f"Apply {len(changes)} change(s) to {zone}", yes)
1238
+ client.request(
1239
+ "PATCH", f"/services/dns/zones/{zone_id(zone)}", json_body={"rrsets": patch}
1240
+ )
1241
+ err_console.print(f"applied {len(changes)} change(s) to {zone}")
1242
+
1243
+
1244
+ # ---------------------------------------------------------------------------
1245
+ # dehydrated DNS-01 hook
1246
+ #
1247
+ # dehydrated invokes its HOOK as a single executable with the event name as
1248
+ # the first argument, e.g. `HOOK deploy_challenge <domain> <token> <value>`.
1249
+ # Only deploy_challenge/clean_challenge plant or remove the validation TXT
1250
+ # record; every other event (deploy_cert, startup_hook, ...) is a no-op so
1251
+ # dehydrated can point all events at one program.
1252
+
1253
+ HOOK_USAGE = (
1254
+ "usage: dncli-dns-hook deploy_challenge|clean_challenge "
1255
+ "<domain> <token_filename> <token_value>"
1256
+ )
1257
+
1258
+
1259
+ def _hook_dispatch(dns: Dns, operation: str, args: list[str]) -> int:
1260
+ """Run one dehydrated hook event. ``args`` are the event arguments after
1261
+ the operation name. Returns the process exit code."""
1262
+
1263
+ op = operation.lower()
1264
+ if op not in ("deploy_challenge", "clean_challenge"):
1265
+ return 0 # no-op for every other dehydrated event
1266
+ if len(args) < 3:
1267
+ err_console.print(HOOK_USAGE)
1268
+ return 2
1269
+ # args: <domain> <token_filename> <token_value>; the filename is unused
1270
+ domain, token_value = args[0], args[2]
1271
+ record = challenge_name(domain)
1272
+ try:
1273
+ if op == "deploy_challenge":
1274
+ dns.set_txt(record, token_value)
1275
+ else:
1276
+ dns.unset_txt(record, token_value)
1277
+ except (AuthError, ApiError) as exc:
1278
+ err_console.print(f"[red]error:[/] {_error_message(exc.status, exc.detail)}")
1279
+ return 1
1280
+ except DevNomadsError as exc:
1281
+ err_console.print(f"[red]error:[/] {exc}")
1282
+ return 1
1283
+ return 0
1284
+
1285
+
1286
+ def hook_main(argv: list[str] | None = None) -> int:
1287
+ """Entry point for the ``dncli-dns-hook`` console script.
1288
+
1289
+ Parses ``sys.argv`` directly (argv[1] is the dehydrated event) so
1290
+ dehydrated can exec it as a single binary.
1291
+ """
1292
+
1293
+ argv = list(sys.argv if argv is None else argv)
1294
+ if len(argv) < 2:
1295
+ err_console.print(HOOK_USAGE)
1296
+ return 2
1297
+ operation = argv[1]
1298
+ if operation.lower() in ("--help", "-h", "help"):
1299
+ err_console.print(HOOK_USAGE)
1300
+ return 0
1301
+ # Resolve credentials only for events that actually touch the API, so
1302
+ # dehydrated's many no-op events (deploy_cert, startup_hook, ...) never
1303
+ # fail just because no key is configured for this hook environment.
1304
+ if operation.lower() not in ("deploy_challenge", "clean_challenge"):
1305
+ return 0
1306
+ try:
1307
+ client = ApiClient.from_environment()
1308
+ except DevNomadsError as exc:
1309
+ err_console.print(f"[red]error:[/] {exc}")
1310
+ return 1
1311
+ try:
1312
+ return _hook_dispatch(Dns(client), operation, argv[2:])
1313
+ finally:
1314
+ client.close()
1315
+
1316
+
1317
+ @dns_app.command(
1318
+ "hook",
1319
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
1320
+ )
1321
+ def dns_hook(
1322
+ ctx: typer.Context,
1323
+ operation: Annotated[
1324
+ str,
1325
+ typer.Argument(
1326
+ help="dehydrated event, e.g. deploy_challenge or clean_challenge."
1327
+ ),
1328
+ ],
1329
+ args: Annotated[
1330
+ list[str] | None,
1331
+ typer.Argument(
1332
+ help="Event arguments: <domain> <token_filename> <token_value>."
1333
+ ),
1334
+ ] = None,
1335
+ ) -> None:
1336
+ """dehydrated DNS-01 hook (deploy_challenge / clean_challenge).
1337
+
1338
+ For dehydrated, prefer the dedicated `dncli-dns-hook` binary, which it
1339
+ can exec directly. This subcommand runs the same logic and honours the
1340
+ global --profile / --api-key options.
1341
+ """
1342
+
1343
+ state = state_from(ctx)
1344
+ dns = Dns(get_client(state).api)
1345
+ code = _hook_dispatch(dns, operation, list(args or []))
1346
+ if code != 0:
1347
+ raise typer.Exit(code=code)
1348
+
1349
+
1350
+ # ---------------------------------------------------------------------------
1351
+ # Certificate issuance (`dncli cert ...`), behind the `cert` extra.
1352
+
1353
+ cert_app = typer.Typer(
1354
+ cls=PrefixGroup, help="Issue and renew TLS certificates.", no_args_is_help=True
1355
+ )
1356
+ app.add_typer(cert_app, name="cert")
1357
+
1358
+ LE_STAGING_DIRECTORY = "https://acme-staging-v02.api.letsencrypt.org/directory"
1359
+ CERT_RENEW_WINDOW_DAYS = 30
1360
+
1361
+
1362
+ def _load_acme() -> Any:
1363
+ """Import devnomads.acme lazily, with a clean message if the extra is
1364
+ missing."""
1365
+
1366
+ try:
1367
+ import devnomads.acme as acme
1368
+ except ImportError as exc:
1369
+ raise CliError(
1370
+ "certificate issuance needs the 'cert' extra; install it with: "
1371
+ 'pip install "devnomads-cli[cert]" '
1372
+ '(or: uv tool install "devnomads-cli[cert]")'
1373
+ ) from exc
1374
+ return acme
1375
+
1376
+
1377
+ def _cert_out_dir(out: Path | None, domain: str) -> Path:
1378
+ return out if out is not None else config_dir() / "certs" / domain
1379
+
1380
+
1381
+ def _account_key_path() -> Path:
1382
+ return config_dir() / "acme" / "account.pem"
1383
+
1384
+
1385
+ def _write_cert_files(
1386
+ out_dir: Path,
1387
+ *,
1388
+ privkey: str,
1389
+ cert: str,
1390
+ fullchain: str,
1391
+ chain: str,
1392
+ ) -> None:
1393
+ out_dir.mkdir(parents=True, exist_ok=True)
1394
+ _write_private(out_dir / "privkey.pem", privkey)
1395
+ for name, content in (
1396
+ ("cert.pem", cert),
1397
+ ("fullchain.pem", fullchain),
1398
+ ("chain.pem", chain),
1399
+ ):
1400
+ (out_dir / name).write_text(content)
1401
+
1402
+
1403
+ def _issue_certificate(
1404
+ state: AppState,
1405
+ domain: str,
1406
+ *,
1407
+ sans: list[str],
1408
+ use_http01: bool,
1409
+ webroot: str | None,
1410
+ standalone: bool,
1411
+ email: str | None,
1412
+ key_type: str,
1413
+ staging: bool,
1414
+ out_dir: Path,
1415
+ ) -> None:
1416
+ """Obtain a certificate for ``domain`` and write it to ``out_dir``."""
1417
+
1418
+ acme = _load_acme()
1419
+
1420
+ directory_url = LE_STAGING_DIRECTORY if staging else acme.DEFAULT_DIRECTORY_URL
1421
+ client = acme.AcmeClient(
1422
+ str(_account_key_path()),
1423
+ directory_url=directory_url,
1424
+ contact_email=email,
1425
+ )
1426
+ try:
1427
+ domain_key = acme.generate_key(key_type)
1428
+ except acme.AcmeError as exc:
1429
+ raise CliError(str(exc)) from exc
1430
+
1431
+ dns_provider = None
1432
+ http01_solver = None
1433
+ if use_http01:
1434
+ if webroot:
1435
+ http01_solver = acme.WebrootSolver(webroot)
1436
+ else:
1437
+ http01_solver = acme.StandaloneSolver()
1438
+ challenge = "http-01"
1439
+ else:
1440
+ dns_provider = acme.DevNomadsDnsProvider(Dns(get_client(state).api))
1441
+ challenge = "dns-01"
1442
+
1443
+ err_console.print(
1444
+ f"issuing {challenge} certificate for {domain}"
1445
+ + (f" (+{len(sans)} SAN(s))" if sans else "")
1446
+ + (" [staging]" if staging else "")
1447
+ )
1448
+ try:
1449
+ with http01_solver or nullcontext() as solver:
1450
+ leaf, fullchain, chain, key_pem = client.obtain_certificate(
1451
+ domain,
1452
+ challenge,
1453
+ domain_key,
1454
+ sans=sans or None,
1455
+ dns_provider=dns_provider,
1456
+ http01_solver=solver if use_http01 else None,
1457
+ )
1458
+ except acme.AcmeError as exc:
1459
+ raise CliError(str(exc)) from exc
1460
+ except DevNomadsError as exc:
1461
+ raise CliError(str(exc)) from exc
1462
+
1463
+ _write_cert_files(
1464
+ out_dir,
1465
+ privkey=key_pem.decode() if isinstance(key_pem, bytes) else key_pem,
1466
+ cert=leaf,
1467
+ fullchain=fullchain,
1468
+ chain=chain,
1469
+ )
1470
+ err_console.print(f"wrote certificate to {out_dir}")
1471
+
1472
+
1473
+ @cert_app.command("issue")
1474
+ def cert_issue(
1475
+ ctx: typer.Context,
1476
+ domain: Annotated[str, typer.Argument(help="Primary domain (certificate CN).")],
1477
+ san: Annotated[
1478
+ list[str] | None,
1479
+ typer.Option("--san", "-d", help="Additional SAN; repeat for more."),
1480
+ ] = None,
1481
+ dns_01: Annotated[
1482
+ bool, typer.Option("--dns-01", help="Use the DNS-01 challenge (default).")
1483
+ ] = False,
1484
+ http_01: Annotated[
1485
+ bool, typer.Option("--http-01", help="Use the HTTP-01 challenge.")
1486
+ ] = False,
1487
+ webroot: Annotated[
1488
+ str | None,
1489
+ typer.Option("--webroot", help="HTTP-01 webroot directory to write into."),
1490
+ ] = None,
1491
+ standalone: Annotated[
1492
+ bool,
1493
+ typer.Option("--standalone", help="HTTP-01 via a built-in server on :80."),
1494
+ ] = False,
1495
+ email: Annotated[
1496
+ str | None, typer.Option("--email", help="ACME account contact email.")
1497
+ ] = None,
1498
+ key_type: Annotated[
1499
+ str, typer.Option("--key-type", help="Certificate key type.")
1500
+ ] = "ec256",
1501
+ staging: Annotated[
1502
+ bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1503
+ ] = False,
1504
+ out: Annotated[
1505
+ Path | None,
1506
+ typer.Option(
1507
+ "--out", help="Output directory (default <config>/certs/<domain>)."
1508
+ ),
1509
+ ] = None,
1510
+ ) -> None:
1511
+ """Issue a TLS certificate for a domain via ACME (Let's Encrypt)."""
1512
+
1513
+ state = state_from(ctx)
1514
+ if dns_01 and http_01:
1515
+ raise CliError("choose only one of --dns-01 / --http-01")
1516
+ if webroot and standalone:
1517
+ raise CliError("choose only one of --webroot / --standalone")
1518
+ use_http01 = http_01 or bool(webroot) or standalone
1519
+ if dns_01 and use_http01:
1520
+ raise CliError("--dns-01 cannot be combined with HTTP-01 options")
1521
+ _issue_certificate(
1522
+ state,
1523
+ domain,
1524
+ sans=list(san or []),
1525
+ use_http01=use_http01,
1526
+ webroot=webroot,
1527
+ standalone=standalone,
1528
+ email=email,
1529
+ key_type=key_type,
1530
+ staging=staging,
1531
+ out_dir=_cert_out_dir(out, domain),
1532
+ )
1533
+
1534
+
1535
+ def _cert_expires_within(cert_path: Path, days: int) -> bool:
1536
+ """True if the certificate at ``cert_path`` expires within ``days``
1537
+ (or is missing/unreadable, so it gets re-issued)."""
1538
+
1539
+ from datetime import datetime, timedelta, timezone
1540
+
1541
+ from cryptography import x509
1542
+
1543
+ try:
1544
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
1545
+ except (OSError, ValueError):
1546
+ return True
1547
+ try:
1548
+ not_after = cert.not_valid_after_utc
1549
+ except AttributeError: # cryptography < 42 fallback
1550
+ not_after = cert.not_valid_after.replace(tzinfo=timezone.utc)
1551
+ return not_after - datetime.now(timezone.utc) <= timedelta(days=days)
1552
+
1553
+
1554
+ @cert_app.command("renew")
1555
+ def cert_renew(
1556
+ ctx: typer.Context,
1557
+ domain: Annotated[
1558
+ str | None,
1559
+ typer.Argument(help="Domain to renew; omit to renew every issued cert."),
1560
+ ] = None,
1561
+ email: Annotated[
1562
+ str | None, typer.Option("--email", help="ACME account contact email.")
1563
+ ] = None,
1564
+ key_type: Annotated[
1565
+ str, typer.Option("--key-type", help="Certificate key type.")
1566
+ ] = "ec256",
1567
+ staging: Annotated[
1568
+ bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1569
+ ] = False,
1570
+ ) -> None:
1571
+ """Re-issue certificates that expire within 30 days; skip the rest."""
1572
+
1573
+ state = state_from(ctx)
1574
+ certs_root = config_dir() / "certs"
1575
+ if domain:
1576
+ domains = [domain]
1577
+ else:
1578
+ domains = sorted(p.name for p in certs_root.glob("*") if p.is_dir())
1579
+ if not domains:
1580
+ err_console.print("[dim]no certificates to renew[/]")
1581
+ return
1582
+
1583
+ for name in domains:
1584
+ out_dir = certs_root / name
1585
+ cert_path = out_dir / "cert.pem"
1586
+ if not _cert_expires_within(cert_path, CERT_RENEW_WINDOW_DAYS):
1587
+ err_console.print(f"{name}: still valid, skipping")
1588
+ continue
1589
+ err_console.print(f"{name}: renewing")
1590
+ _issue_certificate(
1591
+ state,
1592
+ name,
1593
+ sans=[],
1594
+ use_http01=False,
1595
+ webroot=None,
1596
+ standalone=False,
1597
+ email=email,
1598
+ key_type=key_type,
1599
+ staging=staging,
1600
+ out_dir=out_dir,
1601
+ )
1602
+
1603
+
1604
+ # ---------------------------------------------------------------------------
1605
+ # Generated commands. tools/generate.py renders one thin command per
1606
+ # OpenAPI operation between the markers below, from openapi.json plus
1607
+ # the curation overlay in overlay.json. Do not edit the region by hand.
1608
+
1609
+ _SORT_OPT = typer.Option(
1610
+ None,
1611
+ "--sort",
1612
+ help="Sort by field; prefix with - for descending (e.g. --sort -ttl).",
1613
+ )
1614
+ _YES_OPT = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation.")
1615
+ _OUTPUT_OPT = typer.Option(
1616
+ None, "--output", "-o", help="Output format (table or json)."
1617
+ )
1618
+
1619
+
1620
+ def _generated_call(
1621
+ ctx: typer.Context,
1622
+ method: str,
1623
+ path_template: str,
1624
+ path_params: dict[str, Any],
1625
+ *,
1626
+ body: dict[str, Any] | None = None,
1627
+ sort: str | None = None,
1628
+ columns: list[str] | None = None,
1629
+ confirm: str | None = None,
1630
+ yes: bool = False,
1631
+ output: OutputFormat | None = None,
1632
+ ) -> None:
1633
+ """Shared runtime for generated commands: build the path, confirm if
1634
+ needed, call the API, render the result."""
1635
+
1636
+ state = state_from(ctx, output)
1637
+ if confirm:
1638
+ detail = ", ".join(f"{key}={value}" for key, value in path_params.items())
1639
+ _confirm(f"{confirm} ({detail})?" if detail else f"{confirm}?", yes)
1640
+ quoted = {
1641
+ key: urllib.parse.quote(str(value), safe="")
1642
+ for key, value in path_params.items()
1643
+ }
1644
+ path = path_template.format(**quoted)
1645
+ payload = {key: value for key, value in (body or {}).items() if value is not None}
1646
+ data = get_client(state).request(method, path, json_body=payload or None)
1647
+ if data is None:
1648
+ err_console.print("ok")
1649
+ return
1650
+ if isinstance(data, list):
1651
+ data = sort_rows(data, sort)
1652
+ render(state, data, columns=columns, title=path)
1653
+
1654
+
1655
+ # --- BEGIN GENERATED COMMANDS (tools/generate.py; do not edit by hand) ---
1656
+
1657
+ gen_apps = typer.Typer(cls=PrefixGroup, help="Manage apps.", no_args_is_help=True)
1658
+ app.add_typer(gen_apps, name="apps")
1659
+ gen_buckets = typer.Typer(cls=PrefixGroup, help="Manage buckets.", no_args_is_help=True)
1660
+ app.add_typer(gen_buckets, name="buckets")
1661
+ gen_containers = typer.Typer(
1662
+ cls=PrefixGroup, help="Manage containers.", no_args_is_help=True
1663
+ )
1664
+ app.add_typer(gen_containers, name="containers")
1665
+ gen_databases = typer.Typer(
1666
+ cls=PrefixGroup, help="Manage databases.", no_args_is_help=True
1667
+ )
1668
+ app.add_typer(gen_databases, name="databases")
1669
+ gen_domains = typer.Typer(cls=PrefixGroup, help="Manage domains.", no_args_is_help=True)
1670
+ app.add_typer(gen_domains, name="domains")
1671
+ gen_emails = typer.Typer(cls=PrefixGroup, help="Manage emails.", no_args_is_help=True)
1672
+ app.add_typer(gen_emails, name="emails")
1673
+ gen_forwards = typer.Typer(
1674
+ cls=PrefixGroup, help="Manage forwards.", no_args_is_help=True
1675
+ )
1676
+ app.add_typer(gen_forwards, name="forwards")
1677
+ gen_handles = typer.Typer(cls=PrefixGroup, help="Manage handles.", no_args_is_help=True)
1678
+ app.add_typer(gen_handles, name="handles")
1679
+ gen_proxies = typer.Typer(cls=PrefixGroup, help="Manage proxies.", no_args_is_help=True)
1680
+ app.add_typer(gen_proxies, name="proxies")
1681
+ gen_searches = typer.Typer(
1682
+ cls=PrefixGroup, help="Manage searches.", no_args_is_help=True
1683
+ )
1684
+ app.add_typer(gen_searches, name="searches")
1685
+ gen_servers = typer.Typer(cls=PrefixGroup, help="Manage servers.", no_args_is_help=True)
1686
+ app.add_typer(gen_servers, name="servers")
1687
+ gen_sites = typer.Typer(cls=PrefixGroup, help="Manage sites.", no_args_is_help=True)
1688
+ app.add_typer(gen_sites, name="sites")
1689
+ gen_spams = typer.Typer(cls=PrefixGroup, help="Manage spams.", no_args_is_help=True)
1690
+ app.add_typer(gen_spams, name="spams")
1691
+ gen_containers_instances = typer.Typer(
1692
+ cls=PrefixGroup, help="Manage containers instances.", no_args_is_help=True
1693
+ )
1694
+ gen_containers.add_typer(gen_containers_instances, name="instances")
1695
+ gen_databases_clusters = typer.Typer(
1696
+ cls=PrefixGroup, help="Manage databases clusters.", no_args_is_help=True
1697
+ )
1698
+ gen_databases.add_typer(gen_databases_clusters, name="clusters")
1699
+ gen_databases_permissions = typer.Typer(
1700
+ cls=PrefixGroup, help="Manage databases permissions.", no_args_is_help=True
1701
+ )
1702
+ gen_databases.add_typer(gen_databases_permissions, name="permissions")
1703
+ gen_databases_users = typer.Typer(
1704
+ cls=PrefixGroup, help="Manage databases users.", no_args_is_help=True
1705
+ )
1706
+ gen_databases.add_typer(gen_databases_users, name="users")
1707
+ gen_emails_aliases = typer.Typer(
1708
+ cls=PrefixGroup, help="Manage emails aliases.", no_args_is_help=True
1709
+ )
1710
+ gen_emails.add_typer(gen_emails_aliases, name="aliases")
1711
+ gen_emails_dkim = typer.Typer(
1712
+ cls=PrefixGroup, help="Manage emails dkim.", no_args_is_help=True
1713
+ )
1714
+ gen_emails.add_typer(gen_emails_dkim, name="dkim")
1715
+ gen_emails_forwardings = typer.Typer(
1716
+ cls=PrefixGroup, help="Manage emails forwardings.", no_args_is_help=True
1717
+ )
1718
+ gen_emails.add_typer(gen_emails_forwardings, name="forwardings")
1719
+ gen_emails_mailboxes = typer.Typer(
1720
+ cls=PrefixGroup, help="Manage emails mailboxes.", no_args_is_help=True
1721
+ )
1722
+ gen_emails.add_typer(gen_emails_mailboxes, name="mailboxes")
1723
+ gen_emails_records = typer.Typer(
1724
+ cls=PrefixGroup, help="Manage emails records.", no_args_is_help=True
1725
+ )
1726
+ gen_emails.add_typer(gen_emails_records, name="records")
1727
+ gen_emails_transactional = typer.Typer(
1728
+ cls=PrefixGroup, help="Manage emails transactional.", no_args_is_help=True
1729
+ )
1730
+ gen_emails.add_typer(gen_emails_transactional, name="transactional")
1731
+ gen_spams_clusters = typer.Typer(
1732
+ cls=PrefixGroup, help="Manage spams clusters.", no_args_is_help=True
1733
+ )
1734
+ gen_spams.add_typer(gen_spams_clusters, name="clusters")
1735
+ gen_spams_domain = typer.Typer(
1736
+ cls=PrefixGroup, help="Manage spams domain.", no_args_is_help=True
1737
+ )
1738
+ gen_spams.add_typer(gen_spams_domain, name="domain")
1739
+ gen_spams_transports = typer.Typer(
1740
+ cls=PrefixGroup, help="Manage spams transports.", no_args_is_help=True
1741
+ )
1742
+ gen_spams.add_typer(gen_spams_transports, name="transports")
1743
+ gen_containers_instances_volumes = typer.Typer(
1744
+ cls=PrefixGroup, help="Manage containers instances volumes.", no_args_is_help=True
1745
+ )
1746
+ gen_containers_instances.add_typer(gen_containers_instances_volumes, name="volumes")
1747
+ gen_databases_clusters_users = typer.Typer(
1748
+ cls=PrefixGroup, help="Manage databases clusters users.", no_args_is_help=True
1749
+ )
1750
+ gen_databases_clusters.add_typer(gen_databases_clusters_users, name="users")
1751
+ gen_emails_transactional_aliases = typer.Typer(
1752
+ cls=PrefixGroup, help="Manage emails transactional aliases.", no_args_is_help=True
1753
+ )
1754
+ gen_emails_transactional.add_typer(gen_emails_transactional_aliases, name="aliases")
1755
+ gen_emails_transactional_keys = typer.Typer(
1756
+ cls=PrefixGroup, help="Manage emails transactional keys.", no_args_is_help=True
1757
+ )
1758
+ gen_emails_transactional.add_typer(gen_emails_transactional_keys, name="keys")
1759
+ gen_emails_transactional_users = typer.Typer(
1760
+ cls=PrefixGroup, help="Manage emails transactional users.", no_args_is_help=True
1761
+ )
1762
+ gen_emails_transactional.add_typer(gen_emails_transactional_users, name="users")
1763
+ gen_spams_domain_dkim = typer.Typer(
1764
+ cls=PrefixGroup, help="Manage spams domain dkim.", no_args_is_help=True
1765
+ )
1766
+ gen_spams_domain.add_typer(gen_spams_domain_dkim, name="dkim")
1767
+
1768
+
1769
+ @gen_handles.command("create")
1770
+ def gen_handles_create(
1771
+ ctx: typer.Context,
1772
+ client_id: int | None = typer.Option(None),
1773
+ firstname: str | None = typer.Option(None),
1774
+ prefix: str | None = typer.Option(None),
1775
+ lastname: str | None = typer.Option(None),
1776
+ company: str | None = typer.Option(None),
1777
+ vat_id: str | None = typer.Option(None),
1778
+ email: str | None = typer.Option(None),
1779
+ phone_country_code: str | None = typer.Option(None),
1780
+ phone_area_code: str | None = typer.Option(None),
1781
+ phone_number: str | None = typer.Option(None),
1782
+ street: str | None = typer.Option(None),
1783
+ number: str | None = typer.Option(None),
1784
+ zipcode: str | None = typer.Option(None),
1785
+ city: str | None = typer.Option(None),
1786
+ region: str | None = typer.Option(None),
1787
+ country: str | None = typer.Option(None),
1788
+ output: OutputFormat | None = _OUTPUT_OPT,
1789
+ ) -> None:
1790
+ """Create a contact handle."""
1791
+
1792
+ _generated_call(
1793
+ ctx,
1794
+ "POST",
1795
+ "/handles",
1796
+ {},
1797
+ body={
1798
+ "client_id": client_id,
1799
+ "firstname": firstname,
1800
+ "prefix": prefix,
1801
+ "lastname": lastname,
1802
+ "company": company,
1803
+ "vat_id": vat_id,
1804
+ "email": email,
1805
+ "phone_country_code": phone_country_code,
1806
+ "phone_area_code": phone_area_code,
1807
+ "phone_number": phone_number,
1808
+ "street": street,
1809
+ "number": number,
1810
+ "zipcode": zipcode,
1811
+ "city": city,
1812
+ "region": region,
1813
+ "country": country,
1814
+ },
1815
+ output=output,
1816
+ )
1817
+
1818
+
1819
+ @gen_handles.command("list")
1820
+ def gen_handles_index(
1821
+ ctx: typer.Context,
1822
+ sort: str | None = _SORT_OPT,
1823
+ output: OutputFormat | None = _OUTPUT_OPT,
1824
+ ) -> None:
1825
+ """List your contact handles."""
1826
+
1827
+ _generated_call(
1828
+ ctx,
1829
+ "GET",
1830
+ "/handles",
1831
+ {},
1832
+ sort=sort,
1833
+ output=output,
1834
+ )
1835
+
1836
+
1837
+ @gen_handles.command("show")
1838
+ def gen_handles_show(
1839
+ ctx: typer.Context,
1840
+ handle_id: int = typer.Argument(..., metavar="HANDLE_ID"),
1841
+ output: OutputFormat | None = _OUTPUT_OPT,
1842
+ ) -> None:
1843
+ """Show one contact handle."""
1844
+
1845
+ _generated_call(
1846
+ ctx,
1847
+ "GET",
1848
+ "/handles/{handleId}",
1849
+ {"handleId": handle_id},
1850
+ output=output,
1851
+ )
1852
+
1853
+
1854
+ @gen_apps.command("list")
1855
+ def gen_services_apps_index(
1856
+ ctx: typer.Context,
1857
+ sort: str | None = _SORT_OPT,
1858
+ output: OutputFormat | None = _OUTPUT_OPT,
1859
+ ) -> None:
1860
+ """List apps."""
1861
+
1862
+ _generated_call(
1863
+ ctx,
1864
+ "GET",
1865
+ "/services/apps",
1866
+ {},
1867
+ sort=sort,
1868
+ output=output,
1869
+ )
1870
+
1871
+
1872
+ @gen_apps.command("show")
1873
+ def gen_services_apps_show(
1874
+ ctx: typer.Context,
1875
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
1876
+ output: OutputFormat | None = _OUTPUT_OPT,
1877
+ ) -> None:
1878
+ """Show apps details."""
1879
+
1880
+ _generated_call(
1881
+ ctx,
1882
+ "GET",
1883
+ "/services/apps/{serviceId}",
1884
+ {"serviceId": service_id},
1885
+ output=output,
1886
+ )
1887
+
1888
+
1889
+ @gen_apps.command("state")
1890
+ def gen_services_apps_state(
1891
+ ctx: typer.Context,
1892
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
1893
+ state: str = typer.Argument(..., metavar="STATE"),
1894
+ yes: bool = _YES_OPT,
1895
+ output: OutputFormat | None = _OUTPUT_OPT,
1896
+ ) -> None:
1897
+ """Change the state of an app service."""
1898
+
1899
+ _generated_call(
1900
+ ctx,
1901
+ "GET",
1902
+ "/services/apps/{serviceId}/state/{state}",
1903
+ {"serviceId": service_id, "state": state},
1904
+ confirm="State apps",
1905
+ yes=yes,
1906
+ output=output,
1907
+ )
1908
+
1909
+
1910
+ @gen_buckets.command("list")
1911
+ def gen_services_buckets_index(
1912
+ ctx: typer.Context,
1913
+ sort: str | None = _SORT_OPT,
1914
+ output: OutputFormat | None = _OUTPUT_OPT,
1915
+ ) -> None:
1916
+ """List buckets."""
1917
+
1918
+ _generated_call(
1919
+ ctx,
1920
+ "GET",
1921
+ "/services/buckets",
1922
+ {},
1923
+ sort=sort,
1924
+ output=output,
1925
+ )
1926
+
1927
+
1928
+ @gen_buckets.command("show")
1929
+ def gen_services_buckets_show(
1930
+ ctx: typer.Context,
1931
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
1932
+ output: OutputFormat | None = _OUTPUT_OPT,
1933
+ ) -> None:
1934
+ """Show buckets details."""
1935
+
1936
+ _generated_call(
1937
+ ctx,
1938
+ "GET",
1939
+ "/services/buckets/{serviceId}",
1940
+ {"serviceId": service_id},
1941
+ output=output,
1942
+ )
1943
+
1944
+
1945
+ @gen_containers.command("deploy")
1946
+ def gen_services_containers_deploy(
1947
+ ctx: typer.Context,
1948
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
1949
+ yes: bool = _YES_OPT,
1950
+ output: OutputFormat | None = _OUTPUT_OPT,
1951
+ ) -> None:
1952
+ """Deploy a container service."""
1953
+
1954
+ _generated_call(
1955
+ ctx,
1956
+ "GET",
1957
+ "/services/containers/{serviceId}/deploy",
1958
+ {"serviceId": service_id},
1959
+ confirm="Deploy containers",
1960
+ yes=yes,
1961
+ output=output,
1962
+ )
1963
+
1964
+
1965
+ @gen_containers.command("list")
1966
+ def gen_services_containers_index(
1967
+ ctx: typer.Context,
1968
+ sort: str | None = _SORT_OPT,
1969
+ output: OutputFormat | None = _OUTPUT_OPT,
1970
+ ) -> None:
1971
+ """List containers."""
1972
+
1973
+ _generated_call(
1974
+ ctx,
1975
+ "GET",
1976
+ "/services/containers",
1977
+ {},
1978
+ sort=sort,
1979
+ output=output,
1980
+ )
1981
+
1982
+
1983
+ @gen_containers_instances.command("deploy")
1984
+ def gen_services_containers_instances_deploy(
1985
+ ctx: typer.Context,
1986
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
1987
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
1988
+ yes: bool = _YES_OPT,
1989
+ output: OutputFormat | None = _OUTPUT_OPT,
1990
+ ) -> None:
1991
+ """Deploy a container instance."""
1992
+
1993
+ _generated_call(
1994
+ ctx,
1995
+ "GET",
1996
+ "/services/containers/{serviceId}/instances/{instanceId}/deploy",
1997
+ {"serviceId": service_id, "instanceId": instance_id},
1998
+ confirm="Deploy containers instances",
1999
+ yes=yes,
2000
+ output=output,
2001
+ )
2002
+
2003
+
2004
+ @gen_containers_instances.command("list")
2005
+ def gen_services_containers_instances_index(
2006
+ ctx: typer.Context,
2007
+ service_id: str = typer.Argument(..., metavar="SERVICE_ID"),
2008
+ sort: str | None = _SORT_OPT,
2009
+ output: OutputFormat | None = _OUTPUT_OPT,
2010
+ ) -> None:
2011
+ """List containers instances."""
2012
+
2013
+ _generated_call(
2014
+ ctx,
2015
+ "GET",
2016
+ "/services/containers/{serviceId}/instances",
2017
+ {"serviceId": service_id},
2018
+ sort=sort,
2019
+ output=output,
2020
+ )
2021
+
2022
+
2023
+ @gen_containers_instances.command("logs")
2024
+ def gen_services_containers_instances_logs(
2025
+ ctx: typer.Context,
2026
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2027
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2028
+ output: OutputFormat | None = _OUTPUT_OPT,
2029
+ ) -> None:
2030
+ """Show the logs of a container instance."""
2031
+
2032
+ _generated_call(
2033
+ ctx,
2034
+ "GET",
2035
+ "/services/containers/{serviceId}/instances/{instanceId}/logs",
2036
+ {"serviceId": service_id, "instanceId": instance_id},
2037
+ output=output,
2038
+ )
2039
+
2040
+
2041
+ @gen_containers_instances.command("show")
2042
+ def gen_services_containers_instances_show(
2043
+ ctx: typer.Context,
2044
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2045
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2046
+ output: OutputFormat | None = _OUTPUT_OPT,
2047
+ ) -> None:
2048
+ """Show containers instances details."""
2049
+
2050
+ _generated_call(
2051
+ ctx,
2052
+ "GET",
2053
+ "/services/containers/{serviceId}/instances/{instanceId}",
2054
+ {"serviceId": service_id, "instanceId": instance_id},
2055
+ output=output,
2056
+ )
2057
+
2058
+
2059
+ @gen_containers_instances.command("state")
2060
+ def gen_services_containers_instances_state(
2061
+ ctx: typer.Context,
2062
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2063
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2064
+ state: str = typer.Argument(..., metavar="STATE"),
2065
+ yes: bool = _YES_OPT,
2066
+ output: OutputFormat | None = _OUTPUT_OPT,
2067
+ ) -> None:
2068
+ """Change the state of a container instance."""
2069
+
2070
+ _generated_call(
2071
+ ctx,
2072
+ "GET",
2073
+ "/services/containers/{serviceId}/instances/{instanceId}/state/{state}",
2074
+ {"serviceId": service_id, "instanceId": instance_id, "state": state},
2075
+ confirm="State containers instances",
2076
+ yes=yes,
2077
+ output=output,
2078
+ )
2079
+
2080
+
2081
+ @gen_containers_instances.command("update")
2082
+ def gen_services_containers_instances_update(
2083
+ ctx: typer.Context,
2084
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2085
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2086
+ image: str | None = typer.Option(None),
2087
+ output: OutputFormat | None = _OUTPUT_OPT,
2088
+ ) -> None:
2089
+ """Update containers instances."""
2090
+
2091
+ _generated_call(
2092
+ ctx,
2093
+ "PUT",
2094
+ "/services/containers/{serviceId}/instances/{instanceId}",
2095
+ {"serviceId": service_id, "instanceId": instance_id},
2096
+ body={"image": image},
2097
+ output=output,
2098
+ )
2099
+
2100
+
2101
+ @gen_containers_instances_volumes.command("list")
2102
+ def gen_services_containers_instances_volumes_index(
2103
+ ctx: typer.Context,
2104
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2105
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2106
+ sort: str | None = _SORT_OPT,
2107
+ output: OutputFormat | None = _OUTPUT_OPT,
2108
+ ) -> None:
2109
+ """List containers instances volumes."""
2110
+
2111
+ _generated_call(
2112
+ ctx,
2113
+ "GET",
2114
+ "/services/containers/{serviceId}/instances/{instanceId}/volumes",
2115
+ {"serviceId": service_id, "instanceId": instance_id},
2116
+ sort=sort,
2117
+ output=output,
2118
+ )
2119
+
2120
+
2121
+ @gen_containers_instances_volumes.command("show")
2122
+ def gen_services_containers_instances_volumes_show(
2123
+ ctx: typer.Context,
2124
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2125
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2126
+ volume_id: int = typer.Argument(..., metavar="VOLUME_ID"),
2127
+ output: OutputFormat | None = _OUTPUT_OPT,
2128
+ ) -> None:
2129
+ """Show containers instances volumes details."""
2130
+
2131
+ _generated_call(
2132
+ ctx,
2133
+ "GET",
2134
+ "/services/containers/{serviceId}/instances/{instanceId}/volumes/{volumeId}",
2135
+ {"serviceId": service_id, "instanceId": instance_id, "volumeId": volume_id},
2136
+ output=output,
2137
+ )
2138
+
2139
+
2140
+ @gen_containers_instances_volumes.command("update")
2141
+ def gen_services_containers_instances_volumes_update(
2142
+ ctx: typer.Context,
2143
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2144
+ instance_id: int = typer.Argument(..., metavar="INSTANCE_ID"),
2145
+ volume_id: int = typer.Argument(..., metavar="VOLUME_ID"),
2146
+ path: str | None = typer.Option(None),
2147
+ uid: str | None = typer.Option(None),
2148
+ output: OutputFormat | None = _OUTPUT_OPT,
2149
+ ) -> None:
2150
+ """Update containers instances volumes."""
2151
+
2152
+ _generated_call(
2153
+ ctx,
2154
+ "PUT",
2155
+ "/services/containers/{serviceId}/instances/{instanceId}/volumes/{volumeId}",
2156
+ {"serviceId": service_id, "instanceId": instance_id, "volumeId": volume_id},
2157
+ body={"path": path, "uid": uid},
2158
+ output=output,
2159
+ )
2160
+
2161
+
2162
+ @gen_containers.command("show")
2163
+ def gen_services_containers_show(
2164
+ ctx: typer.Context,
2165
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2166
+ output: OutputFormat | None = _OUTPUT_OPT,
2167
+ ) -> None:
2168
+ """Show containers details."""
2169
+
2170
+ _generated_call(
2171
+ ctx,
2172
+ "GET",
2173
+ "/services/containers/{serviceId}",
2174
+ {"serviceId": service_id},
2175
+ output=output,
2176
+ )
2177
+
2178
+
2179
+ @gen_containers.command("update")
2180
+ def gen_services_containers_update(
2181
+ ctx: typer.Context,
2182
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2183
+ registry_url: str | None = typer.Option(None),
2184
+ description: str | None = typer.Option(None),
2185
+ port: int | None = typer.Option(None),
2186
+ output: OutputFormat | None = _OUTPUT_OPT,
2187
+ ) -> None:
2188
+ """Update containers."""
2189
+
2190
+ _generated_call(
2191
+ ctx,
2192
+ "PUT",
2193
+ "/services/container/{serviceId}",
2194
+ {"serviceId": service_id},
2195
+ body={"registry_url": registry_url, "description": description, "port": port},
2196
+ output=output,
2197
+ )
2198
+
2199
+
2200
+ @gen_databases_clusters.command("list")
2201
+ def gen_services_databases_clusters_index(
2202
+ ctx: typer.Context,
2203
+ sort: str | None = _SORT_OPT,
2204
+ output: OutputFormat | None = _OUTPUT_OPT,
2205
+ ) -> None:
2206
+ """List databases clusters."""
2207
+
2208
+ _generated_call(
2209
+ ctx,
2210
+ "GET",
2211
+ "/services/databases/clusters",
2212
+ {},
2213
+ sort=sort,
2214
+ output=output,
2215
+ )
2216
+
2217
+
2218
+ @gen_databases_clusters.command("show")
2219
+ def gen_services_databases_clusters_show(
2220
+ ctx: typer.Context,
2221
+ cluster_id: int = typer.Argument(..., metavar="CLUSTER_ID"),
2222
+ output: OutputFormat | None = _OUTPUT_OPT,
2223
+ ) -> None:
2224
+ """Show databases clusters details."""
2225
+
2226
+ _generated_call(
2227
+ ctx,
2228
+ "GET",
2229
+ "/services/databases/clusters/{clusterId}",
2230
+ {"clusterId": cluster_id},
2231
+ output=output,
2232
+ )
2233
+
2234
+
2235
+ @gen_databases_clusters_users.command("list")
2236
+ def gen_services_databases_clusters_users_index(
2237
+ ctx: typer.Context,
2238
+ cluster_id: int = typer.Argument(..., metavar="CLUSTER_ID"),
2239
+ sort: str | None = _SORT_OPT,
2240
+ output: OutputFormat | None = _OUTPUT_OPT,
2241
+ ) -> None:
2242
+ """List databases clusters users."""
2243
+
2244
+ _generated_call(
2245
+ ctx,
2246
+ "GET",
2247
+ "/services/databases/clusters/{clusterId}/users",
2248
+ {"clusterId": cluster_id},
2249
+ sort=sort,
2250
+ output=output,
2251
+ )
2252
+
2253
+
2254
+ @gen_databases_clusters_users.command("show")
2255
+ def gen_services_databases_clusters_users_show(
2256
+ ctx: typer.Context,
2257
+ cluster_id: int = typer.Argument(..., metavar="CLUSTER_ID"),
2258
+ user_id: int = typer.Argument(..., metavar="USER_ID"),
2259
+ output: OutputFormat | None = _OUTPUT_OPT,
2260
+ ) -> None:
2261
+ """Show databases clusters users details."""
2262
+
2263
+ _generated_call(
2264
+ ctx,
2265
+ "GET",
2266
+ "/services/databases/clusters/{clusterId}/users/{userId}",
2267
+ {"clusterId": cluster_id, "userId": user_id},
2268
+ output=output,
2269
+ )
2270
+
2271
+
2272
+ @gen_databases.command("list")
2273
+ def gen_services_databases_index(
2274
+ ctx: typer.Context,
2275
+ sort: str | None = _SORT_OPT,
2276
+ output: OutputFormat | None = _OUTPUT_OPT,
2277
+ ) -> None:
2278
+ """List databases."""
2279
+
2280
+ _generated_call(
2281
+ ctx,
2282
+ "GET",
2283
+ "/services/databases",
2284
+ {},
2285
+ sort=sort,
2286
+ output=output,
2287
+ )
2288
+
2289
+
2290
+ @gen_databases_permissions.command("create")
2291
+ def gen_services_databases_permissions_create(
2292
+ ctx: typer.Context,
2293
+ service_id: str = typer.Argument(..., metavar="SERVICE_ID"),
2294
+ user_id: str | None = typer.Option(None),
2295
+ permissions: list[str] | None = typer.Option(None),
2296
+ output: OutputFormat | None = _OUTPUT_OPT,
2297
+ ) -> None:
2298
+ """Create databases permissions."""
2299
+
2300
+ _generated_call(
2301
+ ctx,
2302
+ "POST",
2303
+ "/services/databases/{serviceId}/permissions",
2304
+ {"serviceId": service_id},
2305
+ body={"user_id": user_id, "permissions": permissions},
2306
+ output=output,
2307
+ )
2308
+
2309
+
2310
+ @gen_databases.command("show")
2311
+ def gen_services_databases_show(
2312
+ ctx: typer.Context,
2313
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2314
+ output: OutputFormat | None = _OUTPUT_OPT,
2315
+ ) -> None:
2316
+ """Show databases details."""
2317
+
2318
+ _generated_call(
2319
+ ctx,
2320
+ "GET",
2321
+ "/services/databases/{serviceId}",
2322
+ {"serviceId": service_id},
2323
+ output=output,
2324
+ )
2325
+
2326
+
2327
+ @gen_databases.command("create")
2328
+ def gen_services_databases_store(
2329
+ ctx: typer.Context,
2330
+ name: str | None = typer.Option(None),
2331
+ client_id: int | None = typer.Option(None),
2332
+ cluster_id: int | None = typer.Option(None),
2333
+ output: OutputFormat | None = _OUTPUT_OPT,
2334
+ ) -> None:
2335
+ """Create databases."""
2336
+
2337
+ _generated_call(
2338
+ ctx,
2339
+ "POST",
2340
+ "/services/databases",
2341
+ {},
2342
+ body={"name": name, "client_id": client_id, "cluster_id": cluster_id},
2343
+ output=output,
2344
+ )
2345
+
2346
+
2347
+ @gen_databases_users.command("create")
2348
+ def gen_services_databases_users_store(
2349
+ ctx: typer.Context,
2350
+ cluster_id: str = typer.Argument(..., metavar="CLUSTER_ID"),
2351
+ username: str | None = typer.Option(None),
2352
+ password: str | None = typer.Option(None),
2353
+ client_id: int | None = typer.Option(None),
2354
+ output: OutputFormat | None = _OUTPUT_OPT,
2355
+ ) -> None:
2356
+ """Create databases users."""
2357
+
2358
+ _generated_call(
2359
+ ctx,
2360
+ "POST",
2361
+ "/services/databases/clusters/{clusterId}/users",
2362
+ {"clusterId": cluster_id},
2363
+ body={"username": username, "password": password, "client_id": client_id},
2364
+ output=output,
2365
+ )
2366
+
2367
+
2368
+ @gen_databases_users.command("update")
2369
+ def gen_services_databases_users_update(
2370
+ ctx: typer.Context,
2371
+ cluster_id: str = typer.Argument(..., metavar="CLUSTER_ID"),
2372
+ user_id: str = typer.Argument(..., metavar="USER_ID"),
2373
+ password: str | None = typer.Option(None),
2374
+ output: OutputFormat | None = _OUTPUT_OPT,
2375
+ ) -> None:
2376
+ """Update databases users."""
2377
+
2378
+ _generated_call(
2379
+ ctx,
2380
+ "PUT",
2381
+ "/services/databases/clusters/{clusterId}/users/{userId}",
2382
+ {"clusterId": cluster_id, "userId": user_id},
2383
+ body={"password": password},
2384
+ output=output,
2385
+ )
2386
+
2387
+
2388
+ @gen_domains.command("create")
2389
+ def gen_services_domains_create(
2390
+ ctx: typer.Context,
2391
+ nameserver_group: str | None = typer.Option(None),
2392
+ domain: str | None = typer.Option(None),
2393
+ client_id: int | None = typer.Option(None),
2394
+ type: str | None = typer.Option(None),
2395
+ token: str | None = typer.Option(None),
2396
+ handle_id_owner: int | None = typer.Option(None),
2397
+ handle_id_administrative: int | None = typer.Option(None),
2398
+ handle_id_technical: int | None = typer.Option(None),
2399
+ output: OutputFormat | None = _OUTPUT_OPT,
2400
+ ) -> None:
2401
+ """Create domains."""
2402
+
2403
+ _generated_call(
2404
+ ctx,
2405
+ "POST",
2406
+ "/services/domains",
2407
+ {},
2408
+ body={
2409
+ "nameserver_group": nameserver_group,
2410
+ "domain": domain,
2411
+ "client_id": client_id,
2412
+ "type": type,
2413
+ "token": token,
2414
+ "handle_id_owner": handle_id_owner,
2415
+ "handle_id_administrative": handle_id_administrative,
2416
+ "handle_id_technical": handle_id_technical,
2417
+ },
2418
+ output=output,
2419
+ )
2420
+
2421
+
2422
+ @gen_domains.command("list")
2423
+ def gen_services_domains_index(
2424
+ ctx: typer.Context,
2425
+ sort: str | None = _SORT_OPT,
2426
+ output: OutputFormat | None = _OUTPUT_OPT,
2427
+ ) -> None:
2428
+ """List domains."""
2429
+
2430
+ _generated_call(
2431
+ ctx,
2432
+ "GET",
2433
+ "/services/domains",
2434
+ {},
2435
+ sort=sort,
2436
+ output=output,
2437
+ )
2438
+
2439
+
2440
+ @gen_domains.command("show")
2441
+ def gen_services_domains_show(
2442
+ ctx: typer.Context,
2443
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2444
+ output: OutputFormat | None = _OUTPUT_OPT,
2445
+ ) -> None:
2446
+ """Show domains details."""
2447
+
2448
+ _generated_call(
2449
+ ctx,
2450
+ "GET",
2451
+ "/services/domains/{serviceId}",
2452
+ {"serviceId": service_id},
2453
+ output=output,
2454
+ )
2455
+
2456
+
2457
+ @gen_emails_aliases.command("create")
2458
+ def gen_services_emails_aliases_create(
2459
+ ctx: typer.Context,
2460
+ service_id: str = typer.Argument(..., metavar="SERVICE_ID"),
2461
+ emailaddress: str | None = typer.Option(None),
2462
+ alias: str | None = typer.Option(None),
2463
+ output: OutputFormat | None = _OUTPUT_OPT,
2464
+ ) -> None:
2465
+ """Create emails aliases."""
2466
+
2467
+ _generated_call(
2468
+ ctx,
2469
+ "POST",
2470
+ "/services/emails/{serviceId}/aliases",
2471
+ {"serviceId": service_id},
2472
+ body={"emailaddress": emailaddress, "alias": alias},
2473
+ output=output,
2474
+ )
2475
+
2476
+
2477
+ @gen_emails_aliases.command("delete")
2478
+ def gen_services_emails_aliases_delete(
2479
+ ctx: typer.Context,
2480
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2481
+ alias_id: int = typer.Argument(..., metavar="ALIAS_ID"),
2482
+ yes: bool = _YES_OPT,
2483
+ output: OutputFormat | None = _OUTPUT_OPT,
2484
+ ) -> None:
2485
+ """Delete emails aliases."""
2486
+
2487
+ _generated_call(
2488
+ ctx,
2489
+ "DELETE",
2490
+ "/services/emails/{serviceId}/aliases/{aliasId}",
2491
+ {"serviceId": service_id, "aliasId": alias_id},
2492
+ confirm="Delete emails aliases",
2493
+ yes=yes,
2494
+ output=output,
2495
+ )
2496
+
2497
+
2498
+ @gen_emails_aliases.command("list")
2499
+ def gen_services_emails_aliases_index(
2500
+ ctx: typer.Context,
2501
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2502
+ sort: str | None = _SORT_OPT,
2503
+ output: OutputFormat | None = _OUTPUT_OPT,
2504
+ ) -> None:
2505
+ """List emails aliases."""
2506
+
2507
+ _generated_call(
2508
+ ctx,
2509
+ "GET",
2510
+ "/services/emails/{serviceId}/aliases",
2511
+ {"serviceId": service_id},
2512
+ sort=sort,
2513
+ output=output,
2514
+ )
2515
+
2516
+
2517
+ @gen_emails_aliases.command("show")
2518
+ def gen_services_emails_aliases_show(
2519
+ ctx: typer.Context,
2520
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2521
+ alias_id: int = typer.Argument(..., metavar="ALIAS_ID"),
2522
+ output: OutputFormat | None = _OUTPUT_OPT,
2523
+ ) -> None:
2524
+ """Show emails aliases details."""
2525
+
2526
+ _generated_call(
2527
+ ctx,
2528
+ "GET",
2529
+ "/services/emails/{serviceId}/aliases/{aliasId}",
2530
+ {"serviceId": service_id, "aliasId": alias_id},
2531
+ output=output,
2532
+ )
2533
+
2534
+
2535
+ @gen_emails.command("create")
2536
+ def gen_services_emails_create(
2537
+ ctx: typer.Context,
2538
+ domain: str | None = typer.Option(None),
2539
+ client_id: int | None = typer.Option(None),
2540
+ type: str | None = typer.Option(None),
2541
+ return_path_domain: str | None = typer.Option(None),
2542
+ relay_hosts: list[str] | None = typer.Option(None),
2543
+ delivery_rate: int | None = typer.Option(None),
2544
+ output: OutputFormat | None = _OUTPUT_OPT,
2545
+ ) -> None:
2546
+ """Create emails."""
2547
+
2548
+ _generated_call(
2549
+ ctx,
2550
+ "POST",
2551
+ "/services/emails",
2552
+ {},
2553
+ body={
2554
+ "domain": domain,
2555
+ "client_id": client_id,
2556
+ "type": type,
2557
+ "return_path_domain": return_path_domain,
2558
+ "relay_hosts": relay_hosts,
2559
+ "delivery_rate": delivery_rate,
2560
+ },
2561
+ output=output,
2562
+ )
2563
+
2564
+
2565
+ @gen_emails.command("delete")
2566
+ def gen_services_emails_delete(
2567
+ ctx: typer.Context,
2568
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2569
+ yes: bool = _YES_OPT,
2570
+ output: OutputFormat | None = _OUTPUT_OPT,
2571
+ ) -> None:
2572
+ """Delete emails."""
2573
+
2574
+ _generated_call(
2575
+ ctx,
2576
+ "DELETE",
2577
+ "/services/emails/{serviceId}",
2578
+ {"serviceId": service_id},
2579
+ confirm="Delete emails",
2580
+ yes=yes,
2581
+ output=output,
2582
+ )
2583
+
2584
+
2585
+ @gen_emails_dkim.command("show")
2586
+ def gen_services_emails_dkim_show(
2587
+ ctx: typer.Context,
2588
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2589
+ output: OutputFormat | None = _OUTPUT_OPT,
2590
+ ) -> None:
2591
+ """Show the DKIM configuration of an email service."""
2592
+
2593
+ _generated_call(
2594
+ ctx,
2595
+ "GET",
2596
+ "/services/emails/{serviceId}/dkim",
2597
+ {"serviceId": service_id},
2598
+ output=output,
2599
+ )
2600
+
2601
+
2602
+ @gen_emails_forwardings.command("create")
2603
+ def gen_services_emails_forwardings_create(
2604
+ ctx: typer.Context,
2605
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2606
+ emailaddress: str | None = typer.Option(None),
2607
+ target: str | None = typer.Option(None),
2608
+ output: OutputFormat | None = _OUTPUT_OPT,
2609
+ ) -> None:
2610
+ """Create emails forwardings."""
2611
+
2612
+ _generated_call(
2613
+ ctx,
2614
+ "POST",
2615
+ "/services/emails/{serviceId}/forwardings",
2616
+ {"serviceId": service_id},
2617
+ body={"emailaddress": emailaddress, "target": target},
2618
+ output=output,
2619
+ )
2620
+
2621
+
2622
+ @gen_emails_forwardings.command("delete")
2623
+ def gen_services_emails_forwardings_delete(
2624
+ ctx: typer.Context,
2625
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2626
+ forwarding_id: int = typer.Argument(..., metavar="FORWARDING_ID"),
2627
+ yes: bool = _YES_OPT,
2628
+ output: OutputFormat | None = _OUTPUT_OPT,
2629
+ ) -> None:
2630
+ """Delete emails forwardings."""
2631
+
2632
+ _generated_call(
2633
+ ctx,
2634
+ "DELETE",
2635
+ "/services/emails/{serviceId}/forwardings/{forwardingId}",
2636
+ {"serviceId": service_id, "forwardingId": forwarding_id},
2637
+ confirm="Delete emails forwardings",
2638
+ yes=yes,
2639
+ output=output,
2640
+ )
2641
+
2642
+
2643
+ @gen_emails_forwardings.command("list")
2644
+ def gen_services_emails_forwardings_index(
2645
+ ctx: typer.Context,
2646
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2647
+ sort: str | None = _SORT_OPT,
2648
+ output: OutputFormat | None = _OUTPUT_OPT,
2649
+ ) -> None:
2650
+ """List emails forwardings."""
2651
+
2652
+ _generated_call(
2653
+ ctx,
2654
+ "GET",
2655
+ "/services/emails/{serviceId}/forwardings",
2656
+ {"serviceId": service_id},
2657
+ sort=sort,
2658
+ output=output,
2659
+ )
2660
+
2661
+
2662
+ @gen_emails_forwardings.command("show")
2663
+ def gen_services_emails_forwardings_show(
2664
+ ctx: typer.Context,
2665
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2666
+ forwarding_id: int = typer.Argument(..., metavar="FORWARDING_ID"),
2667
+ output: OutputFormat | None = _OUTPUT_OPT,
2668
+ ) -> None:
2669
+ """Show emails forwardings details."""
2670
+
2671
+ _generated_call(
2672
+ ctx,
2673
+ "GET",
2674
+ "/services/emails/{serviceId}/forwardings/{forwardingId}",
2675
+ {"serviceId": service_id, "forwardingId": forwarding_id},
2676
+ output=output,
2677
+ )
2678
+
2679
+
2680
+ @gen_emails.command("list")
2681
+ def gen_services_emails_index(
2682
+ ctx: typer.Context,
2683
+ sort: str | None = _SORT_OPT,
2684
+ output: OutputFormat | None = _OUTPUT_OPT,
2685
+ ) -> None:
2686
+ """List emails."""
2687
+
2688
+ _generated_call(
2689
+ ctx,
2690
+ "GET",
2691
+ "/services/emails",
2692
+ {},
2693
+ sort=sort,
2694
+ output=output,
2695
+ )
2696
+
2697
+
2698
+ @gen_emails_mailboxes.command("create")
2699
+ def gen_services_emails_mailboxes_create(
2700
+ ctx: typer.Context,
2701
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2702
+ emailaddress: str | None = typer.Option(None),
2703
+ password: str | None = typer.Option(None),
2704
+ output: OutputFormat | None = _OUTPUT_OPT,
2705
+ ) -> None:
2706
+ """Create emails mailboxes."""
2707
+
2708
+ _generated_call(
2709
+ ctx,
2710
+ "POST",
2711
+ "/services/emails/{serviceId}/mailboxes",
2712
+ {"serviceId": service_id},
2713
+ body={"emailaddress": emailaddress, "password": password},
2714
+ output=output,
2715
+ )
2716
+
2717
+
2718
+ @gen_emails_mailboxes.command("delete")
2719
+ def gen_services_emails_mailboxes_delete(
2720
+ ctx: typer.Context,
2721
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2722
+ emailaddress: str = typer.Argument(..., metavar="EMAILADDRESS"),
2723
+ yes: bool = _YES_OPT,
2724
+ output: OutputFormat | None = _OUTPUT_OPT,
2725
+ ) -> None:
2726
+ """Delete emails mailboxes."""
2727
+
2728
+ _generated_call(
2729
+ ctx,
2730
+ "DELETE",
2731
+ "/services/emails/{serviceId}/mailboxes/{emailaddress}",
2732
+ {"serviceId": service_id, "emailaddress": emailaddress},
2733
+ confirm="Delete emails mailboxes",
2734
+ yes=yes,
2735
+ output=output,
2736
+ )
2737
+
2738
+
2739
+ @gen_emails_mailboxes.command("list")
2740
+ def gen_services_emails_mailboxes_index(
2741
+ ctx: typer.Context,
2742
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2743
+ sort: str | None = _SORT_OPT,
2744
+ output: OutputFormat | None = _OUTPUT_OPT,
2745
+ ) -> None:
2746
+ """List emails mailboxes."""
2747
+
2748
+ _generated_call(
2749
+ ctx,
2750
+ "GET",
2751
+ "/services/emails/{serviceId}/mailboxes",
2752
+ {"serviceId": service_id},
2753
+ sort=sort,
2754
+ output=output,
2755
+ )
2756
+
2757
+
2758
+ @gen_emails_mailboxes.command("show")
2759
+ def gen_services_emails_mailboxes_show(
2760
+ ctx: typer.Context,
2761
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2762
+ emailaddress: str = typer.Argument(..., metavar="EMAILADDRESS"),
2763
+ output: OutputFormat | None = _OUTPUT_OPT,
2764
+ ) -> None:
2765
+ """Show emails mailboxes details."""
2766
+
2767
+ _generated_call(
2768
+ ctx,
2769
+ "GET",
2770
+ "/services/emails/{serviceId}/mailboxes/{emailaddress}",
2771
+ {"serviceId": service_id, "emailaddress": emailaddress},
2772
+ output=output,
2773
+ )
2774
+
2775
+
2776
+ @gen_emails_mailboxes.command("update")
2777
+ def gen_services_emails_mailboxes_update(
2778
+ ctx: typer.Context,
2779
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2780
+ emailaddress: str = typer.Argument(..., metavar="EMAILADDRESS"),
2781
+ password: str | None = typer.Option(None),
2782
+ output: OutputFormat | None = _OUTPUT_OPT,
2783
+ ) -> None:
2784
+ """Update emails mailboxes."""
2785
+
2786
+ _generated_call(
2787
+ ctx,
2788
+ "PUT",
2789
+ "/services/emails/{serviceId}/mailboxes/{emailaddress}",
2790
+ {"serviceId": service_id, "emailaddress": emailaddress},
2791
+ body={"password": password},
2792
+ output=output,
2793
+ )
2794
+
2795
+
2796
+ @gen_emails_records.command("list")
2797
+ def gen_services_emails_records_index(
2798
+ ctx: typer.Context,
2799
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2800
+ sort: str | None = _SORT_OPT,
2801
+ output: OutputFormat | None = _OUTPUT_OPT,
2802
+ ) -> None:
2803
+ """List the DNS records an email service needs."""
2804
+
2805
+ _generated_call(
2806
+ ctx,
2807
+ "GET",
2808
+ "/services/emails/{serviceId}/records",
2809
+ {"serviceId": service_id},
2810
+ sort=sort,
2811
+ output=output,
2812
+ )
2813
+
2814
+
2815
+ @gen_emails.command("show")
2816
+ def gen_services_emails_show(
2817
+ ctx: typer.Context,
2818
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2819
+ output: OutputFormat | None = _OUTPUT_OPT,
2820
+ ) -> None:
2821
+ """Show emails details."""
2822
+
2823
+ _generated_call(
2824
+ ctx,
2825
+ "GET",
2826
+ "/services/emails/{serviceId}",
2827
+ {"serviceId": service_id},
2828
+ output=output,
2829
+ )
2830
+
2831
+
2832
+ @gen_emails_transactional_aliases.command("create")
2833
+ def gen_services_emails_transactional_aliases_create(
2834
+ ctx: typer.Context,
2835
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2836
+ domain: str | None = typer.Option(None),
2837
+ output: OutputFormat | None = _OUTPUT_OPT,
2838
+ ) -> None:
2839
+ """Create emails transactional aliases."""
2840
+
2841
+ _generated_call(
2842
+ ctx,
2843
+ "POST",
2844
+ "/services/emails/{serviceId}/transactional/aliases",
2845
+ {"serviceId": service_id},
2846
+ body={"domain": domain},
2847
+ output=output,
2848
+ )
2849
+
2850
+
2851
+ @gen_emails_transactional_aliases.command("delete")
2852
+ def gen_services_emails_transactional_aliases_delete(
2853
+ ctx: typer.Context,
2854
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2855
+ alias_id: int = typer.Argument(..., metavar="ALIAS_ID"),
2856
+ yes: bool = _YES_OPT,
2857
+ output: OutputFormat | None = _OUTPUT_OPT,
2858
+ ) -> None:
2859
+ """Delete emails transactional aliases."""
2860
+
2861
+ _generated_call(
2862
+ ctx,
2863
+ "DELETE",
2864
+ "/services/emails/{serviceId}/transactional/aliases/{aliasId}",
2865
+ {"serviceId": service_id, "aliasId": alias_id},
2866
+ confirm="Delete emails transactional aliases",
2867
+ yes=yes,
2868
+ output=output,
2869
+ )
2870
+
2871
+
2872
+ @gen_emails_transactional_aliases.command("list")
2873
+ def gen_services_emails_transactional_aliases_index(
2874
+ ctx: typer.Context,
2875
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2876
+ sort: str | None = _SORT_OPT,
2877
+ output: OutputFormat | None = _OUTPUT_OPT,
2878
+ ) -> None:
2879
+ """List emails transactional aliases."""
2880
+
2881
+ _generated_call(
2882
+ ctx,
2883
+ "GET",
2884
+ "/services/emails/{serviceId}/transactional/aliases",
2885
+ {"serviceId": service_id},
2886
+ sort=sort,
2887
+ output=output,
2888
+ )
2889
+
2890
+
2891
+ @gen_emails_transactional_keys.command("create")
2892
+ def gen_services_emails_transactional_keys_create(
2893
+ ctx: typer.Context,
2894
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2895
+ name: str | None = typer.Option(None),
2896
+ key: str | None = typer.Option(None),
2897
+ active: bool | None = typer.Option(None),
2898
+ output: OutputFormat | None = _OUTPUT_OPT,
2899
+ ) -> None:
2900
+ """Create emails transactional keys."""
2901
+
2902
+ _generated_call(
2903
+ ctx,
2904
+ "POST",
2905
+ "/services/emails/{serviceId}/transactional/keys",
2906
+ {"serviceId": service_id},
2907
+ body={"name": name, "key": key, "active": active},
2908
+ output=output,
2909
+ )
2910
+
2911
+
2912
+ @gen_emails_transactional_keys.command("delete")
2913
+ def gen_services_emails_transactional_keys_delete(
2914
+ ctx: typer.Context,
2915
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2916
+ key_id: int = typer.Argument(..., metavar="KEY_ID"),
2917
+ yes: bool = _YES_OPT,
2918
+ output: OutputFormat | None = _OUTPUT_OPT,
2919
+ ) -> None:
2920
+ """Delete emails transactional keys."""
2921
+
2922
+ _generated_call(
2923
+ ctx,
2924
+ "DELETE",
2925
+ "/services/emails/{serviceId}/transactional/keys/{keyId}",
2926
+ {"serviceId": service_id, "keyId": key_id},
2927
+ confirm="Delete emails transactional keys",
2928
+ yes=yes,
2929
+ output=output,
2930
+ )
2931
+
2932
+
2933
+ @gen_emails_transactional_keys.command("list")
2934
+ def gen_services_emails_transactional_keys_index(
2935
+ ctx: typer.Context,
2936
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2937
+ sort: str | None = _SORT_OPT,
2938
+ output: OutputFormat | None = _OUTPUT_OPT,
2939
+ ) -> None:
2940
+ """List emails transactional keys."""
2941
+
2942
+ _generated_call(
2943
+ ctx,
2944
+ "GET",
2945
+ "/services/emails/{serviceId}/transactional/keys",
2946
+ {"serviceId": service_id},
2947
+ sort=sort,
2948
+ output=output,
2949
+ )
2950
+
2951
+
2952
+ @gen_emails_transactional_keys.command("show")
2953
+ def gen_services_emails_transactional_keys_show(
2954
+ ctx: typer.Context,
2955
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2956
+ key_id: int = typer.Argument(..., metavar="KEY_ID"),
2957
+ output: OutputFormat | None = _OUTPUT_OPT,
2958
+ ) -> None:
2959
+ """Show emails transactional keys details."""
2960
+
2961
+ _generated_call(
2962
+ ctx,
2963
+ "GET",
2964
+ "/services/emails/{serviceId}/transactional/keys/{keyId}",
2965
+ {"serviceId": service_id, "keyId": key_id},
2966
+ output=output,
2967
+ )
2968
+
2969
+
2970
+ @gen_emails_transactional_keys.command("update")
2971
+ def gen_services_emails_transactional_keys_update(
2972
+ ctx: typer.Context,
2973
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2974
+ key_id: int = typer.Argument(..., metavar="KEY_ID"),
2975
+ name: str | None = typer.Option(None),
2976
+ key: str | None = typer.Option(None),
2977
+ active: bool | None = typer.Option(None),
2978
+ output: OutputFormat | None = _OUTPUT_OPT,
2979
+ ) -> None:
2980
+ """Update emails transactional keys."""
2981
+
2982
+ _generated_call(
2983
+ ctx,
2984
+ "PUT",
2985
+ "/services/emails/{serviceId}/transactional/keys/{keyId}",
2986
+ {"serviceId": service_id, "keyId": key_id},
2987
+ body={"name": name, "key": key, "active": active},
2988
+ output=output,
2989
+ )
2990
+
2991
+
2992
+ @gen_emails_transactional_users.command("create")
2993
+ def gen_services_emails_transactional_users_create(
2994
+ ctx: typer.Context,
2995
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
2996
+ emailaddress: str | None = typer.Option(None),
2997
+ password: str | None = typer.Option(None),
2998
+ delivery_mode: str | None = typer.Option(None),
2999
+ output: OutputFormat | None = _OUTPUT_OPT,
3000
+ ) -> None:
3001
+ """Create emails transactional users."""
3002
+
3003
+ _generated_call(
3004
+ ctx,
3005
+ "POST",
3006
+ "/services/emails/{serviceId}/transactional/users",
3007
+ {"serviceId": service_id},
3008
+ body={
3009
+ "emailaddress": emailaddress,
3010
+ "password": password,
3011
+ "delivery_mode": delivery_mode,
3012
+ },
3013
+ output=output,
3014
+ )
3015
+
3016
+
3017
+ @gen_emails_transactional_users.command("delete")
3018
+ def gen_services_emails_transactional_users_delete(
3019
+ ctx: typer.Context,
3020
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3021
+ user_id: int = typer.Argument(..., metavar="USER_ID"),
3022
+ yes: bool = _YES_OPT,
3023
+ output: OutputFormat | None = _OUTPUT_OPT,
3024
+ ) -> None:
3025
+ """Delete emails transactional users."""
3026
+
3027
+ _generated_call(
3028
+ ctx,
3029
+ "DELETE",
3030
+ "/services/emails/{serviceId}/transactional/users/{userId}",
3031
+ {"serviceId": service_id, "userId": user_id},
3032
+ confirm="Delete emails transactional users",
3033
+ yes=yes,
3034
+ output=output,
3035
+ )
3036
+
3037
+
3038
+ @gen_emails_transactional_users.command("list")
3039
+ def gen_services_emails_transactional_users_index(
3040
+ ctx: typer.Context,
3041
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3042
+ sort: str | None = _SORT_OPT,
3043
+ output: OutputFormat | None = _OUTPUT_OPT,
3044
+ ) -> None:
3045
+ """List emails transactional users."""
3046
+
3047
+ _generated_call(
3048
+ ctx,
3049
+ "GET",
3050
+ "/services/emails/{serviceId}/transactional/users",
3051
+ {"serviceId": service_id},
3052
+ sort=sort,
3053
+ output=output,
3054
+ )
3055
+
3056
+
3057
+ @gen_emails_transactional_users.command("show")
3058
+ def gen_services_emails_transactional_users_show(
3059
+ ctx: typer.Context,
3060
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3061
+ user_id: int = typer.Argument(..., metavar="USER_ID"),
3062
+ output: OutputFormat | None = _OUTPUT_OPT,
3063
+ ) -> None:
3064
+ """Show emails transactional users details."""
3065
+
3066
+ _generated_call(
3067
+ ctx,
3068
+ "GET",
3069
+ "/services/emails/{serviceId}/transactional/users/{userId}",
3070
+ {"serviceId": service_id, "userId": user_id},
3071
+ output=output,
3072
+ )
3073
+
3074
+
3075
+ @gen_emails_transactional_users.command("update")
3076
+ def gen_services_emails_transactional_users_update(
3077
+ ctx: typer.Context,
3078
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3079
+ user_id: int = typer.Argument(..., metavar="USER_ID"),
3080
+ password: str | None = typer.Option(None),
3081
+ delivery_mode: str | None = typer.Option(None),
3082
+ output: OutputFormat | None = _OUTPUT_OPT,
3083
+ ) -> None:
3084
+ """Update emails transactional users."""
3085
+
3086
+ _generated_call(
3087
+ ctx,
3088
+ "PUT",
3089
+ "/services/emails/{serviceId}/transactional/users/{userId}",
3090
+ {"serviceId": service_id, "userId": user_id},
3091
+ body={"password": password, "delivery_mode": delivery_mode},
3092
+ output=output,
3093
+ )
3094
+
3095
+
3096
+ @gen_forwards.command("list")
3097
+ def gen_services_forwards_index(
3098
+ ctx: typer.Context,
3099
+ sort: str | None = _SORT_OPT,
3100
+ output: OutputFormat | None = _OUTPUT_OPT,
3101
+ ) -> None:
3102
+ """List forwards."""
3103
+
3104
+ _generated_call(
3105
+ ctx,
3106
+ "GET",
3107
+ "/services/forwards",
3108
+ {},
3109
+ sort=sort,
3110
+ output=output,
3111
+ )
3112
+
3113
+
3114
+ @gen_forwards.command("show")
3115
+ def gen_services_forwards_show(
3116
+ ctx: typer.Context,
3117
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3118
+ output: OutputFormat | None = _OUTPUT_OPT,
3119
+ ) -> None:
3120
+ """Show forwards details."""
3121
+
3122
+ _generated_call(
3123
+ ctx,
3124
+ "GET",
3125
+ "/services/forwards/{serviceId}",
3126
+ {"serviceId": service_id},
3127
+ output=output,
3128
+ )
3129
+
3130
+
3131
+ @gen_proxies.command("down")
3132
+ def gen_services_proxies_down(
3133
+ ctx: typer.Context,
3134
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3135
+ yes: bool = _YES_OPT,
3136
+ output: OutputFormat | None = _OUTPUT_OPT,
3137
+ ) -> None:
3138
+ """Bring a proxy down."""
3139
+
3140
+ _generated_call(
3141
+ ctx,
3142
+ "GET",
3143
+ "/services/proxies/{serviceId}/down",
3144
+ {"serviceId": service_id},
3145
+ confirm="Down proxies",
3146
+ yes=yes,
3147
+ output=output,
3148
+ )
3149
+
3150
+
3151
+ @gen_proxies.command("list")
3152
+ def gen_services_proxies_index(
3153
+ ctx: typer.Context,
3154
+ sort: str | None = _SORT_OPT,
3155
+ output: OutputFormat | None = _OUTPUT_OPT,
3156
+ ) -> None:
3157
+ """List proxies."""
3158
+
3159
+ _generated_call(
3160
+ ctx,
3161
+ "GET",
3162
+ "/services/proxies",
3163
+ {},
3164
+ sort=sort,
3165
+ output=output,
3166
+ )
3167
+
3168
+
3169
+ @gen_proxies.command("show")
3170
+ def gen_services_proxies_show(
3171
+ ctx: typer.Context,
3172
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3173
+ output: OutputFormat | None = _OUTPUT_OPT,
3174
+ ) -> None:
3175
+ """Show proxies details."""
3176
+
3177
+ _generated_call(
3178
+ ctx,
3179
+ "GET",
3180
+ "/services/proxies/{serviceId}",
3181
+ {"serviceId": service_id},
3182
+ output=output,
3183
+ )
3184
+
3185
+
3186
+ @gen_proxies.command("up")
3187
+ def gen_services_proxies_up(
3188
+ ctx: typer.Context,
3189
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3190
+ yes: bool = _YES_OPT,
3191
+ output: OutputFormat | None = _OUTPUT_OPT,
3192
+ ) -> None:
3193
+ """Bring a proxy up."""
3194
+
3195
+ _generated_call(
3196
+ ctx,
3197
+ "GET",
3198
+ "/services/proxies/{serviceId}/up",
3199
+ {"serviceId": service_id},
3200
+ confirm="Up proxies",
3201
+ yes=yes,
3202
+ output=output,
3203
+ )
3204
+
3205
+
3206
+ @gen_searches.command("list")
3207
+ def gen_services_searches_index(
3208
+ ctx: typer.Context,
3209
+ sort: str | None = _SORT_OPT,
3210
+ output: OutputFormat | None = _OUTPUT_OPT,
3211
+ ) -> None:
3212
+ """List searches."""
3213
+
3214
+ _generated_call(
3215
+ ctx,
3216
+ "GET",
3217
+ "/services/searches",
3218
+ {},
3219
+ sort=sort,
3220
+ output=output,
3221
+ )
3222
+
3223
+
3224
+ @gen_searches.command("show")
3225
+ def gen_services_searches_show(
3226
+ ctx: typer.Context,
3227
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3228
+ output: OutputFormat | None = _OUTPUT_OPT,
3229
+ ) -> None:
3230
+ """Show searches details."""
3231
+
3232
+ _generated_call(
3233
+ ctx,
3234
+ "GET",
3235
+ "/services/searches/{serviceId}",
3236
+ {"serviceId": service_id},
3237
+ output=output,
3238
+ )
3239
+
3240
+
3241
+ @gen_servers.command("list")
3242
+ def gen_services_servers_index(
3243
+ ctx: typer.Context,
3244
+ sort: str | None = _SORT_OPT,
3245
+ output: OutputFormat | None = _OUTPUT_OPT,
3246
+ ) -> None:
3247
+ """List servers."""
3248
+
3249
+ _generated_call(
3250
+ ctx,
3251
+ "GET",
3252
+ "/services/servers",
3253
+ {},
3254
+ sort=sort,
3255
+ output=output,
3256
+ )
3257
+
3258
+
3259
+ @gen_servers.command("show")
3260
+ def gen_services_servers_show(
3261
+ ctx: typer.Context,
3262
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3263
+ output: OutputFormat | None = _OUTPUT_OPT,
3264
+ ) -> None:
3265
+ """Show servers details."""
3266
+
3267
+ _generated_call(
3268
+ ctx,
3269
+ "GET",
3270
+ "/services/servers/{serviceId}",
3271
+ {"serviceId": service_id},
3272
+ output=output,
3273
+ )
3274
+
3275
+
3276
+ @gen_servers.command("state")
3277
+ def gen_services_servers_state(
3278
+ ctx: typer.Context,
3279
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3280
+ state: str = typer.Argument(..., metavar="STATE"),
3281
+ yes: bool = _YES_OPT,
3282
+ output: OutputFormat | None = _OUTPUT_OPT,
3283
+ ) -> None:
3284
+ """Change the state of a server."""
3285
+
3286
+ _generated_call(
3287
+ ctx,
3288
+ "GET",
3289
+ "/services/servers/{serviceId}/state/{state}",
3290
+ {"serviceId": service_id, "state": state},
3291
+ confirm="State servers",
3292
+ yes=yes,
3293
+ output=output,
3294
+ )
3295
+
3296
+
3297
+ @gen_sites.command("list")
3298
+ def gen_services_sites_index(
3299
+ ctx: typer.Context,
3300
+ sort: str | None = _SORT_OPT,
3301
+ output: OutputFormat | None = _OUTPUT_OPT,
3302
+ ) -> None:
3303
+ """List sites."""
3304
+
3305
+ _generated_call(
3306
+ ctx,
3307
+ "GET",
3308
+ "/services/sites",
3309
+ {},
3310
+ sort=sort,
3311
+ output=output,
3312
+ )
3313
+
3314
+
3315
+ @gen_sites.command("show")
3316
+ def gen_services_sites_show(
3317
+ ctx: typer.Context,
3318
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3319
+ output: OutputFormat | None = _OUTPUT_OPT,
3320
+ ) -> None:
3321
+ """Show sites details."""
3322
+
3323
+ _generated_call(
3324
+ ctx,
3325
+ "GET",
3326
+ "/services/sites/{serviceId}",
3327
+ {"serviceId": service_id},
3328
+ output=output,
3329
+ )
3330
+
3331
+
3332
+ @gen_spams_clusters.command("list")
3333
+ def gen_services_spams_clusters_index(
3334
+ ctx: typer.Context,
3335
+ sort: str | None = _SORT_OPT,
3336
+ output: OutputFormat | None = _OUTPUT_OPT,
3337
+ ) -> None:
3338
+ """List spams clusters."""
3339
+
3340
+ _generated_call(
3341
+ ctx,
3342
+ "GET",
3343
+ "/services/spams/clusters",
3344
+ {},
3345
+ sort=sort,
3346
+ output=output,
3347
+ )
3348
+
3349
+
3350
+ @gen_spams_clusters.command("show")
3351
+ def gen_services_spams_clusters_show(
3352
+ ctx: typer.Context,
3353
+ cluster_id: int = typer.Argument(..., metavar="CLUSTER_ID"),
3354
+ output: OutputFormat | None = _OUTPUT_OPT,
3355
+ ) -> None:
3356
+ """Show spams clusters details."""
3357
+
3358
+ _generated_call(
3359
+ ctx,
3360
+ "GET",
3361
+ "/services/spams/clusters/{clusterId}",
3362
+ {"clusterId": cluster_id},
3363
+ output=output,
3364
+ )
3365
+
3366
+
3367
+ @gen_spams_domain.command("create")
3368
+ def gen_services_spams_domain_create(
3369
+ ctx: typer.Context,
3370
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3371
+ domain: str | None = typer.Option(None),
3372
+ output: OutputFormat | None = _OUTPUT_OPT,
3373
+ ) -> None:
3374
+ """Create spams domain."""
3375
+
3376
+ _generated_call(
3377
+ ctx,
3378
+ "POST",
3379
+ "/services/spams/{serviceId}/domains",
3380
+ {"serviceId": service_id},
3381
+ body={"domain": domain},
3382
+ output=output,
3383
+ )
3384
+
3385
+
3386
+ @gen_spams_domain.command("delete")
3387
+ def gen_services_spams_domain_delete(
3388
+ ctx: typer.Context,
3389
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3390
+ domain: str = typer.Argument(..., metavar="DOMAIN"),
3391
+ yes: bool = _YES_OPT,
3392
+ output: OutputFormat | None = _OUTPUT_OPT,
3393
+ ) -> None:
3394
+ """Delete spams domain."""
3395
+
3396
+ _generated_call(
3397
+ ctx,
3398
+ "DELETE",
3399
+ "/services/spams/{serviceId}/domains/{domain}",
3400
+ {"serviceId": service_id, "domain": domain},
3401
+ confirm="Delete spams domain",
3402
+ yes=yes,
3403
+ output=output,
3404
+ )
3405
+
3406
+
3407
+ @gen_spams_domain_dkim.command("disable")
3408
+ def gen_services_spams_domain_dkim_disable(
3409
+ ctx: typer.Context,
3410
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3411
+ domain: str = typer.Argument(..., metavar="DOMAIN"),
3412
+ yes: bool = _YES_OPT,
3413
+ output: OutputFormat | None = _OUTPUT_OPT,
3414
+ ) -> None:
3415
+ """Disable DKIM for a spam filter domain."""
3416
+
3417
+ _generated_call(
3418
+ ctx,
3419
+ "GET",
3420
+ "/services/spams/{serviceId}/domains/{domain}/dkim/disable",
3421
+ {"serviceId": service_id, "domain": domain},
3422
+ confirm="Disable spams domain dkim",
3423
+ yes=yes,
3424
+ output=output,
3425
+ )
3426
+
3427
+
3428
+ @gen_spams_domain_dkim.command("enable")
3429
+ def gen_services_spams_domain_dkim_enable(
3430
+ ctx: typer.Context,
3431
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3432
+ domain: str = typer.Argument(..., metavar="DOMAIN"),
3433
+ yes: bool = _YES_OPT,
3434
+ output: OutputFormat | None = _OUTPUT_OPT,
3435
+ ) -> None:
3436
+ """Enable DKIM for a spam filter domain."""
3437
+
3438
+ _generated_call(
3439
+ ctx,
3440
+ "GET",
3441
+ "/services/spams/{serviceId}/domains/{domain}/dkim/enable",
3442
+ {"serviceId": service_id, "domain": domain},
3443
+ confirm="Enable spams domain dkim",
3444
+ yes=yes,
3445
+ output=output,
3446
+ )
3447
+
3448
+
3449
+ @gen_spams_domain.command("list")
3450
+ def gen_services_spams_domain_index(
3451
+ ctx: typer.Context,
3452
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3453
+ sort: str | None = _SORT_OPT,
3454
+ output: OutputFormat | None = _OUTPUT_OPT,
3455
+ ) -> None:
3456
+ """List spams domain."""
3457
+
3458
+ _generated_call(
3459
+ ctx,
3460
+ "GET",
3461
+ "/services/spams/{serviceId}/domains",
3462
+ {"serviceId": service_id},
3463
+ sort=sort,
3464
+ output=output,
3465
+ )
3466
+
3467
+
3468
+ @gen_spams_domain.command("show")
3469
+ def gen_services_spams_domain_show(
3470
+ ctx: typer.Context,
3471
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3472
+ domain: str = typer.Argument(..., metavar="DOMAIN"),
3473
+ output: OutputFormat | None = _OUTPUT_OPT,
3474
+ ) -> None:
3475
+ """Show spams domain details."""
3476
+
3477
+ _generated_call(
3478
+ ctx,
3479
+ "GET",
3480
+ "/services/spams/{serviceId}/domains/{domain}",
3481
+ {"serviceId": service_id, "domain": domain},
3482
+ output=output,
3483
+ )
3484
+
3485
+
3486
+ @gen_spams.command("list")
3487
+ def gen_services_spams_index(
3488
+ ctx: typer.Context,
3489
+ sort: str | None = _SORT_OPT,
3490
+ output: OutputFormat | None = _OUTPUT_OPT,
3491
+ ) -> None:
3492
+ """List spams."""
3493
+
3494
+ _generated_call(
3495
+ ctx,
3496
+ "GET",
3497
+ "/services/spams",
3498
+ {},
3499
+ sort=sort,
3500
+ output=output,
3501
+ )
3502
+
3503
+
3504
+ @gen_spams.command("show")
3505
+ def gen_services_spams_show(
3506
+ ctx: typer.Context,
3507
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3508
+ output: OutputFormat | None = _OUTPUT_OPT,
3509
+ ) -> None:
3510
+ """Show spams details."""
3511
+
3512
+ _generated_call(
3513
+ ctx,
3514
+ "GET",
3515
+ "/services/spams/{serviceId}",
3516
+ {"serviceId": service_id},
3517
+ output=output,
3518
+ )
3519
+
3520
+
3521
+ @gen_spams_transports.command("create")
3522
+ def gen_services_spams_transports_create(
3523
+ ctx: typer.Context,
3524
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3525
+ domain: str | None = typer.Option(None),
3526
+ host: str | None = typer.Option(None),
3527
+ port: int | None = typer.Option(None),
3528
+ output: OutputFormat | None = _OUTPUT_OPT,
3529
+ ) -> None:
3530
+ """Create spams transports."""
3531
+
3532
+ _generated_call(
3533
+ ctx,
3534
+ "POST",
3535
+ "/services/spams/{serviceId}/transports",
3536
+ {"serviceId": service_id},
3537
+ body={"domain": domain, "host": host, "port": port},
3538
+ output=output,
3539
+ )
3540
+
3541
+
3542
+ @gen_spams_transports.command("delete")
3543
+ def gen_services_spams_transports_delete(
3544
+ ctx: typer.Context,
3545
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3546
+ domain: str = typer.Argument(..., metavar="DOMAIN"),
3547
+ yes: bool = _YES_OPT,
3548
+ output: OutputFormat | None = _OUTPUT_OPT,
3549
+ ) -> None:
3550
+ """Delete spams transports."""
3551
+
3552
+ _generated_call(
3553
+ ctx,
3554
+ "DELETE",
3555
+ "/services/spams/{serviceId}/transports/{domain}",
3556
+ {"serviceId": service_id, "domain": domain},
3557
+ confirm="Delete spams transports",
3558
+ yes=yes,
3559
+ output=output,
3560
+ )
3561
+
3562
+
3563
+ @gen_spams_transports.command("list")
3564
+ def gen_services_spams_transports_index(
3565
+ ctx: typer.Context,
3566
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3567
+ sort: str | None = _SORT_OPT,
3568
+ output: OutputFormat | None = _OUTPUT_OPT,
3569
+ ) -> None:
3570
+ """List spams transports."""
3571
+
3572
+ _generated_call(
3573
+ ctx,
3574
+ "GET",
3575
+ "/services/spams/{serviceId}/transports",
3576
+ {"serviceId": service_id},
3577
+ sort=sort,
3578
+ output=output,
3579
+ )
3580
+
3581
+
3582
+ @gen_spams_transports.command("show")
3583
+ def gen_services_spams_transports_show(
3584
+ ctx: typer.Context,
3585
+ service_id: int = typer.Argument(..., metavar="SERVICE_ID"),
3586
+ domain: str = typer.Argument(..., metavar="DOMAIN"),
3587
+ output: OutputFormat | None = _OUTPUT_OPT,
3588
+ ) -> None:
3589
+ """Show spams transports details."""
3590
+
3591
+ _generated_call(
3592
+ ctx,
3593
+ "GET",
3594
+ "/services/spams/{serviceId}/transports/{domain}",
3595
+ {"serviceId": service_id, "domain": domain},
3596
+ output=output,
3597
+ )
3598
+
3599
+
3600
+ # --- END GENERATED COMMANDS ---
3601
+
3602
+
3603
+ def run() -> None:
3604
+ app()
3605
+
3606
+
3607
+ if __name__ == "__main__":
3608
+ run()