devnomads-cli 0.11.2__tar.gz → 0.12.1__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.
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/PKG-INFO +10 -7
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/README.md +8 -5
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/devnomads_cli.egg-info/PKG-INFO +10 -7
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/devnomads_cli.egg-info/requires.txt +1 -1
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/dncli.py +209 -62
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/pyproject.toml +2 -2
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_cert.py +29 -22
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_cli.py +21 -1
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_generate.py +24 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_versions.py +62 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/LICENSE +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/setup.cfg +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_config.py +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_hook.py +0 -0
- {devnomads_cli-0.11.2 → devnomads_cli-0.12.1}/tests/test_transfer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -11,7 +11,7 @@ Requires-Dist: typer>=0.19
|
|
|
11
11
|
Requires-Dist: httpx>=0.27
|
|
12
12
|
Requires-Dist: rich>=13
|
|
13
13
|
Requires-Dist: cryptography>=42
|
|
14
|
-
Requires-Dist: devnomads[acme]>=0.6.
|
|
14
|
+
Requires-Dist: devnomads[acme]>=0.6.2
|
|
15
15
|
Dynamic: license-file
|
|
16
16
|
|
|
17
17
|
# dncli
|
|
@@ -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
|
-
|
|
250
|
-
|
|
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
|
|
293
|
-
certificate, and the
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
277
|
-
certificate, and the
|
|
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.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -11,7 +11,7 @@ Requires-Dist: typer>=0.19
|
|
|
11
11
|
Requires-Dist: httpx>=0.27
|
|
12
12
|
Requires-Dist: rich>=13
|
|
13
13
|
Requires-Dist: cryptography>=42
|
|
14
|
-
Requires-Dist: devnomads[acme]>=0.6.
|
|
14
|
+
Requires-Dist: devnomads[acme]>=0.6.2
|
|
15
15
|
Dynamic: license-file
|
|
16
16
|
|
|
17
17
|
# dncli
|
|
@@ -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
|
-
|
|
250
|
-
|
|
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
|
|
293
|
-
certificate, and the
|
|
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.
|
|
68
|
+
__version__ = "0.12.1"
|
|
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
|
|
272
|
-
|
|
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
|
|
808
|
-
"""
|
|
809
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2321
|
-
"""
|
|
2322
|
-
|
|
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
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
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
|
|
2337
|
-
|
|
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,
|
|
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 +
|
|
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
|
-
|
|
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
|
-
"
|
|
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 +
|
|
2856
|
+
"""Re-export an issued certificate as one bundle (key + cert + chain).
|
|
2814
2857
|
|
|
2815
|
-
The default PEM bundle
|
|
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 = "
|
|
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
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
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
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
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
|
|
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.
|
|
3
|
+
version = "0.12.1"
|
|
4
4
|
description = "Manage your DevNomads services from the command line"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -11,7 +11,7 @@ dependencies = [
|
|
|
11
11
|
"httpx>=0.27",
|
|
12
12
|
"rich>=13",
|
|
13
13
|
"cryptography>=42",
|
|
14
|
-
"devnomads[acme]>=0.6.
|
|
14
|
+
"devnomads[acme]>=0.6.2",
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
[project.scripts]
|
|
@@ -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
|
|
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
|
|
350
|
-
|
|
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
|
|
367
|
-
assert dncli.
|
|
368
|
-
assert dncli.
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
624
|
-
assert text.index("EC PRIVATE KEY") < text.index("INTER")
|
|
625
|
-
|
|
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"
|
|
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
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|