arthexis 0.1.13__py3-none-any.whl → 0.1.15__py3-none-any.whl
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.
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
- arthexis-0.1.15.dist-info/RECORD +110 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -43
- config/auth_app.py +7 -7
- config/celery.py +32 -32
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -682
- config/settings_helpers.py +109 -109
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3795 -2809
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +149 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -75
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3637 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +840 -368
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +952 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2168 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2201 -1417
- core/widgets.py +213 -94
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -1161
- nodes/apps.py +87 -85
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1764 -1597
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3830 -3116
- nodes/urls.py +15 -14
- nodes/utils.py +121 -105
- nodes/views.py +683 -619
- ocpp/admin.py +948 -948
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1459
- ocpp/evcs.py +844 -844
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -917
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -11
- ocpp/simulator.py +745 -745
- ocpp/status_display.py +26 -26
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -4094
- ocpp/transactions_io.py +189 -189
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1251
- pages/admin.py +769 -539
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -198
- pages/middleware.py +209 -153
- pages/models.py +643 -426
- pages/tasks.py +74 -0
- pages/tests.py +3025 -2200
- pages/urls.py +26 -25
- pages/utils.py +23 -12
- pages/views.py +1176 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
nodes/dns.py
CHANGED
|
@@ -1,203 +1,203 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from collections import defaultdict
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from typing import Iterable, Mapping, MutableMapping, TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
import requests
|
|
8
|
-
from django.utils import timezone
|
|
9
|
-
from dns import exception as dns_exception
|
|
10
|
-
from dns import resolver as dns_resolver
|
|
11
|
-
from requests import Response
|
|
12
|
-
|
|
13
|
-
if TYPE_CHECKING: # pragma: no cover - imported for type checking only
|
|
14
|
-
from .models import DNSRecord, NodeManager
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass
|
|
18
|
-
class DeploymentResult:
|
|
19
|
-
deployed: list["DNSRecord"]
|
|
20
|
-
failures: MutableMapping["DNSRecord", str]
|
|
21
|
-
skipped: MutableMapping["DNSRecord", str]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _error_from_response(response: Response) -> str:
|
|
25
|
-
try:
|
|
26
|
-
payload = response.json()
|
|
27
|
-
except ValueError:
|
|
28
|
-
payload = None
|
|
29
|
-
|
|
30
|
-
if isinstance(payload, dict):
|
|
31
|
-
for key in ("message", "detail", "error"):
|
|
32
|
-
message = payload.get(key)
|
|
33
|
-
if message:
|
|
34
|
-
return str(message)
|
|
35
|
-
elif isinstance(payload, list) and payload:
|
|
36
|
-
first = payload[0]
|
|
37
|
-
if isinstance(first, Mapping) and "message" in first:
|
|
38
|
-
return str(first["message"])
|
|
39
|
-
return str(first)
|
|
40
|
-
|
|
41
|
-
reason = response.reason or ""
|
|
42
|
-
return f"{response.status_code} {reason}".strip()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def deploy_records(manager: "NodeManager", records: Iterable["DNSRecord"]) -> DeploymentResult:
|
|
46
|
-
filtered: list["DNSRecord"] = []
|
|
47
|
-
skipped: MutableMapping["DNSRecord", str] = {}
|
|
48
|
-
for record in records:
|
|
49
|
-
if record.provider != record.Provider.GODADDY:
|
|
50
|
-
skipped[record] = "Unsupported DNS provider"
|
|
51
|
-
continue
|
|
52
|
-
domain = record.get_domain(manager)
|
|
53
|
-
if not domain:
|
|
54
|
-
skipped[record] = "Domain is required for deployment"
|
|
55
|
-
continue
|
|
56
|
-
filtered.append(record)
|
|
57
|
-
|
|
58
|
-
if not filtered:
|
|
59
|
-
return DeploymentResult([], {}, skipped)
|
|
60
|
-
|
|
61
|
-
session = requests.Session()
|
|
62
|
-
session.headers.update(
|
|
63
|
-
{
|
|
64
|
-
"Authorization": manager.get_auth_header(),
|
|
65
|
-
"Accept": "application/json",
|
|
66
|
-
"Content-Type": "application/json",
|
|
67
|
-
}
|
|
68
|
-
)
|
|
69
|
-
customer_id = manager.get_customer_id()
|
|
70
|
-
if customer_id:
|
|
71
|
-
session.headers["X-Shopper-Id"] = customer_id
|
|
72
|
-
|
|
73
|
-
grouped: MutableMapping[tuple[str, str, str], list["DNSRecord"]] = defaultdict(list)
|
|
74
|
-
for record in filtered:
|
|
75
|
-
key = (
|
|
76
|
-
record.get_domain(manager),
|
|
77
|
-
record.record_type,
|
|
78
|
-
record.get_name(),
|
|
79
|
-
)
|
|
80
|
-
grouped[key].append(record)
|
|
81
|
-
|
|
82
|
-
deployed: list["DNSRecord"] = []
|
|
83
|
-
failures: MutableMapping["DNSRecord", str] = {}
|
|
84
|
-
now = timezone.now()
|
|
85
|
-
|
|
86
|
-
base_url = manager.get_base_url()
|
|
87
|
-
for (domain, record_type, name), grouped_records in grouped.items():
|
|
88
|
-
payload = [record.to_godaddy_payload() for record in grouped_records]
|
|
89
|
-
url = f"{base_url}/v1/domains/{domain}/records/{record_type}/{name or '@'}"
|
|
90
|
-
try:
|
|
91
|
-
response = session.put(url, json=payload, timeout=30)
|
|
92
|
-
except requests.RequestException as exc:
|
|
93
|
-
message = str(exc)
|
|
94
|
-
for record in grouped_records:
|
|
95
|
-
record.mark_error(message, manager=manager)
|
|
96
|
-
failures[record] = message
|
|
97
|
-
continue
|
|
98
|
-
|
|
99
|
-
if response.status_code >= 400:
|
|
100
|
-
message = _error_from_response(response)
|
|
101
|
-
for record in grouped_records:
|
|
102
|
-
record.mark_error(message, manager=manager)
|
|
103
|
-
failures[record] = message
|
|
104
|
-
continue
|
|
105
|
-
|
|
106
|
-
for record in grouped_records:
|
|
107
|
-
record.mark_deployed(manager=manager, timestamp=now)
|
|
108
|
-
deployed.append(record)
|
|
109
|
-
|
|
110
|
-
return DeploymentResult(deployed, failures, skipped)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def create_resolver() -> dns_resolver.Resolver:
|
|
114
|
-
return dns_resolver.Resolver()
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _normalize_hostname(value: str) -> str:
|
|
118
|
-
return value.rstrip(".").lower()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _extract_txt(rdata) -> str:
|
|
122
|
-
strings = getattr(rdata, "strings", None)
|
|
123
|
-
if strings:
|
|
124
|
-
parts = []
|
|
125
|
-
for segment in strings:
|
|
126
|
-
if isinstance(segment, bytes):
|
|
127
|
-
parts.append(segment.decode("utf-8", "ignore"))
|
|
128
|
-
else:
|
|
129
|
-
parts.append(str(segment))
|
|
130
|
-
return "".join(parts)
|
|
131
|
-
return str(rdata).strip('"')
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _matches_record(record: "DNSRecord", rdata) -> bool:
|
|
135
|
-
expected = (record.resolve_sigils("data") or "").strip()
|
|
136
|
-
rtype = record.record_type
|
|
137
|
-
|
|
138
|
-
if rtype == record.Type.A:
|
|
139
|
-
return getattr(rdata, "address", str(rdata)) == expected
|
|
140
|
-
if rtype == record.Type.AAAA:
|
|
141
|
-
return getattr(rdata, "address", str(rdata)) == expected
|
|
142
|
-
if rtype == record.Type.CNAME:
|
|
143
|
-
actual = getattr(rdata, "target", None)
|
|
144
|
-
if actual is None:
|
|
145
|
-
return False
|
|
146
|
-
return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
|
|
147
|
-
if rtype == record.Type.NS:
|
|
148
|
-
actual = getattr(rdata, "target", None)
|
|
149
|
-
if actual is None:
|
|
150
|
-
return False
|
|
151
|
-
return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
|
|
152
|
-
if rtype == record.Type.MX:
|
|
153
|
-
host = getattr(rdata, "exchange", None)
|
|
154
|
-
if host is None:
|
|
155
|
-
return False
|
|
156
|
-
host_match = _normalize_hostname(host.to_text()) == _normalize_hostname(expected)
|
|
157
|
-
priority = getattr(rdata, "preference", None)
|
|
158
|
-
priority_match = record.priority is None or priority == record.priority
|
|
159
|
-
return host_match and priority_match
|
|
160
|
-
if rtype == record.Type.SRV:
|
|
161
|
-
target = getattr(rdata, "target", None)
|
|
162
|
-
if target is None:
|
|
163
|
-
return False
|
|
164
|
-
target_match = _normalize_hostname(target.to_text()) == _normalize_hostname(expected)
|
|
165
|
-
priority_match = record.priority is None or getattr(rdata, "priority", None) == record.priority
|
|
166
|
-
weight_match = record.weight is None or getattr(rdata, "weight", None) == record.weight
|
|
167
|
-
port_match = record.port is None or getattr(rdata, "port", None) == record.port
|
|
168
|
-
return target_match and priority_match and weight_match and port_match
|
|
169
|
-
if rtype == record.Type.TXT:
|
|
170
|
-
actual = _extract_txt(rdata)
|
|
171
|
-
return actual == expected
|
|
172
|
-
|
|
173
|
-
return str(rdata).strip() == expected
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def validate_record(
|
|
177
|
-
record: "DNSRecord", resolver: dns_resolver.Resolver | None = None
|
|
178
|
-
) -> tuple[bool, str]:
|
|
179
|
-
resolver = resolver or create_resolver()
|
|
180
|
-
fqdn = record.fqdn()
|
|
181
|
-
if not fqdn:
|
|
182
|
-
message = "Domain is required for validation"
|
|
183
|
-
record.mark_error(message)
|
|
184
|
-
return False, message
|
|
185
|
-
|
|
186
|
-
try:
|
|
187
|
-
answers = resolver.resolve(fqdn, record.record_type)
|
|
188
|
-
except (dns_exception.DNSException, OSError) as exc:
|
|
189
|
-
message = str(exc)
|
|
190
|
-
record.mark_error(message)
|
|
191
|
-
return False, message
|
|
192
|
-
|
|
193
|
-
for rdata in answers:
|
|
194
|
-
if _matches_record(record, rdata):
|
|
195
|
-
record.last_verified_at = timezone.now()
|
|
196
|
-
record.last_error = ""
|
|
197
|
-
record.save(update_fields=["last_verified_at", "last_error"])
|
|
198
|
-
return True, ""
|
|
199
|
-
|
|
200
|
-
message = "DNS record does not match expected value"
|
|
201
|
-
record.mark_error(message)
|
|
202
|
-
return False, message
|
|
203
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable, Mapping, MutableMapping, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
from dns import exception as dns_exception
|
|
10
|
+
from dns import resolver as dns_resolver
|
|
11
|
+
from requests import Response
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover - imported for type checking only
|
|
14
|
+
from .models import DNSRecord, NodeManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DeploymentResult:
|
|
19
|
+
deployed: list["DNSRecord"]
|
|
20
|
+
failures: MutableMapping["DNSRecord", str]
|
|
21
|
+
skipped: MutableMapping["DNSRecord", str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _error_from_response(response: Response) -> str:
|
|
25
|
+
try:
|
|
26
|
+
payload = response.json()
|
|
27
|
+
except ValueError:
|
|
28
|
+
payload = None
|
|
29
|
+
|
|
30
|
+
if isinstance(payload, dict):
|
|
31
|
+
for key in ("message", "detail", "error"):
|
|
32
|
+
message = payload.get(key)
|
|
33
|
+
if message:
|
|
34
|
+
return str(message)
|
|
35
|
+
elif isinstance(payload, list) and payload:
|
|
36
|
+
first = payload[0]
|
|
37
|
+
if isinstance(first, Mapping) and "message" in first:
|
|
38
|
+
return str(first["message"])
|
|
39
|
+
return str(first)
|
|
40
|
+
|
|
41
|
+
reason = response.reason or ""
|
|
42
|
+
return f"{response.status_code} {reason}".strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def deploy_records(manager: "NodeManager", records: Iterable["DNSRecord"]) -> DeploymentResult:
|
|
46
|
+
filtered: list["DNSRecord"] = []
|
|
47
|
+
skipped: MutableMapping["DNSRecord", str] = {}
|
|
48
|
+
for record in records:
|
|
49
|
+
if record.provider != record.Provider.GODADDY:
|
|
50
|
+
skipped[record] = "Unsupported DNS provider"
|
|
51
|
+
continue
|
|
52
|
+
domain = record.get_domain(manager)
|
|
53
|
+
if not domain:
|
|
54
|
+
skipped[record] = "Domain is required for deployment"
|
|
55
|
+
continue
|
|
56
|
+
filtered.append(record)
|
|
57
|
+
|
|
58
|
+
if not filtered:
|
|
59
|
+
return DeploymentResult([], {}, skipped)
|
|
60
|
+
|
|
61
|
+
session = requests.Session()
|
|
62
|
+
session.headers.update(
|
|
63
|
+
{
|
|
64
|
+
"Authorization": manager.get_auth_header(),
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
customer_id = manager.get_customer_id()
|
|
70
|
+
if customer_id:
|
|
71
|
+
session.headers["X-Shopper-Id"] = customer_id
|
|
72
|
+
|
|
73
|
+
grouped: MutableMapping[tuple[str, str, str], list["DNSRecord"]] = defaultdict(list)
|
|
74
|
+
for record in filtered:
|
|
75
|
+
key = (
|
|
76
|
+
record.get_domain(manager),
|
|
77
|
+
record.record_type,
|
|
78
|
+
record.get_name(),
|
|
79
|
+
)
|
|
80
|
+
grouped[key].append(record)
|
|
81
|
+
|
|
82
|
+
deployed: list["DNSRecord"] = []
|
|
83
|
+
failures: MutableMapping["DNSRecord", str] = {}
|
|
84
|
+
now = timezone.now()
|
|
85
|
+
|
|
86
|
+
base_url = manager.get_base_url()
|
|
87
|
+
for (domain, record_type, name), grouped_records in grouped.items():
|
|
88
|
+
payload = [record.to_godaddy_payload() for record in grouped_records]
|
|
89
|
+
url = f"{base_url}/v1/domains/{domain}/records/{record_type}/{name or '@'}"
|
|
90
|
+
try:
|
|
91
|
+
response = session.put(url, json=payload, timeout=30)
|
|
92
|
+
except requests.RequestException as exc:
|
|
93
|
+
message = str(exc)
|
|
94
|
+
for record in grouped_records:
|
|
95
|
+
record.mark_error(message, manager=manager)
|
|
96
|
+
failures[record] = message
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if response.status_code >= 400:
|
|
100
|
+
message = _error_from_response(response)
|
|
101
|
+
for record in grouped_records:
|
|
102
|
+
record.mark_error(message, manager=manager)
|
|
103
|
+
failures[record] = message
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
for record in grouped_records:
|
|
107
|
+
record.mark_deployed(manager=manager, timestamp=now)
|
|
108
|
+
deployed.append(record)
|
|
109
|
+
|
|
110
|
+
return DeploymentResult(deployed, failures, skipped)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def create_resolver() -> dns_resolver.Resolver:
|
|
114
|
+
return dns_resolver.Resolver()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_hostname(value: str) -> str:
|
|
118
|
+
return value.rstrip(".").lower()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_txt(rdata) -> str:
|
|
122
|
+
strings = getattr(rdata, "strings", None)
|
|
123
|
+
if strings:
|
|
124
|
+
parts = []
|
|
125
|
+
for segment in strings:
|
|
126
|
+
if isinstance(segment, bytes):
|
|
127
|
+
parts.append(segment.decode("utf-8", "ignore"))
|
|
128
|
+
else:
|
|
129
|
+
parts.append(str(segment))
|
|
130
|
+
return "".join(parts)
|
|
131
|
+
return str(rdata).strip('"')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _matches_record(record: "DNSRecord", rdata) -> bool:
|
|
135
|
+
expected = (record.resolve_sigils("data") or "").strip()
|
|
136
|
+
rtype = record.record_type
|
|
137
|
+
|
|
138
|
+
if rtype == record.Type.A:
|
|
139
|
+
return getattr(rdata, "address", str(rdata)) == expected
|
|
140
|
+
if rtype == record.Type.AAAA:
|
|
141
|
+
return getattr(rdata, "address", str(rdata)) == expected
|
|
142
|
+
if rtype == record.Type.CNAME:
|
|
143
|
+
actual = getattr(rdata, "target", None)
|
|
144
|
+
if actual is None:
|
|
145
|
+
return False
|
|
146
|
+
return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
|
|
147
|
+
if rtype == record.Type.NS:
|
|
148
|
+
actual = getattr(rdata, "target", None)
|
|
149
|
+
if actual is None:
|
|
150
|
+
return False
|
|
151
|
+
return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
|
|
152
|
+
if rtype == record.Type.MX:
|
|
153
|
+
host = getattr(rdata, "exchange", None)
|
|
154
|
+
if host is None:
|
|
155
|
+
return False
|
|
156
|
+
host_match = _normalize_hostname(host.to_text()) == _normalize_hostname(expected)
|
|
157
|
+
priority = getattr(rdata, "preference", None)
|
|
158
|
+
priority_match = record.priority is None or priority == record.priority
|
|
159
|
+
return host_match and priority_match
|
|
160
|
+
if rtype == record.Type.SRV:
|
|
161
|
+
target = getattr(rdata, "target", None)
|
|
162
|
+
if target is None:
|
|
163
|
+
return False
|
|
164
|
+
target_match = _normalize_hostname(target.to_text()) == _normalize_hostname(expected)
|
|
165
|
+
priority_match = record.priority is None or getattr(rdata, "priority", None) == record.priority
|
|
166
|
+
weight_match = record.weight is None or getattr(rdata, "weight", None) == record.weight
|
|
167
|
+
port_match = record.port is None or getattr(rdata, "port", None) == record.port
|
|
168
|
+
return target_match and priority_match and weight_match and port_match
|
|
169
|
+
if rtype == record.Type.TXT:
|
|
170
|
+
actual = _extract_txt(rdata)
|
|
171
|
+
return actual == expected
|
|
172
|
+
|
|
173
|
+
return str(rdata).strip() == expected
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_record(
|
|
177
|
+
record: "DNSRecord", resolver: dns_resolver.Resolver | None = None
|
|
178
|
+
) -> tuple[bool, str]:
|
|
179
|
+
resolver = resolver or create_resolver()
|
|
180
|
+
fqdn = record.fqdn()
|
|
181
|
+
if not fqdn:
|
|
182
|
+
message = "Domain is required for validation"
|
|
183
|
+
record.mark_error(message)
|
|
184
|
+
return False, message
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
answers = resolver.resolve(fqdn, record.record_type)
|
|
188
|
+
except (dns_exception.DNSException, OSError) as exc:
|
|
189
|
+
message = str(exc)
|
|
190
|
+
record.mark_error(message)
|
|
191
|
+
return False, message
|
|
192
|
+
|
|
193
|
+
for rdata in answers:
|
|
194
|
+
if _matches_record(record, rdata):
|
|
195
|
+
record.last_verified_at = timezone.now()
|
|
196
|
+
record.last_error = ""
|
|
197
|
+
record.save(update_fields=["last_verified_at", "last_error"])
|
|
198
|
+
return True, ""
|
|
199
|
+
|
|
200
|
+
message = "DNS record does not match expected value"
|
|
201
|
+
record.mark_error(message)
|
|
202
|
+
return False, message
|
|
203
|
+
|
nodes/feature_checks.py
CHANGED
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from typing import Any, Callable, Dict, Iterable, Optional
|
|
5
|
-
|
|
6
|
-
from django.contrib import messages
|
|
7
|
-
|
|
8
|
-
if False: # pragma: no cover - typing imports only
|
|
9
|
-
from .models import Node, NodeFeature
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass(frozen=True)
|
|
13
|
-
class FeatureCheckResult:
|
|
14
|
-
"""Outcome of a feature validation."""
|
|
15
|
-
|
|
16
|
-
success: bool
|
|
17
|
-
message: str
|
|
18
|
-
level: int = messages.INFO
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
FeatureCheck = Callable[["NodeFeature", Optional["Node"]], Any]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class FeatureCheckRegistry:
|
|
25
|
-
"""Registry for feature validation callbacks."""
|
|
26
|
-
|
|
27
|
-
def __init__(self) -> None:
|
|
28
|
-
self._checks: Dict[str, FeatureCheck] = {}
|
|
29
|
-
self._default_check: Optional[FeatureCheck] = None
|
|
30
|
-
|
|
31
|
-
def register(self, slug: str) -> Callable[[FeatureCheck], FeatureCheck]:
|
|
32
|
-
"""Register ``func`` as the validator for ``slug``."""
|
|
33
|
-
|
|
34
|
-
def decorator(func: FeatureCheck) -> FeatureCheck:
|
|
35
|
-
self._checks[slug] = func
|
|
36
|
-
return func
|
|
37
|
-
|
|
38
|
-
return decorator
|
|
39
|
-
|
|
40
|
-
def register_default(self, func: FeatureCheck) -> FeatureCheck:
|
|
41
|
-
"""Register ``func`` as the fallback validator."""
|
|
42
|
-
|
|
43
|
-
self._default_check = func
|
|
44
|
-
return func
|
|
45
|
-
|
|
46
|
-
def get(self, slug: str) -> Optional[FeatureCheck]:
|
|
47
|
-
return self._checks.get(slug)
|
|
48
|
-
|
|
49
|
-
def items(self) -> Iterable[tuple[str, FeatureCheck]]:
|
|
50
|
-
return self._checks.items()
|
|
51
|
-
|
|
52
|
-
def run(
|
|
53
|
-
self, feature: "NodeFeature", *, node: Optional["Node"] = None
|
|
54
|
-
) -> Optional[FeatureCheckResult]:
|
|
55
|
-
check = self._checks.get(feature.slug)
|
|
56
|
-
if check is None:
|
|
57
|
-
check = self._default_check
|
|
58
|
-
if check is None:
|
|
59
|
-
return None
|
|
60
|
-
result = check(feature, node)
|
|
61
|
-
return self._normalize_result(feature, result)
|
|
62
|
-
|
|
63
|
-
def _normalize_result(
|
|
64
|
-
self, feature: "NodeFeature", result: Any
|
|
65
|
-
) -> FeatureCheckResult:
|
|
66
|
-
if isinstance(result, FeatureCheckResult):
|
|
67
|
-
return result
|
|
68
|
-
if result is None:
|
|
69
|
-
return FeatureCheckResult(
|
|
70
|
-
True,
|
|
71
|
-
f"{feature.display} check completed successfully.",
|
|
72
|
-
messages.SUCCESS,
|
|
73
|
-
)
|
|
74
|
-
if isinstance(result, tuple) and len(result) >= 2:
|
|
75
|
-
success, message, *rest = result
|
|
76
|
-
level = rest[0] if rest else (
|
|
77
|
-
messages.SUCCESS if success else messages.ERROR
|
|
78
|
-
)
|
|
79
|
-
return FeatureCheckResult(bool(success), str(message), int(level))
|
|
80
|
-
if isinstance(result, bool):
|
|
81
|
-
message = (
|
|
82
|
-
f"{feature.display} check {'passed' if result else 'failed'}."
|
|
83
|
-
)
|
|
84
|
-
level = messages.SUCCESS if result else messages.ERROR
|
|
85
|
-
return FeatureCheckResult(result, message, level)
|
|
86
|
-
raise TypeError(
|
|
87
|
-
f"Unsupported feature check result type: {type(result)!r}"
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
feature_checks = FeatureCheckRegistry()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@feature_checks.register_default
|
|
95
|
-
def _default_feature_check(
|
|
96
|
-
feature: "NodeFeature", node: Optional["Node"]
|
|
97
|
-
) -> FeatureCheckResult:
|
|
98
|
-
from .models import Node
|
|
99
|
-
|
|
100
|
-
target: Optional["Node"] = node or Node.get_local()
|
|
101
|
-
if target is None:
|
|
102
|
-
return FeatureCheckResult(
|
|
103
|
-
False,
|
|
104
|
-
f"No local node is registered; cannot verify {feature.display}.",
|
|
105
|
-
messages.WARNING,
|
|
106
|
-
)
|
|
107
|
-
try:
|
|
108
|
-
enabled = feature.is_enabled
|
|
109
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
110
|
-
return FeatureCheckResult(
|
|
111
|
-
False,
|
|
112
|
-
f"{feature.display} check failed: {exc}",
|
|
113
|
-
messages.ERROR,
|
|
114
|
-
)
|
|
115
|
-
if enabled:
|
|
116
|
-
return FeatureCheckResult(
|
|
117
|
-
True,
|
|
118
|
-
f"{feature.display} is enabled on {target.hostname}.",
|
|
119
|
-
messages.SUCCESS,
|
|
120
|
-
)
|
|
121
|
-
return FeatureCheckResult(
|
|
122
|
-
False,
|
|
123
|
-
f"{feature.display} is not enabled on {target.hostname}.",
|
|
124
|
-
messages.WARNING,
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
__all__ = [
|
|
129
|
-
"FeatureCheck",
|
|
130
|
-
"FeatureCheckRegistry",
|
|
131
|
-
"FeatureCheckResult",
|
|
132
|
-
"feature_checks",
|
|
133
|
-
]
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable, Dict, Iterable, Optional
|
|
5
|
+
|
|
6
|
+
from django.contrib import messages
|
|
7
|
+
|
|
8
|
+
if False: # pragma: no cover - typing imports only
|
|
9
|
+
from .models import Node, NodeFeature
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FeatureCheckResult:
|
|
14
|
+
"""Outcome of a feature validation."""
|
|
15
|
+
|
|
16
|
+
success: bool
|
|
17
|
+
message: str
|
|
18
|
+
level: int = messages.INFO
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FeatureCheck = Callable[["NodeFeature", Optional["Node"]], Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FeatureCheckRegistry:
|
|
25
|
+
"""Registry for feature validation callbacks."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._checks: Dict[str, FeatureCheck] = {}
|
|
29
|
+
self._default_check: Optional[FeatureCheck] = None
|
|
30
|
+
|
|
31
|
+
def register(self, slug: str) -> Callable[[FeatureCheck], FeatureCheck]:
|
|
32
|
+
"""Register ``func`` as the validator for ``slug``."""
|
|
33
|
+
|
|
34
|
+
def decorator(func: FeatureCheck) -> FeatureCheck:
|
|
35
|
+
self._checks[slug] = func
|
|
36
|
+
return func
|
|
37
|
+
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
def register_default(self, func: FeatureCheck) -> FeatureCheck:
|
|
41
|
+
"""Register ``func`` as the fallback validator."""
|
|
42
|
+
|
|
43
|
+
self._default_check = func
|
|
44
|
+
return func
|
|
45
|
+
|
|
46
|
+
def get(self, slug: str) -> Optional[FeatureCheck]:
|
|
47
|
+
return self._checks.get(slug)
|
|
48
|
+
|
|
49
|
+
def items(self) -> Iterable[tuple[str, FeatureCheck]]:
|
|
50
|
+
return self._checks.items()
|
|
51
|
+
|
|
52
|
+
def run(
|
|
53
|
+
self, feature: "NodeFeature", *, node: Optional["Node"] = None
|
|
54
|
+
) -> Optional[FeatureCheckResult]:
|
|
55
|
+
check = self._checks.get(feature.slug)
|
|
56
|
+
if check is None:
|
|
57
|
+
check = self._default_check
|
|
58
|
+
if check is None:
|
|
59
|
+
return None
|
|
60
|
+
result = check(feature, node)
|
|
61
|
+
return self._normalize_result(feature, result)
|
|
62
|
+
|
|
63
|
+
def _normalize_result(
|
|
64
|
+
self, feature: "NodeFeature", result: Any
|
|
65
|
+
) -> FeatureCheckResult:
|
|
66
|
+
if isinstance(result, FeatureCheckResult):
|
|
67
|
+
return result
|
|
68
|
+
if result is None:
|
|
69
|
+
return FeatureCheckResult(
|
|
70
|
+
True,
|
|
71
|
+
f"{feature.display} check completed successfully.",
|
|
72
|
+
messages.SUCCESS,
|
|
73
|
+
)
|
|
74
|
+
if isinstance(result, tuple) and len(result) >= 2:
|
|
75
|
+
success, message, *rest = result
|
|
76
|
+
level = rest[0] if rest else (
|
|
77
|
+
messages.SUCCESS if success else messages.ERROR
|
|
78
|
+
)
|
|
79
|
+
return FeatureCheckResult(bool(success), str(message), int(level))
|
|
80
|
+
if isinstance(result, bool):
|
|
81
|
+
message = (
|
|
82
|
+
f"{feature.display} check {'passed' if result else 'failed'}."
|
|
83
|
+
)
|
|
84
|
+
level = messages.SUCCESS if result else messages.ERROR
|
|
85
|
+
return FeatureCheckResult(result, message, level)
|
|
86
|
+
raise TypeError(
|
|
87
|
+
f"Unsupported feature check result type: {type(result)!r}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
feature_checks = FeatureCheckRegistry()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@feature_checks.register_default
|
|
95
|
+
def _default_feature_check(
|
|
96
|
+
feature: "NodeFeature", node: Optional["Node"]
|
|
97
|
+
) -> FeatureCheckResult:
|
|
98
|
+
from .models import Node
|
|
99
|
+
|
|
100
|
+
target: Optional["Node"] = node or Node.get_local()
|
|
101
|
+
if target is None:
|
|
102
|
+
return FeatureCheckResult(
|
|
103
|
+
False,
|
|
104
|
+
f"No local node is registered; cannot verify {feature.display}.",
|
|
105
|
+
messages.WARNING,
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
enabled = feature.is_enabled
|
|
109
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
110
|
+
return FeatureCheckResult(
|
|
111
|
+
False,
|
|
112
|
+
f"{feature.display} check failed: {exc}",
|
|
113
|
+
messages.ERROR,
|
|
114
|
+
)
|
|
115
|
+
if enabled:
|
|
116
|
+
return FeatureCheckResult(
|
|
117
|
+
True,
|
|
118
|
+
f"{feature.display} is enabled on {target.hostname}.",
|
|
119
|
+
messages.SUCCESS,
|
|
120
|
+
)
|
|
121
|
+
return FeatureCheckResult(
|
|
122
|
+
False,
|
|
123
|
+
f"{feature.display} is not enabled on {target.hostname}.",
|
|
124
|
+
messages.WARNING,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"FeatureCheck",
|
|
130
|
+
"FeatureCheckRegistry",
|
|
131
|
+
"FeatureCheckResult",
|
|
132
|
+
"feature_checks",
|
|
133
|
+
]
|