devnomads-cli 0.11.2__tar.gz → 0.12.0__tar.gz

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.
Files changed (21) hide show
  1. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/PKG-INFO +9 -6
  2. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/README.md +8 -5
  3. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/devnomads_cli.egg-info/PKG-INFO +9 -6
  4. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/dncli.py +209 -62
  5. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/pyproject.toml +1 -1
  6. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_cert.py +29 -22
  7. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_cli.py +21 -1
  8. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_generate.py +24 -0
  9. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_versions.py +62 -0
  10. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/LICENSE +0 -0
  11. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  12. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  13. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  14. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/devnomads_cli.egg-info/requires.txt +0 -0
  15. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  16. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/setup.cfg +0 -0
  17. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_config.py +0 -0
  18. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_generated_cli.py +0 -0
  19. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_helpers.py +0 -0
  20. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_hook.py +0 -0
  21. {devnomads_cli-0.11.2 → devnomads_cli-0.12.0}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.11.2
3
+ Version: 0.12.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -245,9 +245,9 @@ DNS-01 challenge records would not be visible to the CA.
245
245
 
246
246
  The certificate is always written to `~/.config/dncli/certs/<domain>/`
247
247
  as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
248
- Pass `--out <file>` to additionally export a single PEM bundle of
249
- exactly three blocks - the key, the certificate, and the issuing
250
- intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
248
+ Pass `--out <file>` to additionally export a single PEM bundle of the
249
+ key, the certificate, and the full chain (intermediate plus any
250
+ cross-signed roots), in that order. Add `-v`/`--verbose` for detailed ACME
251
251
  progress.
252
252
 
253
253
  Keys are ECDSA P-384 by default. Pick another with `--key-type`
@@ -289,8 +289,11 @@ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
289
289
  ```
290
290
 
291
291
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
292
- `--password`). Both bundle formats carry the same three items: key,
293
- certificate, and the issuing intermediate.
292
+ `--password`). Both bundle formats carry the same items: key,
293
+ certificate, and the full chain. The chain includes the cross-signs:
294
+ with Let's Encrypt's current hierarchy the path to a trusted root runs
295
+ through the intermediate (e.g. YE2), Root YE (cross-signed by ISRG
296
+ Root X2) and ISRG Root X2 (cross-signed by ISRG Root X1).
294
297
 
295
298
  `dncli certs delete <domain>` removes a stored certificate and its
296
299
  private key from disk (after confirmation; `--yes` skips it). By
@@ -229,9 +229,9 @@ DNS-01 challenge records would not be visible to the CA.
229
229
 
230
230
  The certificate is always written to `~/.config/dncli/certs/<domain>/`
231
231
  as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
232
- Pass `--out <file>` to additionally export a single PEM bundle of
233
- exactly three blocks - the key, the certificate, and the issuing
234
- intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
232
+ Pass `--out <file>` to additionally export a single PEM bundle of the
233
+ key, the certificate, and the full chain (intermediate plus any
234
+ cross-signed roots), in that order. Add `-v`/`--verbose` for detailed ACME
235
235
  progress.
236
236
 
237
237
  Keys are ECDSA P-384 by default. Pick another with `--key-type`
@@ -273,8 +273,11 @@ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
273
273
  ```
274
274
 
275
275
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
276
- `--password`). Both bundle formats carry the same three items: key,
277
- certificate, and the issuing intermediate.
276
+ `--password`). Both bundle formats carry the same items: key,
277
+ certificate, and the full chain. The chain includes the cross-signs:
278
+ with Let's Encrypt's current hierarchy the path to a trusted root runs
279
+ through the intermediate (e.g. YE2), Root YE (cross-signed by ISRG
280
+ Root X2) and ISRG Root X2 (cross-signed by ISRG Root X1).
278
281
 
279
282
  `dncli certs delete <domain>` removes a stored certificate and its
280
283
  private key from disk (after confirmation; `--yes` skips it). By
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.11.2
3
+ Version: 0.12.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -245,9 +245,9 @@ DNS-01 challenge records would not be visible to the CA.
245
245
 
246
246
  The certificate is always written to `~/.config/dncli/certs/<domain>/`
247
247
  as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
248
- Pass `--out <file>` to additionally export a single PEM bundle of
249
- exactly three blocks - the key, the certificate, and the issuing
250
- intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
248
+ Pass `--out <file>` to additionally export a single PEM bundle of the
249
+ key, the certificate, and the full chain (intermediate plus any
250
+ cross-signed roots), in that order. Add `-v`/`--verbose` for detailed ACME
251
251
  progress.
252
252
 
253
253
  Keys are ECDSA P-384 by default. Pick another with `--key-type`
