devnomads-cli 0.5.8__tar.gz → 0.6.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.
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/PKG-INFO +1 -1
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/PKG-INFO +1 -1
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/dncli.py +63 -11
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/pyproject.toml +1 -1
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_cli.py +92 -7
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/LICENSE +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/README.md +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/setup.cfg +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_cert.py +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_config.py +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_generate.py +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_hook.py +0 -0
- {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_transfer.py +0 -0
|
@@ -27,6 +27,7 @@ import io
|
|
|
27
27
|
import json
|
|
28
28
|
import logging
|
|
29
29
|
import os
|
|
30
|
+
import re
|
|
30
31
|
import secrets
|
|
31
32
|
import stat
|
|
32
33
|
import sys
|
|
@@ -51,7 +52,7 @@ from rich.markup import escape
|
|
|
51
52
|
from rich.table import Table
|
|
52
53
|
from typer.core import TyperGroup
|
|
53
54
|
|
|
54
|
-
__version__ = "0.
|
|
55
|
+
__version__ = "0.6.0"
|
|
55
56
|
|
|
56
57
|
# Distribution names, for reporting versions via `dncli version`.
|
|
57
58
|
CLI_PACKAGE = "devnomads-cli"
|
|
@@ -90,6 +91,10 @@ ENV_CONFIG_DIR = "DN_CONFIG_DIR"
|
|
|
90
91
|
ENV_EAB_KID = "DN_ZEROSSL_EAB_KID"
|
|
91
92
|
ENV_EAB_HMAC_KEY = "DN_ZEROSSL_EAB_HMAC_KEY"
|
|
92
93
|
PROVIDERS = ("auroradns", "transip")
|
|
94
|
+
# DevNomads keys are a long alphanumeric token (Laravel Sanctum style),
|
|
95
|
+
# optionally carrying a numeric "<id>|" prefix. Matched loosely, for a
|
|
96
|
+
# warning only - never to reject a key outright.
|
|
97
|
+
API_KEY_RE = re.compile(r"^(?:\d+\|)?[A-Za-z0-9]{40,}$")
|
|
93
98
|
REQUEST_TIMEOUT = 30.0
|
|
94
99
|
RECORD_COLUMNS = ["name", "type", "ttl", "content", "disabled"]
|
|
95
100
|
|
|
@@ -1243,30 +1248,46 @@ def configure(
|
|
|
1243
1248
|
|
|
1244
1249
|
if ctx.invoked_subcommand:
|
|
1245
1250
|
return
|
|
1251
|
+
if provider is not None and provider not in PROVIDERS:
|
|
1252
|
+
raise CliError(
|
|
1253
|
+
f"unknown provider '{provider}' (available: {', '.join(PROVIDERS)})"
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
profile = typer.prompt("Profile name", default=profile, err=True)
|
|
1257
|
+
|
|
1246
1258
|
if provider is None:
|
|
1247
1259
|
section = profile
|
|
1248
|
-
values = {
|
|
1249
|
-
"api_key": typer.prompt("DevNomads API key", hide_input=True, err=True)
|
|
1250
|
-
}
|
|
1251
1260
|
elif provider == "auroradns":
|
|
1252
1261
|
section = f"auroradns:{profile}"
|
|
1262
|
+
else: # transip
|
|
1263
|
+
section = f"transip:{profile}"
|
|
1264
|
+
|
|
1265
|
+
parser = load_credentials()
|
|
1266
|
+
if parser.has_section(section) and not typer.confirm(
|
|
1267
|
+
f"profile '{section}' already exists; overwrite it?", err=True
|
|
1268
|
+
):
|
|
1269
|
+
raise typer.Abort()
|
|
1270
|
+
|
|
1271
|
+
if provider is None:
|
|
1272
|
+
api_key = typer.prompt("DevNomads API key", hide_input=True, err=True)
|
|
1273
|
+
if not API_KEY_RE.match(api_key.strip()) and not typer.confirm(
|
|
1274
|
+
"that does not look like a DevNomads API key; use it anyway?", err=True
|
|
1275
|
+
):
|
|
1276
|
+
raise typer.Abort()
|
|
1277
|
+
values = {"api_key": api_key}
|
|
1278
|
+
elif provider == "auroradns":
|
|
1253
1279
|
values = {
|
|
1254
1280
|
"api_key": typer.prompt("AuroraDNS API key", hide_input=True, err=True),
|
|
1255
1281
|
"secret_key": typer.prompt(
|
|
1256
1282
|
"AuroraDNS secret key", hide_input=True, err=True
|
|
1257
1283
|
),
|
|
1258
1284
|
}
|
|
1259
|
-
|
|
1260
|
-
section = f"transip:{profile}"
|
|
1285
|
+
else: # transip
|
|
1261
1286
|
login = typer.prompt("TransIP login name", err=True)
|
|
1262
1287
|
key_source = typer.prompt("Path to TransIP private key (PEM)", err=True)
|
|
1263
1288
|
key_path = _import_private_key(Path(key_source).expanduser(), profile)
|
|
1264
1289
|
values = {"login": login, "private_key_file": str(key_path)}
|
|
1265
|
-
|
|
1266
|
-
raise CliError(
|
|
1267
|
-
f"unknown provider '{provider}' (available: {', '.join(PROVIDERS)})"
|
|
1268
|
-
)
|
|
1269
|
-
parser = load_credentials()
|
|
1290
|
+
|
|
1270
1291
|
if not parser.has_section(section):
|
|
1271
1292
|
parser.add_section(section)
|
|
1272
1293
|
for key, value in values.items():
|
|
@@ -1274,6 +1295,37 @@ def configure(
|
|
|
1274
1295
|
save_credentials(parser)
|
|
1275
1296
|
err_console.print(f"profile '{section}' written to {credentials_path()}")
|
|
1276
1297
|
|
|
1298
|
+
if provider is None:
|
|
1299
|
+
_verify_devnomads_key(
|
|
1300
|
+
parser.get(section, "api_url", fallback=DEFAULT_API_URL),
|
|
1301
|
+
values["api_key"],
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _verify_devnomads_key(api_url: str, api_key: str) -> None:
|
|
1306
|
+
"""Best-effort check that a freshly stored key actually authenticates.
|
|
1307
|
+
|
|
1308
|
+
There is no no-op auth endpoint, so this hits the cheapest one -
|
|
1309
|
+
GET /handles. A 401 is reported as a warning (the key is still stored);
|
|
1310
|
+
network or other errors are ignored so `configure` works offline.
|
|
1311
|
+
"""
|
|
1312
|
+
|
|
1313
|
+
try:
|
|
1314
|
+
with ApiClient(
|
|
1315
|
+
api_url,
|
|
1316
|
+
api_key,
|
|
1317
|
+
user_agent=f"dncli/{__version__}",
|
|
1318
|
+
timeout=REQUEST_TIMEOUT,
|
|
1319
|
+
) as client:
|
|
1320
|
+
client.request("GET", "/handles")
|
|
1321
|
+
except AuthError:
|
|
1322
|
+
err_console.print(
|
|
1323
|
+
"warning: the key was stored, but the API rejected it (401) - "
|
|
1324
|
+
"double-check the token"
|
|
1325
|
+
)
|
|
1326
|
+
except DevNomadsError:
|
|
1327
|
+
pass # offline or transient; can't verify, so don't complain
|
|
1328
|
+
|
|
1277
1329
|
|
|
1278
1330
|
def _import_private_key(source: Path, profile: str) -> Path:
|
|
1279
1331
|
try:
|
|
@@ -5,7 +5,7 @@ import stat
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
import respx
|
|
8
|
-
from httpx import Response
|
|
8
|
+
from httpx import ConnectError, Response
|
|
9
9
|
from typer.testing import CliRunner
|
|
10
10
|
|
|
11
11
|
import dncli
|
|
@@ -14,6 +14,11 @@ from dncli import app
|
|
|
14
14
|
API = "https://api.test"
|
|
15
15
|
runner = CliRunner()
|
|
16
16
|
|
|
17
|
+
# a token shaped like a real DevNomads key (passes the loose format check)
|
|
18
|
+
VALID_KEY = "LL2bKLzNOQaEvKKcXVdfmdCteJPWmbNkWclmOOos514764bb"
|
|
19
|
+
# configure verifies a stored key against this endpoint at the default api_url
|
|
20
|
+
HANDLES = f"{dncli.DEFAULT_API_URL}/handles"
|
|
21
|
+
|
|
17
22
|
|
|
18
23
|
def all_output(result):
|
|
19
24
|
"""Combined stdout + stderr; result.output mixes both on every
|
|
@@ -391,25 +396,105 @@ def test_unknown_profile_fails_clearly(configured):
|
|
|
391
396
|
assert "'nope' not found" in all_output(result)
|
|
392
397
|
|
|
393
398
|
|
|
399
|
+
@respx.mock
|
|
394
400
|
def test_configure_writes_profile(isolated_config):
|
|
395
|
-
|
|
401
|
+
respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
|
|
402
|
+
# first prompt is the profile name; empty line accepts the "default" default
|
|
403
|
+
result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
|
|
396
404
|
assert result.exit_code == 0
|
|
397
405
|
path = dncli.credentials_path()
|
|
398
406
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
399
407
|
parser = dncli.load_credentials()
|
|
400
|
-
assert parser["default"]["api_key"] ==
|
|
408
|
+
assert parser["default"]["api_key"] == VALID_KEY
|
|
401
409
|
# the API URL is hard coded, never prompted for or stored
|
|
402
410
|
assert "api_url" not in parser["default"]
|
|
403
411
|
|
|
404
412
|
|
|
413
|
+
@respx.mock
|
|
414
|
+
def test_configure_verifies_key_with_no_handles(isolated_config):
|
|
415
|
+
# an account with zero handles still returns 200 - a valid key, no warning
|
|
416
|
+
respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
|
|
417
|
+
result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
|
|
418
|
+
assert result.exit_code == 0
|
|
419
|
+
assert "rejected" not in all_output(result)
|
|
420
|
+
assert dncli.load_credentials()["default"]["api_key"] == VALID_KEY
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@respx.mock
|
|
424
|
+
def test_configure_warns_when_api_rejects_key(isolated_config):
|
|
425
|
+
respx.get(HANDLES).mock(return_value=Response(401, json={"message": "no"}))
|
|
426
|
+
result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
|
|
427
|
+
assert result.exit_code == 0 # non-blocking: the key is still stored
|
|
428
|
+
assert "rejected it (401)" in all_output(result)
|
|
429
|
+
assert dncli.load_credentials()["default"]["api_key"] == VALID_KEY
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@respx.mock
|
|
433
|
+
def test_configure_verify_ignores_network_errors(isolated_config):
|
|
434
|
+
respx.get(HANDLES).mock(side_effect=ConnectError("offline"))
|
|
435
|
+
result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
|
|
436
|
+
assert result.exit_code == 0 # offline configure still succeeds
|
|
437
|
+
assert "rejected" not in all_output(result)
|
|
438
|
+
assert dncli.load_credentials()["default"]["api_key"] == VALID_KEY
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@respx.mock
|
|
442
|
+
def test_configure_prompts_for_profile_name(isolated_config):
|
|
443
|
+
respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
|
|
444
|
+
# typing a name at the profile prompt overrides the "default" default
|
|
445
|
+
result = runner.invoke(app, ["configure"], input=f"acme\n{VALID_KEY}\n")
|
|
446
|
+
assert result.exit_code == 0
|
|
447
|
+
parser = dncli.load_credentials()
|
|
448
|
+
assert parser["acme"]["api_key"] == VALID_KEY
|
|
449
|
+
assert not parser.has_section("default")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@respx.mock
|
|
405
453
|
def test_configure_named_profile(write_profile):
|
|
454
|
+
respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
|
|
406
455
|
write_profile(api_key="existing")
|
|
407
456
|
result = runner.invoke(
|
|
408
|
-
app, ["configure", "--profile", "acme"], input="
|
|
457
|
+
app, ["configure", "--profile", "acme"], input=f"\n{VALID_KEY}\n"
|
|
409
458
|
)
|
|
410
459
|
assert result.exit_code == 0
|
|
411
460
|
parser = dncli.load_credentials()
|
|
412
|
-
assert parser["acme"]["api_key"] ==
|
|
461
|
+
assert parser["acme"]["api_key"] == VALID_KEY
|
|
462
|
+
assert parser["default"]["api_key"] == "existing" # untouched
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@respx.mock
|
|
466
|
+
def test_configure_confirms_before_overwrite(write_profile):
|
|
467
|
+
respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
|
|
468
|
+
write_profile(api_key="existing")
|
|
469
|
+
result = runner.invoke(app, ["configure"], input=f"\ny\n{VALID_KEY}\n")
|
|
470
|
+
assert result.exit_code == 0
|
|
471
|
+
assert "already exists" in all_output(result)
|
|
472
|
+
parser = dncli.load_credentials()
|
|
473
|
+
assert parser["default"]["api_key"] == VALID_KEY
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@respx.mock
|
|
477
|
+
def test_configure_warns_on_malformed_key(isolated_config):
|
|
478
|
+
respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
|
|
479
|
+
# a key that fails the loose format check; "y" accepts it anyway
|
|
480
|
+
result = runner.invoke(app, ["configure"], input="\nnot-a-real-key\ny\n")
|
|
481
|
+
assert result.exit_code == 0
|
|
482
|
+
assert "does not look like" in all_output(result)
|
|
483
|
+
parser = dncli.load_credentials()
|
|
484
|
+
assert parser["default"]["api_key"] == "not-a-real-key"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_configure_malformed_key_declined_aborts(isolated_config):
|
|
488
|
+
result = runner.invoke(app, ["configure"], input="\nnot-a-real-key\nn\n")
|
|
489
|
+
assert result.exit_code == 1
|
|
490
|
+
assert not dncli.load_credentials().has_section("default")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_configure_abort_leaves_profile_untouched(write_profile):
|
|
494
|
+
write_profile(api_key="existing")
|
|
495
|
+
result = runner.invoke(app, ["configure"], input="\nn\n")
|
|
496
|
+
assert result.exit_code == 1
|
|
497
|
+
parser = dncli.load_credentials()
|
|
413
498
|
assert parser["default"]["api_key"] == "existing" # untouched
|
|
414
499
|
|
|
415
500
|
|
|
@@ -419,7 +504,7 @@ def test_configure_transip_provider(isolated_config, tmp_path):
|
|
|
419
504
|
result = runner.invoke(
|
|
420
505
|
app,
|
|
421
506
|
["configure", "--provider", "transip", "--profile", "personal"],
|
|
422
|
-
input=f"
|
|
507
|
+
input=f"\nloek\n{key_file}\n",
|
|
423
508
|
)
|
|
424
509
|
assert result.exit_code == 0
|
|
425
510
|
parser = dncli.load_credentials()
|
|
@@ -437,7 +522,7 @@ def test_configure_rejects_non_pem_key(isolated_config, tmp_path):
|
|
|
437
522
|
result = runner.invoke(
|
|
438
523
|
app,
|
|
439
524
|
["configure", "--provider", "transip"],
|
|
440
|
-
input=f"
|
|
525
|
+
input=f"\nloek\n{bogus}\n",
|
|
441
526
|
)
|
|
442
527
|
assert result.exit_code == 1
|
|
443
528
|
assert "PEM" in all_output(result)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|