arthexis 0.1.26__py3-none-any.whl → 0.1.28__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/tasks.py CHANGED
@@ -16,6 +16,20 @@ from django.utils import timezone
16
16
 
17
17
  AUTO_UPGRADE_HEALTH_DELAY_SECONDS = 30
18
18
  AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
19
+ AUTO_UPGRADE_NETWORK_FAILURE_LOCK_NAME = "auto_upgrade_network_failures.lck"
20
+ AUTO_UPGRADE_NETWORK_FAILURE_THRESHOLD = 3
21
+
22
+ _NETWORK_FAILURE_PATTERNS = (
23
+ "could not resolve host",
24
+ "couldn't resolve host",
25
+ "failed to connect",
26
+ "connection timed out",
27
+ "network is unreachable",
28
+ "temporary failure in name resolution",
29
+ "name or service not known",
30
+ "could not resolve proxy",
31
+ "no route to host",
32
+ )
19
33
 
20
34
  SEVERITY_NORMAL = "normal"
21
35
  SEVERITY_LOW = "low"
@@ -128,11 +142,12 @@ def _read_remote_version(base_dir: Path, branch: str) -> str | None:
128
142
  f"origin/{branch}:VERSION",
129
143
  ],
130
144
  cwd=base_dir,
145
+ stderr=subprocess.STDOUT,
146
+ text=True,
131
147
  )
132
- .decode()
133
148
  .strip()
134
149
  )
135
- except subprocess.CalledProcessError: # pragma: no cover - git failure
150
+ except (subprocess.CalledProcessError, FileNotFoundError): # pragma: no cover - git failure
136
151
  return None
137
152
 
138
153
 
@@ -176,6 +191,141 @@ def _add_skipped_revision(base_dir: Path, revision: str) -> None:
176
191
  )
177
192
 
178
193
 
