scalio-platform 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scalio_platform-0.1.0/PKG-INFO +59 -0
- scalio_platform-0.1.0/README.md +43 -0
- scalio_platform-0.1.0/pyproject.toml +29 -0
- scalio_platform-0.1.0/setup.cfg +4 -0
- scalio_platform-0.1.0/src/scalio_platform/__init__.py +6 -0
- scalio_platform-0.1.0/src/scalio_platform/app_links.py +64 -0
- scalio_platform-0.1.0/src/scalio_platform/error_report.py +179 -0
- scalio_platform-0.1.0/src/scalio_platform/health.py +62 -0
- scalio_platform-0.1.0/src/scalio_platform/job_lease.py +99 -0
- scalio_platform-0.1.0/src/scalio_platform/migrations.py +76 -0
- scalio_platform-0.1.0/src/scalio_platform.egg-info/PKG-INFO +59 -0
- scalio_platform-0.1.0/src/scalio_platform.egg-info/SOURCES.txt +19 -0
- scalio_platform-0.1.0/src/scalio_platform.egg-info/dependency_links.txt +1 -0
- scalio_platform-0.1.0/src/scalio_platform.egg-info/requires.txt +9 -0
- scalio_platform-0.1.0/src/scalio_platform.egg-info/top_level.txt +1 -0
- scalio_platform-0.1.0/tests/test_app_links.py +83 -0
- scalio_platform-0.1.0/tests/test_error_report.py +161 -0
- scalio_platform-0.1.0/tests/test_health.py +71 -0
- scalio_platform-0.1.0/tests/test_job_lease.py +129 -0
- scalio_platform-0.1.0/tests/test_migrations.py +91 -0
- scalio_platform-0.1.0/tests/test_texts.py +10 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scalio-platform
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pymongo>=4.6
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
13
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
14
|
+
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# scalio-platform
|
|
18
|
+
|
|
19
|
+
Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
|
|
20
|
+
Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
|
|
21
|
+
scalio-docs). Nothing in this package is secret.
|
|
22
|
+
|
|
23
|
+
| Module | What it does |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `scalio_platform.job_lease` | Runs a background loop on one instance at a time (lease in `db.job_leases`). During an update two instances run side by side; without the lease both would act. |
|
|
26
|
+
| `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
|
|
27
|
+
| `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
|
|
28
|
+
| `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
|
|
29
|
+
| `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
|
|
30
|
+
|
|
31
|
+
## Use in an app
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
scalio-platform==0.1.0 # backend/requirements.txt, exact version
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from scalio_platform import error_report, health, job_lease
|
|
39
|
+
from scalio_platform.migrations import BASELINE, run_migrations
|
|
40
|
+
|
|
41
|
+
MIGRATIONS = BASELINE # append (2, migrate_002_...) and so on, never edit past steps
|
|
42
|
+
|
|
43
|
+
async def startup():
|
|
44
|
+
await run_migrations(db, MIGRATIONS) # before the background loops
|
|
45
|
+
for name, loop in LOOPS.items():
|
|
46
|
+
asyncio.create_task(job_lease.run_exclusive(db, name, loop))
|
|
47
|
+
|
|
48
|
+
error_report.install(app, "citadel", build_info)
|
|
49
|
+
code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A migration step must be safe to run twice: two instances can start at the same moment.
|
|
53
|
+
|
|
54
|
+
## Develop and release
|
|
55
|
+
|
|
56
|
+
- `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
|
|
57
|
+
- Release: bump `pyproject.toml` and `__version__`, add the CHANGELOG entry, merge, then «Freigeben» in the Scalio
|
|
58
|
+
Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
|
|
59
|
+
- A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# scalio-platform
|
|
2
|
+
|
|
3
|
+
Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
|
|
4
|
+
Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
|
|
5
|
+
scalio-docs). Nothing in this package is secret.
|
|
6
|
+
|
|
7
|
+
| Module | What it does |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `scalio_platform.job_lease` | Runs a background loop on one instance at a time (lease in `db.job_leases`). During an update two instances run side by side; without the lease both would act. |
|
|
10
|
+
| `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
|
|
11
|
+
| `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
|
|
12
|
+
| `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
|
|
13
|
+
| `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
|
|
14
|
+
|
|
15
|
+
## Use in an app
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
scalio-platform==0.1.0 # backend/requirements.txt, exact version
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from scalio_platform import error_report, health, job_lease
|
|
23
|
+
from scalio_platform.migrations import BASELINE, run_migrations
|
|
24
|
+
|
|
25
|
+
MIGRATIONS = BASELINE # append (2, migrate_002_...) and so on, never edit past steps
|
|
26
|
+
|
|
27
|
+
async def startup():
|
|
28
|
+
await run_migrations(db, MIGRATIONS) # before the background loops
|
|
29
|
+
for name, loop in LOOPS.items():
|
|
30
|
+
asyncio.create_task(job_lease.run_exclusive(db, name, loop))
|
|
31
|
+
|
|
32
|
+
error_report.install(app, "citadel", build_info)
|
|
33
|
+
code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A migration step must be safe to run twice: two instances can start at the same moment.
|
|
37
|
+
|
|
38
|
+
## Develop and release
|
|
39
|
+
|
|
40
|
+
- `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
|
|
41
|
+
- Release: bump `pyproject.toml` and `__version__`, add the CHANGELOG entry, merge, then «Freigeben» in the Scalio
|
|
42
|
+
Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
|
|
43
|
+
- A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scalio-platform"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
dependencies = ["httpx>=0.27", "pymongo>=4.6"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27", "mongomock-motor>=0.0.29", "ruff>=0.6"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
22
|
+
|
|
23
|
+
[tool.ruff]
|
|
24
|
+
line-length = 120
|
|
25
|
+
target-version = "py311"
|
|
26
|
+
|
|
27
|
+
[tool.ruff.lint]
|
|
28
|
+
select = ["E", "F", "W", "I", "B"]
|
|
29
|
+
ignore = ["B008"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Shared building blocks of the Scalio apps (architecture review C2/C4, ADR 0058).
|
|
2
|
+
|
|
3
|
+
Modules: job_lease (one instance per background loop), migrations (schema version and steps), health
|
|
4
|
+
(GET /api/health), error_report (errors to Scalio Monitor), app_links (product switcher).
|
|
5
|
+
"""
|
|
6
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Links of the product switcher: where the other Scalio apps of this installation run.
|
|
2
|
+
|
|
3
|
+
SCALIO_APP_LINKS lists them per instance, e.g.
|
|
4
|
+
SCALIO_APP_LINKS=nexus=https://dev.scalionexus.com,rosetta=https://dev.scaliorosetta.com
|
|
5
|
+
Apps without a link stay greyed out in the switcher. Module of the shared package scalio-platform (formerly a
|
|
6
|
+
copy in every app with the switcher).
|
|
7
|
+
|
|
8
|
+
Which Scalio functions this instance shows depends on how it runs:
|
|
9
|
+
SCALIO_STAGE=dev|test|demo our own instances (set in scalio-infra); empty = production
|
|
10
|
+
SAAS_MODE=true a SaaS customer instance (set by the provisioning engine)
|
|
11
|
+
A self-hosted customer sets neither, so the switcher stays hidden there (we cannot know which other apps that
|
|
12
|
+
customer has licensed). Its support button shows while the license maintenance runs (ADR 0022, T11): the caller
|
|
13
|
+
passes that in (support.py).
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
APPS = ("nexus", "rosetta", "foundry", "citadel", "beacon", "pulse")
|
|
19
|
+
STAGES = ("dev", "test", "demo")
|
|
20
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_app_links(raw: str | None) -> dict:
|
|
24
|
+
"""Known app keys with an http(s) URL; anything else is ignored."""
|
|
25
|
+
links = {}
|
|
26
|
+
for part in (raw or "").split(","):
|
|
27
|
+
key, sep, url = part.partition("=")
|
|
28
|
+
key, url = key.strip().lower(), url.strip().rstrip("/")
|
|
29
|
+
if not sep or key not in APPS:
|
|
30
|
+
continue
|
|
31
|
+
parts = urlsplit(url)
|
|
32
|
+
if parts.scheme in ("http", "https") and parts.netloc and not parts.username and not parts.password:
|
|
33
|
+
links[key] = url
|
|
34
|
+
return links
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def stage() -> str:
|
|
38
|
+
"""dev, test or demo for our own instances, "prod" otherwise (also for unknown values)."""
|
|
39
|
+
value = (os.environ.get("SCALIO_STAGE") or "").strip().lower()
|
|
40
|
+
return value if value in STAGES else "prod"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def saas_mode() -> bool:
|
|
44
|
+
return (os.environ.get("SAAS_MODE") or "").strip().lower() in _TRUE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def features(self_hosted_support: bool = False) -> dict:
|
|
48
|
+
"""Scalio functions shown by this instance. Demo shows the switcher but takes no support tickets.
|
|
49
|
+
self_hosted_support: a self-hosted customer's maintenance runs (decided by the caller from the license)."""
|
|
50
|
+
current, saas = stage(), saas_mode()
|
|
51
|
+
return {
|
|
52
|
+
"switcher": saas or current in STAGES,
|
|
53
|
+
"support": saas or current in ("dev", "test") or (current == "prod" and bool(self_hosted_support)),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def app_links(support: bool | None = None) -> dict:
|
|
58
|
+
"""Public answer of GET /api/apps. Links only where the switcher is shown. support: the caller's answer
|
|
59
|
+
whether the support button shows (support.shown(): maintenance, hidden by an admin)."""
|
|
60
|
+
shown = features()
|
|
61
|
+
if support is not None:
|
|
62
|
+
shown["support"] = bool(support)
|
|
63
|
+
links = parse_app_links(os.environ.get("SCALIO_APP_LINKS")) if shown["switcher"] else {}
|
|
64
|
+
return {"links": links, "stage": stage(), "saas": saas_mode(), "features": shown}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Report unexpected errors to Scalio Monitor (monitoring.scaliocloud.com, POST /ingest/error).
|
|
2
|
+
|
|
3
|
+
Module of the shared package scalio-platform (formerly a copy in every app); protocol in scalio-monitor docs/ERRORS.md.
|
|
4
|
+
Off unless ERROR_REPORT_URL and ERROR_REPORT_TOKEN are set, so self-hosted customers send nothing unless they
|
|
5
|
+
opt in (decision M4). Sent are only: type, message, stack, route template, method, status, version, commit.
|
|
6
|
+
Never request bodies, headers, query strings, cookies or user data; the monitor cleans the text again.
|
|
7
|
+
|
|
8
|
+
from scalio_platform import error_report
|
|
9
|
+
error_report.install(app, "nexus", build_info) # after app = FastAPI(...)
|
|
10
|
+
await error_report.report_exception(exc, kind="job", path="lifecycle_task") # background loops
|
|
11
|
+
|
|
12
|
+
The report runs in the background with a short timeout: a slow or missing monitor never slows a request.
|
|
13
|
+
The same error at the same place is sent at most once per minute per process.
|
|
14
|
+
The groups the monitor answered are kept in memory (`recent_groups()`): a support ticket lists them with a link to
|
|
15
|
+
the monitor (ADR 0022, T9; `support_router(..., errors=error_report.errors_for_support)`).
|
|
16
|
+
"""
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
import traceback
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("error_report")
|
|
28
|
+
TIMEOUT = 5.0
|
|
29
|
+
REPEAT_SECONDS = 60
|
|
30
|
+
MAX_STACK = 8000
|
|
31
|
+
MAX_GROUPS = 50
|
|
32
|
+
_FINGERPRINT = re.compile(r"^[0-9a-f]{8,64}$")
|
|
33
|
+
_ID = re.compile(r"^(?:\d+|[0-9a-f]{24}|[0-9a-f-]{36}|[\w-]{20,})$", re.I) # numbers, ObjectIds, UUIDs, tokens
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Reporter:
|
|
37
|
+
def __init__(self, app_name: str, build_info=None, url: str | None = None, token: str | None = None,
|
|
38
|
+
transport: httpx.AsyncBaseTransport | None = None, clock=time.monotonic):
|
|
39
|
+
self.app_name = app_name
|
|
40
|
+
self.build_info = build_info or (lambda: {})
|
|
41
|
+
self.url = (url if url is not None else os.environ.get("ERROR_REPORT_URL", "")).strip().rstrip("/")
|
|
42
|
+
self.token = (token if token is not None else os.environ.get("ERROR_REPORT_TOKEN", "")).strip()
|
|
43
|
+
self.transport, self.clock = transport, clock
|
|
44
|
+
self._client: httpx.AsyncClient | None = None
|
|
45
|
+
self._last: dict = {}
|
|
46
|
+
self._tasks: set = set()
|
|
47
|
+
self._groups: dict = {}
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def enabled(self) -> bool:
|
|
51
|
+
return bool(self.url and self.token)
|
|
52
|
+
|
|
53
|
+
def payload(self, exc: BaseException, kind: str, path: str = "", method: str = "",
|
|
54
|
+
status: int | None = None) -> dict:
|
|
55
|
+
try:
|
|
56
|
+
build = self.build_info() or {}
|
|
57
|
+
except Exception: # noqa: BLE001 - a broken version lookup must not hide the error
|
|
58
|
+
build = {}
|
|
59
|
+
stack = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))[-MAX_STACK:]
|
|
60
|
+
return {"app": self.app_name, "kind": kind, "type": type(exc).__name__, "message": str(exc)[:2000],
|
|
61
|
+
"stack": stack, "path": path, "method": method, "status": status,
|
|
62
|
+
"version": str(build.get("version") or ""), "commit": str(build.get("commit") or "")}
|
|
63
|
+
|
|
64
|
+
def _repeat(self, body: dict) -> bool:
|
|
65
|
+
tb = body["stack"].strip().splitlines()
|
|
66
|
+
key = (body["type"], body["path"], tb[-3] if len(tb) >= 3 else body["message"][:100])
|
|
67
|
+
now = self.clock()
|
|
68
|
+
if now - self._last.get(key, -REPEAT_SECONDS) < REPEAT_SECONDS:
|
|
69
|
+
return True
|
|
70
|
+
self._last[key] = now
|
|
71
|
+
if len(self._last) > 500:
|
|
72
|
+
self._last = {k: v for k, v in self._last.items() if now - v < REPEAT_SECONDS}
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
async def send(self, body: dict) -> bool:
|
|
76
|
+
if self._client is None:
|
|
77
|
+
self._client = httpx.AsyncClient(transport=self.transport, timeout=TIMEOUT)
|
|
78
|
+
try:
|
|
79
|
+
r = await self._client.post(f"{self.url}/ingest/error", json=body,
|
|
80
|
+
headers={"Authorization": f"Bearer {self.token}"})
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
log.warning("error report not sent: %s", type(exc).__name__) # never log the token
|
|
83
|
+
return False
|
|
84
|
+
if r.status_code != 200:
|
|
85
|
+
return False
|
|
86
|
+
self._remember(body, r)
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def _remember(self, body: dict, response: httpx.Response) -> None:
|
|
90
|
+
try:
|
|
91
|
+
fp = str((response.json() or {}).get("group") or "")
|
|
92
|
+
except ValueError:
|
|
93
|
+
return
|
|
94
|
+
if not _FINGERPRINT.match(fp):
|
|
95
|
+
return
|
|
96
|
+
old = self._groups.pop(fp, {})
|
|
97
|
+
self._groups[fp] = {"fingerprint": fp, "type": body["type"], "route": body["path"] or body["kind"],
|
|
98
|
+
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
99
|
+
"count": old.get("count", 0) + 1, "monitor": self.url}
|
|
100
|
+
while len(self._groups) > MAX_GROUPS:
|
|
101
|
+
self._groups.pop(next(iter(self._groups)))
|
|
102
|
+
|
|
103
|
+
def recent_groups(self, limit: int = 10) -> list:
|
|
104
|
+
"""Last error groups this process reported, oldest first (no messages, no stack)."""
|
|
105
|
+
return [dict(g) for g in list(self._groups.values())[-limit:]]
|
|
106
|
+
|
|
107
|
+
def submit(self, exc: BaseException, kind: str = "backend", path: str = "", method: str = "",
|
|
108
|
+
status: int | None = None):
|
|
109
|
+
"""Queue a report in the background. Returns the task, or None when off or repeated."""
|
|
110
|
+
if not self.enabled:
|
|
111
|
+
return None
|
|
112
|
+
body = self.payload(exc, kind, path, method, status)
|
|
113
|
+
if self._repeat(body):
|
|
114
|
+
return None
|
|
115
|
+
task = asyncio.get_running_loop().create_task(self.send(body))
|
|
116
|
+
self._tasks.add(task)
|
|
117
|
+
task.add_done_callback(self._tasks.discard)
|
|
118
|
+
return task
|
|
119
|
+
|
|
120
|
+
async def drain(self):
|
|
121
|
+
"""Wait for queued reports (tests, shutdown)."""
|
|
122
|
+
if self._tasks:
|
|
123
|
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def route_template(scope: dict) -> str:
|
|
127
|
+
route = scope.get("route")
|
|
128
|
+
if route is not None and getattr(route, "path", None):
|
|
129
|
+
return route.path
|
|
130
|
+
parts = [("{id}" if _ID.match(p) else p) for p in (scope.get("path") or "").split("/")]
|
|
131
|
+
return "/".join(parts)[:300]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ErrorReportMiddleware:
|
|
135
|
+
"""Pure ASGI middleware: reports exceptions no handler caught (the 500s), then lets them go on."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, app, reporter: Reporter):
|
|
138
|
+
self.app, self.reporter = app, reporter
|
|
139
|
+
|
|
140
|
+
async def __call__(self, scope, receive, send):
|
|
141
|
+
if scope["type"] != "http":
|
|
142
|
+
return await self.app(scope, receive, send)
|
|
143
|
+
try:
|
|
144
|
+
await self.app(scope, receive, send)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
try:
|
|
147
|
+
self.reporter.submit(exc, "backend", route_template(scope), scope.get("method", ""), 500)
|
|
148
|
+
except Exception: # noqa: BLE001 - reporting must never change the answer
|
|
149
|
+
log.warning("error report failed")
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_reporter: Reporter | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def install(app, app_name: str, build_info=None, **kw) -> Reporter:
|
|
157
|
+
"""Report the unhandled errors of this FastAPI app. Off without ERROR_REPORT_URL/ERROR_REPORT_TOKEN."""
|
|
158
|
+
global _reporter
|
|
159
|
+
_reporter = Reporter(app_name, build_info, **kw)
|
|
160
|
+
app.add_middleware(ErrorReportMiddleware, reporter=_reporter)
|
|
161
|
+
if _reporter.enabled:
|
|
162
|
+
log.info("error reports to %s are on", _reporter.url)
|
|
163
|
+
return _reporter
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def report_exception(exc: BaseException, kind: str = "job", path: str = "") -> None:
|
|
167
|
+
"""For background loops: report an exception that would otherwise only reach the log."""
|
|
168
|
+
if _reporter is not None:
|
|
169
|
+
_reporter.submit(exc, kind, path)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def recent_groups(limit: int = 10) -> list:
|
|
173
|
+
"""Last error groups reported by this process: fingerprint, type, route, time, count, monitor."""
|
|
174
|
+
return _reporter.recent_groups(limit) if _reporter is not None else []
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def errors_for_support(_user=None) -> list:
|
|
178
|
+
"""For the support ticket's diagnostic block (scalio-connect support_router errors=...)."""
|
|
179
|
+
return recent_groups()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""GET /api/health: the same answer in every Scalio app, read by the monitoring dashboard
|
|
2
|
+
(monitoring.scaliocloud.com, concept https://claude.ai/artifact/Ls47WPEtKL2LQfEiMroJFH).
|
|
3
|
+
|
|
4
|
+
{"status": "ok" | "degraded" | "down", "version": "...", "commit": "...", "db": "ok" | "error" | "none",
|
|
5
|
+
"jobs": {"<loop>": {"ok": true, "last_seen": "<ISO time>"}}}
|
|
6
|
+
|
|
7
|
+
HTTP 200 for ok and degraded, 503 for down (database not answering). degraded = a background loop that should
|
|
8
|
+
run has no valid lease (job_leases, see job_lease.py), so nobody runs it. No login, no secrets, no counts of
|
|
9
|
+
customers or users, no host names. db "none" = the app runs without a database (cockpit without MONGO_URL).
|
|
10
|
+
Module of the shared package scalio-platform (formerly a copy in every app).
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
|
|
15
|
+
DB_TIMEOUT = 2.0 # seconds; the dashboard checks every minute and must not hang on a dead database
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _aware(value):
|
|
19
|
+
if isinstance(value, datetime) and value.tzinfo is None:
|
|
20
|
+
return value.replace(tzinfo=UTC) # pymongo returns naive UTC
|
|
21
|
+
return value if isinstance(value, datetime) else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _db_ok(db, timeout: float) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
await asyncio.wait_for(db.command("ping"), timeout)
|
|
27
|
+
return True
|
|
28
|
+
except Exception: # noqa: BLE001 - any failure means "down" for the dashboard
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _lease_jobs(db, expected, now) -> dict:
|
|
33
|
+
found = {}
|
|
34
|
+
async for d in db["job_leases"].find({"_id": {"$in": list(expected)}}, {"until": 1, "renewedAt": 1}):
|
|
35
|
+
found[d["_id"]] = d
|
|
36
|
+
jobs = {}
|
|
37
|
+
for name in sorted(expected):
|
|
38
|
+
d = found.get(name) or {}
|
|
39
|
+
until, seen = _aware(d.get("until")), _aware(d.get("renewedAt"))
|
|
40
|
+
jobs[name] = {"ok": bool(until and until >= now), "last_seen": seen.isoformat() if seen else None}
|
|
41
|
+
return jobs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def health_report(db, build: dict, lease_jobs=(), timeout: float = DB_TIMEOUT, now=None):
|
|
45
|
+
"""(HTTP status, body) for GET /api/health. `lease_jobs` = names of the loops that run under a job lease."""
|
|
46
|
+
now = now or datetime.now(UTC)
|
|
47
|
+
body = {"status": "ok", "version": build.get("version", ""), "commit": build.get("commit", ""),
|
|
48
|
+
"db": "ok", "jobs": {}}
|
|
49
|
+
if db is None:
|
|
50
|
+
body["db"] = "none"
|
|
51
|
+
return 200, body
|
|
52
|
+
if not await _db_ok(db, timeout):
|
|
53
|
+
body.update(status="down", db="error")
|
|
54
|
+
return 503, body
|
|
55
|
+
if lease_jobs:
|
|
56
|
+
try:
|
|
57
|
+
body["jobs"] = await asyncio.wait_for(_lease_jobs(db, lease_jobs, now), timeout)
|
|
58
|
+
except Exception: # noqa: BLE001
|
|
59
|
+
body["jobs"] = {}
|
|
60
|
+
if any(not j["ok"] for j in body["jobs"].values()):
|
|
61
|
+
body["status"] = "degraded"
|
|
62
|
+
return 200, body
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Run each background loop on one backend instance only.
|
|
2
|
+
|
|
3
|
+
During an update two backends or workers can run side by side for a moment (compose recreate, Container Apps
|
|
4
|
+
revision swap). Without a lock both would run the same job twice (act in a tenant, bill, send mail). Each loop
|
|
5
|
+
therefore runs only while its instance holds a lease in
|
|
6
|
+
db.job_leases ({_id: loop name, owner, until}). The holder renews it every RENEW seconds; another instance
|
|
7
|
+
waits and takes over once the lease has expired (TTL seconds after the holder stopped renewing). A graceful
|
|
8
|
+
stop releases the lease at once.
|
|
9
|
+
|
|
10
|
+
Architecture review C1: https://claude.ai/artifact/2GWNZiXnnFVo5RTRnwm1Zu
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
|
|
19
|
+
from pymongo import ReturnDocument
|
|
20
|
+
from pymongo.errors import DuplicateKeyError
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("scalio_platform.lease")
|
|
23
|
+
|
|
24
|
+
COLLECTION = "job_leases"
|
|
25
|
+
OWNER = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
|
|
26
|
+
TTL = 60 # seconds a lease stays valid without renewal
|
|
27
|
+
RENEW = 20 # holder renews this often
|
|
28
|
+
WAIT = 15 # a waiting instance retries this often
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _now():
|
|
32
|
+
return datetime.now(timezone.utc)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def acquire(db, name: str, owner: str = OWNER, ttl: float = TTL) -> bool:
|
|
36
|
+
"""Take or renew the lease `name`. True when `owner` holds it afterwards."""
|
|
37
|
+
now = _now()
|
|
38
|
+
try:
|
|
39
|
+
doc = await db[COLLECTION].find_one_and_update(
|
|
40
|
+
{"_id": name, "$or": [{"owner": owner}, {"until": {"$lt": now}}]},
|
|
41
|
+
{"$set": {"owner": owner, "until": now + timedelta(seconds=ttl), "renewedAt": now}},
|
|
42
|
+
upsert=True, return_document=ReturnDocument.AFTER)
|
|
43
|
+
except DuplicateKeyError: # someone else holds a valid lease
|
|
44
|
+
return False
|
|
45
|
+
return bool(doc) and doc.get("owner") == owner
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def release(db, name: str, owner: str = OWNER):
|
|
49
|
+
await db[COLLECTION].delete_one({"_id": name, "owner": owner})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_exclusive(db, name: str, loop_factory, owner: str = OWNER,
|
|
53
|
+
ttl: float = TTL, renew: float = RENEW, wait: float = WAIT):
|
|
54
|
+
"""Run `loop_factory()` only while holding the lease `name`.
|
|
55
|
+
|
|
56
|
+
Waits for the lease, starts the loop, renews the lease while the loop runs, and cancels the loop when the
|
|
57
|
+
lease is lost (another instance took it over). If the database cannot be reached for a renewal, the loop
|
|
58
|
+
keeps running until the lease it already holds would expire, so a short outage does not stop the work.
|
|
59
|
+
"""
|
|
60
|
+
task = None
|
|
61
|
+
try:
|
|
62
|
+
while True:
|
|
63
|
+
held_until = None
|
|
64
|
+
while held_until is None:
|
|
65
|
+
try:
|
|
66
|
+
if await acquire(db, name, owner, ttl):
|
|
67
|
+
held_until = _now() + timedelta(seconds=ttl)
|
|
68
|
+
break
|
|
69
|
+
except Exception as e: # noqa: BLE001
|
|
70
|
+
logger.warning(f"lease {name}: acquire failed: {e}")
|
|
71
|
+
await asyncio.sleep(wait)
|
|
72
|
+
|
|
73
|
+
task = asyncio.create_task(loop_factory())
|
|
74
|
+
while True:
|
|
75
|
+
done, _ = await asyncio.wait({task}, timeout=renew)
|
|
76
|
+
if done:
|
|
77
|
+
return task.result() # the loop ended by itself (or raised)
|
|
78
|
+
try:
|
|
79
|
+
if await acquire(db, name, owner, ttl):
|
|
80
|
+
held_until = _now() + timedelta(seconds=ttl)
|
|
81
|
+
continue
|
|
82
|
+
logger.warning(f"lease {name}: taken over by another instance, stopping the loop here")
|
|
83
|
+
except Exception as e: # noqa: BLE001
|
|
84
|
+
if _now() + timedelta(seconds=renew) < held_until:
|
|
85
|
+
logger.warning(f"lease {name}: renewal failed, keeping the loop for now: {e}")
|
|
86
|
+
continue
|
|
87
|
+
logger.warning(f"lease {name}: renewal failed and the lease expires, stopping the loop: {e}")
|
|
88
|
+
task.cancel()
|
|
89
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
90
|
+
task = None
|
|
91
|
+
break
|
|
92
|
+
finally:
|
|
93
|
+
if task is not None and not task.done():
|
|
94
|
+
task.cancel()
|
|
95
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
96
|
+
try:
|
|
97
|
+
await release(db, name, owner)
|
|
98
|
+
except Exception as e: # noqa: BLE001
|
|
99
|
+
logger.info(f"lease {name}: release failed, it expires on its own: {e}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Schema migrations for MongoDB: ordered steps, each applied once, with the schema version stored in the database.
|
|
2
|
+
|
|
3
|
+
Taken from scalio-nexus `backend/migrations.py` (architecture review C4). The version lives in
|
|
4
|
+
`db.meta` ({id: "schema_version", version, appliedAt}), the same document Nexus already writes, so Nexus keeps its
|
|
5
|
+
history. Export/import (scalio-backup) can read it with `schema_version(db)` on both sides.
|
|
6
|
+
|
|
7
|
+
from scalio_platform.migrations import BASELINE, run_migrations
|
|
8
|
+
|
|
9
|
+
async def migrate_002_rename_field(db):
|
|
10
|
+
await db.items.update_many({"old": {"$exists": True}}, {"$rename": {"old": "new"}})
|
|
11
|
+
|
|
12
|
+
MIGRATIONS = BASELINE + [(2, migrate_002_rename_field)]
|
|
13
|
+
await run_migrations(db, MIGRATIONS) # at startup, before the background loops
|
|
14
|
+
|
|
15
|
+
Rules: versions strictly increasing, append only, never edit or reorder a past step. Every step must be safe to run
|
|
16
|
+
again (idempotent): during an update two instances can start at the same moment and both run it.
|
|
17
|
+
A database with a newer version than the image knows (an older image after a rollback) is left as it is; the
|
|
18
|
+
call logs a warning and returns the stored version.
|
|
19
|
+
"""
|
|
20
|
+
import logging
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("scalio_platform.migrations")
|
|
24
|
+
|
|
25
|
+
DOC_ID = "schema_version"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def migrate_001_baseline(db):
|
|
29
|
+
"""Baseline: marks a database as managed by the runner. Changes nothing."""
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
BASELINE = [(1, migrate_001_baseline)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _checked(migrations) -> list:
|
|
37
|
+
steps = list(migrations)
|
|
38
|
+
versions = [v for v, _ in steps]
|
|
39
|
+
if any(not isinstance(v, int) or v < 1 for v in versions):
|
|
40
|
+
raise ValueError("migration versions must be positive integers")
|
|
41
|
+
if versions != sorted(versions) or len(set(versions)) != len(versions):
|
|
42
|
+
raise ValueError("migration versions must be strictly increasing (append only)")
|
|
43
|
+
return steps
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def schema_version(db, collection: str = "meta") -> int:
|
|
47
|
+
"""The schema version stored in the database, 0 for a database the runner never touched."""
|
|
48
|
+
doc = await db[collection].find_one({"id": DOC_ID}, {"_id": 0, "version": 1}) or {}
|
|
49
|
+
return int(doc.get("version", 0))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_migrations(db, migrations, collection: str = "meta", log: logging.Logger | None = None) -> int:
|
|
53
|
+
"""Apply every step whose version is above the stored one, in order, storing the version after each step.
|
|
54
|
+
Returns the schema version afterwards. A failing step raises; the version stays at the last good step."""
|
|
55
|
+
log = log or logger
|
|
56
|
+
steps = _checked(migrations)
|
|
57
|
+
current = await schema_version(db, collection)
|
|
58
|
+
latest = steps[-1][0] if steps else 0
|
|
59
|
+
if current > latest:
|
|
60
|
+
log.warning("database schema_version %s is newer than this build knows (%s); nothing migrated",
|
|
61
|
+
current, latest)
|
|
62
|
+
return current
|
|
63
|
+
applied = current
|
|
64
|
+
for version, step in steps:
|
|
65
|
+
if version <= current:
|
|
66
|
+
continue
|
|
67
|
+
log.info("running DB migration %s: %s", version, getattr(step, "__name__", "step"))
|
|
68
|
+
await step(db)
|
|
69
|
+
await db[collection].update_one( # $max: a second instance that is already further never goes back
|
|
70
|
+
{"id": DOC_ID},
|
|
71
|
+
{"$max": {"version": version}, "$set": {"appliedAt": datetime.now(timezone.utc).isoformat()}},
|
|
72
|
+
upsert=True)
|
|
73
|
+
applied = version
|
|
74
|
+
if applied != current:
|
|
75
|
+
log.info("DB migrations complete, schema_version now %s", applied)
|
|
76
|
+
return applied
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scalio-platform
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pymongo>=4.6
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
13
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
14
|
+
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# scalio-platform
|
|
18
|
+
|
|
19
|
+
Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
|
|
20
|
+
Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
|
|
21
|
+
scalio-docs). Nothing in this package is secret.
|
|
22
|
+
|
|
23
|
+
| Module | What it does |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `scalio_platform.job_lease` | Runs a background loop on one instance at a time (lease in `db.job_leases`). During an update two instances run side by side; without the lease both would act. |
|
|
26
|
+
| `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
|
|
27
|
+
| `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
|
|
28
|
+
| `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
|
|
29
|
+
| `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
|
|
30
|
+
|
|
31
|
+
## Use in an app
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
scalio-platform==0.1.0 # backend/requirements.txt, exact version
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from scalio_platform import error_report, health, job_lease
|
|
39
|
+
from scalio_platform.migrations import BASELINE, run_migrations
|
|
40
|
+
|
|
41
|
+
MIGRATIONS = BASELINE # append (2, migrate_002_...) and so on, never edit past steps
|
|
42
|
+
|
|
43
|
+
async def startup():
|
|
44
|
+
await run_migrations(db, MIGRATIONS) # before the background loops
|
|
45
|
+
for name, loop in LOOPS.items():
|
|
46
|
+
asyncio.create_task(job_lease.run_exclusive(db, name, loop))
|
|
47
|
+
|
|
48
|
+
error_report.install(app, "citadel", build_info)
|
|
49
|
+
code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A migration step must be safe to run twice: two instances can start at the same moment.
|
|
53
|
+
|
|
54
|
+
## Develop and release
|
|
55
|
+
|
|
56
|
+
- `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
|
|
57
|
+
- Release: bump `pyproject.toml` and `__version__`, add the CHANGELOG entry, merge, then «Freigeben» in the Scalio
|
|
58
|
+
Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
|
|
59
|
+
- A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/scalio_platform/__init__.py
|
|
4
|
+
src/scalio_platform/app_links.py
|
|
5
|
+
src/scalio_platform/error_report.py
|
|
6
|
+
src/scalio_platform/health.py
|
|
7
|
+
src/scalio_platform/job_lease.py
|
|
8
|
+
src/scalio_platform/migrations.py
|
|
9
|
+
src/scalio_platform.egg-info/PKG-INFO
|
|
10
|
+
src/scalio_platform.egg-info/SOURCES.txt
|
|
11
|
+
src/scalio_platform.egg-info/dependency_links.txt
|
|
12
|
+
src/scalio_platform.egg-info/requires.txt
|
|
13
|
+
src/scalio_platform.egg-info/top_level.txt
|
|
14
|
+
tests/test_app_links.py
|
|
15
|
+
tests/test_error_report.py
|
|
16
|
+
tests/test_health.py
|
|
17
|
+
tests/test_job_lease.py
|
|
18
|
+
tests/test_migrations.py
|
|
19
|
+
tests/test_texts.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scalio_platform
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Product switcher links (SCALIO_APP_LINKS)."""
|
|
2
|
+
from scalio_platform.app_links import app_links, parse_app_links
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_known_apps_with_https_links():
|
|
6
|
+
raw = "nexus=https://dev.scalionexus.com/, rosetta = https://dev.scaliorosetta.com ,citadel=http://localhost:3001"
|
|
7
|
+
assert parse_app_links(raw) == {
|
|
8
|
+
"nexus": "https://dev.scalionexus.com",
|
|
9
|
+
"rosetta": "https://dev.scaliorosetta.com",
|
|
10
|
+
"citadel": "http://localhost:3001",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_unknown_apps_and_bad_urls_are_ignored():
|
|
15
|
+
raw = "compass=https://portal.scaliocloud.com,foundry=javascript:alert(1),nexus=,rosetta,citadel=https://u:p@x.com,pulse=ftp://x"
|
|
16
|
+
assert parse_app_links(raw) == {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_empty_or_missing_setting(monkeypatch):
|
|
20
|
+
assert parse_app_links("") == {}
|
|
21
|
+
assert parse_app_links(None) == {}
|
|
22
|
+
monkeypatch.delenv("SCALIO_APP_LINKS", raising=False)
|
|
23
|
+
monkeypatch.setenv("SCALIO_STAGE", "dev")
|
|
24
|
+
assert app_links()["links"] == {}
|
|
25
|
+
monkeypatch.setenv("SCALIO_APP_LINKS", "foundry=https://dev.scaliofoundry.com")
|
|
26
|
+
assert app_links()["links"] == {"foundry": "https://dev.scaliofoundry.com"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _mode(monkeypatch, stage=None, saas=None, links="nexus=https://dev.scalionexus.com"):
|
|
30
|
+
for key, value in (("SCALIO_STAGE", stage), ("SAAS_MODE", saas), ("SCALIO_APP_LINKS", links)):
|
|
31
|
+
if value is None:
|
|
32
|
+
monkeypatch.delenv(key, raising=False)
|
|
33
|
+
else:
|
|
34
|
+
monkeypatch.setenv(key, value)
|
|
35
|
+
return app_links()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_self_hosted_customer_shows_neither_switcher_nor_support(monkeypatch):
|
|
39
|
+
# Nothing set: a customer's own installation. Links are not even sent.
|
|
40
|
+
assert _mode(monkeypatch) == {"links": {}, "stage": "prod", "saas": False,
|
|
41
|
+
"features": {"switcher": False, "support": False}}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_dev_and_test_show_switcher_and_support(monkeypatch):
|
|
45
|
+
for stage in ("dev", " TEST "):
|
|
46
|
+
answer = _mode(monkeypatch, stage=stage)
|
|
47
|
+
assert answer["stage"] == stage.strip().lower()
|
|
48
|
+
assert answer["features"] == {"switcher": True, "support": True}
|
|
49
|
+
assert answer["links"] == {"nexus": "https://dev.scalionexus.com"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_demo_shows_switcher_without_support(monkeypatch):
|
|
53
|
+
answer = _mode(monkeypatch, stage="demo")
|
|
54
|
+
assert answer["stage"] == "demo"
|
|
55
|
+
assert answer["features"] == {"switcher": True, "support": False}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_saas_shows_both_in_production(monkeypatch):
|
|
59
|
+
answer = _mode(monkeypatch, saas="true")
|
|
60
|
+
assert answer["stage"] == "prod" and answer["saas"] is True
|
|
61
|
+
assert answer["features"] == {"switcher": True, "support": True}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_unknown_stage_counts_as_production(monkeypatch):
|
|
65
|
+
answer = _mode(monkeypatch, stage="staging")
|
|
66
|
+
assert answer["stage"] == "prod"
|
|
67
|
+
assert answer["features"] == {"switcher": False, "support": False}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_self_hosted_support_follows_the_callers_answer(monkeypatch):
|
|
71
|
+
"""ADR 0022, T11: a self-hosted customer shows support while maintenance runs; support.py decides."""
|
|
72
|
+
from scalio_platform.app_links import features
|
|
73
|
+
_mode(monkeypatch)
|
|
74
|
+
assert features()["support"] is False
|
|
75
|
+
assert features(self_hosted_support=True)["support"] is True
|
|
76
|
+
assert app_links(support=True)["features"]["support"] is True
|
|
77
|
+
_mode(monkeypatch, stage="demo")
|
|
78
|
+
assert features(self_hosted_support=True)["support"] is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_beacon_link_is_kept():
|
|
82
|
+
"""Scalio Beacon is in the switcher (scalio-ui 0.15.0), so its link must not be dropped."""
|
|
83
|
+
assert parse_app_links("beacon=https://dev.scaliobeacon.com") == {"beacon": "https://dev.scaliobeacon.com"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""error_report.py: unexpected errors go to Scalio Monitor, only when configured, never with request data.
|
|
2
|
+
Module scalio_platform.error_report (formerly a copy in every Scalio Python app)."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
from scalio_platform import error_report
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Monitor:
|
|
14
|
+
def __init__(self, status=200):
|
|
15
|
+
self.sent, self.status = [], status
|
|
16
|
+
|
|
17
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
18
|
+
self.sent.append((str(request.url), request.headers.get("authorization"), json.loads(request.content)))
|
|
19
|
+
return httpx.Response(self.status, json={"ok": True})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TOKEN = "tok-123" # fake
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make(monitor, url="https://mon.test", token=None, clock=None):
|
|
26
|
+
token = TOKEN if token is None else token
|
|
27
|
+
app = FastAPI()
|
|
28
|
+
|
|
29
|
+
@app.get("/api/users/{user_id}")
|
|
30
|
+
async def boom(user_id: str):
|
|
31
|
+
raise KeyError(f"user {user_id}")
|
|
32
|
+
|
|
33
|
+
@app.get("/ok")
|
|
34
|
+
async def ok():
|
|
35
|
+
return {"ok": True}
|
|
36
|
+
|
|
37
|
+
kw = {"clock": clock} if clock else {}
|
|
38
|
+
rep = error_report.install(app, "nexus", lambda: {"version": "1.2.5", "commit": "abc"}, url=url, token=token,
|
|
39
|
+
transport=httpx.MockTransport(monitor.handler), **kw)
|
|
40
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
41
|
+
client.__enter__() # keeps one event loop, so the background report can be awaited
|
|
42
|
+
return client, rep
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def call(client, rep, path, **kw):
|
|
46
|
+
r = client.get(path, **kw)
|
|
47
|
+
client.portal.call(rep.drain)
|
|
48
|
+
return r
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def teardown_function():
|
|
52
|
+
error_report._reporter = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_unhandled_error_is_reported_without_request_data():
|
|
56
|
+
mon = Monitor()
|
|
57
|
+
client, rep = make(mon)
|
|
58
|
+
r = call(client, rep, "/api/users/u-42?token=secret-q", headers={"Cookie": "sid=cookie-secret",
|
|
59
|
+
"Authorization": "Bearer user-secret"})
|
|
60
|
+
assert r.status_code == 500
|
|
61
|
+
assert len(mon.sent) == 1
|
|
62
|
+
url, auth, body = mon.sent[0]
|
|
63
|
+
assert url == "https://mon.test/ingest/error" and auth == "Bearer tok-123"
|
|
64
|
+
assert body["app"] == "nexus" and body["kind"] == "backend" and body["type"] == "KeyError"
|
|
65
|
+
assert body["path"] == "/api/users/{user_id}" and body["method"] == "GET" and body["status"] == 500
|
|
66
|
+
assert body["version"] == "1.2.5" and body["commit"] == "abc" and "boom" in body["stack"]
|
|
67
|
+
raw = json.dumps(body)
|
|
68
|
+
for secret in ("secret-q", "cookie-secret", "user-secret"):
|
|
69
|
+
assert secret not in raw
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_off_without_url_or_token_and_nothing_for_good_requests():
|
|
73
|
+
mon = Monitor()
|
|
74
|
+
client, rep = make(mon, token="")
|
|
75
|
+
assert not rep.enabled
|
|
76
|
+
call(client, rep, "/api/users/1")
|
|
77
|
+
client2, rep2 = make(mon)
|
|
78
|
+
assert call(client2, rep2, "/ok").status_code == 200
|
|
79
|
+
assert mon.sent == []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_same_error_once_per_minute():
|
|
83
|
+
now = [1000.0]
|
|
84
|
+
mon = Monitor()
|
|
85
|
+
client, rep = make(mon, clock=lambda: now[0])
|
|
86
|
+
call(client, rep, "/api/users/1")
|
|
87
|
+
call(client, rep, "/api/users/2")
|
|
88
|
+
assert len(mon.sent) == 1
|
|
89
|
+
now[0] += 61
|
|
90
|
+
call(client, rep, "/api/users/3")
|
|
91
|
+
assert len(mon.sent) == 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_monitor_down_does_not_change_the_answer(caplog):
|
|
95
|
+
def down(request):
|
|
96
|
+
raise httpx.ConnectError("no route", request=request)
|
|
97
|
+
app = FastAPI()
|
|
98
|
+
|
|
99
|
+
@app.get("/x")
|
|
100
|
+
async def x():
|
|
101
|
+
raise ValueError("x")
|
|
102
|
+
rep = error_report.install(app, "nexus", url="https://mon.test", token="tok-123",
|
|
103
|
+
transport=httpx.MockTransport(down))
|
|
104
|
+
with TestClient(app, raise_server_exceptions=False) as client:
|
|
105
|
+
assert client.get("/x").status_code == 500
|
|
106
|
+
client.portal.call(rep.drain)
|
|
107
|
+
assert "tok-123" not in caplog.text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_route_template_without_a_route():
|
|
111
|
+
path = "/api/items/123/sub/0123456789abcdef01234567"
|
|
112
|
+
assert error_report.route_template({"path": path}) == "/api/items/{id}/sub/{id}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_background_job_report():
|
|
116
|
+
mon = Monitor()
|
|
117
|
+
rep = error_report.Reporter("nexus", url="https://mon.test", token="t", transport=httpx.MockTransport(mon.handler))
|
|
118
|
+
error_report._reporter = rep
|
|
119
|
+
|
|
120
|
+
async def run():
|
|
121
|
+
try:
|
|
122
|
+
raise RuntimeError("loop broke")
|
|
123
|
+
except RuntimeError as exc:
|
|
124
|
+
await error_report.report_exception(exc, path="lifecycle_task")
|
|
125
|
+
await rep.drain()
|
|
126
|
+
asyncio.run(run())
|
|
127
|
+
assert mon.sent[0][2]["kind"] == "job" and mon.sent[0][2]["path"] == "lifecycle_task"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class GroupingMonitor(Monitor):
|
|
131
|
+
"""Answers like Scalio Monitor: the fingerprint of the group the report landed in."""
|
|
132
|
+
|
|
133
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
134
|
+
super().handler(request)
|
|
135
|
+
body = json.loads(request.content)
|
|
136
|
+
return httpx.Response(200, json={"ok": True, "group": "0123456789abcdef" if body["type"] == "KeyError"
|
|
137
|
+
else "fedcba9876543210", "new": True})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_reported_groups_are_kept_for_support_tickets():
|
|
141
|
+
"""A support ticket lists the last error groups of the instance with a link to the monitor (ADR 0022, T9)."""
|
|
142
|
+
mon = GroupingMonitor()
|
|
143
|
+
t = [0.0]
|
|
144
|
+
client, rep = make(mon, clock=lambda: t[0])
|
|
145
|
+
call(client, rep, "/api/users/1")
|
|
146
|
+
t[0] += 120 # past the repeat window
|
|
147
|
+
call(client, rep, "/api/users/2")
|
|
148
|
+
groups = error_report.recent_groups()
|
|
149
|
+
assert len(groups) == 1
|
|
150
|
+
g = groups[0]
|
|
151
|
+
assert g["fingerprint"] == "0123456789abcdef" and g["type"] == "KeyError" and g["count"] == 2
|
|
152
|
+
assert g["route"] == "/api/users/{user_id}" and g["monitor"] == "https://mon.test" and g["time"]
|
|
153
|
+
assert asyncio.run(error_report.errors_for_support(None)) == groups
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_no_groups_without_reporter_or_with_a_bad_answer():
|
|
157
|
+
assert error_report.recent_groups() == []
|
|
158
|
+
mon = Monitor() # answers without "group"
|
|
159
|
+
client, rep = make(mon)
|
|
160
|
+
call(client, rep, "/api/users/1")
|
|
161
|
+
assert error_report.recent_groups() == []
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""GET /api/health (health.py): the answer the monitoring dashboard reads. Offline (mongomock)."""
|
|
2
|
+
import asyncio
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
from mongomock_motor import AsyncMongoMockClient
|
|
6
|
+
|
|
7
|
+
from scalio_platform import health as H
|
|
8
|
+
|
|
9
|
+
BUILD = {"version": "1.2.5", "commit": "e93339f", "build_date": "2026-10-27", "service": "backend"}
|
|
10
|
+
NOW = datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(coro):
|
|
14
|
+
return asyncio.run(coro)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_ok_with_running_jobs():
|
|
18
|
+
async def go():
|
|
19
|
+
db = AsyncMongoMockClient()["health_test"]
|
|
20
|
+
await db.job_leases.insert_one({"_id": "workflow_task", "until": NOW + timedelta(seconds=40),
|
|
21
|
+
"renewedAt": NOW - timedelta(seconds=20), "owner": "a"})
|
|
22
|
+
return await H.health_report(db, BUILD, lease_jobs=["workflow_task"], now=NOW)
|
|
23
|
+
code, body = run(go())
|
|
24
|
+
assert code == 200
|
|
25
|
+
assert body == {"status": "ok", "version": "1.2.5", "commit": "e93339f", "db": "ok",
|
|
26
|
+
"jobs": {"workflow_task": {"ok": True, "last_seen": "2026-10-27T11:59:40+00:00"}}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_degraded_when_a_lease_expired_or_is_missing():
|
|
30
|
+
async def go():
|
|
31
|
+
db = AsyncMongoMockClient()["health_test"]
|
|
32
|
+
await db.job_leases.insert_one({"_id": "pam_worker_task", "until": NOW - timedelta(minutes=5),
|
|
33
|
+
"renewedAt": NOW - timedelta(minutes=6)})
|
|
34
|
+
return await H.health_report(db, BUILD, lease_jobs=["pam_worker_task", "hr_task"], now=NOW)
|
|
35
|
+
code, body = run(go())
|
|
36
|
+
assert code == 200 and body["status"] == "degraded"
|
|
37
|
+
assert body["jobs"]["pam_worker_task"]["ok"] is False
|
|
38
|
+
assert body["jobs"]["hr_task"] == {"ok": False, "last_seen": None}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_old_lease_names_do_not_count():
|
|
42
|
+
async def go():
|
|
43
|
+
db = AsyncMongoMockClient()["health_test"]
|
|
44
|
+
await db.job_leases.insert_one({"_id": "removed_loop", "until": NOW - timedelta(days=3)})
|
|
45
|
+
return await H.health_report(db, BUILD, lease_jobs=[], now=NOW)
|
|
46
|
+
code, body = run(go())
|
|
47
|
+
assert code == 200 and body["status"] == "ok" and body["jobs"] == {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DeadDb:
|
|
51
|
+
async def command(self, _name):
|
|
52
|
+
await asyncio.sleep(10)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_down_with_503_when_the_database_does_not_answer():
|
|
56
|
+
code, body = run(H.health_report(DeadDb(), BUILD, lease_jobs=["x"], timeout=0.05, now=NOW))
|
|
57
|
+
assert code == 503
|
|
58
|
+
assert body == {"status": "down", "version": "1.2.5", "commit": "e93339f", "db": "error", "jobs": {}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_no_secrets_or_counts_in_the_answer():
|
|
62
|
+
async def go():
|
|
63
|
+
db = AsyncMongoMockClient()["health_test"]
|
|
64
|
+
return await H.health_report(db, BUILD, now=NOW)
|
|
65
|
+
_code, body = run(go())
|
|
66
|
+
assert set(body) == {"status", "version", "commit", "db", "jobs"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_app_without_database():
|
|
70
|
+
code, body = run(H.health_report(None, BUILD, now=NOW))
|
|
71
|
+
assert code == 200 and body["status"] == "ok" and body["db"] == "none"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Background loops run on one backend instance at a time (job_lease.py, architecture review C1).
|
|
2
|
+
Offline (mongomock).
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
from mongomock_motor import AsyncMongoMockClient
|
|
8
|
+
|
|
9
|
+
from scalio_platform import job_lease as L
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fresh_db():
|
|
13
|
+
return AsyncMongoMockClient()["lease_test"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_only_one_owner_holds_a_lease():
|
|
17
|
+
async def run():
|
|
18
|
+
db = fresh_db()
|
|
19
|
+
assert await L.acquire(db, "workflow_task", "a")
|
|
20
|
+
assert not await L.acquire(db, "workflow_task", "b")
|
|
21
|
+
assert await L.acquire(db, "workflow_task", "a") # renewal by the holder
|
|
22
|
+
assert await L.acquire(db, "other_task", "b") # leases are per loop
|
|
23
|
+
asyncio.run(run())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_expired_lease_is_taken_over_and_release_frees_it():
|
|
27
|
+
async def run():
|
|
28
|
+
db = fresh_db()
|
|
29
|
+
assert await L.acquire(db, "pam_worker_task", "a")
|
|
30
|
+
await db.job_leases.update_one({"_id": "pam_worker_task"},
|
|
31
|
+
{"$set": {"until": datetime.now(timezone.utc) - timedelta(seconds=1)}})
|
|
32
|
+
assert await L.acquire(db, "pam_worker_task", "b")
|
|
33
|
+
await L.release(db, "pam_worker_task", "a") # not the holder: no effect
|
|
34
|
+
assert not await L.acquire(db, "pam_worker_task", "a")
|
|
35
|
+
await L.release(db, "pam_worker_task", "b")
|
|
36
|
+
assert await L.acquire(db, "pam_worker_task", "a")
|
|
37
|
+
asyncio.run(run())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_two_instances_never_run_the_same_loop_together():
|
|
41
|
+
"""Instance A and B start the same loop side by side (rolling update): only one runs it at any time;
|
|
42
|
+
when A stops, B takes over."""
|
|
43
|
+
async def run():
|
|
44
|
+
db = fresh_db()
|
|
45
|
+
running, max_running, ran = set(), [0], []
|
|
46
|
+
|
|
47
|
+
def make(owner):
|
|
48
|
+
async def loop():
|
|
49
|
+
running.add(owner)
|
|
50
|
+
ran.append(owner)
|
|
51
|
+
max_running[0] = max(max_running[0], len(running))
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
await asyncio.sleep(0.01)
|
|
55
|
+
finally:
|
|
56
|
+
running.discard(owner)
|
|
57
|
+
return loop
|
|
58
|
+
|
|
59
|
+
kw = {"ttl": 0.3, "renew": 0.05, "wait": 0.05}
|
|
60
|
+
a = asyncio.create_task(L.run_exclusive(db, "lifecycle_task", make("a"), owner="a", **kw))
|
|
61
|
+
await asyncio.sleep(0.05)
|
|
62
|
+
b = asyncio.create_task(L.run_exclusive(db, "lifecycle_task", make("b"), owner="b", **kw))
|
|
63
|
+
await asyncio.sleep(0.4)
|
|
64
|
+
assert running == {"a"} and max_running[0] == 1
|
|
65
|
+
a.cancel() # graceful stop of A releases the lease
|
|
66
|
+
await asyncio.gather(a, return_exceptions=True)
|
|
67
|
+
await asyncio.sleep(0.2)
|
|
68
|
+
assert running == {"b"} and ran == ["a", "b"] and max_running[0] == 1
|
|
69
|
+
b.cancel()
|
|
70
|
+
await asyncio.gather(b, return_exceptions=True)
|
|
71
|
+
assert await db.job_leases.count_documents({}) == 0
|
|
72
|
+
asyncio.run(run())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_loop_stops_when_the_lease_is_taken_over():
|
|
76
|
+
async def run():
|
|
77
|
+
db = fresh_db()
|
|
78
|
+
stopped = asyncio.Event()
|
|
79
|
+
|
|
80
|
+
async def loop():
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
await asyncio.sleep(0.01)
|
|
84
|
+
finally:
|
|
85
|
+
stopped.set()
|
|
86
|
+
|
|
87
|
+
t = asyncio.create_task(L.run_exclusive(db, "hr_runtime_task", loop, owner="a", ttl=5, renew=0.05, wait=10))
|
|
88
|
+
await asyncio.sleep(0.05)
|
|
89
|
+
until = datetime.now(timezone.utc) + timedelta(seconds=60)
|
|
90
|
+
await db.job_leases.update_one({"_id": "hr_runtime_task"}, {"$set": {"owner": "b", "until": until}})
|
|
91
|
+
await asyncio.wait_for(stopped.wait(), timeout=1)
|
|
92
|
+
t.cancel()
|
|
93
|
+
await asyncio.gather(t, return_exceptions=True)
|
|
94
|
+
assert (await db.job_leases.find_one({"_id": "hr_runtime_task"}))["owner"] == "b"
|
|
95
|
+
asyncio.run(run())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_short_database_outage_keeps_the_loop_running():
|
|
99
|
+
async def run():
|
|
100
|
+
db = fresh_db()
|
|
101
|
+
ticks = []
|
|
102
|
+
|
|
103
|
+
async def loop():
|
|
104
|
+
while True:
|
|
105
|
+
ticks.append(1)
|
|
106
|
+
await asyncio.sleep(0.01)
|
|
107
|
+
|
|
108
|
+
real = L.acquire
|
|
109
|
+
calls = {"n": 0}
|
|
110
|
+
|
|
111
|
+
async def flaky(*a, **k):
|
|
112
|
+
calls["n"] += 1
|
|
113
|
+
if calls["n"] in (2, 3):
|
|
114
|
+
raise RuntimeError("mongo unreachable")
|
|
115
|
+
return await real(*a, **k)
|
|
116
|
+
|
|
117
|
+
L.acquire = flaky
|
|
118
|
+
try:
|
|
119
|
+
t = asyncio.create_task(L.run_exclusive(db, "exo_task", loop, owner="a", ttl=1, renew=0.05, wait=0.05))
|
|
120
|
+
await asyncio.sleep(0.3)
|
|
121
|
+
assert not t.done()
|
|
122
|
+
before = len(ticks)
|
|
123
|
+
await asyncio.sleep(0.05)
|
|
124
|
+
assert len(ticks) > before # still running after two failed renewals
|
|
125
|
+
t.cancel()
|
|
126
|
+
await asyncio.gather(t, return_exceptions=True)
|
|
127
|
+
finally:
|
|
128
|
+
L.acquire = real
|
|
129
|
+
asyncio.run(run())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Schema migrations (scalio_platform.migrations): each step once, version in db.meta, safe with two instances."""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from mongomock_motor import AsyncMongoMockClient
|
|
6
|
+
|
|
7
|
+
from scalio_platform import migrations as M
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fresh_db():
|
|
11
|
+
return AsyncMongoMockClient()["migrations_test"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_new_database_runs_every_step_once_and_stores_the_version():
|
|
15
|
+
async def run():
|
|
16
|
+
db, calls = fresh_db(), []
|
|
17
|
+
|
|
18
|
+
async def m2(db):
|
|
19
|
+
calls.append(2)
|
|
20
|
+
await db.items.update_many({}, {"$rename": {"old": "new"}})
|
|
21
|
+
|
|
22
|
+
async def m3(db):
|
|
23
|
+
calls.append(3)
|
|
24
|
+
|
|
25
|
+
await db.items.insert_one({"old": 1})
|
|
26
|
+
steps = M.BASELINE + [(2, m2), (3, m3)]
|
|
27
|
+
assert await M.schema_version(db) == 0
|
|
28
|
+
assert await M.run_migrations(db, steps) == 3
|
|
29
|
+
assert calls == [2, 3]
|
|
30
|
+
assert await db.items.find_one({}, {"_id": 0}) == {"new": 1}
|
|
31
|
+
assert await M.run_migrations(db, steps) == 3 # second start: nothing runs again
|
|
32
|
+
assert calls == [2, 3]
|
|
33
|
+
doc = await db.meta.find_one({"id": "schema_version"})
|
|
34
|
+
assert doc["version"] == 3 and doc["appliedAt"]
|
|
35
|
+
asyncio.run(run())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_nexus_history_is_kept():
|
|
39
|
+
"""Nexus already stored version 1 in the same document: only newer steps run."""
|
|
40
|
+
async def run():
|
|
41
|
+
db, calls = fresh_db(), []
|
|
42
|
+
await db.meta.insert_one({"id": "schema_version", "version": 1, "appliedAt": "2026-10-27T00:00:00+00:00"})
|
|
43
|
+
|
|
44
|
+
async def m2(db):
|
|
45
|
+
calls.append(2)
|
|
46
|
+
|
|
47
|
+
assert await M.run_migrations(db, M.BASELINE + [(2, m2)]) == 2
|
|
48
|
+
assert calls == [2]
|
|
49
|
+
asyncio.run(run())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_failing_step_keeps_the_last_good_version():
|
|
53
|
+
async def run():
|
|
54
|
+
db = fresh_db()
|
|
55
|
+
|
|
56
|
+
async def broken(db):
|
|
57
|
+
raise RuntimeError("boom")
|
|
58
|
+
|
|
59
|
+
with pytest.raises(RuntimeError):
|
|
60
|
+
await M.run_migrations(db, M.BASELINE + [(2, broken)])
|
|
61
|
+
assert await M.schema_version(db) == 1
|
|
62
|
+
asyncio.run(run())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_newer_database_is_left_alone():
|
|
66
|
+
async def run():
|
|
67
|
+
db = fresh_db()
|
|
68
|
+
await db.meta.insert_one({"id": "schema_version", "version": 7})
|
|
69
|
+
assert await M.run_migrations(db, M.BASELINE) == 7
|
|
70
|
+
assert await M.schema_version(db) == 7
|
|
71
|
+
asyncio.run(run())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_version_never_goes_back_when_two_instances_race():
|
|
75
|
+
async def run():
|
|
76
|
+
db = fresh_db()
|
|
77
|
+
|
|
78
|
+
async def m2(db):
|
|
79
|
+
await asyncio.sleep(0.01)
|
|
80
|
+
|
|
81
|
+
steps = M.BASELINE + [(2, m2)]
|
|
82
|
+
await asyncio.gather(M.run_migrations(db, steps), M.run_migrations(db, steps))
|
|
83
|
+
assert await M.schema_version(db) == 2
|
|
84
|
+
assert await db.meta.count_documents({"id": "schema_version"}) == 1
|
|
85
|
+
asyncio.run(run())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("steps", [[(2, None), (1, None)], [(1, None), (1, None)], [(0, None)]])
|
|
89
|
+
def test_bad_step_lists_are_refused(steps):
|
|
90
|
+
with pytest.raises(ValueError):
|
|
91
|
+
asyncio.run(M.run_migrations(fresh_db(), steps))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""German text is written with "ss", never the sharp s (project rule)."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_no_sharp_s():
|
|
8
|
+
sharp = "ß"
|
|
9
|
+
for p in list(ROOT.glob("src/**/*.py")) + list(ROOT.glob("*.md")):
|
|
10
|
+
assert sharp not in p.read_text(encoding="utf-8"), p
|