devnomads 0.3.0__tar.gz → 0.4.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 (39) hide show
  1. {devnomads-0.3.0 → devnomads-0.4.0}/PKG-INFO +1 -1
  2. {devnomads-0.3.0 → devnomads-0.4.0}/openapi.json +140 -0
  3. {devnomads-0.3.0 → devnomads-0.4.0}/pyproject.toml +1 -1
  4. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/client.py +19 -3
  5. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/api/_services.py +37 -0
  6. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_acme_client.py +23 -0
  7. {devnomads-0.3.0 → devnomads-0.4.0}/uv.lock +1213 -1203
  8. {devnomads-0.3.0 → devnomads-0.4.0}/.flake8 +0 -0
  9. {devnomads-0.3.0 → devnomads-0.4.0}/.gitignore +0 -0
  10. {devnomads-0.3.0 → devnomads-0.4.0}/.gitlab-ci.yml +0 -0
  11. {devnomads-0.3.0 → devnomads-0.4.0}/Makefile +0 -0
  12. {devnomads-0.3.0 → devnomads-0.4.0}/README.md +0 -0
  13. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/__init__.py +0 -0
  14. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/__init__.py +0 -0
  15. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/challenge_server.py +0 -0
  16. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/dns01.py +0 -0
  17. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/errors.py +0 -0
  18. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/http01.py +0 -0
  19. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/keys.py +0 -0
  20. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/acme/verify.py +0 -0
  21. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/api/__init__.py +0 -0
  22. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/api/client.py +0 -0
  23. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/api/credentials.py +0 -0
  24. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/api/errors.py +0 -0
  25. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/dns/__init__.py +0 -0
  26. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/dns/errors.py +0 -0
  27. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/dns/names.py +0 -0
  28. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/dns/zones.py +0 -0
  29. {devnomads-0.3.0 → devnomads-0.4.0}/src/devnomads/py.typed +0 -0
  30. {devnomads-0.3.0 → devnomads-0.4.0}/tests/conftest.py +0 -0
  31. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_acme_challenges.py +0 -0
  32. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_acme_keys.py +0 -0
  33. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_client.py +0 -0
  34. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_credentials.py +0 -0
  35. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_dns.py +0 -0
  36. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_names.py +0 -0
  37. {devnomads-0.3.0 → devnomads-0.4.0}/tests/test_services.py +0 -0
  38. {devnomads-0.3.0 → devnomads-0.4.0}/tools/bump_version.py +0 -0
  39. {devnomads-0.3.0 → devnomads-0.4.0}/tools/generate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Python client library for the DevNomads API (transport, DNS, ACME)
5
5
  Project-URL: Homepage, https://devnomads.nl
6
6
  Author-email: DevNomads <support@devnomads.nl>
@@ -2926,6 +2926,146 @@
2926
2926
  ]
2927
2927
  }
2928
2928
  },