194
+ def _network_failure_lock_path(base_dir: Path) -> Path:
195
+ return base_dir / "locks" / AUTO_UPGRADE_NETWORK_FAILURE_LOCK_NAME
196
+
197
+
198
+ def _read_network_failure_count(base_dir: Path) -> int:
199
+ lock_path = _network_failure_lock_path(base_dir)
200
+ try:
201
+ raw_value = lock_path.read_text(encoding="utf-8").strip()
202
+ except FileNotFoundError:
203
+ return 0
204
+ except OSError:
205
+ logger.warning("Failed to read auto-upgrade network failure lockfile")
206
+ return 0
207
+ if not raw_value:
208
+ return 0
209
+ try:
210
+ return int(raw_value)
211
+ except ValueError:
212
+ logger.warning(
213
+ "Invalid auto-upgrade network failure lockfile contents: %s", raw_value
214
+ )
215
+ return 0
216
+
217
+
218
+ def _write_network_failure_count(base_dir: Path, count: int) -> None:
219
+ lock_path = _network_failure_lock_path(base_dir)
220
+ try:
221
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
222
+ lock_path.write_text(str(count), encoding="utf-8")
223
+ except OSError:
224
+ logger.warning("Failed to update auto-upgrade network failure lockfile")
225
+
226
+
227
+ def _reset_network_failure_count(base_dir: Path) -> None:
228
+ lock_path = _network_failure_lock_path(base_dir)
229
+ try:
230
+ if lock_path.exists():
231
+ lock_path.unlink()
232
+ except OSError:
233
+ logger.warning("Failed to remove auto-upgrade network failure lockfile")
234
+
235
+
236
+ def _extract_error_output(exc: subprocess.CalledProcessError) -> str:
237
+ parts: list[str] = []
238
+ for attr in ("stderr", "stdout", "output"):
239
+ value = getattr(exc, attr, None)
240
+ if not value:
241
+ continue
242
+ if isinstance(value, bytes):
243
+ try:
244
+ value = value.decode()
245
+ except Exception: # pragma: no cover - best effort decoding
246
+ value = value.decode(errors="ignore")
247
+ parts.append(str(value))
248
+ detail = " ".join(part.strip() for part in parts if part)
249
+ if not detail:
250
+ detail = str(exc)
251
+ return detail
252
+
253
+
254
+ def _is_network_failure(exc: subprocess.CalledProcessError) -> bool:
255
+ command = exc.cmd
256
+ if isinstance(command, (list, tuple)):
257
+ if not command:
258
+ return False
259
+ first = str(command[0])
260
+ else:
261
+ command_str = str(command)
262
+ first = command_str.split()[0] if command_str else ""
263
+ if "git" not in first:
264
+ return False
265
+ detail = _extract_error_output(exc).lower()
266
+ return any(pattern in detail for pattern in _NETWORK_FAILURE_PATTERNS)
267
+
268
+
269
+ def _record_network_failure(base_dir: Path, detail: str) -> int:
270
+ count = _read_network_failure_count(base_dir) + 1
271
+ _write_network_failure_count(base_dir, count)
272
+ _append_auto_upgrade_log(
273
+ base_dir,
274
+ f"Auto-upgrade network failure {count}: {detail}",
275
+ )
276
+ return count
277
+
278
+
279
+ def _charge_point_active(base_dir: Path) -> bool:
280
+ lock_path = base_dir / "locks" / "charging.lck"
281
+ if lock_path.exists():
282
+ return True
283
+ try:
284
+ from ocpp import store # type: ignore
285
+ except Exception:
286
+ return False
287
+ try:
288
+ connections = getattr(store, "connections", {})
289
+ except Exception: # pragma: no cover - defensive
290
+ return False
291
+ return bool(connections)
292
+
293
+
294
+ def _trigger_auto_upgrade_reboot(base_dir: Path) -> None:
295
+ try:
296
+ subprocess.run(["sudo", "systemctl", "reboot"], check=False)
297
+ except Exception: # pragma: no cover - best effort reboot command
298
+ logger.exception(
299
+ "Failed to trigger reboot after repeated auto-upgrade network failures"
300
+ )
301
+
302
+
303
+ def _reboot_if_no_charge_point(base_dir: Path) -> None:
304
+ if _charge_point_active(base_dir):
305
+ _append_auto_upgrade_log(
306
+ base_dir,
307
+ "Skipping reboot after repeated auto-upgrade network failures; a charge point is active",
308
+ )
309
+ return
310
+ _append_auto_upgrade_log(
311
+ base_dir,
312
+ "Rebooting due to repeated auto-upgrade network failures",
313
+ )
314
+ _trigger_auto_upgrade_reboot(base_dir)
315
+
316
+
317
+ def _handle_network_failure_if_applicable(
318
+ base_dir: Path, exc: subprocess.CalledProcessError
319
+ ) -> bool:
320
+ if not _is_network_failure(exc):
321
+ return False
322
+ detail = _extract_error_output(exc)
323
+ failure_count = _record_network_failure(base_dir, detail)
324
+ if failure_count >= AUTO_UPGRADE_NETWORK_FAILURE_THRESHOLD:
325
+ _reboot_if_no_charge_point(base_dir)
326
+ return True
327
+
328
+
179
329
  def _resolve_service_url(base_dir: Path) -> str:
180
330
  """Return the local URL used to probe the Django suite."""
181
331
 
@@ -189,7 +339,7 @@ def _resolve_service_url(base_dir: Path) -> str:
189
339
  value = ""
190
340
  if value:
191
341
  mode = value.lower()
192
- port = 8000 if mode == "public" else 8888
342
+ port = 8888
193
343
  return f"http://127.0.0.1:{port}/"
194
344
 
195
345
 
@@ -214,153 +364,178 @@ def check_github_updates() -> None:
214
364
  base_dir = Path(__file__).resolve().parent.parent
215
365
  mode_file = base_dir / "locks" / "auto_upgrade.lck"
216
366
  mode = "version"
217
- if mode_file.exists():
367
+ reset_network_failures = True
368
+ try:
369
+ if mode_file.exists():
370
+ try:
371
+ raw_mode = mode_file.read_text().strip()
372
+ except (OSError, UnicodeDecodeError):
373
+ logger.warning(
374
+ "Failed to read auto-upgrade mode lockfile", exc_info=True
375
+ )
376
+ else:
377
+ cleaned_mode = raw_mode.lower()
378
+ if cleaned_mode:
379
+ mode = cleaned_mode
380
+
381
+ branch = "main"
218
382
  try:
