devnomads-cli 0.9.1__tar.gz → 0.10.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.9.1 → devnomads_cli-0.10.0}/PKG-INFO +13 -1
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/README.md +12 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/PKG-INFO +13 -1
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/dncli.py +67 -6
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/pyproject.toml +1 -1
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_cli.py +86 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/LICENSE +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/setup.cfg +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_cert.py +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_config.py +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_generate.py +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_hook.py +0 -0
- {devnomads_cli-0.9.1 → devnomads_cli-0.10.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.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -81,6 +81,18 @@ dncli dns records set example.com www A 192.0.2.1 --ttl 3600
|
|
|
81
81
|
dncli dns records delete example.com www A
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
Anywhere a zone is expected you may pass the service id of a domain
|
|
85
|
+
instead of its name, so the ids from `dncli domains list` work here
|
|
86
|
+
too:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
dncli dns records list 42
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
An id always means that domain's own zone. A delegated subzone such
|
|
93
|
+
as `foo.example.com` has no domain service of its own, so address it
|
|
94
|
+
by name.
|
|
95
|
+
|
|
84
96
|
## Moving a zone from another provider
|
|
85
97
|
|
|
86
98
|
`dncli dns transfer` copies a zone from your current DNS provider
|
|
@@ -65,6 +65,18 @@ dncli dns records set example.com www A 192.0.2.1 --ttl 3600
|
|
|
65
65
|
dncli dns records delete example.com www A
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
Anywhere a zone is expected you may pass the service id of a domain
|
|
69
|
+
instead of its name, so the ids from `dncli domains list` work here
|
|
70
|
+
too:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
dncli dns records list 42
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
An id always means that domain's own zone. A delegated subzone such
|
|
77
|
+
as `foo.example.com` has no domain service of its own, so address it
|
|
78
|
+
by name.
|
|
79
|
+
|
|
68
80
|
## Moving a zone from another provider
|
|
69
81
|
|
|
70
82
|
`dncli dns transfer` copies a zone from your current DNS provider
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -81,6 +81,18 @@ dncli dns records set example.com www A 192.0.2.1 --ttl 3600
|
|
|
81
81
|
dncli dns records delete example.com www A
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
Anywhere a zone is expected you may pass the service id of a domain
|
|
85
|
+
instead of its name, so the ids from `dncli domains list` work here
|
|
86
|
+
too:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
dncli dns records list 42
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
An id always means that domain's own zone. A delegated subzone such
|
|
93
|
+
as `foo.example.com` has no domain service of its own, so address it
|
|
94
|
+
by name.
|
|
95
|
+
|
|
84
96
|
## Moving a zone from another provider
|
|
85
97
|
|
|
86
98
|
`dncli dns transfer` copies a zone from your current DNS provider
|
|
@@ -53,7 +53,7 @@ from rich.markup import escape
|
|
|
53
53
|
from rich.table import Table
|
|
54
54
|
from typer.core import TyperGroup
|
|
55
55
|
|
|
56
|
-
__version__ = "0.
|
|
56
|
+
__version__ = "0.10.0"
|
|
57
57
|
|
|
58
58
|
# Distribution names, for reporting versions via `dncli version`.
|
|
59
59
|
CLI_PACKAGE = "devnomads-cli"
|
|
@@ -673,6 +673,45 @@ def zone_id(zone: str) -> str:
|
|
|
673
673
|
return zone if zone.endswith(".") else f"{zone}."
|
|
674
674
|
|
|
675
675
|
|
|
676
|
+
def resolve_zone(state: AppState, zone: str) -> str:
|
|
677
|
+
"""Map an all-digit domain service id to that domain's zone name; any
|
|
678
|
+
other value is taken to be a zone name already and passed through.
|
|
679
|
+
|
|
680
|
+
An id always means the domain product's own zone. A delegated subzone
|
|
681
|
+
such as foo.example.com has no domain service of its own, so it can
|
|
682
|
+
only be addressed by name.
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
text = str(zone)
|
|
686
|
+
if not text.isdigit():
|
|
687
|
+
return text.rstrip(".")
|
|
688
|
+
client = get_client(state)
|
|
689
|
+
rows = client.request("GET", "/services/domains")
|
|
690
|
+
if isinstance(rows, dict):
|
|
691
|
+
rows = rows.get("data", [])
|
|
692
|
+
match = next(
|
|
693
|
+
(
|
|
694
|
+
row
|
|
695
|
+
for row in (rows or [])
|
|
696
|
+
if isinstance(row, dict) and str(row.get("service_id")) == text
|
|
697
|
+
),
|
|
698
|
+
None,
|
|
699
|
+
)
|
|
700
|
+
if match is None:
|
|
701
|
+
raise CliError(f"no domain service with id {text}")
|
|
702
|
+
name = str(match.get("entity") or "").rstrip(".")
|
|
703
|
+
if not name:
|
|
704
|
+
raise CliError(f"domain service {text} has no domain name")
|
|
705
|
+
zones = client.request("GET", "/services/dns/zones")
|
|
706
|
+
known = {str(item.get("name", "")).rstrip(".").lower() for item in zones or []}
|
|
707
|
+
if name.lower() not in known:
|
|
708
|
+
raise CliError(
|
|
709
|
+
f"{name} (service {text}) is not hosted on DevNomads DNS; "
|
|
710
|
+
"dncli dns zones list shows your zones"
|
|
711
|
+
)
|
|
712
|
+
return name
|
|
713
|
+
|
|
714
|
+
|
|
676
715
|
def fqdn(name: str, zone: str) -> str:
|
|
677
716
|
"""Absolute record name with trailing dot, PowerDNS style."""
|
|
678
717
|
|
|
@@ -1438,13 +1477,17 @@ def zones_list(
|
|
|
1438
1477
|
@zones_app.command("show")
|
|
1439
1478
|
def zones_show(
|
|
1440
1479
|
ctx: typer.Context,
|
|
1441
|
-
zone: Annotated[
|
|
1480
|
+
zone: Annotated[
|
|
1481
|
+
str,
|
|
1482
|
+
typer.Argument(help="Zone name (example.com) or the service id of a domain."),
|
|
1483
|
+
],
|
|
1442
1484
|
output: OutputOption = None,
|
|
1443
1485
|
columns: ColumnsOption = None,
|
|
1444
1486
|
) -> None:
|
|
1445
1487
|
"""Show a DNS zone including its records."""
|
|
1446
1488
|
|
|
1447
1489
|
state = state_from(ctx, output, columns)
|
|
1490
|
+
zone = resolve_zone(state, zone)
|
|
1448
1491
|
data = get_client(state).request("GET", f"/services/dns/zones/{zone_id(zone)}")
|
|
1449
1492
|
if resolve_format(state) is not OutputFormat.table or not isinstance(data, dict):
|
|
1450
1493
|
render(state, data)
|
|
@@ -1465,7 +1508,10 @@ def zones_show(
|
|
|
1465
1508
|
@records_app.command("list")
|
|
1466
1509
|
def records_list(
|
|
1467
1510
|
ctx: typer.Context,
|
|
1468
|
-
zone: Annotated[
|
|
1511
|
+
zone: Annotated[
|
|
1512
|
+
str,
|
|
1513
|
+
typer.Argument(help="Zone name (example.com) or the service id of a domain."),
|
|
1514
|
+
],
|
|
1469
1515
|
sort: SortOption = None,
|
|
1470
1516
|
output: OutputOption = None,
|
|
1471
1517
|
columns: ColumnsOption = None,
|
|
@@ -1473,6 +1519,7 @@ def records_list(
|
|
|
1473
1519
|
"""List the records in a zone, one row per value."""
|
|
1474
1520
|
|
|
1475
1521
|
state = state_from(ctx, output, columns)
|
|
1522
|
+
zone = resolve_zone(state, zone)
|
|
1476
1523
|
data = get_client(state).request("GET", f"/services/dns/zones/{zone_id(zone)}")
|
|
1477
1524
|
rrsets = data.get("rrsets", []) if isinstance(data, dict) else []
|
|
1478
1525
|
render(
|
|
@@ -1486,7 +1533,10 @@ def records_list(
|
|
|
1486
1533
|
@records_app.command("set")
|
|
1487
1534
|
def records_set(
|
|
1488
1535
|
ctx: typer.Context,
|
|
1489
|
-
zone: Annotated[
|
|
1536
|
+
zone: Annotated[
|
|
1537
|
+
str,
|
|
1538
|
+
typer.Argument(help="Zone name (example.com) or the service id of a domain."),
|
|
1539
|
+
],
|
|
1490
1540
|
name: Annotated[
|
|
1491
1541
|
str, typer.Argument(help="Record name relative to the zone; @ for the apex.")
|
|
1492
1542
|
],
|
|
@@ -1504,6 +1554,7 @@ def records_set(
|
|
|
1504
1554
|
"""Create or replace a record set (PowerDNS REPLACE semantics)."""
|
|
1505
1555
|
|
|
1506
1556
|
state = state_from(ctx)
|
|
1557
|
+
zone = resolve_zone(state, zone)
|
|
1507
1558
|
rrset = build_rrset(
|
|
1508
1559
|
zone, name, rtype, changetype="REPLACE", ttl=ttl, contents=content
|
|
1509
1560
|
)
|
|
@@ -1520,7 +1571,10 @@ def records_set(
|
|
|
1520
1571
|
@records_app.command("delete")
|
|
1521
1572
|
def records_delete(
|
|
1522
1573
|
ctx: typer.Context,
|
|
1523
|
-
zone: Annotated[
|
|
1574
|
+
zone: Annotated[
|
|
1575
|
+
str,
|
|
1576
|
+
typer.Argument(help="Zone name (example.com) or the service id of a domain."),
|
|
1577
|
+
],
|
|
1524
1578
|
name: Annotated[
|
|
1525
1579
|
str, typer.Argument(help="Record name relative to the zone; @ for the apex.")
|
|
1526
1580
|
],
|
|
@@ -1534,6 +1588,7 @@ def records_delete(
|
|
|
1534
1588
|
"""Delete a whole record set (all values for name + type)."""
|
|
1535
1589
|
|
|
1536
1590
|
state = state_from(ctx)
|
|
1591
|
+
zone = resolve_zone(state, zone)
|
|
1537
1592
|
rrset = build_rrset(zone, name, rtype, changetype="DELETE")
|
|
1538
1593
|
_confirm(f"Delete all {rrset['type']} records for {rrset['name']}?", yes)
|
|
1539
1594
|
payload = {"rrsets": [rrset]}
|
|
@@ -1555,7 +1610,12 @@ def dns_transfer(
|
|
|
1555
1610
|
),
|
|
1556
1611
|
],
|
|
1557
1612
|
zone: Annotated[
|
|
1558
|
-
str,
|
|
1613
|
+
str,
|
|
1614
|
+
typer.Option(
|
|
1615
|
+
"--zone",
|
|
1616
|
+
help="Zone name on both sides (example.com), or the service id "
|
|
1617
|
+
"of a DevNomads domain.",
|
|
1618
|
+
),
|
|
1559
1619
|
],
|
|
1560
1620
|
source_profile: Annotated[
|
|
1561
1621
|
str | None,
|
|
@@ -1583,6 +1643,7 @@ def dns_transfer(
|
|
|
1583
1643
|
|
|
1584
1644
|
state = state_from(ctx, output, columns)
|
|
1585
1645
|
client = get_client(state) # resolve DevNomads credentials first: fail fast
|
|
1646
|
+
zone = resolve_zone(state, zone)
|
|
1586
1647
|
source = build_source(from_, source_profile)
|
|
1587
1648
|
zones = client.request("GET", "/services/dns/zones")
|
|
1588
1649
|
known = {str(item.get("name", "")).rstrip(".") for item in zones or []}
|
|
@@ -337,6 +337,92 @@ def test_records_delete_with_yes(configured):
|
|
|
337
337
|
assert payload["rrsets"][0]["changetype"] == "DELETE"
|
|
338
338
|
|
|
339
339
|
|
|
340
|
+
# --- service id -> zone name resolution --------------------------------------
|
|
341
|
+
|
|
342
|
+
DOMAINS = [
|
|
343
|
+
{"service_id": 42, "type": "domain", "entity": "example.com"},
|
|
344
|
+
{"service_id": 43, "type": "domain", "entity": "elsewhere.com"},
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def mock_domains(zones=("example.com.",)):
|
|
349
|
+
respx.get(f"{API}/services/domains").mock(
|
|
350
|
+
return_value=Response(200, json={"data": DOMAINS})
|
|
351
|
+
)
|
|
352
|
+
respx.get(f"{API}/services/dns/zones").mock(
|
|
353
|
+
return_value=Response(200, json=[{"id": z, "name": z} for z in zones])
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@respx.mock
|
|
358
|
+
def test_records_list_accepts_a_domain_service_id(configured):
|
|
359
|
+
mock_domains()
|
|
360
|
+
respx.get(f"{API}/services/dns/zones/example.com.").mock(
|
|
361
|
+
return_value=Response(200, json=ZONE)
|
|
362
|
+
)
|
|
363
|
+
result = runner.invoke(app, ["--out", "json", "dns", "records", "list", "42"])
|
|
364
|
+
assert result.exit_code == 0, all_output(result)
|
|
365
|
+
assert json.loads(result.output)[0]["name"] == "www.example.com."
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@respx.mock
|
|
369
|
+
def test_records_set_by_service_id_builds_names_from_the_zone(configured):
|
|
370
|
+
mock_domains()
|
|
371
|
+
route = respx.patch(f"{API}/services/dns/zones/example.com.").mock(
|
|
372
|
+
return_value=Response(204)
|
|
373
|
+
)
|
|
374
|
+
result = runner.invoke(
|
|
375
|
+
app, ["dns", "records", "set", "42", "www", "A", "192.0.2.1"]
|
|
376
|
+
)
|
|
377
|
+
assert result.exit_code == 0, all_output(result)
|
|
378
|
+
payload = json.loads(route.calls.last.request.content)
|
|
379
|
+
assert payload["rrsets"][0]["name"] == "www.example.com."
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@respx.mock
|
|
383
|
+
def test_zones_show_by_service_id(configured):
|
|
384
|
+
mock_domains()
|
|
385
|
+
route = respx.get(f"{API}/services/dns/zones/example.com.").mock(
|
|
386
|
+
return_value=Response(200, json=ZONE)
|
|
387
|
+
)
|
|
388
|
+
result = runner.invoke(app, ["--out", "json", "dns", "zones", "show", "42"])
|
|
389
|
+
assert result.exit_code == 0, all_output(result)
|
|
390
|
+
assert route.called
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@respx.mock
|
|
394
|
+
def test_unknown_service_id_fails_clearly(configured):
|
|
395
|
+
mock_domains()
|
|
396
|
+
result = runner.invoke(app, ["dns", "records", "list", "999"])
|
|
397
|
+
assert result.exit_code == 1
|
|
398
|
+
assert "no domain service with id 999" in all_output(result)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@respx.mock
|
|
402
|
+
def test_domain_without_a_devnomads_zone_fails_clearly(configured):
|
|
403
|
+
# 43 is elsewhere.com: registered with us, DNS hosted somewhere else.
|
|
404
|
+
mock_domains()
|
|
405
|
+
result = runner.invoke(app, ["dns", "records", "list", "43"])
|
|
406
|
+
assert result.exit_code == 1
|
|
407
|
+
output = all_output(result)
|
|
408
|
+
assert "elsewhere.com (service 43) is not hosted on DevNomads DNS" in output
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@respx.mock
|
|
412
|
+
def test_zone_names_do_not_trigger_a_lookup(configured):
|
|
413
|
+
domains = respx.get(f"{API}/services/domains").mock(
|
|
414
|
+
return_value=Response(200, json={"data": DOMAINS})
|
|
415
|
+
)
|
|
416
|
+
respx.get(f"{API}/services/dns/zones/example.com.").mock(
|
|
417
|
+
return_value=Response(200, json=ZONE)
|
|
418
|
+
)
|
|
419
|
+
result = runner.invoke(
|
|
420
|
+
app, ["--out", "json", "dns", "records", "list", "example.com"]
|
|
421
|
+
)
|
|
422
|
+
assert result.exit_code == 0, all_output(result)
|
|
423
|
+
assert not domains.called
|
|
424
|
+
|
|
425
|
+
|
|
340
426
|
@respx.mock
|
|
341
427
|
def test_api_error_message_and_exit_code(configured):
|
|
342
428
|
respx.get(f"{API}/services").mock(
|
|
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
|