scalio-platform 0.1.0__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.
@@ -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,10 @@
1
+ scalio_platform/__init__.py,sha256=6Z9-Mkj-VzWJbzg_7I6AUGYVByJ61i5N1BPWYP8JmHQ,302
2
+ scalio_platform/app_links.py,sha256=Vk15N3KDfrbp2gokCEEXw_AVzI3HB7VdDW3CXfmfn1k,2930
3
+ scalio_platform/error_report.py,sha256=ecqm-T3JsrplopYz9tHquwlPdoN7AriUrFxcaWCbFVA,7997
4
+ scalio_platform/health.py,sha256=T98ERn8tQEoYQh3nmH_iqc5UsVzlKR2_Cuv7_2x2s20,2719
5
+ scalio_platform/job_lease.py,sha256=TU76Bajo4NFUC1nKc1rxk5rgFftmB5lgXgNs3s2T_8M,4396
6
+ scalio_platform/migrations.py,sha256=y6_ogIjsNZH3M3-RxiJEW07oHWCjTSqtqPamDUYw3Po,3481
7
+ scalio_platform-0.1.0.dist-info/METADATA,sha256=Xq9TTrsjzoQ0Bo4Z6k2239J5rW2h5lfDH7WXYQt4Q8U,2848
8
+ scalio_platform-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ scalio_platform-0.1.0.dist-info/top_level.txt,sha256=K0RroEgVIQmXeH1i0EVm4i5WZK8uMFeoZPoUEgKQzMM,16
10
+ scalio_platform-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ scalio_platform