219
- raw_mode = mode_file.read_text().strip()
220
- except (OSError, UnicodeDecodeError):
221
- logger.warning(
222
- "Failed to read auto-upgrade mode lockfile", exc_info=True
383
+ subprocess.run(
384
+ ["git", "fetch", "origin", branch],
385
+ cwd=base_dir,
386
+ check=True,
387
+ capture_output=True,
388
+ text=True,
223
389
  )
224
- else:
225
- cleaned_mode = raw_mode.lower()
226
- if cleaned_mode:
227
- mode = cleaned_mode
390
+ except subprocess.CalledProcessError as exc:
391
+ if _handle_network_failure_if_applicable(base_dir, exc):
392
+ reset_network_failures = False
393
+ raise
228
394
 
229
- branch = "main"
230
- subprocess.run(["git", "fetch", "origin", branch], cwd=base_dir, check=True)
231
-
232
- log_file = _auto_upgrade_log_path(base_dir)
233
- with log_file.open("a") as fh:
234
- fh.write(
235
- f"{timezone.now().isoformat()} check_github_updates triggered\n"
236
- )
395
+ log_file = _auto_upgrade_log_path(base_dir)
396
+ with log_file.open("a") as fh:
397
+ fh.write(
398
+ f"{timezone.now().isoformat()} check_github_updates triggered\n"
399
+ )
237
400
 
238
- notify = None
239
- startup = None
240
- try: # pragma: no cover - optional dependency
241
- from core.notifications import notify # type: ignore
242
- except Exception:
243
401
  notify = None
244
- try: # pragma: no cover - optional dependency
245
- from nodes.apps import _startup_notification as startup # type: ignore
246
- except Exception:
247
402
  startup = None
403
+ try: # pragma: no cover - optional dependency
404
+ from core.notifications import notify # type: ignore
405
+ except Exception:
406
+ notify = None
407
+ try: # pragma: no cover - optional dependency
408
+ from nodes.apps import _startup_notification as startup # type: ignore
409
+ except Exception:
410
+ startup = None
248
411
 
249
- remote_revision = (
250
- subprocess.check_output(
251
- ["git", "rev-parse", f"origin/{branch}"], cwd=base_dir
252
- )
253
- .decode()
254
- .strip()
255
- )
256
-
257
- skipped_revisions = _load_skipped_revisions(base_dir)
258
- if remote_revision in skipped_revisions:
259
- _append_auto_upgrade_log(
260
- base_dir, f"Skipping auto-upgrade for blocked revision {remote_revision}"
261
- )
262
- if startup:
263
- startup()
264
- return
265
-
266
- remote_version = _read_remote_version(base_dir, branch)
267
- local_version = _read_local_version(base_dir)
268
- remote_severity = _resolve_release_severity(remote_version)
269
-
270
- upgrade_stamp = timezone.now().strftime("@ %Y%m%d %H:%M")
271
-
272
- upgrade_was_applied = False
273
-
274
- if mode == "latest":
275
- local_revision = (
276
- subprocess.check_output(["git", "rev-parse", branch], cwd=base_dir)
277
- .decode()
278
- .strip()
279
- )
280
- if local_revision == remote_revision:
281
- if startup:
282
- startup()
283
- return
284
-
285
- if (
286
- remote_version
287
- and local_version
288
- and remote_version != local_version
289
- and remote_severity == SEVERITY_LOW
290
- and _shares_stable_series(local_version, remote_version)
291
- ):
412
+ try:
413
+ remote_revision = subprocess.check_output(
414
+ ["git", "rev-parse", f"origin/{branch}"],
415
+ cwd=base_dir,
416
+ stderr=subprocess.STDOUT,
417
+ text=True,
418
+ ).strip()
419
+ except subprocess.CalledProcessError as exc:
420
+ if _handle_network_failure_if_applicable(base_dir, exc):
421
+ reset_network_failures = False
422
+ raise
423
+
424
+ skipped_revisions = _load_skipped_revisions(base_dir)
425
+ if remote_revision in skipped_revisions:
292
426
  _append_auto_upgrade_log(
293
427
  base_dir,
294
- f"Skipping auto-upgrade for low severity patch {remote_version}",
428
+ f"Skipping auto-upgrade for blocked revision {remote_revision}",
295
429
  )
296
430
  if startup:
297
431
  startup()
298
432
  return
299
433
 
300
- if notify:
301
- notify("Upgrading...", upgrade_stamp)
302
- args = ["./upgrade.sh", "--latest", "--no-restart"]
303
- upgrade_was_applied = True
304
- else:
305
- local_value = local_version or "0"
306
- remote_value = remote_version or local_value
434
+ remote_version = _read_remote_version(base_dir, branch)
435
+ local_version = _read_local_version(base_dir)
436
+ remote_severity = _resolve_release_severity(remote_version)
307
437
 
308
- if local_value == remote_value:
309
- if startup:
310
- startup()
311
- return
438
+ upgrade_stamp = timezone.now().strftime("@ %Y%m%d %H:%M")
312
439
 
313
- if (
314
- mode == "stable"
315
- and local_version
316
- and remote_version
317
- and remote_version != local_version
318
- and _shares_stable_series(local_version, remote_version)
319
- and remote_severity != SEVERITY_CRITICAL
320
- ):
321
- if startup:
322
- startup()
323
- return
440
+ upgrade_was_applied = False
324
441
 
325
- if notify:
326
- notify("Upgrading...", upgrade_stamp)
327
- if mode == "stable":
328
- args = ["./upgrade.sh", "--stable", "--no-restart"]
442
+ if mode == "latest":
443
+ local_revision = (
444
+ subprocess.check_output(
445
+ ["git", "rev-parse", branch],
446
+ cwd=base_dir,
447
+ stderr=subprocess.STDOUT,
448
+ text=True,
449
+ )
450
+ .strip()
451
+ )
452
+ if local_revision == remote_revision:
453
+ if startup:
454
+ startup()
455
+ return
456
+
457
+ if (
458
+ remote_version
459
+ and local_version
460
+ and remote_version != local_version
461
+ and remote_severity == SEVERITY_LOW
462
+ and _shares_stable_series(local_version, remote_version)
463
+ ):
464
+ _append_auto_upgrade_log(
465
+ base_dir,
466
+ f"Skipping auto-upgrade for low severity patch {remote_version}",
467
+ )
468
+ if startup:
469
+ startup()
470
+ return
471
+
472
+ if notify:
473
+ notify("Upgrading...", upgrade_stamp)
474
+ args = ["./upgrade.sh", "--latest", "--no-restart"]
475
+ upgrade_was_applied = True
329
476
  else:
330
- args = ["./upgrade.sh", "--no-restart"]
331
- upgrade_was_applied = True
477
+ local_value = local_version or "0"
478
+ remote_value = remote_version or local_value
479
+
480
+ if local_value == remote_value:
481
+ if startup:
482
+ startup()
483
+ return
484
+
485
+ if (
486
+ mode == "stable"
487
+ and local_version
488
+ and remote_version
489
+ and remote_version != local_version
490
+ and _shares_stable_series(local_version, remote_version)
491
+ and remote_severity != SEVERITY_CRITICAL
492
+ ):
493
+ if startup:
494
+ startup()
495
+ return
496
+
497
+ if notify:
498
+ notify("Upgrading...", upgrade_stamp)
499
+ if mode == "stable":
500
+ args = ["./upgrade.sh", "--stable", "--no-restart"]
501
+ else:
502
+ args = ["./upgrade.sh", "--no-restart"]
503
+ upgrade_was_applied = True
332
504
 
333
- with log_file.open("a") as fh:
334
- fh.write(
335
- f"{timezone.now().isoformat()} running: {' '.join(args)}\n"
336
- )
505
+ with log_file.open("a") as fh:
506
+ fh.write(
507
+ f"{timezone.now().isoformat()} running: {' '.join(args)}\n"
508
+ )
337
509
 
338
- subprocess.run(args, cwd=base_dir, check=True)
339
-
340
- service_file = base_dir / "locks/service.lck"
341
- if service_file.exists():
342
- service = service_file.read_text().strip()
343
- subprocess.run(
344
- [
345
- "sudo",
346
- "systemctl",
347
- "kill",
348
- "--signal=TERM",
349
- service,
350
- ]
351
- )
352
- else:
353
- subprocess.run(["pkill", "-f", "manage.py runserver"])
510
+ subprocess.run(args, cwd=base_dir, check=True)
354
511
 
355
- if upgrade_was_applied:
356
- _append_auto_upgrade_log(
357
- base_dir,
358
- (
359
- "Scheduled post-upgrade health check in %s seconds"
360
- % AUTO_UPGRADE_HEALTH_DELAY_SECONDS
361
- ),
362
- )
363
- _schedule_health_check(1)
512
+ service_file = base_dir / "locks/service.lck"
513
+ if service_file.exists():
514
+ service = service_file.read_text().strip()
515
+ subprocess.run(
516
+ [
517
+ "sudo",
518
+ "systemctl",
519
+ "kill",
520
+ "--signal=TERM",
521
+ service,
522
+ ]
523
+ )
524
+ else:
525
+ subprocess.run(["pkill", "-f", "manage.py runserver"])
526
+
527
+ if upgrade_was_applied:
528
+ _append_auto_upgrade_log(
529
+ base_dir,
530
+ (
531
+ "Scheduled post-upgrade health check in %s seconds"
532
+ % AUTO_UPGRADE_HEALTH_DELAY_SECONDS
533
+ ),
534
+ )
535
+ _schedule_health_check(1)
536
+ finally:
537
+ if reset_network_failures:
538
+ _reset_network_failure_count(base_dir)
364
539
 
365
540
 
366
541
  @shared_task
core/test_system_info.py CHANGED
@@ -64,13 +64,28 @@ class SystemInfoModeTests(SimpleTestCase):
64
64
  try:
65
65
  info = _gather_info()
66
66
  self.assertEqual(info["mode"], "public")
67
- self.assertEqual(info["port"], 8000)
67
+ self.assertEqual(info["port"], 8888)
68
68
  finally:
69
69
  lock_file.unlink()
70
70
  if not any(lock_dir.iterdir()):
71
71
  lock_dir.rmdir()
72
72
 
73
73
 
74
+ class SystemInfoPortLockTests(SimpleTestCase):
75
+ def test_uses_backend_port_lock_when_present(self):
76
+ lock_dir = Path(settings.BASE_DIR) / "locks"
77
+ lock_dir.mkdir(exist_ok=True)
78
+ port_file = lock_dir / "backend_port.lck"
79
+ port_file.write_text("9010", encoding="utf-8")
80
+ try:
81
+ info = _gather_info()
82
+ self.assertEqual(info["port"], 9010)
83
+ finally:
84
+ port_file.unlink()
85
+ if not any(lock_dir.iterdir()):
86
+ lock_dir.rmdir()
87
+
88
+
74
89
  class SystemInfoRevisionTests(SimpleTestCase):
75
90
  @patch("core.system.revision.get_revision", return_value="abcdef1234567890")
76
91
  def test_includes_full_revision(self, mock_revision):
@@ -146,21 +161,44 @@ class SystemInfoRunserverDetectionTests(SimpleTestCase):
146
161
  mock_run.return_value = CompletedProcess(
147
162
  args=["pgrep"],
148
163
  returncode=0,
149
- stdout="123 python manage.py runserver 0.0.0.0:8000 --noreload\n",
164
+ stdout="123 python manage.py runserver 0.0.0.0:8888 --noreload\n",
150
165
  )
151
166
 
152
167
  info = _gather_info()
153
168
 
154
169
  self.assertTrue(info["running"])
155
- self.assertEqual(info["port"], 8000)
170
+ self.assertEqual(info["port"], 8888)
156
171
 
157
- @patch("core.system._probe_ports", return_value=(True, 8000))
172
+ @patch("core.system._probe_ports", return_value=(True, 8888))
158
173
  @patch("core.system.subprocess.run", side_effect=FileNotFoundError)
159
174
  def test_falls_back_to_port_probe_when_pgrep_missing(self, mock_run, mock_probe):
160
175
  info = _gather_info()
161
176
 
162
177
  self.assertTrue(info["running"])
163
- self.assertEqual(info["port"], 8000)
178
+ self.assertEqual(info["port"], 8888)
179
+
180
+ @patch("core.system._probe_ports", return_value=(False, None))
181
+ @patch("core.system.subprocess.run")
182
+ def test_runserver_fallbacks_to_backend_port_lock(self, mock_run, mock_probe):
183
+ lock_dir = Path(settings.BASE_DIR) / "locks"
184
+ lock_dir.mkdir(exist_ok=True)
185
+ port_file = lock_dir / "backend_port.lck"
186
+ port_file.write_text("9042", encoding="utf-8")
187
+ mock_run.return_value = CompletedProcess(
188
+ args=["pgrep"],
189
+ returncode=0,
190
+ stdout="123 python manage.py runserver --noreload\n",
191
+ )
192
+
193
+ try:
194
+ info = _gather_info()
195
+ finally:
196
+ port_file.unlink()
197
+ if not any(lock_dir.iterdir()):
198
+ lock_dir.rmdir()
199
+
200
+ self.assertTrue(info["running"])
201
+ self.assertEqual(info["port"], 9042)
164
202
 
165
203
 
166
204
  class SystemSigilValueTests(SimpleTestCase):