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.

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 io
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, messages
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, reverse
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
- info["port"] = 8000 if mode == "public" else 8888
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
- info["features"] = {
47
- "celery": (lock_dir / "celery.lck").exists(),
48
- "lcd_screen": (lock_dir / "lcd_screen.lck").exists(),
49
- "control": (lock_dir / "control.lck").exists(),
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
- try:
69
- subprocess.run(
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
- except Exception:
77
- running = False
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, "commands": commands})
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
- log_dir = base_dir / "logs"
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(f"{datetime.utcnow().isoformat()} check_github_updates triggered\n")
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(f"{datetime.utcnow().isoformat()} running: {' '.join(args)}\n")
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
+