2929
+ "/services/servers/{serviceId}/snapshots": {
2930
+ "get": {
2931
+ "tags": [
2932
+ "Server"
2933
+ ],
2934
+ "summary": "Get list of server snapshots",
2935
+ "description": "Get list of snapshots for a server service",
2936
+ "operationId": "services.servers.snapshots.index",
2937
+ "parameters": [
2938
+ {
2939
+ "name": "serviceId",
2940
+ "in": "path",
2941
+ "description": "Service ID",
2942
+ "required": true,
2943
+ "schema": {
2944
+ "type": "integer"
2945
+ }
2946
+ }
2947
+ ],
2948
+ "responses": {
2949
+ "200": {
2950
+ "description": "Success"
2951
+ }
2952
+ },
2953
+ "security": [
2954
+ {
2955
+ "bearerToken": []
2956
+ }
2957
+ ]
2958
+ },
2959
+ "post": {
2960
+ "tags": [
2961
+ "Server"
2962
+ ],
2963
+ "summary": "Create server snapshot",
2964
+ "description": "Create a new snapshot for a server service",
2965
+ "operationId": "services.servers.snapshots.create",
2966
+ "parameters": [
2967
+ {
2968
+ "name": "serviceId",
2969
+ "in": "path",
2970
+ "description": "Service ID",
2971
+ "required": true,
2972
+ "schema": {
2973
+ "type": "integer"
2974
+ }
2975
+ }
2976
+ ],
2977
+ "responses": {
2978
+ "202": {
2979
+ "description": "Accepted"
2980
+ }
2981
+ },
2982
+ "security": [
2983
+ {
2984
+ "bearerToken": []
2985
+ }
2986
+ ]
2987
+ }
2988
+ },
2989
+ "/services/servers/{serviceId}/snapshots/{snapshotUuid}/restore": {
2990
+ "get": {
2991
+ "tags": [
2992
+ "Server"
2993
+ ],
2994
+ "summary": "Restore server snapshot",
2995
+ "description": "Restore a snapshot for a server service",
2996
+ "operationId": "services.servers.snapshots.restore",
2997
+ "parameters": [
2998
+ {
2999
+ "name": "serviceId",
3000
+ "in": "path",
3001
+ "description": "Service ID",
3002
+ "required": true,
3003
+ "schema": {
3004
+ "type": "integer"
3005
+ }
3006
+ },
3007
+ {
3008
+ "name": "snapshotUuid",
3009
+ "in": "path",
3010
+ "description": "Snapshot UUID",
3011
+ "required": true,
3012
+ "schema": {
3013
+ "type": "string"
3014
+ }
3015
+ }
3016
+ ],
3017
+ "responses": {
3018
+ "202": {
3019
+ "description": "Accepted"
3020
+ }
3021
+ },
3022
+ "security": [
3023
+ {
3024
+ "bearerToken": []
3025
+ }
3026
+ ]
3027
+ }
3028
+ },
3029
+ "/services/servers/{serviceId}/snapshots/{snapshotUuid}": {
3030
+ "delete": {
3031
+ "tags": [
3032
+ "Server"
3033
+ ],
3034
+ "summary": "Delete server snapshot",
3035
+ "description": "Delete a snapshot for a server service",
3036
+ "operationId": "services.servers.snapshots.delete",
3037
+ "parameters": [
3038
+ {
3039
+ "name": "serviceId",
3040
+ "in": "path",
3041
+ "description": "Service ID",
3042
+ "required": true,
3043
+ "schema": {
3044
+ "type": "integer"
3045
+ }
3046
+ },
3047
+ {
3048
+ "name": "snapshotUuid",
3049
+ "in": "path",
3050
+ "description": "Snapshot UUID",
3051
+ "required": true,
3052
+ "schema": {
3053
+ "type": "string"
3054
+ }
3055
+ }
3056
+ ],
3057
+ "responses": {
3058
+ "202": {
3059
+ "description": "Accepted"
3060
+ }
3061
+ },
3062
+ "security": [
3063
+ {
3064
+ "bearerToken": []
3065
+ }
3066
+ ]
3067
+ }
3068
+ },
2929
3069
  "/services": {
2930
3070
  "get": {
2931
3071
  "tags": [
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devnomads"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Python client library for the DevNomads API (transport, DNS, ACME)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -88,6 +88,9 @@ def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str
88
88
  ident = getattr(getattr(authz_body, "identifier", None), "value", None)
89
89
  if ident is None:
90
90
  return None
91
+ # The map is keyed by lowercased identifiers; lowercase the authz value
92
+ # too (belt and braces — CAs already lowercase it) so the lookup matches.
93
+ ident = ident.lower()
91
94
  lookup = f"*.{ident}" if getattr(authz_body, "wildcard", False) else ident
92
95
  return challenge_types_map.get(lookup)
93
96
 
@@ -415,7 +418,17 @@ class AcmeClient:
415
418
 
416
419
 
417
420
  def _normalize_identifiers(names: list[str]) -> list[str]:
418
- """IDNA-encode and de-duplicate identifiers, preserving order."""
421
+ """IDNA-encode, lowercase and de-duplicate identifiers, preserving order.
422
+
423
+ DNS names are case-insensitive and ACME servers (Let's Encrypt) echo
424
+ authorization identifiers back lowercased. Python's IDNA codec, however,
425
+ preserves the case of plain-ASCII labels, so a mixed-case config entry
426
+ like ``www.Example.com`` would otherwise key the challenge-type map in a
427
+ case the CA never uses — the challenge lookup at answer time then misses
428
+ and issuance fails with "no challenge type configured". Lowercasing here
429
+ keeps every identifier canonical (punycode output is already lowercase,
430
+ so this is a no-op for internationalised names).
431
+ """
419
432
 
420
433
  identifiers: list[str] = []
421
434
  seen: set[str] = set()
@@ -423,7 +436,7 @@ def _normalize_identifiers(names: list[str]) -> list[str]:
423
436
  if not name:
424
437
  continue
425
438
  try:
426
- ascii_name = name.encode("idna").decode("ascii")
439
+ ascii_name = name.encode("idna").decode("ascii").lower()
427
440
  except Exception: # noqa: BLE001 # nosec B112
428
441
  continue
429
442
  if ascii_name not in seen:
@@ -442,11 +455,14 @@ def _normalize_challenge_types(
442
455
 
443
456
  result: dict[str, str] = {}
444
457
  for ident in identifiers:
458
+ # ``identifiers`` are already IDNA-normalised and lowercased; resolve
459
+ # each against the caller's map case-insensitively so a mixed-case
460
+ # key (``www.Example.com``) still matches its lowercased identifier.
445
461
  ct = challenge_types.get(ident)
446
462
  if ct is None:
447
463
  for key, value in challenge_types.items():
448
464
  try:
449
- if key.encode("idna").decode("ascii") == ident:
465
+ if key.encode("idna").decode("ascii").lower() == ident:
450
466
  ct = value
451
467
  break
452
468
  except Exception: # noqa: BLE001 # nosec B112
@@ -717,6 +717,43 @@ class ServersResource:
717
717
  path = f"/services/servers/{service_id}/state/{state}"
718
718
  return self._client.request("GET", path, params=params)
719
719
 
720
+ @property
721
+ def snapshots(self) -> ServersSnapshotsResource:
722
+ return ServersSnapshotsResource(self._client)
723
+
724
+
725
+ class ServersSnapshotsResource:
726
+ """Generated resource accessor."""
727
+
728
+ def __init__(self, client: Client) -> None:
729
+ self._client = client
730
+
731
+ def create(
732
+ self, service_id: str, params: dict | None = None, body: Any = None
733
+ ) -> Any:
734
+ """Create server snapshot"""
735
+ path = f"/services/servers/{service_id}/snapshots"
736
+ return self._client.request("POST", path, params=params, json_body=body)
737
+
738
+ def delete(
739
+ self, service_id: str, snapshot_uuid: str, params: dict | None = None
740
+ ) -> Any:
741
+ """Delete server snapshot"""
742
+ path = f"/services/servers/{service_id}/snapshots/{snapshot_uuid}"
743
+ return self._client.request("DELETE", path, params=params)
744
+
745
+ def list(self, service_id: str, params: dict | None = None) -> Any:
746
+ """Get list of server snapshots"""
747
+ path = f"/services/servers/{service_id}/snapshots"
748
+ return self._client.request("GET", path, params=params)
749
+
750
+ def restore(
751
+ self, service_id: str, snapshot_uuid: str, params: dict | None = None
752
+ ) -> Any:
753
+ """Restore server snapshot"""
754
+ path = f"/services/servers/{service_id}/snapshots/{snapshot_uuid}/restore"
755
+ return self._client.request("GET", path, params=params)
756
+
720
757
 
721
758
  class SitesResource:
722
759
  """Generated resource accessor."""
@@ -51,6 +51,29 @@ def test_challenge_for_authz_wildcard_reprefixes():
51
51
  assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
52
52
 
53
53
 
54
+ def test_normalize_identifiers_lowercases():
55
+ # DNS is case-insensitive and CAs lowercase identifiers; a mixed-case
56
+ # ASCII label must not survive with its original case (Python's IDNA
57
+ # codec would otherwise preserve it).
58
+ assert _normalize_identifiers(["www.Ahlersbuilding.BE"]) == [
59
+ "www.ahlersbuilding.be"
60
+ ]
61
+
62
+
63
+ def test_mixed_case_challenge_map_resolves_against_lowercased_authz():
64
+ """Regression: a mixed-case config entry keyed the challenge-type map in
65
+ a case the CA never uses, so the challenge lookup missed and issuance
66
+ failed with "no challenge type configured". The map must resolve against
67
+ the lowercased authorization identifier the CA echoes back."""
68
+ # certmonger builds the map keyed by the original (mixed) case:
69
+ challenge_types = {"www.Ahlersbuilding.be": "dns-01"}
70
+ identifiers = _normalize_identifiers(["www.Ahlersbuilding.be"])
71
+ types_map = _normalize_challenge_types(challenge_types, identifiers)
72
+ # LE hands back the identifier lowercased:
73
+ body = _authz("www.ahlersbuilding.be", False)
74
+ assert _challenge_for_authz(types_map, body) == "dns-01"
75
+
76
+
54
77
  def test_split_fullchain():
55
78
  leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
56
79
  rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"