arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/system.py
CHANGED
|
@@ -1,23 +1,93 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from contextlib import closing
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
import
|
|
5
|
+
import re
|
|
5
6
|
import socket
|
|
6
7
|
import subprocess
|
|
7
8
|
import shutil
|
|
8
|
-
import argparse
|
|
9
|
-
import time
|
|
10
9
|
|
|
11
|
-
from django import forms
|
|
12
10
|
from django.conf import settings
|
|
13
|
-
from django.contrib import admin
|
|
14
|
-
from django.core.management import get_commands, load_command_class
|
|
15
|
-
from django.http import Http404
|
|
16
|
-
from django.shortcuts import redirect
|
|
11
|
+
from django.contrib import admin
|
|
17
12
|
from django.template.response import TemplateResponse
|
|
18
|
-
from django.urls import path
|
|
13
|
+
from django.urls import path
|
|
19
14
|
from django.utils.translation import gettext_lazy as _
|
|
20
15
|
|
|
16
|
+
from utils import revision
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_RUNSERVER_PORT_PATTERN = re.compile(r":(\d{2,5})(?:\D|$)")
|
|
20
|
+
_RUNSERVER_PORT_FLAG_PATTERN = re.compile(r"--port(?:=|\s+)(\d{2,5})", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_runserver_port(command_line: str) -> int | None:
|
|
24
|
+
"""Extract the HTTP port from a runserver command line."""
|
|
25
|
+
|
|
26
|
+
for pattern in (_RUNSERVER_PORT_PATTERN, _RUNSERVER_PORT_FLAG_PATTERN):
|
|
27
|
+
match = pattern.search(command_line)
|
|
28
|
+
if match:
|
|
29
|
+
try:
|
|
30
|
+
return int(match.group(1))
|
|
31
|
+
except ValueError:
|
|
32
|
+
continue
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _detect_runserver_process() -> tuple[bool, int | None]:
|
|
37
|
+
"""Return whether the dev server is running and the port if available."""
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["pgrep", "-af", "manage.py runserver"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
return False, None
|
|
48
|
+
except Exception:
|
|
49
|
+
return False, None
|
|
50
|
+
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
return False, None
|
|
53
|
+
|
|
54
|
+
output = result.stdout.strip()
|
|
55
|
+
if not output:
|
|
56
|
+
return False, None
|
|
57
|
+
|
|
58
|
+
port = None
|
|
59
|
+
for line in output.splitlines():
|
|
60
|
+
port = _parse_runserver_port(line)
|
|
61
|
+
if port is not None:
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
if port is None:
|
|
65
|
+
port = 8000
|
|
66
|
+
|
|
67
|
+
return True, port
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _probe_ports(candidates: list[int]) -> tuple[bool, int | None]:
|
|
71
|
+
"""Attempt to connect to localhost on the provided ports."""
|
|
72
|
+
|
|
73
|
+
for port in candidates:
|
|
74
|
+
try:
|
|
75
|
+
with closing(socket.create_connection(("localhost", port), timeout=0.25)):
|
|
76
|
+
return True, port
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
return False, None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _port_candidates(default_port: int) -> list[int]:
|
|
83
|
+
"""Return a prioritized list of ports to probe for the HTTP service."""
|
|
84
|
+
|
|
85
|
+
candidates = [default_port]
|
|
86
|
+
for port in (8000, 8888):
|
|
87
|
+
if port not in candidates:
|
|
88
|
+
candidates.append(port)
|
|
89
|
+
return candidates
|
|
90
|
+
|
|
21
91
|
|
|
22
92
|
def _gather_info() -> dict:
|
|
23
93
|
"""Collect basic system information similar to status.sh."""
|
|
@@ -26,6 +96,7 @@ def _gather_info() -> dict:
|
|
|
26
96
|
info: dict[str, object] = {}
|
|
27
97
|
|
|
28
98
|
info["installed"] = (base_dir / ".venv").exists()
|
|
99
|
+
info["revision"] = revision.get_revision()
|
|
29
100
|
|
|
30
101
|
service_file = lock_dir / "service.lck"
|
|
31
102
|
info["service"] = service_file.read_text().strip() if service_file.exists() else ""
|
|
@@ -33,7 +104,8 @@ def _gather_info() -> dict:
|
|
|
33
104
|
mode_file = lock_dir / "nginx_mode.lck"
|
|
34
105
|
mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
|
|
35
106
|
info["mode"] = mode
|
|
36
|
-
|
|
107
|
+
default_port = 8000 if mode == "public" else 8888
|
|
108
|
+
detected_port: int | None = None
|
|
37
109
|
|
|
38
110
|
screen_file = lock_dir / "screen_mode.lck"
|
|
39
111
|
info["screen_mode"] = (
|
|
@@ -43,11 +115,68 @@ def _gather_info() -> dict:
|
|
|
43
115
|
# Use settings.NODE_ROLE as the single source of truth for the node role.
|
|
44
116
|
info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
|
|
45
117
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
118
|
+
features: list[dict[str, object]] = []
|
|
119
|
+
try:
|
|
120
|
+
from nodes.models import Node, NodeFeature
|
|
121
|
+
except Exception:
|
|
122
|
+
info["features"] = features
|
|
123
|
+
else:
|
|
124
|
+
feature_map: dict[str, dict[str, object]] = {}
|
|
125
|
+
|
|
126
|
+
def _add_feature(feature: NodeFeature, flag: str) -> None:
|
|
127
|
+
slug = getattr(feature, "slug", "") or ""
|
|
128
|
+
if not slug:
|
|
129
|
+
return
|
|
130
|
+
display = (getattr(feature, "display", "") or "").strip()
|
|
131
|
+
normalized = display or slug.replace("-", " ").title()
|
|
132
|
+
entry = feature_map.setdefault(
|
|
133
|
+
slug,
|
|
134
|
+
{
|
|
135
|
+
"slug": slug,
|
|
136
|
+
"display": normalized,
|
|
137
|
+
"expected": False,
|
|
138
|
+
"actual": False,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
if display:
|
|
142
|
+
entry["display"] = display
|
|
143
|
+
entry[flag] = True
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
expected_features = (
|
|
147
|
+
NodeFeature.objects.filter(roles__name=info["role"]).only("slug", "display").distinct()
|
|
148
|
+
)
|
|
149
|
+
except Exception:
|
|
150
|
+
expected_features = []
|
|
151
|
+
try:
|
|
152
|
+
for feature in expected_features:
|
|
153
|
+
_add_feature(feature, "expected")
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
local_node = Node.get_local()
|
|
159
|
+
except Exception:
|
|
160
|
+
local_node = None
|
|
161
|
+
|
|
162
|
+
actual_features = []
|
|
163
|
+
if local_node:
|
|
164
|
+
try:
|
|
165
|
+
actual_features = list(local_node.features.only("slug", "display"))
|
|
166
|
+
except Exception:
|
|
167
|
+
actual_features = []
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
for feature in actual_features:
|
|
171
|
+
_add_feature(feature, "actual")
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
features = sorted(
|
|
176
|
+
feature_map.values(),
|
|
177
|
+
key=lambda item: str(item.get("display", "")).lower(),
|
|
178
|
+
)
|
|
179
|
+
info["features"] = features
|
|
51
180
|
|
|
52
181
|
running = False
|
|
53
182
|
service_status = ""
|
|
@@ -65,17 +194,20 @@ def _gather_info() -> dict:
|
|
|
65
194
|
except Exception:
|
|
66
195
|
pass
|
|
67
196
|
else:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
["pgrep", "-f", "manage.py runserver"],
|
|
71
|
-
check=True,
|
|
72
|
-
stdout=subprocess.PIPE,
|
|
73
|
-
stderr=subprocess.PIPE,
|
|
74
|
-
)
|
|
197
|
+
process_running, process_port = _detect_runserver_process()
|
|
198
|
+
if process_running:
|
|
75
199
|
running = True
|
|
76
|
-
|
|
77
|
-
|
|
200
|
+
detected_port = process_port
|
|
201
|
+
|
|
202
|
+
if not running or detected_port is None:
|
|
203
|
+
probe_running, probe_port = _probe_ports(_port_candidates(default_port))
|
|
204
|
+
if probe_running:
|
|
205
|
+
running = True
|
|
206
|
+
if detected_port is None:
|
|
207
|
+
detected_port = probe_port
|
|
208
|
+
|
|
78
209
|
info["running"] = running
|
|
210
|
+
info["port"] = detected_port if detected_port is not None else default_port
|
|
79
211
|
info["service_status"] = service_status
|
|
80
212
|
|
|
81
213
|
try:
|
|
@@ -92,125 +224,12 @@ def _gather_info() -> dict:
|
|
|
92
224
|
|
|
93
225
|
def _system_view(request):
|
|
94
226
|
info = _gather_info()
|
|
95
|
-
if request.method == "POST" and request.user.is_superuser:
|
|
96
|
-
action = request.POST.get("action")
|
|
97
|
-
stop_script = Path(settings.BASE_DIR) / "stop.sh"
|
|
98
|
-
args = [str(stop_script)]
|
|
99
|
-
if action == "stop":
|
|
100
|
-
password = request.POST.get("password", "")
|
|
101
|
-
if not request.user.check_password(password):
|
|
102
|
-
messages.error(request, _("Incorrect password."))
|
|
103
|
-
else:
|
|
104
|
-
lock_file = Path(settings.BASE_DIR) / "locks" / "charging.lck"
|
|
105
|
-
age = None
|
|
106
|
-
if lock_file.exists():
|
|
107
|
-
age = time.time() - lock_file.stat().st_mtime
|
|
108
|
-
if lock_file.exists() and age is not None and age <= 600:
|
|
109
|
-
messages.error(request, _("Charging session in progress."))
|
|
110
|
-
else:
|
|
111
|
-
if info["service"]:
|
|
112
|
-
args.append("--all")
|
|
113
|
-
subprocess.Popen(args)
|
|
114
|
-
return redirect(reverse("admin:index"))
|
|
115
|
-
elif action == "restart":
|
|
116
|
-
subprocess.Popen(args)
|
|
117
|
-
return redirect(reverse("admin:index"))
|
|
118
|
-
|
|
119
|
-
excluded = {
|
|
120
|
-
"shell",
|
|
121
|
-
"dbshell",
|
|
122
|
-
"createsuperuser",
|
|
123
|
-
"changepassword",
|
|
124
|
-
"startapp",
|
|
125
|
-
"startproject",
|
|
126
|
-
"runserver",
|
|
127
|
-
}
|
|
128
|
-
commands = sorted(cmd for cmd in get_commands().keys() if cmd not in excluded)
|
|
129
227
|
|
|
130
228
|
context = admin.site.each_context(request)
|
|
131
|
-
context.update({"title": _("System"), "info": info
|
|
229
|
+
context.update({"title": _("System"), "info": info})
|
|
132
230
|
return TemplateResponse(request, "admin/system.html", context)
|
|
133
231
|
|
|
134
232
|
|
|
135
|
-
def _build_form(parser: argparse.ArgumentParser) -> type[forms.Form]:
|
|
136
|
-
fields: dict[str, forms.Field] = {}
|
|
137
|
-
for action in parser._actions:
|
|
138
|
-
if action.help == argparse.SUPPRESS or action.dest == "help":
|
|
139
|
-
continue
|
|
140
|
-
label = action.option_strings[0] if action.option_strings else action.dest
|
|
141
|
-
required = (
|
|
142
|
-
action.required
|
|
143
|
-
if action.option_strings
|
|
144
|
-
else action.nargs not in ["?", "*", argparse.OPTIONAL]
|
|
145
|
-
)
|
|
146
|
-
fields[action.dest] = forms.CharField(label=label, required=required)
|
|
147
|
-
return type("CommandForm", (forms.Form,), fields)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _system_command_view(request, command):
|
|
151
|
-
commands = get_commands()
|
|
152
|
-
if command not in commands:
|
|
153
|
-
raise Http404
|
|
154
|
-
app_name = commands[command]
|
|
155
|
-
cmd_instance = load_command_class(app_name, command)
|
|
156
|
-
parser = cmd_instance.create_parser("manage.py", command)
|
|
157
|
-
form_class = _build_form(parser)
|
|
158
|
-
form = form_class(request.POST or None)
|
|
159
|
-
output = ""
|
|
160
|
-
|
|
161
|
-
has_required = any(
|
|
162
|
-
(a.option_strings and a.required)
|
|
163
|
-
or (not a.option_strings and a.nargs not in ["?", "*", argparse.OPTIONAL])
|
|
164
|
-
for a in parser._actions
|
|
165
|
-
if a.help != argparse.SUPPRESS and a.dest != "help"
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
if not has_required and request.method == "GET":
|
|
169
|
-
out = io.StringIO()
|
|
170
|
-
cmd_instance.stdout = out
|
|
171
|
-
cmd_instance.stderr = out
|
|
172
|
-
try:
|
|
173
|
-
cmd_instance.run_from_argv(["manage.py", command])
|
|
174
|
-
except Exception as exc:
|
|
175
|
-
out.write(str(exc))
|
|
176
|
-
output = out.getvalue()
|
|
177
|
-
form = None
|
|
178
|
-
elif request.method == "POST" and form.is_valid():
|
|
179
|
-
argv = ["manage.py", command]
|
|
180
|
-
for action in parser._actions:
|
|
181
|
-
if action.help == argparse.SUPPRESS or action.dest == "help":
|
|
182
|
-
continue
|
|
183
|
-
val = form.cleaned_data.get(action.dest)
|
|
184
|
-
if val in (None, ""):
|
|
185
|
-
continue
|
|
186
|
-
if action.option_strings:
|
|
187
|
-
argv.append(action.option_strings[0])
|
|
188
|
-
if action.nargs != 0:
|
|
189
|
-
argv.append(val)
|
|
190
|
-
else:
|
|
191
|
-
argv.append(val)
|
|
192
|
-
out = io.StringIO()
|
|
193
|
-
cmd_instance.stdout = out
|
|
194
|
-
cmd_instance.stderr = out
|
|
195
|
-
try:
|
|
196
|
-
cmd_instance.run_from_argv(argv)
|
|
197
|
-
except Exception as exc:
|
|
198
|
-
out.write(str(exc))
|
|
199
|
-
output = out.getvalue()
|
|
200
|
-
form = None
|
|
201
|
-
|
|
202
|
-
context = admin.site.each_context(request)
|
|
203
|
-
context.update(
|
|
204
|
-
{
|
|
205
|
-
"title": command,
|
|
206
|
-
"command_name": command,
|
|
207
|
-
"form": form,
|
|
208
|
-
"output": output,
|
|
209
|
-
}
|
|
210
|
-
)
|
|
211
|
-
return TemplateResponse(request, "admin/system_command.html", context)
|
|
212
|
-
|
|
213
|
-
|
|
214
233
|
def patch_admin_system_view() -> None:
|
|
215
234
|
"""Add custom admin view for system information."""
|
|
216
235
|
original_get_urls = admin.site.get_urls
|
|
@@ -219,11 +238,6 @@ def patch_admin_system_view() -> None:
|
|
|
219
238
|
urls = original_get_urls()
|
|
220
239
|
custom = [
|
|
221
240
|
path("system/", admin.site.admin_view(_system_view), name="system"),
|
|
222
|
-
path(
|
|
223
|
-
"system/command/<str:command>/",
|
|
224
|
-
admin.site.admin_view(_system_command_view),
|
|
225
|
-
name="system_command",
|
|
226
|
-
),
|
|
227
241
|
]
|
|
228
242
|
return custom + urls
|
|
229
243
|
|
core/tasks.py
CHANGED
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import subprocess
|
|
5
|
-
from datetime import datetime
|
|
6
5
|
from pathlib import Path
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
7
8
|
|
|
8
9
|
from celery import shared_task
|
|
9
10
|
from django.conf import settings
|
|
@@ -15,6 +16,10 @@ from django.utils import timezone
|
|
|
15
16
|
from nodes.models import NetMessage
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
AUTO_UPGRADE_HEALTH_DELAY_SECONDS = 30
|
|
20
|
+
AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS = 3
|
|
21
|
+
|
|
22
|
+
|
|
18
23
|
logger = logging.getLogger(__name__)
|
|
19
24
|
|
|
20
25
|
|
|
@@ -41,6 +46,41 @@ def birthday_greetings() -> None:
|
|
|
41
46
|
)
|
|
42
47
|
|
|
43
48
|
|
|
49
|
+
def _auto_upgrade_log_path(base_dir: Path) -> Path:
|
|
50
|
+
"""Return the log file used for auto-upgrade events."""
|
|
51
|
+
|
|
52
|
+
log_dir = base_dir / "logs"
|
|
53
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return log_dir / "auto-upgrade.log"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _append_auto_upgrade_log(base_dir: Path, message: str) -> None:
|
|
58
|
+
"""Append ``message`` to the auto-upgrade log, ignoring errors."""
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
log_file = _auto_upgrade_log_path(base_dir)
|
|
62
|
+
timestamp = timezone.now().isoformat()
|
|
63
|
+
with log_file.open("a") as fh:
|
|
64
|
+
fh.write(f"{timestamp} {message}\n")
|
|
65
|
+
except Exception: # pragma: no cover - best effort logging only
|
|
66
|
+
logger.warning("Failed to append auto-upgrade log entry: %s", message)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_service_url(base_dir: Path) -> str:
|
|
70
|
+
"""Return the local URL used to probe the Django suite."""
|
|
71
|
+
|
|
72
|
+
lock_dir = base_dir / "locks"
|
|
73
|
+
mode_file = lock_dir / "nginx_mode.lck"
|
|
74
|
+
mode = "internal"
|
|
75
|
+
if mode_file.exists():
|
|
76
|
+
try:
|
|
77
|
+
mode = mode_file.read_text().strip() or "internal"
|
|
78
|
+
except OSError:
|
|
79
|
+
mode = "internal"
|
|
80
|
+
port = 8000 if mode == "public" else 8888
|
|
81
|
+
return f"http://127.0.0.1:{port}/"
|
|
82
|
+
|
|
83
|
+
|
|
44
84
|
@shared_task
|
|
45
85
|
def check_github_updates() -> None:
|
|
46
86
|
"""Check the GitHub repo for updates and upgrade if needed."""
|
|
@@ -53,11 +93,11 @@ def check_github_updates() -> None:
|
|
|
53
93
|
branch = "main"
|
|
54
94
|
subprocess.run(["git", "fetch", "origin", branch], cwd=base_dir, check=True)
|
|
55
95
|
|
|
56
|
-
|
|
57
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
-
log_file = log_dir / "auto-upgrade.log"
|
|
96
|
+
log_file = _auto_upgrade_log_path(base_dir)
|
|
59
97
|
with log_file.open("a") as fh:
|
|
60
|
-
fh.write(
|
|
98
|
+
fh.write(
|
|
99
|
+
f"{timezone.now().isoformat()} check_github_updates triggered\n"
|
|
100
|
+
)
|
|
61
101
|
|
|
62
102
|
notify = None
|
|
63
103
|
startup = None
|
|
@@ -70,6 +110,10 @@ def check_github_updates() -> None:
|
|
|
70
110
|
except Exception:
|
|
71
111
|
startup = None
|
|
72
112
|
|
|
113
|
+
upgrade_stamp = timezone.now().strftime("@ %Y%m%d %H:%M")
|
|
114
|
+
|
|
115
|
+
upgrade_was_applied = False
|
|
116
|
+
|
|
73
117
|
if mode == "latest":
|
|
74
118
|
local = (
|
|
75
119
|
subprocess.check_output(["git", "rev-parse", branch], cwd=base_dir)
|
|
@@ -93,8 +137,9 @@ def check_github_updates() -> None:
|
|
|
93
137
|
startup()
|
|
94
138
|
return
|
|
95
139
|
if notify:
|
|
96
|
-
notify("Upgrading...",
|
|
140
|
+
notify("Upgrading...", upgrade_stamp)
|
|
97
141
|
args = ["./upgrade.sh", "--latest", "--no-restart"]
|
|
142
|
+
upgrade_was_applied = True
|
|
98
143
|
else:
|
|
99
144
|
local = "0"
|
|
100
145
|
version_file = base_dir / "VERSION"
|
|
@@ -117,11 +162,14 @@ def check_github_updates() -> None:
|
|
|
117
162
|
startup()
|
|
118
163
|
return
|
|
119
164
|
if notify:
|
|
120
|
-
notify("Upgrading...",
|
|
165
|
+
notify("Upgrading...", upgrade_stamp)
|
|
121
166
|
args = ["./upgrade.sh", "--no-restart"]
|
|
167
|
+
upgrade_was_applied = True
|
|
122
168
|
|
|
123
169
|
with log_file.open("a") as fh:
|
|
124
|
-
fh.write(
|
|
170
|
+
fh.write(
|
|
171
|
+
f"{timezone.now().isoformat()} running: {' '.join(args)}\n"
|
|
172
|
+
)
|
|
125
173
|
|
|
126
174
|
subprocess.run(args, cwd=base_dir, check=True)
|
|
127
175
|
|
|
@@ -140,6 +188,16 @@ def check_github_updates() -> None:
|
|
|
140
188
|
else:
|
|
141
189
|
subprocess.run(["pkill", "-f", "manage.py runserver"])
|
|
142
190
|
|
|
191
|
+
if upgrade_was_applied:
|
|
192
|
+
_append_auto_upgrade_log(
|
|
193
|
+
base_dir,
|
|
194
|
+
(
|
|
195
|
+
"Scheduled post-upgrade health check in %s seconds"
|
|
196
|
+
% AUTO_UPGRADE_HEALTH_DELAY_SECONDS
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
_schedule_health_check(1)
|
|
200
|
+
|
|
143
201
|
|
|
144
202
|
@shared_task
|
|
145
203
|
def poll_email_collectors() -> None:
|
|
@@ -181,6 +239,91 @@ def report_runtime_issue(
|
|
|
181
239
|
return response
|
|
182
240
|
|
|
183
241
|
|
|
242
|
+
def _record_health_check_result(
|
|
243
|
+
base_dir: Path, attempt: int, status: int | None, detail: str
|
|
244
|
+
) -> None:
|
|
245
|
+
status_display = status if status is not None else "unreachable"
|
|
246
|
+
message = "Health check attempt %s %s (%s)" % (attempt, detail, status_display)
|
|
247
|
+
_append_auto_upgrade_log(base_dir, message)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _schedule_health_check(next_attempt: int) -> None:
|
|
251
|
+
verify_auto_upgrade_health.apply_async(
|
|
252
|
+
kwargs={"attempt": next_attempt},
|
|
253
|
+
countdown=AUTO_UPGRADE_HEALTH_DELAY_SECONDS,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@shared_task
|
|
258
|
+
def verify_auto_upgrade_health(attempt: int = 1) -> bool | None:
|
|
259
|
+
"""Verify the upgraded suite responds successfully.
|
|
260
|
+
|
|
261
|
+
When the check fails three times in a row the upgrade is rolled back by
|
|
262
|
+
invoking ``upgrade.sh --revert``.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
base_dir = Path(__file__).resolve().parent.parent
|
|
266
|
+
url = _resolve_service_url(base_dir)
|
|
267
|
+
request = urllib.request.Request(
|
|
268
|
+
url,
|
|
269
|
+
headers={"User-Agent": "Arthexis-AutoUpgrade/1.0"},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
status: int | None = None
|
|
273
|
+
try:
|
|
274
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
275
|
+
status = getattr(response, "status", response.getcode())
|
|
276
|
+
except urllib.error.HTTPError as exc:
|
|
277
|
+
status = exc.code
|
|
278
|
+
logger.warning(
|
|
279
|
+
"Auto-upgrade health check attempt %s returned HTTP %s", attempt, exc.code
|
|
280
|
+
)
|
|
281
|
+
except urllib.error.URLError as exc:
|
|
282
|
+
logger.warning(
|
|
283
|
+
"Auto-upgrade health check attempt %s failed: %s", attempt, exc
|
|
284
|
+
)
|
|
285
|
+
except Exception as exc: # pragma: no cover - unexpected network error
|
|
286
|
+
logger.exception(
|
|
287
|
+
"Unexpected error probing suite during auto-upgrade attempt %s", attempt
|
|
288
|
+
)
|
|
289
|
+
detail = f"failed with {exc}"
|
|
290
|
+
_record_health_check_result(base_dir, attempt, status, detail)
|
|
291
|
+
if attempt >= AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS:
|
|
292
|
+
_append_auto_upgrade_log(
|
|
293
|
+
base_dir,
|
|
294
|
+
"Health check raised unexpected error; reverting upgrade",
|
|
295
|
+
)
|
|
296
|
+
subprocess.run(["./upgrade.sh", "--revert"], cwd=base_dir, check=True)
|
|
297
|
+
else:
|
|
298
|
+
_schedule_health_check(attempt + 1)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
if status == 200:
|
|
302
|
+
_record_health_check_result(base_dir, attempt, status, "succeeded")
|
|
303
|
+
logger.info(
|
|
304
|
+
"Auto-upgrade health check succeeded on attempt %s with HTTP %s",
|
|
305
|
+
attempt,
|
|
306
|
+
status,
|
|
307
|
+
)
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
_record_health_check_result(base_dir, attempt, status, "failed")
|
|
311
|
+
|
|
312
|
+
if attempt >= AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS:
|
|
313
|
+
logger.error(
|
|
314
|
+
"Auto-upgrade health check failed after %s attempts; reverting", attempt
|
|
315
|
+
)
|
|
316
|
+
_append_auto_upgrade_log(
|
|
317
|
+
base_dir,
|
|
318
|
+
"Health check failed three times; reverting upgrade",
|
|
319
|
+
)
|
|
320
|
+
subprocess.run(["./upgrade.sh", "--revert"], cwd=base_dir, check=True)
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
_schedule_health_check(attempt + 1)
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
184
327
|
@shared_task
|
|
185
328
|
def run_client_report_schedule(schedule_id: int) -> None:
|
|
186
329
|
"""Execute a :class:`core.models.ClientReportSchedule` run."""
|
core/test_system_info.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from pathlib import Path
|
|
3
|
+
from subprocess import CompletedProcess
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
3
6
|
|
|
4
7
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
5
8
|
|
|
@@ -8,7 +11,8 @@ import django
|
|
|
8
11
|
django.setup()
|
|
9
12
|
|
|
10
13
|
from django.conf import settings
|
|
11
|
-
from django.test import SimpleTestCase, override_settings
|
|
14
|
+
from django.test import SimpleTestCase, TestCase, override_settings
|
|
15
|
+
from nodes.models import Node, NodeFeature, NodeRole
|
|
12
16
|
from core.system import _gather_info
|
|
13
17
|
|
|
14
18
|
|
|
@@ -41,3 +45,35 @@ class SystemInfoScreenModeTests(SimpleTestCase):
|
|
|
41
45
|
lock_file.unlink()
|
|
42
46
|
if not any(lock_dir.iterdir()):
|
|
43
47
|
lock_dir.rmdir()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SystemInfoRevisionTests(SimpleTestCase):
|
|
51
|
+
@patch("core.system.revision.get_revision", return_value="abcdef1234567890")
|
|
52
|
+
def test_includes_full_revision(self, mock_revision):
|
|
53
|
+
info = _gather_info()
|
|
54
|
+
self.assertEqual(info["revision"], "abcdef1234567890")
|
|
55
|
+
mock_revision.assert_called_once()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SystemInfoRunserverDetectionTests(SimpleTestCase):
|
|
59
|
+
@patch("core.system.subprocess.run")
|
|
60
|
+
def test_detects_runserver_process_port(self, mock_run):
|
|
61
|
+
mock_run.return_value = CompletedProcess(
|
|
62
|
+
args=["pgrep"],
|
|
63
|
+
returncode=0,
|
|
64
|
+
stdout="123 python manage.py runserver 0.0.0.0:8000 --noreload\n",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
info = _gather_info()
|
|
68
|
+
|
|
69
|
+
self.assertTrue(info["running"])
|
|
70
|
+
self.assertEqual(info["port"], 8000)
|
|
71
|
+
|
|
72
|
+
@patch("core.system._probe_ports", return_value=(True, 8000))
|
|
73
|
+
@patch("core.system.subprocess.run", side_effect=FileNotFoundError)
|
|
74
|
+
def test_falls_back_to_port_probe_when_pgrep_missing(self, mock_run, mock_probe):
|
|
75
|
+
info = _gather_info()
|
|
76
|
+
|
|
77
|
+
self.assertTrue(info["running"])
|
|
78
|
+
self.assertEqual(info["port"], 8000)
|
|
79
|
+
|