@@ -289,8 +289,11 @@ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
289
289
  ```
290
290
 
291
291
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
292
- `--password`). Both bundle formats carry the same three items: key,
293
- certificate, and the issuing intermediate.
292
+ `--password`). Both bundle formats carry the same items: key,
293
+ certificate, and the full chain. The chain includes the cross-signs:
294
+ with Let's Encrypt's current hierarchy the path to a trusted root runs
295
+ through the intermediate (e.g. YE2), Root YE (cross-signed by ISRG
296
+ Root X2) and ISRG Root X2 (cross-signed by ISRG Root X1).
294
297
 
295
298
  `dncli certs delete <domain>` removes a stored certificate and its
296
299
  private key from disk (after confirmation; `--yes` skips it). By
@@ -43,6 +43,7 @@ from pathlib import Path
43
43
  from typing import (
44
44
  Annotated,
45
45
  Any,
46
+ Callable,
46
47
  ContextManager,
47
48
  Iterable,
48
49
  Literal,
@@ -64,7 +65,7 @@ from rich.markup import escape
64
65
  from rich.table import Table
65
66
  from typer.core import TyperGroup
66
67
 
67
- __version__ = "0.11.2"
68
+ __version__ = "0.12.0"
68
69
 
69
70
  # Distribution names, for reporting versions via `dncli version`.
70
71
  CLI_PACKAGE = "devnomads-cli"
@@ -268,13 +269,28 @@ def devnomads_profiles(parser: configparser.ConfigParser) -> list[str]:
268
269
  return [section for section in parser.sections() if ":" not in section]
269
270
 
270
271
 
271
- def resolve_credentials(state: AppState) -> tuple[str, str]:
272
- """Resolve (api_key, api_url): flag > env > profile > [default]."""
272
+ def _profile_section(
273
+ state: AppState,
274
+ ) -> tuple[configparser.ConfigParser, str, configparser.SectionProxy | None]:
275
+ """The credentials, the active profile name and its section (if any)."""
273
276
 
274
277
  parser = load_credentials()
275
278
  profile = state.profile or os.environ.get(ENV_PROFILE) or DEFAULT_PROFILE
279
+ return parser, profile, parser[profile] if parser.has_section(profile) else None
280
+
281
+
282
+ def resolve_api_url(state: AppState) -> str:
283
+ """The API URL of the active profile; no API key needed."""
284
+
285
+ _, _, section = _profile_section(state)
286
+ return section.get("api_url", DEFAULT_API_URL) if section else DEFAULT_API_URL
287
+
288
+
289
+ def resolve_credentials(state: AppState) -> tuple[str, str]:
290
+ """Resolve (api_key, api_url): flag > env > profile > [default]."""
291
+
292
+ parser, profile, section = _profile_section(state)
276
293
  explicit = bool(state.profile or os.environ.get(ENV_PROFILE))
277
- section = parser[profile] if parser.has_section(profile) else None
278
294
  api_url = section.get("api_url", DEFAULT_API_URL) if section else DEFAULT_API_URL
279
295
  api_key = (
280
296
  state.api_key
@@ -703,6 +719,7 @@ class DevNomadsClient:
703
719
 
704
720
  def __init__(self, api_url: str, api_key: str, *, debug: bool = False) -> None:
705
721
  self.debug = debug
722
+ self.api_url = api_url
706
723
  self._client = ApiClient(
707
724
  api_url,
708
725
  api_key,
@@ -775,7 +792,7 @@ class DevNomadsClient:
775
792
  response = self._client.send(
776
793
  method, path, params=params, json_body=json_body, timeout=timeout
777
794
  )
778
- check_api_version(response.headers.get("x-api-version"))
795
+ check_api_version(response.headers.get("x-api-version"), self.api_url)
779
796
  return response
780
797
 
781
798
 
@@ -802,19 +819,35 @@ def _error_message(status: int, detail: str) -> str:
802
819
 
803
820
 
804
821
  _api_version_warned = False
822
+ _api_spec_checked = False
805
823
 
806
824
 
807
- def check_api_version(reported: str | None) -> None:
808
- """Warn once per run when the API's major version (X-Api-Version header)
809
- differs from the one this dncli was generated for: commands may then
810
- send or expect the wrong fields. Silent while the API sends no header."""
825
+ def _api_compatible(have: tuple[int, int, int], built: tuple[int, int, int]) -> bool:
826
+ """Same major version. Before 1.0 the minor version counts as the major
827
+ one, as in semver: a 0.x API may change anything in a minor release."""
811
828
 
812
- global _api_version_warned
829
+ size = 2 if built[0] == 0 else 1
830
+ return have[:size] == built[:size]
831
+
832
+
833
+ def check_api_version(reported: str | None, api_url: str | None = None) -> None:
834
+ """Warn once per run when the API's version is incompatible with the one
835
+ this dncli was generated for: commands may then send or expect the wrong
836
+ fields. The API's version comes from its X-Api-Version header; while it
837
+ sends none, from the cached version of its OpenAPI spec (see
838
+ cached_api_version)."""
839
+
840
+ global _api_version_warned, _api_spec_checked
841
+ if _api_version_warned:
842
+ return
843
+ if reported is None and api_url is not None and not _api_spec_checked:
844
+ _api_spec_checked = True
845
+ reported = cached_api_version(api_url)
813
846
  have, built = _version_tuple(reported), _version_tuple(API_VERSION)
814
- if _api_version_warned or have is None or built is None or have[0] == built[0]:
847
+ if have is None or built is None or _api_compatible(have, built):
815
848
  return
816
849
  _api_version_warned = True
817
- if have[0] > built[0]:
850
+ if have > built:
818
851
  advice = f"update dncli: {upgrade_command()}"
819
852
  else:
820
853
  advice = "this dncli is newer than the API; some commands may fail"
@@ -1431,9 +1464,16 @@ def version_cmd(
1431
1464
  output: OutputOption = None,
1432
1465
  columns: ColumnsOption = None,
1433
1466
  ) -> None:
1434
- """Show the dncli and devnomads library versions."""
1467
+ """Show the dncli and devnomads library versions, and the API version
1468
+ dncli was built for next to the one the API runs."""
1435
1469
 
1436
- render(state_from(ctx, output, columns), _versions())
1470
+ state = state_from(ctx, output, columns)
1471
+ api_url = resolve_api_url(state)
1472
+ api = api_spec_version(api_url)
1473
+ if api is not None:
1474
+ remember_api_version(api_url, api)
1475
+ versions = {**_versions(), "api-built-for": API_VERSION}
1476
+ render(state, {**versions, "api": api or "unreachable"})
1437
1477
 
1438
1478
 
1439
1479
  @app.command("upgrade")
@@ -2317,26 +2357,34 @@ def _write_cert_files(
2317
2357
  (out_dir / name).write_text(content)
2318
2358
 
2319
2359
 
2320
- def _first_cert(pem: str) -> str:
2321
- """The first PEM certificate block in ``pem`` (the issuing intermediate
2322
- of a chain), or "" if there is none."""
2360
+ def _pem_certs(pem: str) -> list[str]:
2361
+ """Every PEM certificate block in ``pem``, in order, each ending in a
2362
+ single newline. Whatever sits between blocks - such as the blank line
2363
+ Let's Encrypt puts between the certificates of a chain - is dropped."""
2323
2364
 
2324
2365
  begin = "-----BEGIN CERTIFICATE-----"
2325
2366
  end = "-----END CERTIFICATE-----"
2367
+ certs = []
2326
2368
  start = pem.find(begin)
2327
- if start == -1:
2328
- return ""
2329
- stop = pem.find(end, start)
2330
- if stop == -1:
2331
- return ""
2332
- return pem[start : stop + len(end)] + "\n"
2369
+ while start != -1:
2370
+ stop = pem.find(end, start)
2371
+ if stop == -1:
2372
+ break
2373
+ certs.append(pem[start : stop + len(end)] + "\n")
2374
+ start = pem.find(begin, stop)
2375
+ return certs
2333
2376
 
2334
2377
 
2335
2378
  def _combined_pem(privkey: str, leaf: str, chain: str) -> str:
2336
- """One PEM bundle of exactly three blocks: key, then leaf certificate,
2337
- then the single issuing intermediate (the rest of the chain is dropped)."""
2379
+ """One PEM bundle: key, then leaf certificate, then the whole chain.
2380
+
2381
+ The chain is kept intact, cross-signs included: with the ISRG "gen-Y"
2382
+ hierarchy the path to a trusted root runs through YE2, Root YE
2383
+ (cross-signed by ISRG Root X2) and ISRG Root X2 (cross-signed by
2384
+ ISRG Root X1), and a bundle with only the issuing intermediate
2385
+ verifies nowhere."""
2338
2386
 
2339
- parts = [privkey, leaf, _first_cert(chain)]
2387
+ parts = [privkey, leaf, *_pem_certs(chain)]
2340
2388
  return "".join(p if p.endswith("\n") else p + "\n" for p in parts if p)
2341
2389
 
2342
2390
 
@@ -2355,7 +2403,7 @@ def _export_pkcs12(
2355
2403
  name: str,
2356
2404
  passphrase: str | None = None,
2357
2405
  ) -> None:
2358
- """Write a PKCS#12 (.pfx) bundle of key + leaf + single intermediate at
2406
+ """Write a PKCS#12 (.pfx) bundle of key + leaf + the whole chain at
2359
2407
  0600. Unencrypted unless ``passphrase`` is given."""
2360
2408
 
2361
2409
  from cryptography import x509
@@ -2366,12 +2414,7 @@ def _export_pkcs12(
2366
2414
  try:
2367
2415
  key = serialization.load_pem_private_key(privkey.encode(), password=None)
2368
2416
  cert = x509.load_pem_x509_certificate(leaf.encode())
2369
- intermediate = _first_cert(chain)
2370
- cas = (
2371
- [x509.load_pem_x509_certificate(intermediate.encode())]
2372
- if intermediate
2373
- else None
2374
- )
2417
+ cas = [x509.load_pem_x509_certificate(c.encode()) for c in _pem_certs(chain)]
2375
2418
  except ValueError as exc:
2376
2419
  raise CliError(f"could not read stored certificate files: {exc}") from exc
2377
2420
  if not isinstance(key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
@@ -2624,7 +2667,7 @@ def cert_issue(
2624
2667
  typer.Option(
2625
2668
  "--out",
2626
2669
  help="Also export a single PEM bundle (key + certificate + "
2627
- "intermediate) to this file.",
2670
+ "chain) to this file.",
2628
2671
  ),
2629
2672
  ] = None,
2630
2673
  verbose: Annotated[
@@ -2810,9 +2853,9 @@ def cert_export(
2810
2853
  ),
2811
2854
  ] = None,
2812
2855
  ) -> None:
2813
- """Re-export an issued certificate as one bundle (key + cert + intermediate).
2856
+ """Re-export an issued certificate as one bundle (key + cert + chain).
2814
2857
 
2815
- The default PEM bundle is three blocks; --format pfx writes a PKCS#12 file.
2858
+ The default is a PEM bundle; --format pfx writes a PKCS#12 file.
2816
2859
  """
2817
2860
 
2818
2861
  cert_dir = _cert_dir(domain)
@@ -3303,7 +3346,7 @@ def _wait_for_batch(
3303
3346
 
3304
3347
  # --- BEGIN GENERATED COMMANDS (tools/generate.py; do not edit by hand) ---
3305
3348
 
3306
- API_VERSION = "1.0.0"
3349
+ API_VERSION = "0.6.13"
3307
3350
  SKILL_MD = """\
3308
3351
  ---
3309
3352
  name: devnomads
@@ -3412,6 +3455,9 @@ Job `status` is one of `queued`, `running`, `completed`, `failed`,
3412
3455
  will be lost.
3413
3456
  - Database services cannot be deleted from dncli at the moment; use
3414
3457
  `dncli services discontinue` or the portal.
3458
+ - `dncli servers reinstall <id>` and `dncli servers snapshots restore` keep
3459
+ the service but overwrite its disks. Both need the server shut down
3460
+ (`dncli servers state <id> shutdown`). Confirm with the user first.
3415
3461
 
3416
3462
  ## Where values come from
3417
3463
 
@@ -3421,7 +3467,6 @@ Job `status` is one of `queued`, `running`, `completed`, `failed`,
3421
3467
  | `--product-sku` | `dncli products list` (match `type`) |
3422
3468
  | `--cluster-id` (sites) and allowed `--php-version` | `dncli sites clusters list` (`php_versions`) |
3423
3469
  | `--cluster-id` (containers, databases, proxies) | `dncli <group> clusters list` |
3424
- | `--hypervisor-id` | `dncli servers hypervisors list` (optional) |
3425
3470
  | `--ipv4-network-id`, `--ipv6-network-id` | `dncli servers networks list` (same `vlan`) |
3426
3471
  | `--key-id` | `dncli keys list` |
3427
3472
  | `--handle-id-*`, `--handle-id` | `dncli handles list` |
@@ -4169,6 +4214,10 @@ def gen_keys_store(
4169
4214
  @gen_products.command("list")
4170
4215
  def gen_products_index(
4171
4216
  ctx: typer.Context,
4217
+ filter_type: str | None = typer.Option(
4218
+ None,
4219
+ help="Filter products by exact product type. Multiple types may be separated by commas.",
4220
+ ),
4172
4221
  sort: str | None = _SORT_OPT,
4173
4222
  timeout: int = _TIMEOUT_OPT,
4174
4223
  output: OutputFormat | None = _OUTPUT_OPT,
@@ -4184,6 +4233,7 @@ def gen_products_index(
4184
4233
  "GET",
4185
4234
  "/products",
4186
4235
  {},
4236
+ query={"filter[type]": filter_type},
4187
4237
  sort=sort,
4188
4238
  timeout=timeout,
4189
4239
  output=output,
@@ -8008,6 +8058,44 @@ def gen_services_servers_networks_index(
8008
8058
  )
8009
8059
 
8010
8060
 
8061
+ @gen_servers.command("reinstall")
8062
+ def gen_services_servers_reinstall(
8063
+ ctx: typer.Context,
8064
+ service_id: str = typer.Argument(
8065
+ ...,
8066
+ metavar="SERVICE_ID",
8067
+ help="Service id, or the service's unique entity name (usually its domain or name).",
8068
+ ),
8069
+ template: str = typer.Option(
8070
+ ..., help="Operating system to install, for example debian-13."
8071
+ ),
8072
+ root_pw: str = typer.Option(..., help="New root password: 8-32 ASCII characters."),
8073
+ yes: bool = _YES_OPT,
8074
+ wait: bool = _WAIT_OPT,
8075
+ timeout: int = _TIMEOUT_OPT,
8076
+ output: OutputFormat | None = _OUTPUT_OPT,
8077
+ columns: str | None = _COLUMNS_OPT,
8078
+ ) -> None:
8079
+ """Reinstall the operating system of a server.
8080
+
8081
+ The server must be shut down. Everything on its disks is lost."""
8082
+
8083
+ _generated_call(
8084
+ ctx,
8085
+ "POST",
8086
+ "/services/servers/{serviceId}/reinstall",
8087
+ {"serviceId": service_id},
8088
+ body={"template": template, "root_pw": root_pw},
8089
+ resolve="/services/servers",
8090
+ confirm="Reinstall this server, erasing everything on its disks",
8091
+ yes=yes,
8092
+ wait=wait,
8093
+ timeout=timeout,
8094
+ output=output,
8095
+ select=columns,
8096
+ )
8097
+
8098
+
8011
8099
  @gen_servers.command("show")
8012
8100
  def gen_services_servers_show(
8013
8101
  ctx: typer.Context,
@@ -8205,22 +8293,9 @@ def gen_services_servers_store(
8205
8293
  help="Server product, the `sku` of a product of type server in `dncli products list`; sets memory, CPUs and disk.",
8206
8294
  ),
8207
8295
  name: str = typer.Option(..., help="Server name; also its hostname."),
8208
- hypervisor_id: int | None = typer.Option(
8209
- None,
8210
- help="Hypervisor to place it on, from `dncli servers hypervisors list`; omit to let DevNomads choose.",
8211
- ),
8212
- template: Literal[
8213
- "almalinux-8",
8214
- "almalinux-9",
8215
- "almalinux-10",
8216
- "almalinux-10-kitten",
8217
- "rockylinux-8",
8218
- "rockylinux-9",
8219
- "debian-12",
8220
- "debian-13",
8221
- "ubuntu-22.04",
8222
- "ubuntu-24.04",
8223
- ] = typer.Option(..., help="Operating system to install."),
8296
+ template: str = typer.Option(
8297
+ ..., help="Operating system to install, for example debian-13."
8298
+ ),
8224
8299
  ipv4_network_id: int = typer.Option(
8225
8300
  ...,
8226
8301
  help="IPv4 network, from `dncli servers networks list`; same VLAN as the IPv6 one.",
@@ -8247,7 +8322,6 @@ def gen_services_servers_store(
8247
8322
  "client_id": client_id,
8248
8323
  "product_sku": product_sku,
8249
8324
  "name": name,
8250
- "hypervisor_id": hypervisor_id,
8251
8325
  "template": template,
8252
8326
  "ipv4_network_id": ipv4_network_id,
8253
8327
  "ipv6_network_id": ipv6_network_id,
@@ -8268,19 +8342,25 @@ def gen_services_servers_update(
8268
8342
  help="Service id, or the service's unique entity name (usually its domain or name).",
8269
8343
  ),
8270
8344
  name: str | None = typer.Option(None, help="New name."),
8345
+ product_sku: str | None = typer.Option(
8346
+ None,
8347
+ help="New server product, the `sku` of a product of type server in `dncli products list`; sets memory, CPUs and disk.",
8348
+ ),
8349
+ wait: bool = _WAIT_OPT,
8271
8350
  timeout: int = _TIMEOUT_OPT,
8272
8351
  output: OutputFormat | None = _OUTPUT_OPT,
8273
8352
  columns: str | None = _COLUMNS_OPT,
8274
8353
  ) -> None:
8275
- """Rename a server."""
8354
+ """Rename a server or move it to another product."""
8276
8355
 
8277
8356
  _generated_call(
8278
8357
  ctx,
8279
8358
  "PUT",
8280
8359
  "/services/servers/{serviceId}",
8281
8360
  {"serviceId": service_id},
8282
- body={"name": name},
8361
+ body={"name": name, "product_sku": product_sku},
8283
8362
  resolve="/services/servers",
8363
+ wait=wait,
8284
8364
  timeout=timeout,
8285
8365
  output=output,
8286
8366
  select=columns,
@@ -9214,10 +9294,15 @@ def gen_services_spams_transports_show(
9214
9294
  # ---------------------------------------------------------------------------
9215
9295
  # Update notice: at most once a day, dncli asks PyPI for the latest release in
9216
9296
  # the background and, on an interactive terminal, says so after the command.
9297
+ # The API's version is checked the same way, from its OpenAPI spec, while the
9298
+ # API sends no X-Api-Version header.
9217
9299
 
9218
9300
  PYPI_URL = f"https://pypi.org/pypi/{CLI_PACKAGE}/json"
9219
9301
  UPDATE_CHECK_INTERVAL = 24 * 3600
9220
9302
  UPDATE_CHECK_TIMEOUT = 2.0
9303
+ API_SPEC_PATH = "/docs?api-docs.json"
9304
+ # the background fetches and notify_update share update-check.json
9305
+ _update_state_lock = threading.Lock()
9221
9306
 
9222
9307
 
9223
9308
  def _update_state_path() -> Path:
@@ -9269,6 +9354,13 @@ def latest_version() -> str | None:
9269
9354
  return None
9270
9355
 
9271
9356
 
9357
+ def _change_update_state(change: Callable[[dict[str, Any]], None]) -> None:
9358
+ with _update_state_lock:
9359
+ state = _read_update_state()
9360
+ change(state)
9361
+ _write_update_state(state)
9362
+
9363
+
9272
9364
  def _fetch_latest_version() -> None:
9273
9365
  """Ask PyPI for the latest release and remember it. Any failure is
9274
9366
  ignored: the check must never get in the way of a command."""
@@ -9276,9 +9368,65 @@ def _fetch_latest_version() -> None:
9276
9368
  latest = latest_version()
9277
9369
  if latest is None:
9278
9370
  return
9279
- state = _read_update_state()
9280
- state.update(latest=str(latest), checked_at=time.time())
9281
- _write_update_state(state)
9371
+ _change_update_state(
9372
+ lambda state: state.update(latest=str(latest), checked_at=time.time())
9373
+ )
9374
+
9375
+
9376
+ def api_spec_version(api_url: str) -> str | None:
9377
+ """The version in the API's OpenAPI spec (info.version), or None when
9378
+ the spec cannot be fetched."""
9379
+
9380
+ try:
9381
+ response = httpx.get(
9382
+ api_url.rstrip("/") + API_SPEC_PATH,
9383
+ timeout=UPDATE_CHECK_TIMEOUT,
9384
+ headers={"User-Agent": user_agent()},
9385
+ )
9386
+ response.raise_for_status()
9387
+ return str(response.json()["info"]["version"])
9388
+ except Exception: # best effort, like the PyPI check
9389
+ return None
9390
+
9391
+
9392
+ def remember_api_version(api_url: str, version: str) -> None:
9393
+ """Cache the API's version, per API URL (profiles may use another API)."""
9394
+
9395
+ def change(state: dict[str, Any]) -> None:
9396
+ apis = state.get("api")
9397
+ if not isinstance(apis, dict):
9398
+ apis = state["api"] = {}
9399
+ apis[api_url.rstrip("/")] = {"version": version, "checked_at": time.time()}
9400
+
9401
+ _change_update_state(change)
9402
+
9403
+
9404
+ def _fetch_api_version(api_url: str) -> None:
9405
+ version = api_spec_version(api_url)
9406
+ if version is not None:
9407
+ remember_api_version(api_url, version)
9408
+
9409
+
9410
+ def cached_api_version(api_url: str) -> str | None:
9411
+ """The API's version from its spec as cached by an earlier run, and a
9412
+ background refresh when that is more than a day old. The spec is too
9413
+ large to fetch on every run. Like the update notice, only at a
9414
+ terminal."""
9415
+
9416
+ if not _update_check_enabled():
9417
+ return None
9418
+ apis = _read_update_state().get("api")
9419
+ entry = apis.get(api_url.rstrip("/")) if isinstance(apis, dict) else None
9420
+ entry = entry if isinstance(entry, dict) else {}
9421
+ checked = entry.get("checked_at", 0)
9422
+ if not isinstance(checked, (int, float)) or (
9423
+ time.time() - checked >= UPDATE_CHECK_INTERVAL
9424
+ ):
9425
+ threading.Thread(
9426
+ target=_fetch_api_version, args=(api_url,), daemon=True
9427
+ ).start()
9428
+ version = entry.get("version")
9429
+ return version if isinstance(version, str) else None
9282
9430
 
9283
9431
 
9284
9432
  def start_update_check() -> None:
@@ -9316,8 +9464,7 @@ def notify_update() -> None:
9316
9464
  f"{__version__}): {upgrade_command()}",
9317
9465
  soft_wrap=True,
9318
9466
  )
9319
- state["notified_at"] = time.time()
9320
- _write_update_state(state)
9467
+ _change_update_state(lambda state: state.update(notified_at=time.time()))
9321
9468
 
9322
9469
 
9323
9470
  # Hidden spelling of --yes, for when you really, really mean it. Click cannot
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.11.2"
3
+ version = "0.12.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -13,8 +13,15 @@ runner = CliRunner()
13
13
  LEAF = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n"
14
14
  INTERMEDIATE = "-----BEGIN CERTIFICATE-----\nINTER\n-----END CERTIFICATE-----\n"
15
15
  ROOT = "-----BEGIN CERTIFICATE-----\nROOT\n-----END CERTIFICATE-----\n"
16
- # Let's Encrypt returns a multi-cert chain (intermediate + cross-signs/roots).
16
+ # Let's Encrypt returns a multi-cert chain (intermediate + cross-signs).
17
17
  CHAIN = INTERMEDIATE + ROOT
18
+ #: The gen-Y chain as Let's Encrypt returns it: a blank line before every
19
+ #: certificate after the first. The bodies are stand-ins.
20
+ GEN_Y_CHAIN = (
21
+ "-----BEGIN CERTIFICATE-----\nYE2\n-----END CERTIFICATE-----\n"
22
+ "\n-----BEGIN CERTIFICATE-----\nROOTYE\n-----END CERTIFICATE-----\n"
23
+ "\n-----BEGIN CERTIFICATE-----\nX2BYX1\n-----END CERTIFICATE-----\n"
24
+ )
18
25
  FULLCHAIN = LEAF + CHAIN
19
26
  KEY = b"-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n"
20
27
 
@@ -346,11 +353,8 @@ def test_issue_out_exports_combined_pem(
346
353
  default_dir = dncli.config_dir() / "certs" / "example.com"
347
354
  assert (default_dir / "fullchain.pem").read_text() == FULLCHAIN
348
355
  assert (default_dir / "chain.pem").read_text() == CHAIN
349
- # ...and the combined file is exactly key + leaf + first intermediate,
350
- # i.e. the rest of the chain (roots) is dropped.
351
- assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE
352
- assert ROOT not in out.read_text()
353
- assert out.read_text().count("BEGIN CERTIFICATE") == 2
356
+ # ...and the combined file is key + leaf + the whole chain.
357
+ assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE + ROOT
354
358
  assert stat.S_IMODE(out.stat().st_mode) == 0o600
355
359
 
356
360
 
@@ -363,15 +367,20 @@ def test_our_zone_for_matches_longest_suffix():
363
367
  assert dncli._our_zone_for("other.org", zones) is None
364
368
 
365
369
 
366
- def test_first_cert_takes_only_the_intermediate():
367
- assert dncli._first_cert(INTERMEDIATE + ROOT) == INTERMEDIATE
368
- assert dncli._first_cert("no cert here") == ""
370
+ def test_pem_certs_splits_every_block():
371
+ assert dncli._pem_certs(INTERMEDIATE + ROOT) == [INTERMEDIATE, ROOT]
372
+ assert dncli._pem_certs("no cert here") == []
369
373
 
370
374
 
371
- def test_combined_pem_is_three_blocks(tmp_path):
372
- bundle = dncli._combined_pem(KEY.decode(), LEAF, INTERMEDIATE + ROOT)
373
- assert bundle == KEY.decode() + LEAF + INTERMEDIATE
374
- assert bundle.count("BEGIN CERTIFICATE") == 2 # leaf + one intermediate
375
+ def test_combined_pem_keeps_the_whole_gen_y_chain():
376
+ """Dropping the cross-signs leaves a chain ending at Root YE, which
377
+ common trust stores do not have: nothing can verify it."""
378
+
379
+ bundle = dncli._combined_pem(KEY.decode(), LEAF, GEN_Y_CHAIN)
380
+ assert bundle.count("BEGIN CERTIFICATE") == 4 # leaf + three chain certs
381
+ assert bundle.index("YE2") < bundle.index("ROOTYE") < bundle.index("X2BYX1")
382
+ # no blank lines, so the bundle survives HAProxy's `<<` runtime-API payload
383
+ assert "\n\n" not in bundle
375
384
 
376
385
 
377
386
  def test_export_combined_pem_mode(tmp_path):
@@ -379,7 +388,7 @@ def test_export_combined_pem_mode(tmp_path):
379
388
  dncli._export_combined_pem(
380
389
  out, privkey=KEY.decode(), leaf=LEAF, chain=INTERMEDIATE + ROOT
381
390
  )
382
- assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE
391
+ assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE + ROOT
383
392
  assert stat.S_IMODE(out.stat().st_mode) == 0o600
384
393
 
385
394
 
@@ -620,11 +629,9 @@ def test_cert_export_to_file(configured, isolated_config, tmp_path):
620
629
  result = runner.invoke(app, ["certs", "export", "example.com", "--out", str(out)])
621
630
  assert result.exit_code == 0, result.output
622
631
  text = out.read_text()
623
- # order: private key, then leaf certificate, then the one intermediate
624
- assert text.index("EC PRIVATE KEY") < text.index("INTER")
625
- # only the first intermediate (not the root), and exactly three blocks
626
- assert "ROOT" not in text
627
- assert text.count("BEGIN CERTIFICATE") == 2
632
+ # order: private key, then leaf certificate, then the whole chain
633
+ assert text.index("EC PRIVATE KEY") < text.index("INTER") < text.index("ROOT")
634
+ assert text.count("BEGIN CERTIFICATE") == 3
628
635
  assert stat.S_IMODE(out.stat().st_mode) == 0o600
629
636
 
630
637
 
@@ -636,7 +643,7 @@ def test_cert_export_to_stdout(configured, isolated_config):
636
643
  assert result.exit_code == 0, result.output
637
644
  assert "BEGIN CERTIFICATE" in result.stdout
638
645
  assert "INTER" in result.stdout
639
- assert "ROOT" not in result.stdout
646
+ assert "ROOT" in result.stdout
640
647
 
641
648
 
642
649
  def test_cert_export_missing(isolated_config):
@@ -723,8 +730,8 @@ def test_cert_export_pfx_no_passphrase(configured, isolated_config, tmp_path):
723
730
  key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), None)
724
731
  assert key is not None
725
732
  assert cert.subject == leaf.subject
726
- # only the issuing intermediate, not the root
727
- assert [c.subject for c in cas] == [inter.subject]
733
+ # the whole chain, in order
734
+ assert [c.subject for c in cas] == [inter.subject, root.subject]
728
735
  assert stat.S_IMODE(out.stat().st_mode) == 0o600
729
736
 
730
737
 
@@ -40,12 +40,32 @@ def test_version():
40
40
  assert any(line.startswith("devnomads ") for line in result.output.splitlines())
41
41
 
42
42
 
43
+ @respx.mock
43
44
  def test_version_command_json():
45
+ respx.get(f"{dncli.DEFAULT_API_URL}/docs?api-docs.json").mock(
46
+ return_value=Response(200, json={"info": {"version": "0.7.1"}})
47
+ )
44
48
  result = runner.invoke(app, ["version", "--out", "json"])
45
49
  assert result.exit_code == 0
46
50
  data = json.loads(result.output)
47
- assert set(data) == {"devnomads-cli", "devnomads"}
51
+ assert set(data) == {"devnomads-cli", "devnomads", "api-built-for", "api"}
48
52
  assert all(data.values()) # every version resolved to something
53
+ assert data["api-built-for"] == dncli.API_VERSION
54
+ assert data["api"] == "0.7.1"
55
+ # and remembered for the version check of later runs
56
+ assert dncli._read_update_state()["api"][dncli.DEFAULT_API_URL]["version"] == (
57
+ "0.7.1"
58
+ )
59
+
60
+
61
+ @respx.mock
62
+ def test_version_command_without_the_api():
63
+ respx.get(f"{dncli.DEFAULT_API_URL}/docs?api-docs.json").mock(
64
+ return_value=Response(503)
65
+ )
66
+ result = runner.invoke(app, ["version", "--out", "json"])
67
+ assert result.exit_code == 0
68
+ assert json.loads(result.output)["api"] == "unreachable"
49
69
 
50
70
 
51
71
  @respx.mock
@@ -32,6 +32,8 @@ def test_snake_case_conversion():
32
32
  assert generate.snake("instanceId") == "instance_id"
33
33
  # python keywords get an underscore suffix
34
34
  assert generate.snake("import") == "import_"
35
+ # bracketed query parameters become plain identifiers
36
+ assert generate.snake("filter[type]") == "filter_type"
35
37
 
36
38
 
37
39
  def test_action_mapping_index_becomes_list():
@@ -210,6 +212,28 @@ def test_query_parameters_become_options():
210
212
  assert [(o.name, o.py_type) for o in op.query] == [("lines", "int")]
211
213
 
212
214
 
215
+ def test_bracketed_query_parameter_keeps_its_wire_name():
216
+ spec = make_spec(
217
+ {
218
+ "/products": {
219
+ "get": {
220
+ "operationId": "products.index",
221
+ "parameters": [
222
+ {"name": "filter[type]", "in": "query", "schema": {}}
223
+ ],
224
+ }
225
+ }
226
+ }
227
+ )
228
+ ops = generate.parse_operations(spec, {})
229
+ assert [(o.name, o.python_name) for o in ops[0].query] == [
230
+ ("filter[type]", "filter_type")
231
+ ]
232
+ source = generate.render_source(ops)
233
+ assert "filter_type: str | None" in source
234
+ assert '"filter[type]": filter_type' in source
235
+
236
+
213
237
  def test_destroy_becomes_delete():
214
238
  spec = make_spec(
215
239
  {"/services/sites/{serviceId}": {"delete": {"operationId": "x.sites.destroy"}}}
@@ -96,6 +96,7 @@ class _Dummy:
96
96
  @pytest.fixture
97
97
  def api_warning(monkeypatch):
98
98
  monkeypatch.setattr(dncli, "_api_version_warned", False)
99
+ monkeypatch.setattr(dncli, "_api_spec_checked", False)
99
100
  monkeypatch.setattr(dncli, "API_VERSION", "1.4.0")
100
101
 
101
102
 
@@ -118,6 +119,67 @@ def test_older_api_major_warns(api_warning, capsys):
118
119
  assert "newer than the API" in capsys.readouterr().err
119
120
 
120
121
 
122
+ def test_minor_counts_as_major_before_1_0(api_warning, monkeypatch, capsys):
123
+ monkeypatch.setattr(dncli, "API_VERSION", "0.6.13")
124
+ dncli.check_api_version("0.6.20")
125
+ assert capsys.readouterr().err == ""
126
+ dncli.check_api_version("0.7.0")
127
+ err = capsys.readouterr().err
128
+ assert "the API reports 0.7.0" in err and "update dncli" in err
129
+
130
+
131
+ API = "https://api.test"
132
+
133
+
134
+ def test_cached_spec_version_stands_in_for_the_header(api_warning, interactive, capsys):
135
+ write_state(api={API: {"version": "2.0.0", "checked_at": dncli.time.time()}})
136
+ dncli.check_api_version(None, API + "/")
137
+ assert "the API reports 2.0.0" in capsys.readouterr().err
138
+
139
+
140
+ def test_header_wins_over_the_cached_spec_version(api_warning, interactive, capsys):
141
+ write_state(api={API: {"version": "2.0.0", "checked_at": dncli.time.time()}})
142
+ dncli.check_api_version("1.5.0", API)
143
+ assert capsys.readouterr().err == ""
144
+
145
+
146
+ def test_cached_spec_version_is_only_used_at_a_terminal(api_warning, capsys):
147
+ write_state(api={API: {"version": "2.0.0", "checked_at": dncli.time.time()}})
148
+ dncli.check_api_version(None, API)
149
+ assert capsys.readouterr().err == ""
150
+
151
+
152
+ def test_stale_spec_version_is_refreshed_in_the_background(
153
+ api_warning, interactive, monkeypatch
154
+ ):
155
+ started = []
156
+ monkeypatch.setattr(
157
+ dncli.threading, "Thread", lambda **kw: started.append(kw) or _Dummy()
158
+ )
159
+ write_state(api={API: {"version": "1.4.0", "checked_at": 0}})
160
+ dncli.check_api_version(None, API)
161
+ # looked up once per run, not on every response
162
+ dncli.check_api_version(None, API)
163
+ assert [kw["args"] for kw in started] == [(API,)]
164
+
165
+
166
+ @respx.mock
167
+ def test_fetch_stores_the_api_spec_version():
168
+ respx.get(f"{API}/docs?api-docs.json").mock(
169
+ return_value=Response(200, json={"info": {"version": "0.6.13"}})
170
+ )
171
+ dncli._fetch_api_version(API)
172
+ entry = dncli._read_update_state()["api"][API]
173
+ assert entry["version"] == "0.6.13"
174
+
175
+
176
+ @respx.mock
177
+ def test_api_spec_fetch_failures_are_ignored():
178
+ respx.get(f"{API}/docs?api-docs.json").mock(return_value=Response(500))
179
+ dncli._fetch_api_version(API)
180
+ assert dncli._read_update_state() == {}
181
+
182
+
121
183
  def test_upgrade_required_message():
122
184
  message = dncli._error_message(426, "Upgrade Required")
123
185
  assert "no longer supported" in message
File without changes
File without changes