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.
Files changed (20) hide show
  1. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/PKG-INFO +13 -1
  2. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/README.md +12 -0
  3. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/PKG-INFO +13 -1
  4. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/dncli.py +67 -6
  5. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/pyproject.toml +1 -1
  6. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_cli.py +86 -0
  7. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/LICENSE +0 -0
  8. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/setup.cfg +0 -0
  14. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_cert.py +0 -0
  15. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.9.1 → devnomads_cli-0.10.0}/tests/test_hook.py +0 -0
  20. {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.9.1
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.9.1
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.9.1"
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[str, typer.Argument(help="Zone name, e.g. example.com.")],
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[str, typer.Argument(help="Zone name, e.g. example.com.")],
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[str, typer.Argument(help="Zone name, e.g. example.com.")],
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[str, typer.Argument(help="Zone name, e.g. example.com.")],
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, typer.Option("--zone", help="Zone name on both sides, e.g. example.com.")
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 []}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.9.1"
3
+ version = "0.10.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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