Metadata-Version: 2.4
Name: scalio-platform
Version: 0.7.1
Summary: Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links, setup wizard state, roles and permissions, factory reset, audit log and jobs
License: Proprietary
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.27
Requires-Dist: pymongo>=4.6
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: fastapi>=0.110; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"

# scalio-platform

Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
scalio-docs). Nothing in this package is secret.

| Module | What it does |
| --- | --- |
| `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. |
| `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
| `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
| `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
| `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
| `scalio_platform.roles` | Permission catalog of the app, built-in and own roles (`app_roles`), `require_perm` for routes, shared role API (`roles_router`) for `RoleAdmin` in scalio-ui. |
| `scalio_platform.tenant_jobs` | Runs one job per tenant side by side (`for_each_tenant`): at most `TENANT_JOBS_PARALLEL` at once (default 4), a time budget per tenant (`TENANT_JOBS_TIMEOUT`, default 900 s), a failing tenant never stops the others; for MSP instances with many customers (ADR 0063 `mandant/M11`). |
| `scalio_platform.setup_wizard` | State of the Einrichtungsassistent (`GET/PUT /api/setup/wizard`, ADR 0060): `wizard_state`, `save_wizard`, `parse_update`. |
| `scalio_platform.audit` | Protokoll → Aktionen (ADR 0070): one format in `db.audit_log` (`record`, `target`, `diff`), TTL 365 days, `audit_router` (`GET /api/audit` with filters, cursor and counts per chip, `GET /api/audit/export.csv`), `migrate` for old formats, `uncovered` for the route guard test. |
| `scalio_platform.jobs` | Protokoll → Jobs (ADR 0070): `Registry` of the background jobs, `run` records each run (`db.job_runs`, TTL 90 days; a job returning None, 0, False, an empty list or a dict without summary and counts only updates `db.job_state`), «Jetzt ausführen» through `db.job_requests` and `serve_requests` in the worker, `QueueSource` for the app's queue, `jobs_router`. |

## Use in an app

```
scalio-platform==0.1.0            # backend/requirements.txt, exact version
```

```python
from scalio_platform import error_report, health, job_lease
from scalio_platform.migrations import BASELINE, run_migrations

MIGRATIONS = BASELINE                      # append (2, migrate_002_...) and so on, never edit past steps

async def startup():
    await run_migrations(db, MIGRATIONS)   # before the background loops
    for name, loop in LOOPS.items():
        asyncio.create_task(job_lease.run_exclusive(db, name, loop))

error_report.install(app, "citadel", build_info)
code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))

# probe of the provisioning engine (SaaS on the Docker engine), header X-Provision-Health-Secret
code, body = await health.provision_report(db, build_info(), secret=os.environ.get("PROVISION_HEALTH_SECRET", ""),
                                           provided=request.headers.get("x-provision-health-secret", ""))
```

A migration step must be safe to run twice: two instances can start at the same moment.

### Zurücksetzen (factory reset, ADR 0064 / 0069)

```python
from scalio_platform.factory_reset import factory_reset_router

async def after_wipe(remove_license: bool):
    if remove_license:
        await licensing.runtime.refresh(db)      # free edition right away
    await login_policy.announce_setup_code()     # new setup code in the log

app.include_router(factory_reset_router(
    get_db=lambda: db, current_user=require_admin,
    password_hash=lambda u: u.get("password_hash"), verify_password=auth.verify_password,
    keep_meta=(f"{PRODUCT.code}_installation", "schema_version"), license_meta=(PRODUCT.meta_id,),
    saas_mode=saas_mode, after_wipe=after_wipe))
```

`GET /api/admin/factory-reset` answers `{available, password}` (SaaS: not available, the customer portal resets it);
`POST` takes `{confirm: "RESET", password, remove_license}`. `allowed(user)` narrows who may (Foundry: platform owner),
`error(code)` maps the codes `reset_in_portal`, `reset_confirm_wrong`, `current_password_wrong` to the app's own errors.
The frontend is `FactoryReset` from `@scalio-cloud/ui` 0.27.0.

## Develop and release

- `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
- Release: bump `pyproject.toml` and `__version__`, open a scalio-docs PR with `python3 tools/changelog.py --repo scalio-platform release <version>` (changelog in scalio-docs `changelog/scalio-platform/`, ADR 0066), merge, then «Freigeben» in the Scalio
  Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
- A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.

### Protokoll: Aktionen und Jobs (ADR 0070)

```python
from scalio_platform import audit, jobs
from scalio_platform.audit import Category

CATEGORIES = (Category("access", "Zugriff", "Access", ("user.", "role.")),
              Category("license", "Lizenz", "License", ("license.",), always=True))

await audit.record(db, "user.create", user, category="access", target=audit.target("user", u["id"], u["email"]),
                   detail=f"Konto angelegt · {u['email']}", request=request)

app.include_router(audit.audit_router(get_db=lambda: db, viewer=require_perm(CATALOG, me, get_db, "audit.view"),
                                      categories=CATEGORIES, tenant_scope=lambda u: None), prefix="/api")

# worker
REGISTRY = jobs.Registry([jobs.Job("mail_intake", "Mail-Eingang", "Mail intake", "Jede Minute", "Every minute")])
await jobs.run(db, "mail_intake", mail_intake)            # inside the loop; return None when nothing happened
await jobs.serve_requests(db, REGISTRY, HANDLERS)         # own loop every few seconds («Jetzt ausführen»)

# API
app.include_router(jobs.jobs_router(registry=REGISTRY, get_db=lambda: db, viewer=view, runner=run_perm,
                                    queue=MyQueue(), audit=log), prefix="/api")
```

Call `audit.ensure_indexes(db)` and `jobs.ensure_indexes(db)` at startup. Never write passwords, tokens or contents
into `detail`, `changes` or a job summary (`changes` drops fields named like pass/secret/token/key).
