Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982ef636b8 | ||
|
|
9aad49f16d | ||
|
|
2b5c14385d | ||
|
|
bca3e46293 | ||
|
|
f09d2bf9df | ||
|
|
702421be48 | ||
|
|
bb471df21c | ||
|
|
bfb0d7d7c9 | ||
|
|
1974bf1a2b | ||
|
|
0c9bf6758c | ||
|
|
25da7d49a9 | ||
|
|
40c10089ab | ||
|
|
d6e7c8b0b1 | ||
|
|
5bc7d748f8 | ||
|
|
7117673ecc | ||
|
|
14351b0c94 | ||
|
|
ad57fad1ea | ||
|
|
fa32cca03f | ||
|
|
2d0551a845 | ||
|
|
bb84122061 | ||
|
|
b823a22b9b | ||
|
|
70fc6da811 | ||
|
|
729b84d3af | ||
|
|
b962f6756e | ||
|
|
79d00b84e3 | ||
|
|
842be5edb5 | ||
|
|
7e59a7f2b3 | ||
|
|
5bfbe9a887 | ||
|
|
01f91154e0 | ||
|
|
6c2940aebc | ||
|
|
670693bde8 | ||
|
|
bca6a7c8aa | ||
|
|
21c1fa49b6 | ||
|
|
435b924fd9 | ||
|
|
af5c6af0e7 | ||
|
|
c6ef644842 | ||
|
|
5783d43547 | ||
|
|
e4d2d10c7e | ||
|
|
fe62fd4644 | ||
|
|
9ecdc6d713 | ||
|
|
ca35aad286 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -117,7 +117,7 @@ CI runs the `ci` profile in report-only mode and uploads `audit-reports/` as an
|
||||
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
|
||||
or pass `--strict` locally to turn findings into a failing gate.
|
||||
|
||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
|
||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments use the separate, expiring single-use flow exposed by `python -m govoplan_core.commands.first_admin`; it cannot enable or consume development bootstrap credentials.
|
||||
|
||||
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "versions"
|
||||
/ "f25c9d3e7a01_first_admin_enrollment.py"
|
||||
)
|
||||
_spec = spec_from_file_location("govoplan_first_admin_enrollment_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||
_module = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
|
||||
revision = _module.revision
|
||||
down_revision = _module.down_revision
|
||||
branch_labels = _module.branch_labels
|
||||
depends_on = _module.depends_on
|
||||
upgrade = _module.upgrade
|
||||
downgrade = _module.downgrade
|
||||
@@ -12,7 +12,10 @@ except ModuleNotFoundError as exc:
|
||||
raise
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""add controlled first-administrator enrollment evidence
|
||||
|
||||
Revision ID: f25c9d3e7a01
|
||||
Revises: e14b8c2d6f90
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f25c9d3e7a01"
|
||||
down_revision = "e14b8c2d6f90"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_first_admin_enrollments" not in tables:
|
||||
op.create_table(
|
||||
"core_first_admin_enrollments",
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("state", sa.String(length=24), nullable=False),
|
||||
sa.Column("generation", sa.Integer(), nullable=False),
|
||||
sa.Column("token_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("token_fingerprint", sa.String(length=16), nullable=True),
|
||||
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("consumed_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("consumed_membership_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("consumed_tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("consumed_email", sa.String(length=320), nullable=True),
|
||||
sa.Column("consumed_display_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("consumed_request_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("issue_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("event_count", sa.Integer(), nullable=False),
|
||||
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"installation_id",
|
||||
name=op.f("pk_core_first_admin_enrollments"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_first_admin_enrollments_state"),
|
||||
"core_first_admin_enrollments",
|
||||
["state"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_first_admin_enrollments_expires_at"),
|
||||
"core_first_admin_enrollments",
|
||||
["expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_first_admin_enrollment_events" not in tables:
|
||||
op.create_table(
|
||||
"core_first_admin_enrollment_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("generation", sa.Integer(), nullable=False),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("event_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["installation_id"],
|
||||
["core_first_admin_enrollments.installation_id"],
|
||||
name=op.f(
|
||||
"fk_core_first_admin_enrollment_events_installation_id_core_first_admin_enrollments"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_core_first_admin_enrollment_events"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"installation_id",
|
||||
"sequence",
|
||||
name="uq_core_first_admin_enrollment_event_sequence",
|
||||
),
|
||||
)
|
||||
for column in ("installation_id", "event_type", "event_sha256"):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_first_admin_enrollment_events_{column}"),
|
||||
"core_first_admin_enrollment_events",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_first_admin_enrollment_events" in tables:
|
||||
op.drop_table("core_first_admin_enrollment_events")
|
||||
if "core_first_admin_enrollments" in tables:
|
||||
op.drop_table("core_first_admin_enrollments")
|
||||
@@ -47,6 +47,9 @@ Recommended fields:
|
||||
irreversible
|
||||
- expected effects
|
||||
- idempotency key strategy
|
||||
- recovery mode: atomic, compensating, snapshot restore, forward recovery, or
|
||||
irreversible
|
||||
- concrete verification steps which prove whether the effect occurred
|
||||
- audit event names
|
||||
- preview provider
|
||||
|
||||
@@ -89,10 +92,14 @@ The runner should execute an action plan as follows:
|
||||
4. Run permission and policy checks.
|
||||
5. Generate a consequence preview.
|
||||
6. Reserve or verify the idempotency key.
|
||||
7. Execute the owning module capability.
|
||||
8. Record observed effects.
|
||||
9. Emit events and audit records.
|
||||
10. Mark the command complete, retryable, quarantined, or requiring manual
|
||||
7. Create a durable recovery operation and acquire its execution fence.
|
||||
8. Persist dispatch evidence before a non-atomic provider call.
|
||||
9. Execute the owning module capability.
|
||||
10. Verify the provider result and every announced effect using the action's
|
||||
declared recovery checks.
|
||||
11. Commit the local projection and verified recovery checkpoint together.
|
||||
12. Emit events and audit records.
|
||||
13. Mark the command complete, retryable, quarantined, or requiring manual
|
||||
intervention.
|
||||
|
||||
The runner must never advance workflow state past a required side effect unless
|
||||
@@ -110,7 +117,16 @@ between:
|
||||
6. reconciled, corrected, or compensated outcome.
|
||||
|
||||
An API timeout after dispatch is not a failed effect and must not be retried as
|
||||
a fresh command. The actor context should retain the real identity/account,
|
||||
an ordinary process failure or a fresh command. The runner records an unknown
|
||||
outcome, releases its execution authority, and blocks continuation until an
|
||||
operator or provider reconciliation proves either that the effect occurred or
|
||||
that it is absent.
|
||||
|
||||
`ActionDefinition.recovery_mode` and `recovery_verification` are part of the
|
||||
provider contract. The default is conservative forward recovery with explicit
|
||||
provider-result and effect verification. Atomic mode is valid only when the
|
||||
provider effect and its local projection share the same database transaction.
|
||||
The actor context should retain the real identity/account,
|
||||
represented function or party, delegation or power, and mandate/jurisdiction
|
||||
references when applicable. Domain modules remain responsible for deciding
|
||||
which of those references are required for their action.
|
||||
|
||||
@@ -31,6 +31,7 @@ such as Redis degradation and language fallback are not compatibility paths.
|
||||
| Legacy tenant aliases in API response schemas | Preserves active-tenant response fields used by `0.1.x` clients. | Tagged `0.1.x` API window. | Remove at `0.2` with response-schema migration notes. |
|
||||
| Optional fields in Poll response references | Accepts providers built against the earlier Poll contract. | Tagged `0.1.x` runtime contract window. | Remove or require a new interface version at `0.2`. |
|
||||
| Legacy single-tenant summary providers | Allows modules without the batch provider introduced in `0.1.x`. | Tagged `0.1.x` module contract window. | Remove at `0.2` after manifests advertise the batch provider contract. |
|
||||
| WebUI `react-router-dom` build alias | Resolves tagged `0.1.x` module source imports to Core's single `react-router` runtime so one composition never loads two router contexts. | Tagged `0.1.x` WebUI source window. | Remove at `0.2` after every supported module tag imports `react-router` directly. |
|
||||
|
||||
## Removed Paths
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ PY
|
||||
| `GOVOPLAN_MIGRATION_TRACK` | `release` | Use the release track for normal runtime and deployments. Use `dev` only for fresh/disposable databases that intentionally replay detailed development migrations. |
|
||||
| `DEV_AUTO_MIGRATE_ENABLED` | `true` | Dev convenience only. Production should run migration commands explicitly during deployment. |
|
||||
| `DEV_BOOTSTRAP_ENABLED` | `false` | Dev bootstrap only. `govoplan_core.devserver` and `govoplan/tools/launch/launch-dev.sh` default it to `true`; use controlled first-admin creation outside dev. |
|
||||
| `FIRST_ADMIN_ENROLLMENT_TTL_SECONDS` | `1800` | Lifetime of a locally issued production enrollment credential. Allowed range: 60 seconds to 24 hours. |
|
||||
| `FIRST_ADMIN_ENROLLMENT_FILE` | `/run/govoplan/first-admin-enrollment.json` | Local operator artifact. The command creates it with mode `0600` and never prints the secret. |
|
||||
|
||||
Operator rule: take a database backup before applying migrations or destructive
|
||||
module retirement. For non-SQLite databases, configure deployment-specific
|
||||
@@ -158,6 +160,7 @@ release evidence.
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
|
||||
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
|
||||
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||
|
||||
@@ -177,6 +180,13 @@ crashes, and expired worker leases:
|
||||
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||
```
|
||||
|
||||
Before promoting a worker composition, run the repository worker-runtime drill
|
||||
against the same Redis and Core build. It uses the bounded
|
||||
`govoplan.worker.acceptance` task and records publish/consume, retry, warm
|
||||
SIGTERM, and worker-loss redelivery evidence without accessing tenant data.
|
||||
Production evidence must use the deployed queue configuration and a visibility
|
||||
timeout that is longer than every supported business task.
|
||||
|
||||
### Storage
|
||||
|
||||
| Setting | Default | Notes |
|
||||
@@ -292,8 +302,34 @@ configuration, not the core runtime contract. Store them in a local ignored
|
||||
3. Build the WebUI from `webui/package.release.json` or deploy a prebuilt
|
||||
artifact from the same release tag.
|
||||
4. Run database migrations with the target `DATABASE_URL`.
|
||||
5. Create the first tenant and system owner through the controlled bootstrap or
|
||||
one-time admin command for the deployment.
|
||||
5. Create the first tenant and system owner through the controlled bootstrap:
|
||||
|
||||
```bash
|
||||
python -m govoplan_core.commands.first_admin status
|
||||
python -m govoplan_core.commands.first_admin issue \
|
||||
--reason "initial production installation"
|
||||
```
|
||||
|
||||
The issue command fails when an active system administrator already exists,
|
||||
writes the random credential only to `FIRST_ADMIN_ENROLLMENT_FILE`, and does
|
||||
not print it. Check `GET /api/v1/bootstrap/status`, then submit the account
|
||||
and initial tenant fields to `POST /api/v1/bootstrap/first-admin` with the
|
||||
secret in `X-GovOPlaN-Enrollment-Token`. The operation creates the protected
|
||||
system owner and initial tenant-owner membership in one transaction and
|
||||
retires the credential. A repeated identical request returns the same result
|
||||
without creating another owner.
|
||||
|
||||
If the artifact is lost or expires before use, a local operator may rotate
|
||||
it only while no durable system administrator exists:
|
||||
|
||||
```bash
|
||||
python -m govoplan_core.commands.first_admin recover \
|
||||
--reason "expired installation handoff"
|
||||
```
|
||||
|
||||
Issue and recovery write hash-chained Core evidence and an audit event. They
|
||||
never enable or reuse `DEV_BOOTSTRAP_ENABLED`, `DEV_BOOTSTRAP_PASSWORD`, or
|
||||
`DEV_BOOTSTRAP_API_KEY`.
|
||||
6. Start the API service with `govoplan_core.server.app:app`.
|
||||
7. Start workers when `CELERY_ENABLED=true`.
|
||||
8. Start the WebUI/reverse proxy and verify CORS/cookie settings.
|
||||
@@ -423,6 +459,14 @@ SQLite's backup API; non-SQLite databases require
|
||||
`--database-backup-command`, `--database-restore-check-command`, and
|
||||
`--database-restore-command`.
|
||||
|
||||
Every non-dry run also owns the database-fenced
|
||||
`core:module-lifecycle:deployment` recovery operation. The run record includes
|
||||
its operation id and status. A supervised run reaches durable `succeeded` only
|
||||
after restart and health verification. `recovery_required` or `outcome_unknown`
|
||||
blocks another lifecycle mutation until the recorded operation is reconciled;
|
||||
do not bypass this by deleting `install.lock`. See
|
||||
[`MODULE_LIFECYCLE_RECOVERY.md`](MODULE_LIFECYCLE_RECOVERY.md).
|
||||
|
||||
Database hook commands receive:
|
||||
|
||||
- `GOVOPLAN_INSTALLER_RUN_DIR`
|
||||
|
||||
@@ -19,6 +19,7 @@ operator, and roadmap pages.
|
||||
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
|
||||
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
|
||||
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
|
||||
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
|
||||
|
||||
## Release And Operations
|
||||
|
||||
@@ -38,6 +39,7 @@ operator, and roadmap pages.
|
||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
||||
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
||||
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
|
||||
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
|
||||
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
|
||||
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Durable Recovery Operations
|
||||
|
||||
Modules must use `begin_durable_recovery_operation` for work whose effects can
|
||||
outlive the caller's SQLAlchemy transaction. The helper commits the canonical
|
||||
request hash, recovery plan, precondition evidence, running state, and lease
|
||||
fence before the caller mutates object storage, a queue, a filesystem, or an
|
||||
external provider.
|
||||
|
||||
Each later checkpoint is written through an independent database session. A
|
||||
business-transaction rollback therefore cannot erase evidence of an earlier
|
||||
effect. Successful completion requires concrete verification checks and a valid
|
||||
hash chain. Compensation likewise records recovery-required, recovering, and
|
||||
verified-recovered checkpoints rather than reporting an ordinary failure.
|
||||
A definitive pre-effect or provider rejection records terminal `rejected`
|
||||
evidence instead of being mislabeled as success, atomic rollback, or recovery
|
||||
work.
|
||||
|
||||
If a runtime disappears, another runtime may claim the operation only after the
|
||||
lease expires. The takeover records both fences. A stale compensatable operation
|
||||
becomes recovery-required; a stale forward-only or irreversible external effect
|
||||
becomes outcome-unknown; a database-only atomic operation is recorded failed
|
||||
because its transaction rolled back. Takeover never re-executes the original
|
||||
request automatically.
|
||||
|
||||
Evidence and metadata may contain opaque references, digests, counts, and
|
||||
provider result codes. They must never contain credentials or resolved secrets.
|
||||
Ops is the platform surface for unresolved operation status; owning modules must
|
||||
provide the reconciliation action and business-level explanation.
|
||||
|
||||
Database-only operations must use the durable handle's atomic terminal methods
|
||||
when their module rows and final recovery checkpoint belong to one invariant.
|
||||
Those methods stage the terminal checkpoint and lease release in the caller's
|
||||
SQLAlchemy transaction, then commit the domain rows and recovery evidence
|
||||
together. A failed commit rolls both back and leaves the previously durable
|
||||
`running` record available for stale-fence handling; modules must not commit
|
||||
their domain state first and close an `atomic` recovery record afterwards.
|
||||
|
||||
An owning module may reconcile an `outcome_unknown` provider effect through the
|
||||
claimed durable handle's `resolve_unknown` method. External evidence that the
|
||||
effect occurred records verified success. Evidence that it did not occur moves
|
||||
the operation through recovery-required and recovering to verified recovered,
|
||||
so any later attempt must use a new deliberate idempotency key. The method does
|
||||
not infer provider state and requires the same terminal verification structure
|
||||
and hash-chain checks as ordinary completion.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Core Interface Pattern Migration
|
||||
|
||||
This document records the Core-owned part of the product-wide interface
|
||||
pattern-language rollout. The normative product grammar and complete route
|
||||
inventory live in the `govoplan` meta repository. Core owns reusable behavior;
|
||||
domain modules own their compositions.
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
| Surface | Pattern | Consequence and provenance contract | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| User settings | Two-zone settings workspace with typed controls and unsaved-change protection | Save actions distinguish busy, unchanged, and test-in-progress states; contextual help resolves through Docs or the hosted fallback | `SettingsPage.tsx`, `test-core-interface-patterns.mjs` |
|
||||
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
|
||||
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
|
||||
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
|
||||
|
||||
## Boundary
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
|
||||
they are not reasons to add sibling-private behavior to Core.
|
||||
|
||||
Raw JSON remains permitted only for diagnostics, expert inspection,
|
||||
interchange, or conflict evidence. It is not a primary Core configuration
|
||||
editor.
|
||||
+115
-4
@@ -207,12 +207,20 @@ Other stable runtime capabilities currently include:
|
||||
|
||||
- `identity.directory` and `identity.search`
|
||||
- `organizations.directory`
|
||||
- `idm.directory`
|
||||
- `calendar.outbox` and `calendar.scheduling`
|
||||
- `idm.directory`, `idm.function_assignments`, `idm.relationships`, and
|
||||
`idm.assignment_lifecycle`
|
||||
- `calendar.outbox`, `calendar.scheduling`, `calendar.invitations`, and
|
||||
`calendar.externalProfiles`
|
||||
- `poll.scheduling`
|
||||
- `notifications.dispatch`
|
||||
- `workflow.definitionContributions` and `workflow.runtimeWorker`
|
||||
|
||||
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
|
||||
groups, effective-dated identity relationships, and explicit membership
|
||||
decisions. It deliberately does not expose IDM persistence models or imply an
|
||||
Access permission. Consumers can retain source revisions and inclusion or
|
||||
exclusion provenance while remaining optional-module safe.
|
||||
|
||||
Modules contribute reusable process baselines through
|
||||
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
|
||||
and version, stable key, schema and content hash, native graph/BPMN content,
|
||||
@@ -285,8 +293,10 @@ such as `calendar:sync-source:<id>`.
|
||||
Current named interfaces, generated from the source manifests by the workspace
|
||||
contract checks, are:
|
||||
|
||||
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
|
||||
- `calendar.outbox`, `calendar.scheduling`
|
||||
- `addresses.contact_point_resolution`, `addresses.contact_writer`,
|
||||
`addresses.lookup`, `addresses.recipient_source`
|
||||
- `calendar.external_profiles`, `calendar.invitations`, `calendar.outbox`,
|
||||
`calendar.scheduling`
|
||||
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||
`campaigns.retention`
|
||||
@@ -728,6 +738,13 @@ effects, transitions partial/unknown outcomes honestly, and records verified
|
||||
completion or recovery. Plaintext secrets must never enter recovery metadata or
|
||||
evidence.
|
||||
|
||||
For a conclusive external result, modules may commit their local success
|
||||
projection and the verified terminal checkpoint in one database transaction via
|
||||
`DurableRecoveryOperation.commit_verified_success`. This does not make the
|
||||
external provider effect atomic. It prevents a local `succeeded` state from
|
||||
becoming authoritative when the recovery evidence chain is damaged or the
|
||||
terminal checkpoint cannot commit.
|
||||
|
||||
## Install, Uninstall, And Catalogs
|
||||
|
||||
Core owns the install plan, signed catalog validation, license entitlement
|
||||
@@ -817,6 +834,24 @@ the shared loading and retryable error state around route rendering. The
|
||||
initial static import closure and largest asynchronous chunk are enforced by
|
||||
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||
|
||||
Every public platform interface has a stable declaration identity. Backend
|
||||
routes, capabilities, interfaces, search providers/sources, permissions,
|
||||
frontend routes/navigation, and View surfaces derive that identity from typed
|
||||
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
|
||||
admin sections, widgets, search contexts, and extension actions. Shared form
|
||||
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
|
||||
values when another contract, documentation topic, or automated check must
|
||||
refer to the control across source changes. The static inventory assigns a
|
||||
line-independent source anchor when an explicit ID is absent and reports that
|
||||
fact for later review.
|
||||
|
||||
Core exposes the sanitized runtime declaration set at
|
||||
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
|
||||
`admin:module:read` or `system:settings:read`, and includes only modules
|
||||
effective in the caller's active tenant context. It never serializes factories,
|
||||
credentials, executable callbacks, or mutable module state. Registry validation
|
||||
rejects conflicting declaration IDs before startup.
|
||||
|
||||
WebUI modules receive only the core route context:
|
||||
|
||||
- `settings`
|
||||
@@ -1222,6 +1257,61 @@ devserver, development bootstrap, background worker registry, and migration
|
||||
metadata plan all read the saved desired state from `system_settings` before
|
||||
building their module registry.
|
||||
|
||||
### Tenant entitlement and personal visibility
|
||||
|
||||
Deployment activation remains process-wide: one installed and active registry
|
||||
is shared by every tenant served by that process. Tenant module selection is a
|
||||
separate entitlement document in `core_scopes.settings.module_entitlements`:
|
||||
|
||||
- a system policy marks each installed module `unavailable`, `available`, or
|
||||
`forced` for one tenant;
|
||||
- the tenant selection may enable or disable only available modules;
|
||||
- protected platform modules, forced modules, and transitive dependencies stay
|
||||
effective;
|
||||
- malformed explicit entitlement fails closed to protected modules, while an
|
||||
absent document preserves the pre-entitlement behavior for upgraded tenants;
|
||||
- an optimistic revision prevents concurrent system and tenant administrators
|
||||
from silently replacing each other's changes.
|
||||
|
||||
The authenticated platform metadata and module route guard intersect global
|
||||
runtime activation with the active tenant's effective entitlement. Entitlement
|
||||
does not grant a permission. Access authorization must still allow every API
|
||||
operation and resource.
|
||||
|
||||
The same boundary applies outside authenticated request handling:
|
||||
|
||||
- capability factories retain their owning module, and tenant-scoped capability
|
||||
lookup treats a provider that is unavailable to the tenant as absent;
|
||||
- workers partition scheduled scans by tenant before claiming rows;
|
||||
- new work is rejected while a module is unavailable, while already accepted
|
||||
durable work remains in provider-owned storage and is reported as
|
||||
`operator_action_required` instead of being dropped or executed;
|
||||
- Workflow, Dataflow, event consumers, reconciliation jobs, and external-effect
|
||||
outboxes run inside a tenant execution context, so their optional capability
|
||||
calls inherit the same provider checks;
|
||||
- public signed-link modules declare a `public_tenant_resolver`; valid token
|
||||
context is resolved before the route runs and the module entitlement is then
|
||||
enforced without requiring an authenticated principal.
|
||||
|
||||
Entitlement resolution uses a bounded process-local cache. A local policy
|
||||
mutation invalidates its tenant entry immediately; changes made by another node
|
||||
become authoritative after `TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS`
|
||||
(five seconds by default). This is a bounded staleness optimization, not an
|
||||
authorization grant: a cache miss or resolution failure fails closed.
|
||||
|
||||
Users and groups do not own another module-runtime state. Every WebUI module
|
||||
already contributes a root `<module>.module` View surface, so personal and
|
||||
group module visibility is expressed through Views. View policy controls who
|
||||
may select, assign, edit, derive, or workflow-activate those projections;
|
||||
required View assignments can retain required UI. Thus tenant entitlement owns
|
||||
operational availability, Views own presentation, and Access owns authority.
|
||||
|
||||
Capability-style modules such as Encryption must keep activation separate from
|
||||
domain data state. Making Encryption effective only exposes its capability and
|
||||
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
|
||||
an explicit versioned protection-policy operation owned by Encryption and the
|
||||
module that owns the data.
|
||||
|
||||
Hot enable/disable is a core design principle for every module:
|
||||
|
||||
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
||||
@@ -1329,6 +1419,11 @@ The package install-plan API records operator intent only:
|
||||
default; successful uninstalls are removed from saved startup state by default.
|
||||
Use `--no-activate-installed-modules` or
|
||||
`--keep-uninstalled-modules-in-desired` only for staged rollout workflows.
|
||||
- Every non-dry installer and live active-graph mutation acquires the
|
||||
deployment-wide `core:module-lifecycle:deployment` lease and records a Core
|
||||
recovery operation. Unresolved effects block later lifecycle changes. The
|
||||
operation modes and operator reconciliation contract are defined in
|
||||
`MODULE_LIFECYCLE_RECOVERY.md`.
|
||||
- `govoplan-module-installer --supervise --migrate --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
|
||||
is the preferred disruptive-change path. It applies the plan, optionally runs
|
||||
migrations in a fresh Python process after a fresh-process manifest
|
||||
@@ -1481,6 +1576,22 @@ The first implementation is a platform access gate. It does not replace
|
||||
database backups, process supervision, migration checks, or external load
|
||||
balancer maintenance pages.
|
||||
|
||||
## Connector Runtime Contract
|
||||
|
||||
Core defines provider-neutral connector preview and diagnostic primitives in
|
||||
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
|
||||
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
|
||||
health; the consuming domain module owns mappings, validation, reconciliation,
|
||||
and mutations of its records.
|
||||
|
||||
Every dry run is bounded and identifies the source revision, source fingerprint,
|
||||
immutable input hash, effects, and redacted diagnostics. Its summary must match
|
||||
the returned effect list exactly. An apply token is usable only when the preview
|
||||
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
|
||||
URLs never contain credentials; only credential-envelope references cross the
|
||||
contract. Provider-specific details belong in sanitized provenance rather than
|
||||
in a shared domain schema.
|
||||
|
||||
## Build And Verification
|
||||
|
||||
Backend verification from core:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Module Lifecycle Recovery
|
||||
|
||||
## Migration Revision Namespace
|
||||
|
||||
All enabled module migration directories are assembled into one Alembic graph. Revision IDs are therefore global across Core and every module even though each module owns a separate `migrations/versions` directory. Core validates literal revision declarations before constructing the graph and rejects duplicates with both file paths. A module must assign a new globally unique revision ID; reusing another module's ID can otherwise make Alembic treat an unrelated schema change as already applied or report an ancestor/head overlap.
|
||||
|
||||
When correcting a collision that has already reached a database, first verify the schema objects that identify which migration actually ran. Rename the unapplied migration, or transactionally translate the corresponding `alembic_version` row when the applied owner is unambiguous. Never add both colliding IDs as heads or blindly stamp the database.
|
||||
|
||||
Package changes and live module-graph changes use Core's durable recovery
|
||||
ledger. The local `install.lock` still prevents duplicate work in one runtime
|
||||
directory; the database lease `core:module-lifecycle:deployment` is the
|
||||
deployment-wide authority across API, installer, worker, and scheduler nodes.
|
||||
|
||||
## Declared Boundaries
|
||||
|
||||
| Operation | Recovery mode | Completion condition |
|
||||
| --- | --- | --- |
|
||||
| `module-lifecycle.pre-migration` | compensation | package, WebUI, manifest, and desired-graph evidence match |
|
||||
| `module-lifecycle.post-migration` | forward recovery | migration tasks, manifests, desired graph, restart, and health are verified |
|
||||
| `module-retirement.destroy-data` | snapshot restore | a hashed, restore-checked backup exists and retirement state is verified |
|
||||
| `module-runtime.apply-graph` | compensation | hooks, capability contexts, active graph, and workflow contributions match |
|
||||
|
||||
The installer prepares the recovery operation before it captures the database
|
||||
snapshot. A full database restore therefore retains the prepared operation and
|
||||
its fence instead of erasing the fact that a mutation was attempted. Backup
|
||||
artifacts are hashed and sized before any package, migration, or retirement
|
||||
effect starts.
|
||||
|
||||
Every command boundary records the command source and canonical hashes of the
|
||||
redacted command/result records. Credentials, database URLs, command output,
|
||||
and package-registry secrets are never copied into recovery evidence.
|
||||
|
||||
## Failure And Retry Rules
|
||||
|
||||
- A conclusive failure before effects is terminal `failed`.
|
||||
- A command or compensatable effect that started but did not complete is
|
||||
`recovery_required`.
|
||||
- A lost or unexpected outcome after a migration/external boundary is
|
||||
`outcome_unknown`.
|
||||
- A verified package/database rollback becomes `recovered`.
|
||||
- A supervised install becomes `succeeded` only after restart and all configured
|
||||
health probes succeed.
|
||||
|
||||
An unresolved lifecycle operation blocks every later lifecycle mutation on the
|
||||
same deployment fence, even after its execution lease is released. Operators
|
||||
must inspect the checkpoint chain and run record, restore or complete the
|
||||
declared recovery path, and explicitly reconcile the operation. A new install
|
||||
must not be used as an implicit retry.
|
||||
|
||||
Live graph changes use the same fence. A non-migrating hook or registry failure
|
||||
restores the prior in-process graph and records verified compensation. A failure
|
||||
after migrations begin remains unresolved because restoring the process-local
|
||||
registry does not reverse database schema effects.
|
||||
|
||||
## Operator Evidence
|
||||
|
||||
The installer run record contains the recovery operation id, mode, plan hash,
|
||||
and current lifecycle status. The Ops recovery view is authoritative for the
|
||||
durable state and evidence-chain result. Keep both the run directory and the
|
||||
state-service backup evidence until the operation is terminal and the normal
|
||||
retention policy permits removal.
|
||||
|
||||
Run the module installer rollback drill and recovery-runtime test matrix before
|
||||
enabling lifecycle mutation in a new deployment. Shared-state deployments must
|
||||
still use immutable release images; the ledger does not make in-place package
|
||||
mutation across replicas safe.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Search event indexing contract
|
||||
|
||||
Core defines, but does not implement, the optional Search indexing boundary.
|
||||
Feature modules register `SearchSourceProvider` implementations for bounded
|
||||
backfills and live authorization checks. A provider may additionally implement
|
||||
`SearchEventSourceProvider` to translate a committed `PlatformEvent` into one
|
||||
or more authoritative `SearchIndexChange` values.
|
||||
|
||||
When the Search index-writer capability is active, the platform event worker
|
||||
uses the durable consumer identity `search.indexing.v1`. It accepts only public
|
||||
and internal events, passes the outbox delivery key to each event-capable
|
||||
source, and then advances a bounded batch of queued index changes in the same
|
||||
worker transaction. Stable change IDs make delivery replay idempotent.
|
||||
|
||||
The boundary has three non-negotiable rules:
|
||||
|
||||
- a source may emit changes only for its registered module, provider, resource
|
||||
type, and event tenant;
|
||||
- Search validates every upsert document before queueing it and rejects secret
|
||||
metadata keys;
|
||||
- an index ACL is only a candidate filter. Resources marked for authorization
|
||||
recheck are returned only after the owning source explicitly allows the
|
||||
current principal at query time.
|
||||
|
||||
Search and its worker remain optional. Core-only startup and feature-module
|
||||
operation do not require the Search package.
|
||||
@@ -28,6 +28,11 @@ module artifacts. It provides bounded read/write/list/stat/delete operations
|
||||
for local and S3-compatible storage. Modules own their object-key namespace and
|
||||
business metadata; Core does not interpret module files.
|
||||
|
||||
`stat` and `list_objects` return object size plus a UTC `modified_at` value when
|
||||
the backend can prove it. Reconciliation and retention code may use that value
|
||||
for conservative grace periods, but must treat a missing timestamp as
|
||||
ineligible for automatic deletion rather than guessing an age.
|
||||
|
||||
Rules for modules:
|
||||
|
||||
- Store only opaque object keys in business records, never local absolute
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Tabular Source Preview Contract
|
||||
|
||||
Core defines provider-neutral DTOs for optional tabular source providers. A
|
||||
source declares whether it is live, cached, file-backed, or static; its schema
|
||||
and immutable fingerprint; structured health; and the exact projection,
|
||||
pagination, filter, aggregation, and sorting operations that the provider can
|
||||
push down. Consumers must not infer pushdown support from a provider name.
|
||||
|
||||
Every preview request carries independent row, byte, and elapsed-time budgets.
|
||||
A provider may tighten these values but must return its effective limits,
|
||||
returned byte count, elapsed milliseconds, truncation state, and structured
|
||||
diagnostics. Equivalent fields on the Datasources read request and result
|
||||
preserve that evidence when a live source is consumed through the catalogue.
|
||||
A row that cannot fit within the byte budget fails explicitly rather than
|
||||
leaking a partial value. Timeout, stale fingerprint, unavailable source, and
|
||||
authorization failures remain distinct provider-neutral errors.
|
||||
|
||||
Connector health and preview diagnostics must contain no credentials, endpoint
|
||||
userinfo, row values, or unbounded remote error bodies. A Datasource origin
|
||||
preserves this contract so registration and staging do not erase source mode,
|
||||
health, pushdown, or preview-limit evidence.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Template And Generated Artifact Capability Contracts
|
||||
|
||||
Core defines provider-neutral contracts for optional template libraries and
|
||||
generated artifact storage. Core does not render templates or store generated
|
||||
files itself.
|
||||
|
||||
## Templates
|
||||
|
||||
- `templates.catalog` lists typed, versioned template references and checks a
|
||||
consumer's available fields, usage, and output format.
|
||||
- `templates.renderer` accepts a `TemplateRenderRequest` containing pinned
|
||||
input data and returns immutable render evidence plus an artifact reference.
|
||||
|
||||
The DTOs contain identifiers, hashes, plain mappings, and scalar metadata. They
|
||||
do not expose Template ORM models or require Campaign, Distribution Lists,
|
||||
Addresses, Reporting, Forms, or Mail.
|
||||
|
||||
## Generated Artifacts
|
||||
|
||||
`files.artifact_store` accepts a `ManagedArtifactWriteRequest` and returns a
|
||||
`ManagedArtifactRef`. Producers supply bytes, a safe filename/content type,
|
||||
idempotency key, and non-secret provenance. Files owns path normalization,
|
||||
authorization, versions, storage, and download behavior.
|
||||
|
||||
Consumers must discover both contracts through the module registry and degrade
|
||||
only the unavailable path. A template renderer may return a bounded download
|
||||
when Files is absent. A caller must not infer successful external delivery from
|
||||
successful rendering or artifact persistence.
|
||||
@@ -50,6 +50,11 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
|
||||
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell |
|
||||
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
|
||||
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
|
||||
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
@@ -223,6 +228,10 @@ instead of reproducing their behavior.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
- Shared action-bearing components accept an optional disabled reason. In
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
the domain-specific required field, permission, or in-progress reason.
|
||||
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||
@@ -272,7 +281,7 @@ UI documentation until a central cross-repository audit is available.
|
||||
|
||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||
| --- | --- | --- |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. `PasswordField` may opt into the shared cryptographic generator; the candidate dialog is subordinate to the enclosing field and commits only through its explicit Use action. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||
@@ -303,14 +312,14 @@ converted or reviewed.
|
||||
|
||||
| Surface | Repository | UX State | Next Action |
|
||||
| --- | --- | --- | --- |
|
||||
| File connector settings | `govoplan-files` | First adaptive modal slice started: connections and credentials now use full-state create/edit forms with conditional fields, advanced panels, and blocker primitives. Wizard shell is retained for later assisted setup. Central policy card still needs a layered editor. | Finish provider discovery/test-in-flow, then convert policy editing. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Uses the shared server/credential model visually, but create/edit still needs the same adaptive pattern as files. | Migrate to adaptive server/credential/policy dialogs, with optional assisted wizard later. |
|
||||
| File connector settings | `govoplan-files` | Migrated to the shared adaptive server/credential/policy pattern with provider discovery, typed controls, actionable blockers, consequence-aware removal, and module-owned verification evidence. | Continue only through bounded Files-owned follow-ups. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Migrated to the same layered profile/server/credential/policy pattern, including focused connection tests, unsaved-state handling, contextual help, and permission/target blockers. | Continue only through bounded Mail-owned follow-ups. |
|
||||
| Connector policy/effective rows | `govoplan-core`, module UIs | Effective-policy direction exists, but many editors still expose broad option sets. | Put effective value first, move overrides into modal, and explain blocked edits. |
|
||||
| Admin module management | `govoplan-admin` | Has preflight concepts, but operational choices are still technical and dense. | Convert install/uninstall/package changes to operator wizards. |
|
||||
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
|
||||
| Retention and privacy | `govoplan-core` | Functional editor exists; consequence language and provenance can be stronger. | Layer advanced retention options and add review for broad changes. |
|
||||
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
|
||||
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
|
||||
| User settings | `govoplan-core` | Preferences persistence exists; interface navigation issue was fixed earlier, but the surface still needs UX review. | Keep simple sections, remove double-click traps, and add quiet explanations. |
|
||||
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, and explicit busy/clean disabled-action reasons. | Keep bounded; new contributed sections must satisfy the checklist. |
|
||||
|
||||
## Impact Index
|
||||
|
||||
@@ -339,6 +348,11 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Does the screen explain disabled actions and failed validation in plain
|
||||
language?
|
||||
- Does it say who can fix a blocker and where?
|
||||
- Does a module-localized blocker pass its translated row labels through the
|
||||
shared `ActionBlockerHint` contract instead of reproducing the component?
|
||||
- Does longer field or blocker guidance use a stable `DocumentationHelpLink`
|
||||
topic/context reference, with hosted fallback when the optional Docs module
|
||||
is absent?
|
||||
- Does it reuse existing core patterns for wizard steps, problem lists, modals,
|
||||
help, and review?
|
||||
- Is there a review or preflight step before broad, destructive, or risky
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# WebUI Module Package Layout
|
||||
|
||||
Core discovers a module contribution from `src/module.ts` when `node_modules`
|
||||
links directly to a module's `webui` package. Tagged release dependencies are
|
||||
installed from repository-root packages and expose the same contribution at
|
||||
`webui/src/module.ts`. The Vite registry accepts both layouts and imports the
|
||||
contribution descriptor directly so route-level lazy loading is preserved.
|
||||
|
||||
A release package is invalid if neither entry exists. The module-permutation CI
|
||||
matrix builds source-linked and installed release compositions; it must not fall
|
||||
back to a package root barrel because that would eagerly pull module pages into
|
||||
the shell bundle.
|
||||
@@ -663,6 +663,408 @@
|
||||
"release": "0.1.14",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "93b4c5d6e7f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "a3d5f7b9c1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "a8d1e4f7b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "b1c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "b8d2f5a0c3e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "b8e4c1f7a2d9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "c5d7e8f9a0b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "c7e0a3d6f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8e3f6a9b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "e3c8f4a5b6d7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "e4a1f8c2d7b6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "f1a2b3c4d5e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "f25c9d3e7a01"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "f6c2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "f6d3a8b1c4e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "f7c8d9e0a1b2"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"c7e0a3d6f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"c5d7e8f9a0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"a8d1e4f7b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"e3c8f4a5b6d7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"f6d3a8b1c4e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"f7c8d9e0a1b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"f25c9d3e7a01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"f6c2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"b8d2f5a0c3e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"f1a2b3c4d5e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"a3d5f7b9c1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"b1c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"93b4c5d6e7f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8e3f6a9b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"b8e4c1f7a2d9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"e4a1f8c2d7b6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-08-04T13:09:52Z",
|
||||
"release": "0.1.15",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -37,6 +37,7 @@ govoplan_core = ["py.typed"]
|
||||
[project.scripts]
|
||||
govoplan-config = "govoplan_core.commands.config:main"
|
||||
govoplan-devserver = "govoplan_core.devserver:main"
|
||||
govoplan-first-admin = "govoplan_core.commands.first_admin:main"
|
||||
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
|
||||
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ def get_api_principal(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
cached = getattr(request.state, "govoplan_api_principal", None)
|
||||
if isinstance(cached, ApiPrincipal):
|
||||
return cached
|
||||
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
@@ -143,6 +146,7 @@ def get_api_principal(
|
||||
)
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
||||
request.state.govoplan_api_principal = principal
|
||||
return principal
|
||||
|
||||
|
||||
|
||||
+661
-110
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
FirstAdminProvisioner,
|
||||
)
|
||||
from govoplan_core.core.first_admin import (
|
||||
FirstAdminEnrollmentError,
|
||||
first_admin_enrollment_status,
|
||||
issue_first_admin_credential,
|
||||
)
|
||||
from govoplan_core.core.module_management import (
|
||||
load_startup_enabled_modules,
|
||||
startup_candidate_module_ids,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.server.registry import (
|
||||
available_module_manifests,
|
||||
build_platform_registry,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage the single-use production first-administrator credential",
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=("status", "issue", "recover"),
|
||||
help="Inspect readiness, issue the initial credential, or rotate lost/expired material.",
|
||||
)
|
||||
parser.add_argument("--database-url", default=settings.database_url)
|
||||
parser.add_argument("--installation-id", default=settings.installation_id)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path(settings.first_admin_enrollment_file),
|
||||
help="Root-readable/equivalent JSON credential artifact.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ttl-seconds",
|
||||
type=int,
|
||||
default=settings.first_admin_enrollment_ttl_seconds,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reason",
|
||||
default=None,
|
||||
help="Audited local-operator reason for issue or recovery.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
configure_database(args.database_url)
|
||||
provisioner = _configure_first_admin_provisioner()
|
||||
with get_database().SessionLocal() as session:
|
||||
if args.command == "status":
|
||||
enrollment = first_admin_enrollment_status(
|
||||
session,
|
||||
installation_id=args.installation_id,
|
||||
provisioner=provisioner,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"installation_id": args.installation_id,
|
||||
"enrollment_required": enrollment.enrollment_required,
|
||||
"credential_active": enrollment.credential_active,
|
||||
"state": enrollment.state,
|
||||
"generation": enrollment.generation,
|
||||
"expires_at": (
|
||||
enrollment.expires_at.isoformat()
|
||||
if enrollment.expires_at is not None
|
||||
else None
|
||||
),
|
||||
"readiness": enrollment.readiness,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
reason = args.reason or (
|
||||
"initial production administrator enrollment"
|
||||
if args.command == "issue"
|
||||
else "local operator recovery of first-administrator enrollment"
|
||||
)
|
||||
try:
|
||||
credential = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id=args.installation_id,
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
reason=reason,
|
||||
replace_active=args.command == "recover",
|
||||
)
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"installation_id": args.installation_id,
|
||||
"endpoint": "/api/v1/bootstrap/first-admin",
|
||||
"header": "X-GovOPlaN-Enrollment-Token",
|
||||
"enrollment_token": credential.secret,
|
||||
"fingerprint": credential.fingerprint,
|
||||
"generation": credential.generation,
|
||||
"expires_at": credential.expires_at.isoformat(),
|
||||
}
|
||||
previous = _secure_file_snapshot(args.output)
|
||||
_write_private_json(args.output, payload)
|
||||
try:
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
_restore_secure_file(args.output, previous)
|
||||
raise
|
||||
except FirstAdminEnrollmentError as exc:
|
||||
session.rollback()
|
||||
parser.error(str(exc))
|
||||
|
||||
print(f"First-administrator credential written to {args.output}")
|
||||
print(f"Fingerprint: {credential.fingerprint}")
|
||||
print(f"Expires: {credential.expires_at.isoformat()}")
|
||||
print("The secret was not printed. Read it from the restricted artifact on the host.")
|
||||
|
||||
|
||||
def _configure_first_admin_provisioner() -> FirstAdminProvisioner:
|
||||
raw_enabled = load_startup_enabled_modules(settings.enabled_modules)
|
||||
candidates = startup_candidate_module_ids(settings.enabled_modules, raw_enabled)
|
||||
available = available_module_manifests(
|
||||
enabled_modules=candidates,
|
||||
ignore_load_errors=True,
|
||||
)
|
||||
enabled = load_startup_enabled_modules(
|
||||
settings.enabled_modules,
|
||||
available=available,
|
||||
)
|
||||
registry = build_platform_registry(enabled)
|
||||
context = ModuleContext(registry=registry, settings=settings)
|
||||
registry.configure_capability_context(context)
|
||||
configure_runtime(context)
|
||||
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
|
||||
raise RuntimeError(
|
||||
"Install and enable the Access module before issuing a first-administrator credential."
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
|
||||
if not isinstance(capability, FirstAdminProvisioner):
|
||||
raise RuntimeError("The Access first-administrator capability is invalid.")
|
||||
return capability
|
||||
|
||||
|
||||
def _secure_file_snapshot(path: Path) -> tuple[bytes, int] | None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError(f"Refusing to replace non-regular credential artifact: {path}")
|
||||
if metadata.st_uid != os.geteuid():
|
||||
raise RuntimeError(f"Credential artifact is not owned by the current operator: {path}")
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
||||
raise RuntimeError(f"Credential artifact permissions are too broad: {path}")
|
||||
return path.read_bytes(), stat.S_IMODE(metadata.st_mode)
|
||||
|
||||
|
||||
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
_fsync_directory(path.parent)
|
||||
except Exception:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _restore_secure_file(path: Path, snapshot: tuple[bytes, int] | None) -> None:
|
||||
if snapshot is None:
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
return
|
||||
content, mode = snapshot
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.restore")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
_fsync_directory(path.parent)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if not hasattr(os, "O_DIRECTORY"):
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -33,6 +34,10 @@ from govoplan_core.core.module_installer_notifications import (
|
||||
installer_notification_priority,
|
||||
installer_notification_subject,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
bind_process_runtime_identity,
|
||||
runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||
from govoplan_core.core.module_management import (
|
||||
@@ -107,11 +112,27 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
def main() -> int:
|
||||
args = _build_parser().parse_args()
|
||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||
bind_process_runtime_identity(
|
||||
runtime_identity(
|
||||
settings,
|
||||
software_version=_core_version(),
|
||||
role="installer",
|
||||
)
|
||||
)
|
||||
try:
|
||||
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||
except ModuleInstallerError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
bind_process_runtime_identity(None)
|
||||
|
||||
|
||||
def _core_version() -> str:
|
||||
try:
|
||||
return version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||
|
||||
@@ -21,6 +21,7 @@ CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
|
||||
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
|
||||
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||
@@ -45,6 +46,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
@@ -342,6 +344,19 @@ class DevelopmentBootstrapRef:
|
||||
created_api_key: CreatedApiKeyRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstSystemAdministratorRef:
|
||||
account_id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
membership_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
class FirstAdminProvisioningError(RuntimeError):
|
||||
"""Safe, user-facing rejection from the Access enrollment boundary."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantContextSwitchRef:
|
||||
account_id: str
|
||||
@@ -579,6 +594,25 @@ class TenantAccessProvisioner(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FirstAdminProvisioner(Protocol):
|
||||
"""Narrow Access boundary used only by the production bootstrap flow."""
|
||||
|
||||
def has_durable_system_administrator(self, session: object) -> bool:
|
||||
...
|
||||
|
||||
def create_first_system_administrator(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
) -> FirstSystemAdministratorRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessAdministration(Protocol):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
|
||||
@@ -31,6 +31,13 @@ ActionReversibility = Literal[
|
||||
"corrective_only",
|
||||
"irreversible",
|
||||
]
|
||||
ActionRecoveryMode = Literal[
|
||||
"atomic",
|
||||
"compensation",
|
||||
"snapshot_restore",
|
||||
"forward_recovery",
|
||||
"irreversible",
|
||||
]
|
||||
ActionExecutionState = Literal[
|
||||
"pending",
|
||||
"running",
|
||||
@@ -87,6 +94,10 @@ class ActionDefinition:
|
||||
idempotency_strategy: str = "caller_supplied"
|
||||
audit_event_types: tuple[str, ...] = ()
|
||||
preview_required: bool = True
|
||||
recovery_mode: ActionRecoveryMode = "forward_recovery"
|
||||
recovery_verification: tuple[str, ...] = (
|
||||
"verify the provider result and every announced effect before continuation",
|
||||
)
|
||||
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -96,12 +107,24 @@ class ActionDefinition:
|
||||
_require_text(self.description, "Action description")
|
||||
_require_text(self.input_schema_ref, "Action input schema reference")
|
||||
_require_text(self.idempotency_strategy, "Action idempotency strategy")
|
||||
if self.recovery_mode not in {
|
||||
"atomic",
|
||||
"compensation",
|
||||
"snapshot_restore",
|
||||
"forward_recovery",
|
||||
"irreversible",
|
||||
}:
|
||||
raise ValueError("Action recovery mode is not supported")
|
||||
if any(not value.strip() for value in self.required_scopes):
|
||||
raise ValueError("Action scopes must not be empty")
|
||||
if any(not value.strip() for value in self.required_capabilities):
|
||||
raise ValueError("Action capabilities must not be empty")
|
||||
if any(not value.strip() for value in self.expected_effect_keys):
|
||||
raise ValueError("Expected effect keys must not be empty")
|
||||
if not self.recovery_verification or any(
|
||||
not value.strip() for value in self.recovery_verification
|
||||
):
|
||||
raise ValueError("Actions must declare recovery verification steps")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -384,6 +407,7 @@ __all__ = [
|
||||
"AutomationPrincipalResolution",
|
||||
"AutomationSubjectKind",
|
||||
"ActionPreview",
|
||||
"ActionRecoveryMode",
|
||||
"ActionReversibility",
|
||||
"ActionRiskLevel",
|
||||
"EffectDefinition",
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Protocol, runtime_checkable
|
||||
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
|
||||
CAPABILITY_CALENDAR_EXTERNAL_PROFILES = "calendar.externalProfiles"
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||
|
||||
@@ -90,6 +91,48 @@ class CalendarInvitationRef:
|
||||
degraded_reasons: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarInvitationCalendarRef:
|
||||
id: str
|
||||
name: str
|
||||
color: str | None = None
|
||||
timezone: str = "UTC"
|
||||
source_kind: str = "local"
|
||||
writable: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarExternalProfileRequest:
|
||||
"""Connector-neutral request for a Calendar-owned external profile."""
|
||||
|
||||
profile_kind: str
|
||||
calendar_id: str
|
||||
endpoint_url: str
|
||||
display_name: str | None = None
|
||||
auth_type: str = "none"
|
||||
username: str | None = None
|
||||
credential_ref: str | None = None
|
||||
sync_enabled: bool = True
|
||||
sync_interval_seconds: int = 900
|
||||
sync_direction: str = "two_way"
|
||||
conflict_policy: str = "etag"
|
||||
connector_profile_ref: str | None = None
|
||||
identity_mapping_ref: str | None = None
|
||||
resource_calendar_ref: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarExternalProfileRef:
|
||||
source_id: str
|
||||
calendar_id: str
|
||||
profile_kind: str
|
||||
transport_kind: str
|
||||
connector_profile_ref: str | None = None
|
||||
identity_mapping_ref: str | None = None
|
||||
resource_calendar_ref: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarSchedulingProvider(Protocol):
|
||||
def list_freebusy(
|
||||
@@ -132,6 +175,20 @@ class CalendarOutboxProvider(Protocol):
|
||||
class CalendarInvitationProvider(Protocol):
|
||||
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
|
||||
|
||||
def list_calendars(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None = None,
|
||||
group_ids: Sequence[str] = (),
|
||||
can_admin: bool = False,
|
||||
) -> Sequence[CalendarInvitationCalendarRef]:
|
||||
...
|
||||
|
||||
def render_invitation(self, request: CalendarInvitationRequest) -> str:
|
||||
...
|
||||
|
||||
def upsert_invitation(
|
||||
self,
|
||||
session: object,
|
||||
@@ -151,6 +208,26 @@ class CalendarInvitationProvider(Protocol):
|
||||
) -> CalendarInvitationRef | None:
|
||||
...
|
||||
|
||||
def get_invitations(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
correlation_ids: Sequence[str],
|
||||
) -> Mapping[str, CalendarInvitationRef]:
|
||||
...
|
||||
|
||||
def summarize_invitations(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_module: str,
|
||||
source_resource_type: str,
|
||||
source_resource_id: str | None,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def record_response(
|
||||
self,
|
||||
session: object,
|
||||
@@ -165,6 +242,36 @@ class CalendarInvitationProvider(Protocol):
|
||||
) -> CalendarInvitationRef:
|
||||
...
|
||||
|
||||
def record_icalendar_reply(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
icalendar: str,
|
||||
received_at: datetime | None = None,
|
||||
evidence: Mapping[str, object] | None = None,
|
||||
) -> Sequence[CalendarInvitationRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarExternalProfileProvider(Protocol):
|
||||
"""Optional connector route for Calendar-owned groupware adapters."""
|
||||
|
||||
def supported_profiles(self) -> Sequence[Mapping[str, object]]:
|
||||
...
|
||||
|
||||
def configure_profile(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarExternalProfileRequest,
|
||||
) -> CalendarExternalProfileRef:
|
||||
...
|
||||
|
||||
|
||||
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
@@ -192,3 +299,18 @@ def calendar_invitation_provider(
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
|
||||
return capability if isinstance(capability, CalendarInvitationProvider) else None
|
||||
|
||||
|
||||
def calendar_external_profile_provider(
|
||||
registry: object | None,
|
||||
) -> CalendarExternalProfileProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CalendarExternalProfileProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -95,6 +95,9 @@ class CampaignPolicyContextProvider(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignDeliveryTaskProvider(Protocol):
|
||||
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
|
||||
...
|
||||
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
|
||||
|
||||
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||
ConnectorDiagnosticStage = Literal[
|
||||
"configuration",
|
||||
"authentication",
|
||||
"discovery",
|
||||
"read",
|
||||
"mapping",
|
||||
"planning",
|
||||
"apply",
|
||||
"reconciliation",
|
||||
]
|
||||
ConnectorEffectKind = Literal[
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"conflict",
|
||||
"unchanged",
|
||||
"ignored",
|
||||
]
|
||||
ConnectorOutcomeState = Literal[
|
||||
"preview",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"outcome_unknown",
|
||||
]
|
||||
|
||||
|
||||
class ConnectorContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorEndpoint:
|
||||
"""Sanitized endpoint identity. Credentials never belong in this value."""
|
||||
|
||||
url: str
|
||||
credential_ref: str | None = None
|
||||
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
|
||||
options: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
normalized = self.url.strip()
|
||||
parsed = urlsplit(normalized)
|
||||
if not parsed.scheme or not parsed.hostname:
|
||||
raise ConnectorContractError("Connector endpoints require an absolute URL.")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ConnectorContractError(
|
||||
"Connector endpoint URLs must not contain credentials."
|
||||
)
|
||||
object.__setattr__(self, "url", normalized)
|
||||
if self.credential_ref is not None:
|
||||
credential_ref = self.credential_ref.strip()
|
||||
if not credential_ref:
|
||||
raise ConnectorContractError("Credential references cannot be blank.")
|
||||
object.__setattr__(self, "credential_ref", credential_ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorDiagnostic:
|
||||
severity: ConnectorDiagnosticSeverity
|
||||
code: str
|
||||
message: str
|
||||
stage: ConnectorDiagnosticStage
|
||||
retryable: bool = False
|
||||
source_ref: str | None = None
|
||||
object_ref: str | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code.strip() or len(self.code) > 120:
|
||||
raise ConnectorContractError(
|
||||
"Connector diagnostic codes must contain 1 to 120 characters."
|
||||
)
|
||||
if not self.message.strip():
|
||||
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorEffectPreview:
|
||||
effect: ConnectorEffectKind
|
||||
source_object_ref: str
|
||||
target_object_ref: str | None = None
|
||||
changed_fields: tuple[str, ...] = ()
|
||||
sample: Mapping[str, object] = field(default_factory=dict)
|
||||
reason_code: str | None = None
|
||||
outcome: ConnectorOutcomeState = "preview"
|
||||
revision: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.source_object_ref.strip():
|
||||
raise ConnectorContractError("Preview effects require a source object reference.")
|
||||
if self.outcome != "preview":
|
||||
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorEffectSummary:
|
||||
creates: int = 0
|
||||
updates: int = 0
|
||||
deletes: int = 0
|
||||
conflicts: int = 0
|
||||
unchanged: int = 0
|
||||
ignored: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return (
|
||||
self.creates
|
||||
+ self.updates
|
||||
+ self.deletes
|
||||
+ self.conflicts
|
||||
+ self.unchanged
|
||||
+ self.ignored
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorDryRunRequest:
|
||||
tenant_id: str
|
||||
source_ref: str
|
||||
force_full: bool = False
|
||||
max_items: int = 1_000
|
||||
expected_source_revision: str | None = None
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.tenant_id.strip() or not self.source_ref.strip():
|
||||
raise ConnectorContractError("Dry runs require tenant and source references.")
|
||||
if not 1 <= self.max_items <= 10_000:
|
||||
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorDryRunResult:
|
||||
contract_version: str
|
||||
source_ref: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
input_hash: str
|
||||
generated_at: datetime
|
||||
summary: ConnectorEffectSummary
|
||||
effects: tuple[ConnectorEffectPreview, ...] = ()
|
||||
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
|
||||
truncated: bool = False
|
||||
stale: bool = False
|
||||
apply_token: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
|
||||
raise ConnectorContractError(
|
||||
f"Unsupported connector contract version: {self.contract_version!r}."
|
||||
)
|
||||
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
|
||||
if not str(getattr(self, name)).strip():
|
||||
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
|
||||
if self.summary.total != len(self.effects):
|
||||
raise ConnectorContractError(
|
||||
"Dry-run summary counts must match the returned effect list."
|
||||
)
|
||||
|
||||
@property
|
||||
def can_apply(self) -> bool:
|
||||
return (
|
||||
self.apply_token is not None
|
||||
and not self.truncated
|
||||
and not self.stale
|
||||
and self.summary.conflicts == 0
|
||||
and not any(item.severity == "error" for item in self.diagnostics)
|
||||
)
|
||||
|
||||
|
||||
def summarize_connector_effects(
|
||||
effects: tuple[ConnectorEffectPreview, ...],
|
||||
) -> ConnectorEffectSummary:
|
||||
counts = Counter(item.effect for item in effects)
|
||||
return ConnectorEffectSummary(
|
||||
creates=counts["create"],
|
||||
updates=counts["update"],
|
||||
deletes=counts["delete"],
|
||||
conflicts=counts["conflict"],
|
||||
unchanged=counts["unchanged"],
|
||||
ignored=counts["ignored"],
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConnectorDryRunProvider(Protocol):
|
||||
def preview(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ConnectorDryRunRequest,
|
||||
) -> ConnectorDryRunResult:
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
|
||||
"ConnectorContractError",
|
||||
"ConnectorDiagnostic",
|
||||
"ConnectorDiagnosticSeverity",
|
||||
"ConnectorDiagnosticStage",
|
||||
"ConnectorDryRunProvider",
|
||||
"ConnectorDryRunRequest",
|
||||
"ConnectorDryRunResult",
|
||||
"ConnectorEffectKind",
|
||||
"ConnectorEffectPreview",
|
||||
"ConnectorEffectSummary",
|
||||
"ConnectorEndpoint",
|
||||
"ConnectorOutcomeState",
|
||||
"summarize_connector_effects",
|
||||
]
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
DistributionChannel,
|
||||
DistributionExplanation,
|
||||
DistributionOutcome,
|
||||
DistributionSourceReference,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION = "addresses.contact_point_resolution"
|
||||
CONTACT_POINT_CONTRACT_VERSION = "1.0"
|
||||
|
||||
ContactPointFallbackRule = Literal["none", "primary", "any"]
|
||||
PostalAddressFormat = Literal["domestic", "international"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointResolutionRequest:
|
||||
tenant_id: str
|
||||
subject: DistributionSourceReference
|
||||
effective_at: datetime
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
address_purpose: str | None = None
|
||||
fallback_rule: ContactPointFallbackRule = "primary"
|
||||
locale: str | None = None
|
||||
postal_format: PostalAddressFormat = "domestic"
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointCandidate:
|
||||
channel: DistributionChannel
|
||||
target: str
|
||||
target_key: str
|
||||
status: DistributionOutcome
|
||||
contact_point_id: str | None = None
|
||||
address_purpose: str | None = None
|
||||
locale: str | None = None
|
||||
preferred: bool = False
|
||||
preference_rank: int | None = None
|
||||
reason_code: str | None = None
|
||||
explanation: str | None = None
|
||||
source: DistributionSourceReference | None = None
|
||||
source_revision: str | None = None
|
||||
preference_revision: str | None = None
|
||||
consent_revision: str | None = None
|
||||
value: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointResolution:
|
||||
contract_version: str
|
||||
subject: DistributionSourceReference
|
||||
status: DistributionOutcome
|
||||
contact_id: str | None = None
|
||||
display_name: str | None = None
|
||||
candidates: tuple[ContactPointCandidate, ...] = ()
|
||||
excluded: tuple[ContactPointCandidate, ...] = ()
|
||||
explanations: tuple[DistributionExplanation, ...] = ()
|
||||
source_revision: str | None = None
|
||||
source_fingerprint: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointSourceRequest:
|
||||
tenant_id: str
|
||||
source_id: str
|
||||
effective_at: datetime
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
address_purpose: str | None = None
|
||||
fallback_rule: ContactPointFallbackRule = "primary"
|
||||
locale: str | None = None
|
||||
postal_format: PostalAddressFormat = "domestic"
|
||||
max_items: int = 5_000
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointSourcePreview:
|
||||
contract_version: str
|
||||
source: DistributionSourceReference
|
||||
request: ContactPointSourceRequest
|
||||
resolutions: tuple[ContactPointResolution, ...]
|
||||
total_count: int
|
||||
usable_count: int
|
||||
excluded_count: int
|
||||
offset: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
generated_at: datetime
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointSnapshotRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
contract_version: str
|
||||
source: DistributionSourceReference
|
||||
request: ContactPointSourceRequest
|
||||
resolutions: tuple[ContactPointResolution, ...]
|
||||
recipient_count: int
|
||||
excluded_count: int
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
snapshot_hash: str
|
||||
generated_at: datetime
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContactPointResolutionProvider(Protocol):
|
||||
def resolve_contact_points(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ContactPointResolutionRequest,
|
||||
) -> ContactPointResolution:
|
||||
...
|
||||
|
||||
def preview_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ContactPointSourceRequest,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> ContactPointSourcePreview:
|
||||
...
|
||||
|
||||
def freeze_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ContactPointSourceRequest,
|
||||
) -> ContactPointSnapshotRef:
|
||||
...
|
||||
|
||||
def get_snapshot(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
snapshot_id: str,
|
||||
) -> ContactPointSnapshotRef | None:
|
||||
...
|
||||
|
||||
|
||||
def contact_point_resolution_provider(
|
||||
registry: object | None,
|
||||
) -> ContactPointResolutionProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||
return capability if isinstance(capability, ContactPointResolutionProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION",
|
||||
"CONTACT_POINT_CONTRACT_VERSION",
|
||||
"ContactPointCandidate",
|
||||
"ContactPointFallbackRule",
|
||||
"ContactPointResolution",
|
||||
"ContactPointResolutionProvider",
|
||||
"ContactPointResolutionRequest",
|
||||
"ContactPointSnapshotRef",
|
||||
"ContactPointSourcePreview",
|
||||
"ContactPointSourceRequest",
|
||||
"PostalAddressFormat",
|
||||
"contact_point_resolution_provider",
|
||||
]
|
||||
@@ -52,6 +52,7 @@ class DataflowDatasetRequest:
|
||||
row_limit: int = 500
|
||||
expected_definition_hash: str | None = None
|
||||
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||
run_ref: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -178,6 +179,7 @@ class DataflowTriggerDispatcher(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
@@ -204,6 +206,7 @@ class DataflowRunWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 10,
|
||||
worker_id: str | None = None,
|
||||
@@ -214,6 +217,7 @@ class DataflowRunWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, object]:
|
||||
|
||||
@@ -9,6 +9,14 @@ from govoplan_core.core.external_references import (
|
||||
SOURCE_AUTHORITY_MODES,
|
||||
SourceAuthorityMode,
|
||||
)
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
DEFAULT_PREVIEW_BYTES,
|
||||
DEFAULT_PREVIEW_TIMEOUT_MS,
|
||||
TabularPreviewDiagnostic,
|
||||
TabularPushdown,
|
||||
TabularSourceHealth,
|
||||
TabularSourceMode,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||
@@ -265,6 +273,8 @@ class DatasourceReadRequest:
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -274,6 +284,12 @@ class DatasourceReadResult:
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
materialization: DatasourceMaterialization | None = None
|
||||
returned_bytes: int = 0
|
||||
elapsed_ms: int = 0
|
||||
effective_row_limit: int = 0
|
||||
effective_byte_limit: int = 0
|
||||
effective_timeout_ms: int = 0
|
||||
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -339,6 +355,9 @@ class DatasourceOrigin:
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
source_mode: TabularSourceMode = "cached"
|
||||
pushdown: TabularPushdown = field(default_factory=TabularPushdown)
|
||||
health: TabularSourceHealth = field(default_factory=TabularSourceHealth)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -348,6 +367,8 @@ class DatasourceOriginReadRequest:
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -356,6 +377,12 @@ class DatasourceOriginReadResult:
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
returned_bytes: int = 0
|
||||
elapsed_ms: int = 0
|
||||
effective_row_limit: int = 0
|
||||
effective_byte_limit: int = 0
|
||||
effective_timeout_ms: int = 0
|
||||
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -182,6 +182,8 @@ class PlatformEventOutbox(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
consumers: Sequence[DurableEventConsumer] = (),
|
||||
observer: EventHandler | None = None,
|
||||
limit: int = 100,
|
||||
@@ -203,6 +205,8 @@ class PlatformEventOutbox(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
before: datetime,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, int]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||
|
||||
|
||||
CAPABILITY_FILES_ACCESS = "files.access"
|
||||
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedArtifactWriteRequest:
|
||||
filename: str
|
||||
payload: bytes
|
||||
content_type: str
|
||||
folder: str = "Generated"
|
||||
description: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedArtifactRef:
|
||||
file_asset_id: str
|
||||
file_version_id: str
|
||||
filename: str
|
||||
display_path: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
||||
"""Resource-level access explanation provider for Files-owned resources."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManagedArtifactStore(Protocol):
|
||||
"""Store generated module artifacts without exposing Files internals."""
|
||||
|
||||
def store_artifact(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ManagedArtifactWriteRequest,
|
||||
) -> ManagedArtifactRef: ...
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.access import (
|
||||
FirstAdminProvisioner,
|
||||
FirstAdminProvisioningError,
|
||||
FirstSystemAdministratorRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
_TENANT_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
class FirstAdminEnrollmentState(StrEnum):
|
||||
INACTIVE = "inactive"
|
||||
ACTIVE = "active"
|
||||
CONSUMED = "consumed"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class FirstAdminEnrollmentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollmentUnavailable(FirstAdminEnrollmentError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollmentCredentialError(FirstAdminEnrollmentError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollmentConflict(FirstAdminEnrollmentError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollment(Base, TimestampMixin):
|
||||
__tablename__ = "core_first_admin_enrollments"
|
||||
|
||||
installation_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
default=FirstAdminEnrollmentState.INACTIVE.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
generation: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
token_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
token_fingerprint: Mapped[str | None] = mapped_column(String(16))
|
||||
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
consumed_account_id: Mapped[str | None] = mapped_column(String(36))
|
||||
consumed_membership_id: Mapped[str | None] = mapped_column(String(36))
|
||||
consumed_tenant_id: Mapped[str | None] = mapped_column(String(36))
|
||||
consumed_email: Mapped[str | None] = mapped_column(String(320))
|
||||
consumed_display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
consumed_request_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
issue_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class FirstAdminEnrollmentEvent(Base):
|
||||
__tablename__ = "core_first_admin_enrollment_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"installation_id",
|
||||
"sequence",
|
||||
name="uq_core_first_admin_enrollment_event_sequence",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid4()),
|
||||
)
|
||||
installation_id: Mapped[str] = mapped_column(
|
||||
ForeignKey(
|
||||
"core_first_admin_enrollments.installation_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
generation: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
previous_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
event_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IssuedFirstAdminCredential:
|
||||
secret: str
|
||||
fingerprint: str
|
||||
generation: int
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstAdminEnrollmentStatus:
|
||||
enrollment_required: bool
|
||||
credential_active: bool
|
||||
state: str
|
||||
generation: int
|
||||
expires_at: datetime | None
|
||||
completed_account_id: str | None
|
||||
readiness: dict[str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstAdminEnrollmentResult:
|
||||
administrator: FirstSystemAdministratorRef
|
||||
replayed: bool
|
||||
|
||||
|
||||
def issue_first_admin_credential(
|
||||
session: Session,
|
||||
*,
|
||||
installation_id: str,
|
||||
provisioner: FirstAdminProvisioner,
|
||||
ttl_seconds: int,
|
||||
reason: str,
|
||||
replace_active: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> IssuedFirstAdminCredential:
|
||||
current_time = _utc(now)
|
||||
if ttl_seconds < 60 or ttl_seconds > 24 * 60 * 60:
|
||||
raise ValueError("First-admin enrollment expiry must be between 60 seconds and 24 hours.")
|
||||
if provisioner.has_durable_system_administrator(session):
|
||||
raise FirstAdminEnrollmentUnavailable(
|
||||
"A durable system administrator already exists. Bootstrap enrollment is disabled."
|
||||
)
|
||||
|
||||
enrollment = _locked_enrollment(session, installation_id)
|
||||
if (
|
||||
enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
and _is_future(enrollment.expires_at, current_time)
|
||||
and not replace_active
|
||||
):
|
||||
raise FirstAdminEnrollmentConflict(
|
||||
"An unexpired first-admin credential already exists. Use the recovery command to rotate it."
|
||||
)
|
||||
|
||||
secret = secrets.token_urlsafe(48)
|
||||
token_sha256 = _secret_sha256(secret)
|
||||
fingerprint = token_sha256[:12]
|
||||
expires_at = current_time + timedelta(seconds=ttl_seconds)
|
||||
generation = enrollment.generation + 1
|
||||
if enrollment.state == FirstAdminEnrollmentState.ACTIVE.value:
|
||||
_append_event(
|
||||
session,
|
||||
enrollment,
|
||||
event_type="credential_revoked",
|
||||
generation=enrollment.generation,
|
||||
created_at=current_time,
|
||||
evidence={"reason": "local_operator_recovery"},
|
||||
)
|
||||
enrollment.state = FirstAdminEnrollmentState.ACTIVE.value
|
||||
enrollment.generation = generation
|
||||
enrollment.token_sha256 = token_sha256
|
||||
enrollment.token_fingerprint = fingerprint
|
||||
enrollment.issued_at = current_time
|
||||
enrollment.expires_at = expires_at
|
||||
enrollment.consumed_at = None
|
||||
enrollment.consumed_account_id = None
|
||||
enrollment.consumed_membership_id = None
|
||||
enrollment.consumed_tenant_id = None
|
||||
enrollment.consumed_email = None
|
||||
enrollment.consumed_display_name = None
|
||||
enrollment.consumed_request_sha256 = None
|
||||
enrollment.issue_reason = _bounded_reason(reason)
|
||||
session.add(enrollment)
|
||||
_append_event(
|
||||
session,
|
||||
enrollment,
|
||||
event_type="credential_issued",
|
||||
generation=generation,
|
||||
created_at=current_time,
|
||||
evidence={
|
||||
"fingerprint": fingerprint,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"reason": enrollment.issue_reason,
|
||||
},
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=None,
|
||||
scope="system",
|
||||
action="access.first_admin_enrollment.issued",
|
||||
object_type="first_admin_enrollment",
|
||||
object_id=installation_id,
|
||||
details={
|
||||
"generation": generation,
|
||||
"fingerprint": fingerprint,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"reason": enrollment.issue_reason,
|
||||
},
|
||||
)
|
||||
return IssuedFirstAdminCredential(
|
||||
secret=secret,
|
||||
fingerprint=fingerprint,
|
||||
generation=generation,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def first_admin_enrollment_status(
|
||||
session: Session,
|
||||
*,
|
||||
installation_id: str,
|
||||
provisioner: FirstAdminProvisioner,
|
||||
now: datetime | None = None,
|
||||
) -> FirstAdminEnrollmentStatus:
|
||||
current_time = _utc(now)
|
||||
administrator_exists = provisioner.has_durable_system_administrator(session)
|
||||
enrollment = session.get(FirstAdminEnrollment, installation_id)
|
||||
state = enrollment.state if enrollment is not None else FirstAdminEnrollmentState.INACTIVE.value
|
||||
active = bool(
|
||||
not administrator_exists
|
||||
and enrollment is not None
|
||||
and state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
and enrollment.token_sha256
|
||||
and _is_future(enrollment.expires_at, current_time)
|
||||
)
|
||||
if (
|
||||
not administrator_exists
|
||||
and enrollment is not None
|
||||
and state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
and not active
|
||||
):
|
||||
state = "expired"
|
||||
return FirstAdminEnrollmentStatus(
|
||||
enrollment_required=not administrator_exists,
|
||||
credential_active=active,
|
||||
state="completed" if administrator_exists else state,
|
||||
generation=enrollment.generation if enrollment is not None else 0,
|
||||
expires_at=enrollment.expires_at if enrollment is not None else None,
|
||||
completed_account_id=(
|
||||
enrollment.consumed_account_id if enrollment is not None else None
|
||||
),
|
||||
readiness={
|
||||
"database": True,
|
||||
"access_capability": True,
|
||||
"administrator_absent": not administrator_exists,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def consume_first_admin_credential(
|
||||
session: Session,
|
||||
*,
|
||||
installation_id: str,
|
||||
provisioner: FirstAdminProvisioner,
|
||||
secret: str,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
tenant_slug: str,
|
||||
tenant_name: str,
|
||||
now: datetime | None = None,
|
||||
) -> FirstAdminEnrollmentResult:
|
||||
current_time = _utc(now)
|
||||
normalized_email = email.strip().casefold()
|
||||
clean_display_name = display_name.strip() if display_name and display_name.strip() else None
|
||||
clean_tenant_slug = tenant_slug.strip().casefold()
|
||||
clean_tenant_name = tenant_name.strip()
|
||||
if not normalized_email or "@" not in normalized_email:
|
||||
raise FirstAdminEnrollmentConflict("Enter a valid administrator email address.")
|
||||
if len(password) < 12:
|
||||
raise FirstAdminEnrollmentConflict("The administrator password must contain at least 12 characters.")
|
||||
if not _TENANT_SLUG_RE.fullmatch(clean_tenant_slug):
|
||||
raise FirstAdminEnrollmentConflict(
|
||||
"The initial tenant slug may contain lowercase letters, numbers, and single hyphens."
|
||||
)
|
||||
if not clean_tenant_name:
|
||||
raise FirstAdminEnrollmentConflict("Enter a name for the initial tenant.")
|
||||
|
||||
request_sha256 = _request_sha256(
|
||||
email=normalized_email,
|
||||
display_name=clean_display_name,
|
||||
tenant_slug=clean_tenant_slug,
|
||||
tenant_name=clean_tenant_name,
|
||||
)
|
||||
supplied_sha256 = _secret_sha256(secret)
|
||||
enrollment = session.execute(
|
||||
select(FirstAdminEnrollment)
|
||||
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if enrollment is None:
|
||||
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
|
||||
|
||||
if enrollment.state == FirstAdminEnrollmentState.CONSUMED.value:
|
||||
if (
|
||||
enrollment.token_sha256
|
||||
and hmac.compare_digest(enrollment.token_sha256, supplied_sha256)
|
||||
and enrollment.consumed_request_sha256 == request_sha256
|
||||
and enrollment.consumed_account_id
|
||||
and enrollment.consumed_email
|
||||
):
|
||||
return FirstAdminEnrollmentResult(
|
||||
administrator=FirstSystemAdministratorRef(
|
||||
account_id=enrollment.consumed_account_id,
|
||||
email=enrollment.consumed_email,
|
||||
display_name=enrollment.consumed_display_name,
|
||||
membership_id=enrollment.consumed_membership_id,
|
||||
tenant_id=enrollment.consumed_tenant_id,
|
||||
),
|
||||
replayed=True,
|
||||
)
|
||||
raise FirstAdminEnrollmentCredentialError("The first-admin credential has already been used.")
|
||||
|
||||
if enrollment.state != FirstAdminEnrollmentState.ACTIVE.value or not enrollment.token_sha256:
|
||||
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
|
||||
if not _is_future(enrollment.expires_at, current_time):
|
||||
raise FirstAdminEnrollmentCredentialError(
|
||||
"The first-admin credential has expired. A local operator must issue a replacement."
|
||||
)
|
||||
if not hmac.compare_digest(enrollment.token_sha256, supplied_sha256):
|
||||
raise FirstAdminEnrollmentCredentialError("The first-admin credential is invalid.")
|
||||
if provisioner.has_durable_system_administrator(session):
|
||||
raise FirstAdminEnrollmentUnavailable(
|
||||
"A durable system administrator already exists. Bootstrap enrollment is disabled."
|
||||
)
|
||||
|
||||
tenant = session.execute(
|
||||
select(Tenant).where(Tenant.slug == clean_tenant_slug).with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if tenant is None:
|
||||
tenant = Tenant(
|
||||
slug=clean_tenant_slug,
|
||||
name=clean_tenant_name,
|
||||
default_locale="en",
|
||||
settings={},
|
||||
is_active=True,
|
||||
)
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
elif not tenant.is_active:
|
||||
raise FirstAdminEnrollmentConflict("The selected initial tenant is inactive.")
|
||||
|
||||
try:
|
||||
administrator = provisioner.create_first_system_administrator(
|
||||
session,
|
||||
tenant=tenant,
|
||||
email=normalized_email,
|
||||
display_name=clean_display_name,
|
||||
password=password,
|
||||
)
|
||||
except FirstAdminProvisioningError as exc:
|
||||
raise FirstAdminEnrollmentConflict(str(exc)) from exc
|
||||
enrollment.state = FirstAdminEnrollmentState.CONSUMED.value
|
||||
enrollment.consumed_at = current_time
|
||||
enrollment.consumed_account_id = administrator.account_id
|
||||
enrollment.consumed_membership_id = administrator.membership_id
|
||||
enrollment.consumed_tenant_id = administrator.tenant_id
|
||||
enrollment.consumed_email = administrator.email
|
||||
enrollment.consumed_display_name = administrator.display_name
|
||||
enrollment.consumed_request_sha256 = request_sha256
|
||||
session.add(enrollment)
|
||||
_append_event(
|
||||
session,
|
||||
enrollment,
|
||||
event_type="administrator_created",
|
||||
generation=enrollment.generation,
|
||||
created_at=current_time,
|
||||
evidence={
|
||||
"account_id": administrator.account_id,
|
||||
"membership_id": administrator.membership_id,
|
||||
"tenant_id": administrator.tenant_id,
|
||||
"email_sha256": hashlib.sha256(normalized_email.encode("utf-8")).hexdigest(),
|
||||
},
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=None,
|
||||
scope="system",
|
||||
action="access.first_admin_enrollment.completed",
|
||||
object_type="access_account",
|
||||
object_id=administrator.account_id,
|
||||
details={
|
||||
"generation": enrollment.generation,
|
||||
"membership_id": administrator.membership_id,
|
||||
"tenant_id": administrator.tenant_id,
|
||||
"credential_invalidated": True,
|
||||
},
|
||||
)
|
||||
return FirstAdminEnrollmentResult(administrator=administrator, replayed=False)
|
||||
|
||||
|
||||
def _locked_enrollment(session: Session, installation_id: str) -> FirstAdminEnrollment:
|
||||
enrollment = session.execute(
|
||||
select(FirstAdminEnrollment)
|
||||
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if enrollment is not None:
|
||||
return enrollment
|
||||
enrollment = FirstAdminEnrollment(installation_id=installation_id)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(enrollment)
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
enrollment = session.execute(
|
||||
select(FirstAdminEnrollment)
|
||||
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||
.with_for_update()
|
||||
).scalar_one()
|
||||
return enrollment
|
||||
|
||||
|
||||
def _append_event(
|
||||
session: Session,
|
||||
enrollment: FirstAdminEnrollment,
|
||||
*,
|
||||
event_type: str,
|
||||
generation: int,
|
||||
created_at: datetime,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
sequence = enrollment.event_count + 1
|
||||
payload = {
|
||||
"installation_id": enrollment.installation_id,
|
||||
"sequence": sequence,
|
||||
"event_type": event_type,
|
||||
"generation": generation,
|
||||
"created_at": created_at.isoformat(),
|
||||
"evidence": evidence,
|
||||
"previous_sha256": enrollment.evidence_head_sha256,
|
||||
}
|
||||
event_sha256 = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
session.add(
|
||||
FirstAdminEnrollmentEvent(
|
||||
installation_id=enrollment.installation_id,
|
||||
sequence=sequence,
|
||||
event_type=event_type,
|
||||
generation=generation,
|
||||
evidence=evidence,
|
||||
previous_sha256=enrollment.evidence_head_sha256,
|
||||
event_sha256=event_sha256,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
enrollment.event_count = sequence
|
||||
enrollment.evidence_head_sha256 = event_sha256
|
||||
session.add(enrollment)
|
||||
|
||||
|
||||
def _request_sha256(
|
||||
*,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
tenant_slug: str,
|
||||
tenant_name: str,
|
||||
) -> str:
|
||||
payload = {
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
"tenant_slug": tenant_slug,
|
||||
"tenant_name": tenant_name,
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _secret_sha256(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _utc(value: datetime | None) -> datetime:
|
||||
candidate = value or datetime.now(timezone.utc)
|
||||
if candidate.tzinfo is None:
|
||||
return candidate.replace(tzinfo=timezone.utc)
|
||||
return candidate.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _is_future(value: datetime | None, now: datetime) -> bool:
|
||||
return value is not None and _utc(value) > now
|
||||
|
||||
|
||||
def _bounded_reason(value: str) -> str:
|
||||
clean = value.strip()
|
||||
if not clean:
|
||||
raise ValueError("A local operator reason is required.")
|
||||
return clean[:500]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FirstAdminEnrollment",
|
||||
"FirstAdminEnrollmentConflict",
|
||||
"FirstAdminEnrollmentCredentialError",
|
||||
"FirstAdminEnrollmentError",
|
||||
"FirstAdminEnrollmentEvent",
|
||||
"FirstAdminEnrollmentResult",
|
||||
"FirstAdminEnrollmentState",
|
||||
"FirstAdminEnrollmentStatus",
|
||||
"FirstAdminEnrollmentUnavailable",
|
||||
"IssuedFirstAdminCredential",
|
||||
"consume_first_admin_credential",
|
||||
"first_admin_enrollment_status",
|
||||
"issue_first_admin_credential",
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
@@ -10,9 +10,12 @@ IDM_MODULE_ID = "idm"
|
||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
|
||||
CAPABILITY_IDM_RELATIONSHIPS = "idm.relationships"
|
||||
|
||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
TypedGroupStatus = Literal["active", "inactive"]
|
||||
IdentityRelationshipStatus = Literal["active", "revoked"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -44,6 +47,78 @@ class OrganizationFunctionIncumbencyRef:
|
||||
return not self.assignments
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TypedGroupRef:
|
||||
"""Provider-neutral IDM group fact scoped to one tenant."""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
key: str
|
||||
name: str
|
||||
group_type: str
|
||||
description: str | None = None
|
||||
status: TypedGroupStatus = "active"
|
||||
source_provider: str = "local"
|
||||
source_resource_type: str | None = None
|
||||
source_resource_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
properties: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
revision: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityRelationshipRef:
|
||||
"""An effective-dated relationship from an identity to a typed target."""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
relationship_kind: str
|
||||
subject_identity_id: str
|
||||
target_group_id: str | None = None
|
||||
related_identity_id: str | None = None
|
||||
role: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: IdentityRelationshipStatus = "active"
|
||||
revoked_at: datetime | None = None
|
||||
revoked_by: str | None = None
|
||||
revocation_reason: str | None = None
|
||||
source_provider: str = "local"
|
||||
source_resource_type: str | None = None
|
||||
source_resource_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
properties: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
revision: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityRelationshipDecisionRef:
|
||||
relationship: IdentityRelationshipRef
|
||||
included: bool
|
||||
code: str
|
||||
explanation: str
|
||||
identity_status: IdmStatus | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TypedGroupMembershipResolutionRef:
|
||||
group: TypedGroupRef
|
||||
effective_at: datetime
|
||||
decisions: tuple[IdentityRelationshipDecisionRef, ...] = ()
|
||||
|
||||
@property
|
||||
def identity_ids(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
item.relationship.subject_identity_id
|
||||
for item in self.decisions
|
||||
if item.included
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmDirectory(Protocol):
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
@@ -109,6 +184,79 @@ class IdmFunctionAssignmentDirectory(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmRelationshipDirectory(Protocol):
|
||||
"""Tenant-safe forward/reverse lookup for typed IDM relationships."""
|
||||
|
||||
def get_typed_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> TypedGroupRef | None:
|
||||
...
|
||||
|
||||
def list_typed_groups(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
query: str | None = None,
|
||||
group_types: Sequence[str] = (),
|
||||
include_inactive: bool = False,
|
||||
limit: int = 100,
|
||||
) -> Sequence[TypedGroupRef]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Sequence[IdentityRelationshipRef]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Sequence[IdentityRelationshipRef]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_groups(
|
||||
self,
|
||||
group_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||
...
|
||||
|
||||
def resolve_typed_group_memberships(
|
||||
self,
|
||||
group_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = ("member",),
|
||||
) -> Mapping[str, TypedGroupMembershipResolutionRef]:
|
||||
...
|
||||
|
||||
@runtime_checkable
|
||||
class IdmAssignmentLifecycle(Protocol):
|
||||
"""Worker boundary for time-driven function-assignment transitions."""
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementResolutionError,
|
||||
TenantModuleUnavailable,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
begin_runtime_graph_recovery,
|
||||
canonical_sha256,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.core.workflows import (
|
||||
workflow_definition_contribution_provider,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||
|
||||
|
||||
@@ -26,12 +40,75 @@ class ModuleLifecycleResult:
|
||||
|
||||
|
||||
def require_module_active(module_id: str):
|
||||
def dependency(request: Request) -> None:
|
||||
async def dependency(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> AsyncIterator[None]:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
|
||||
return
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
||||
|
||||
tenant_id: str | None = None
|
||||
if not authorization and not x_api_key and not request.cookies:
|
||||
public_resolver = registry.public_tenant_resolver(module_id)
|
||||
if public_resolver is not None:
|
||||
tenant_id = public_resolver(request, session)
|
||||
if tenant_id is None:
|
||||
yield
|
||||
return
|
||||
else:
|
||||
try:
|
||||
principal = get_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in {
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
}:
|
||||
yield
|
||||
return
|
||||
raise
|
||||
if (
|
||||
not isinstance(principal, ApiPrincipal)
|
||||
or principal.principal.tenant_id is None
|
||||
):
|
||||
yield
|
||||
return
|
||||
tenant_id = principal.principal.tenant_id
|
||||
|
||||
resolver = registry.tenant_entitlement_resolver()
|
||||
try:
|
||||
admission = resolver.require(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state="interactive",
|
||||
)
|
||||
except TenantModuleUnavailable as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Module is unavailable in the active tenant: {module_id}",
|
||||
) from exc
|
||||
except (ModuleEntitlementResolutionError, RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Tenant module entitlement could not be resolved.",
|
||||
) from exc
|
||||
request.state.govoplan_module_admission = admission
|
||||
with tenant_execution_scope(
|
||||
resolver,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state="interactive",
|
||||
):
|
||||
yield
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@@ -99,13 +176,40 @@ class ModuleLifecycleManager:
|
||||
next_set = set(plan.enabled_modules)
|
||||
activated = tuple(module_id for module_id in plan.enabled_modules if module_id not in previous_set)
|
||||
deactivated = tuple(module_id for module_id in previous if module_id not in next_set)
|
||||
graph_changes = bool(activated or deactivated)
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if graph_changes or migrate:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as recovery_session:
|
||||
recovery = begin_runtime_graph_recovery(
|
||||
recovery_session,
|
||||
previous_modules=previous,
|
||||
requested_modules=plan.enabled_modules,
|
||||
migrate=migrate,
|
||||
)
|
||||
|
||||
old_manifests = {
|
||||
manifest.id: manifest for manifest in self.registry.manifests()
|
||||
}
|
||||
try:
|
||||
if recovery is not None:
|
||||
recovery.checkpoint(
|
||||
kind="runtime-graph-effect-started",
|
||||
summary="Runtime module graph entered its mutation boundary",
|
||||
evidence={
|
||||
"activated_sha256": canonical_sha256(activated),
|
||||
"deactivated_sha256": canonical_sha256(deactivated),
|
||||
"migrate": migrate,
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
|
||||
if migrate:
|
||||
self._migrate(plan.enabled_modules)
|
||||
|
||||
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
|
||||
|
||||
old_manifests = {manifest.id: manifest for manifest in self.registry.manifests()}
|
||||
for module_id in deactivated:
|
||||
hook = old_manifests[module_id].on_deactivate
|
||||
if hook is not None:
|
||||
@@ -119,11 +223,64 @@ class ModuleLifecycleManager:
|
||||
if hook is not None:
|
||||
hook(self.context)
|
||||
|
||||
self.reconcile_workflow_definitions()
|
||||
reconciliation = self.reconcile_workflow_definitions()
|
||||
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
|
||||
if recovery is not None:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as recovery_session:
|
||||
recovery.succeed(
|
||||
recovery_session,
|
||||
evidence={
|
||||
"active_graph_sha256": canonical_sha256(
|
||||
self.active_module_ids()
|
||||
),
|
||||
"mounted_graph_sha256": canonical_sha256(
|
||||
self.mounted_module_ids()
|
||||
),
|
||||
"workflow_reconciliation_sha256": canonical_sha256(
|
||||
reconciliation
|
||||
),
|
||||
},
|
||||
commit_projection=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.registry.replace(old_manifests.values())
|
||||
self.configure_runtime()
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
if recovery is not None:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
recovery.unresolved(
|
||||
summary="Runtime graph mutation did not reach verified completion",
|
||||
evidence={
|
||||
"error_type": type(exc).__name__,
|
||||
"previous_graph_sha256": canonical_sha256(previous),
|
||||
"registry_restored": True,
|
||||
"migrate": migrate,
|
||||
},
|
||||
outcome_unknown=migrate,
|
||||
)
|
||||
if not migrate:
|
||||
with get_database().session() as recovery_session:
|
||||
recovery.recovered(
|
||||
recovery_session,
|
||||
evidence={
|
||||
"active_graph_sha256": canonical_sha256(
|
||||
self.active_module_ids()
|
||||
),
|
||||
"previous_graph_restored": (
|
||||
self.active_module_ids() == previous
|
||||
),
|
||||
},
|
||||
summary="Previous runtime module graph was restored",
|
||||
)
|
||||
raise
|
||||
|
||||
return ModuleLifecycleResult(
|
||||
enabled_modules=plan.enabled_modules,
|
||||
activated_modules=activated,
|
||||
|
||||
@@ -56,6 +56,7 @@ class MailDeliveryOutboxProvider(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 250,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
from time import monotonic
|
||||
from typing import Any, Literal
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
|
||||
|
||||
MODULE_ENTITLEMENTS_KEY = "module_entitlements"
|
||||
MODULE_ENTITLEMENT_SCHEMA_VERSION = 1
|
||||
TENANT_PROTECTED_MODULES = ("access", "admin")
|
||||
|
||||
|
||||
class ModuleEntitlementError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementConflict(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementResolutionError(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class TenantModuleUnavailable(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
class TenantModuleOperatorActionRequired(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
TenantWorkState = Literal["interactive", "new", "accepted"]
|
||||
TenantAdmissionDisposition = Literal[
|
||||
"allowed",
|
||||
"rejected",
|
||||
"operator_action_required",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleItem:
|
||||
id: str
|
||||
name: str
|
||||
dependencies: tuple[str, ...]
|
||||
runtime_active: bool
|
||||
availability: str
|
||||
selected: bool
|
||||
effective: bool
|
||||
forced: bool
|
||||
derived_dependency: bool
|
||||
tenant_can_toggle: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleEntitlementState:
|
||||
revision: int
|
||||
configured: bool
|
||||
available_modules: tuple[str, ...]
|
||||
forced_modules: tuple[str, ...]
|
||||
selected_modules: tuple[str, ...]
|
||||
effective_modules: tuple[str, ...]
|
||||
derived_dependencies: tuple[str, ...]
|
||||
modules: tuple[TenantModuleItem, ...]
|
||||
diagnostics: tuple[dict[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleAdmission:
|
||||
tenant_id: str
|
||||
module_id: str
|
||||
revision: int
|
||||
work_state: TenantWorkState
|
||||
allowed: bool
|
||||
disposition: TenantAdmissionDisposition
|
||||
reason: str
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"module_id": self.module_id,
|
||||
"entitlement_revision": self.revision,
|
||||
"work_state": self.work_state,
|
||||
"allowed": self.allowed,
|
||||
"disposition": self.disposition,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CachedTenantEntitlement:
|
||||
expires_at: float
|
||||
tenant_active: bool
|
||||
state: TenantModuleEntitlementState
|
||||
|
||||
|
||||
class TenantModuleEntitlementResolver:
|
||||
"""Resolve tenant-effective modules with bounded process-local caching.
|
||||
|
||||
Cache entries are explicitly invalidated by local mutations and expire
|
||||
quickly so changes made on another application node become authoritative
|
||||
without requiring a database lookup for every capability call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: object,
|
||||
*,
|
||||
ttl_seconds: float = 5.0,
|
||||
max_entries: int = 2048,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._ttl_seconds = max(0.0, min(float(ttl_seconds), 300.0))
|
||||
self._max_entries = max(1, int(max_entries))
|
||||
self._cache: OrderedDict[str, _CachedTenantEntitlement] = OrderedDict()
|
||||
self._lock = RLock()
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantModuleEntitlementState:
|
||||
normalized_tenant_id = str(tenant_id or "").strip()
|
||||
if not normalized_tenant_id:
|
||||
raise ModuleEntitlementResolutionError("Tenant id is required")
|
||||
|
||||
cached = self._cached(normalized_tenant_id)
|
||||
if cached is not None:
|
||||
if not cached.tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return cached.state
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
getter = getattr(session, "get", None)
|
||||
if not callable(getter):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenant = getter(Tenant, normalized_tenant_id)
|
||||
if tenant is None:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is unavailable: {normalized_tenant_id}"
|
||||
)
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
tenant_active = bool(getattr(tenant, "is_active", False))
|
||||
self._store(normalized_tenant_id, tenant_active=tenant_active, state=state)
|
||||
if not tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return state
|
||||
|
||||
def admission(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
if work_state not in {"interactive", "new", "accepted"}:
|
||||
raise ModuleEntitlementError(f"Unsupported tenant work state: {work_state}")
|
||||
normalized_module_id = str(module_id or "").strip()
|
||||
if not normalized_module_id:
|
||||
raise ModuleEntitlementError("Module id is required")
|
||||
state = self.resolve(session, tenant_id)
|
||||
allowed = normalized_module_id in state.effective_modules
|
||||
if allowed:
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=True,
|
||||
disposition="allowed",
|
||||
reason="The module is effective for this tenant.",
|
||||
)
|
||||
accepted = work_state == "accepted"
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=False,
|
||||
disposition=(
|
||||
"operator_action_required" if accepted else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"Accepted durable work was preserved because the owning module "
|
||||
"is no longer effective for this tenant; an operator must resume "
|
||||
"the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
|
||||
def require(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
admission = self.admission(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=work_state,
|
||||
)
|
||||
if admission.allowed:
|
||||
return admission
|
||||
if admission.disposition == "operator_action_required":
|
||||
raise TenantModuleOperatorActionRequired(admission)
|
||||
raise TenantModuleUnavailable(admission)
|
||||
|
||||
def effective_tenant_ids(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return active tenants that may admit new work for one module."""
|
||||
|
||||
return tuple(
|
||||
admission.tenant_id
|
||||
for admission in self.active_tenant_admissions(
|
||||
session,
|
||||
module_id=module_id,
|
||||
work_state="new",
|
||||
)
|
||||
if admission.allowed
|
||||
)
|
||||
|
||||
def active_tenant_admissions(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "new",
|
||||
) -> tuple[TenantModuleAdmission, ...]:
|
||||
"""Resolve one admission per active tenant with a single DB query."""
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
query = getattr(session, "query", None)
|
||||
if not callable(query):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenants = (
|
||||
query(Tenant)
|
||||
.filter(Tenant.is_active.is_(True))
|
||||
.order_by(Tenant.id.asc())
|
||||
.all()
|
||||
)
|
||||
admissions: list[TenantModuleAdmission] = []
|
||||
for tenant in tenants:
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
self._store(tenant.id, tenant_active=True, state=state)
|
||||
allowed = module_id in state.effective_modules
|
||||
accepted = work_state == "accepted"
|
||||
admissions.append(
|
||||
TenantModuleAdmission(
|
||||
tenant_id=tenant.id,
|
||||
module_id=module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=allowed,
|
||||
disposition=(
|
||||
"allowed"
|
||||
if allowed
|
||||
else "operator_action_required"
|
||||
if accepted
|
||||
else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"The module is effective for this tenant."
|
||||
if allowed
|
||||
else "Accepted durable work was preserved because the owning module is no longer effective for this tenant; an operator must resume the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(admissions)
|
||||
|
||||
def invalidate(self, tenant_id: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if tenant_id is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(str(tenant_id), None)
|
||||
|
||||
def _state_from_settings(
|
||||
self,
|
||||
settings: Mapping[str, object] | None,
|
||||
) -> TenantModuleEntitlementState:
|
||||
manifests_method = getattr(self._registry, "manifests", None)
|
||||
if not callable(manifests_method):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolver has no platform registry"
|
||||
)
|
||||
manifests = {manifest.id: manifest for manifest in manifests_method()}
|
||||
return tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=manifests,
|
||||
)
|
||||
|
||||
def _cached(self, tenant_id: str) -> _CachedTenantEntitlement | None:
|
||||
now = monotonic()
|
||||
with self._lock:
|
||||
cached = self._cache.get(tenant_id)
|
||||
if cached is None:
|
||||
return None
|
||||
if cached.expires_at <= now:
|
||||
self._cache.pop(tenant_id, None)
|
||||
return None
|
||||
self._cache.move_to_end(tenant_id)
|
||||
return cached
|
||||
|
||||
def _store(
|
||||
self,
|
||||
tenant_id: str,
|
||||
*,
|
||||
tenant_active: bool,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> None:
|
||||
if self._ttl_seconds <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
self._cache[str(tenant_id)] = _CachedTenantEntitlement(
|
||||
expires_at=monotonic() + self._ttl_seconds,
|
||||
tenant_active=tenant_active,
|
||||
state=state,
|
||||
)
|
||||
self._cache.move_to_end(str(tenant_id))
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantExecutionContext:
|
||||
resolver: TenantModuleEntitlementResolver
|
||||
session: object
|
||||
tenant_id: str
|
||||
work_state: TenantWorkState
|
||||
|
||||
def require_module(self, module_id: str) -> TenantModuleAdmission:
|
||||
return self.resolver.require(
|
||||
self.session,
|
||||
tenant_id=self.tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=self.work_state,
|
||||
)
|
||||
|
||||
|
||||
_TENANT_EXECUTION_CONTEXT: ContextVar[TenantExecutionContext | None] = ContextVar(
|
||||
"govoplan_tenant_execution_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def current_tenant_execution_context() -> TenantExecutionContext | None:
|
||||
return _TENANT_EXECUTION_CONTEXT.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tenant_execution_scope(
|
||||
resolver: TenantModuleEntitlementResolver,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> Iterator[TenantExecutionContext]:
|
||||
context = TenantExecutionContext(
|
||||
resolver=resolver,
|
||||
session=session,
|
||||
tenant_id=str(tenant_id),
|
||||
work_state=work_state,
|
||||
)
|
||||
token = _TENANT_EXECUTION_CONTEXT.set(context)
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
_TENANT_EXECUTION_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def tenant_module_entitlement_state(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> TenantModuleEntitlementState:
|
||||
module_ids = tuple(sorted(manifests))
|
||||
known = set(module_ids)
|
||||
runtime_active = (
|
||||
known
|
||||
if runtime_active_modules is None
|
||||
else known.intersection(_normalized_ids(runtime_active_modules))
|
||||
)
|
||||
protected = known.intersection(_normalized_ids(protected_modules))
|
||||
raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY)
|
||||
configured = isinstance(raw_document, Mapping)
|
||||
diagnostics: list[dict[str, str]] = []
|
||||
|
||||
if not configured:
|
||||
revision = 0
|
||||
requested_available = set(known)
|
||||
requested_forced = set(protected)
|
||||
requested_selected = set(known)
|
||||
else:
|
||||
document = raw_document
|
||||
revision = _revision(document.get("revision"), diagnostics)
|
||||
system_policy = document.get("system_policy")
|
||||
tenant_selection = document.get("tenant_selection")
|
||||
if not isinstance(system_policy, Mapping) or not isinstance(
|
||||
tenant_selection, Mapping
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_document",
|
||||
"The tenant module entitlement document is malformed and was restricted to protected modules.",
|
||||
)
|
||||
)
|
||||
requested_available = set(protected)
|
||||
requested_forced = set(protected)
|
||||
requested_selected = set()
|
||||
else:
|
||||
requested_available = _configured_ids(
|
||||
system_policy.get("available_modules"),
|
||||
field="system_policy.available_modules",
|
||||
known=known,
|
||||
fallback=protected,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_forced = _configured_ids(
|
||||
system_policy.get("forced_modules"),
|
||||
field="system_policy.forced_modules",
|
||||
known=known,
|
||||
fallback=protected,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_selected = _configured_ids(
|
||||
tenant_selection.get("enabled_modules"),
|
||||
field="tenant_selection.enabled_modules",
|
||||
known=known,
|
||||
fallback=(),
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
available, missing_available = _dependency_closure(
|
||||
requested_available | requested_forced | protected,
|
||||
manifests,
|
||||
)
|
||||
forced, missing_forced = _dependency_closure(
|
||||
requested_forced | protected,
|
||||
manifests,
|
||||
)
|
||||
selected = requested_selected.intersection(available)
|
||||
effective_candidates, missing_selected = _dependency_closure(
|
||||
selected | forced,
|
||||
manifests,
|
||||
)
|
||||
effective_candidates.intersection_update(available)
|
||||
effective = effective_candidates.intersection(runtime_active)
|
||||
derived = effective_candidates - selected - forced
|
||||
|
||||
for module_id in sorted(
|
||||
missing_available | missing_forced | missing_selected
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.missing_dependency",
|
||||
f"A selected module requires unavailable dependency {module_id}.",
|
||||
)
|
||||
)
|
||||
|
||||
items: list[TenantModuleItem] = []
|
||||
for module_id in module_ids:
|
||||
manifest = manifests[module_id]
|
||||
is_available = module_id in available
|
||||
is_forced = module_id in forced
|
||||
is_selected = module_id in selected
|
||||
is_derived = module_id in derived
|
||||
is_runtime_active = module_id in runtime_active
|
||||
is_effective = module_id in effective
|
||||
reason: str | None = None
|
||||
if not is_available:
|
||||
reason = "Unavailable by system policy."
|
||||
elif is_forced:
|
||||
reason = "Required by system policy or a protected platform dependency."
|
||||
elif is_derived:
|
||||
reason = "Required by another selected module."
|
||||
elif not is_runtime_active and (is_selected or is_forced):
|
||||
reason = "Selected for this tenant, but the module is not active in the deployment."
|
||||
items.append(
|
||||
TenantModuleItem(
|
||||
id=module_id,
|
||||
name=manifest.name,
|
||||
dependencies=tuple(manifest.dependencies),
|
||||
runtime_active=is_runtime_active,
|
||||
availability=(
|
||||
"forced" if is_forced else "available" if is_available else "unavailable"
|
||||
),
|
||||
selected=is_selected,
|
||||
effective=is_effective,
|
||||
forced=is_forced,
|
||||
derived_dependency=is_derived,
|
||||
tenant_can_toggle=is_available and not is_forced and not is_derived,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
return TenantModuleEntitlementState(
|
||||
revision=revision,
|
||||
configured=configured,
|
||||
available_modules=tuple(sorted(available)),
|
||||
forced_modules=tuple(sorted(forced)),
|
||||
selected_modules=tuple(sorted(selected)),
|
||||
effective_modules=tuple(sorted(effective)),
|
||||
derived_dependencies=tuple(sorted(derived)),
|
||||
modules=tuple(items),
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def update_system_tenant_module_policy(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
available_modules: Iterable[str],
|
||||
forced_modules: Iterable[str],
|
||||
enabled_modules: Iterable[str],
|
||||
expected_revision: int | None,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||
current = tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
_check_revision(current.revision, expected_revision)
|
||||
known = set(manifests)
|
||||
available_requested = _validated_requested_ids(
|
||||
available_modules, known=known, field="available_modules"
|
||||
)
|
||||
forced_requested = _validated_requested_ids(
|
||||
forced_modules, known=known, field="forced_modules"
|
||||
)
|
||||
enabled_requested = _validated_requested_ids(
|
||||
enabled_modules, known=known, field="enabled_modules"
|
||||
)
|
||||
protected = known.intersection(_normalized_ids(protected_modules))
|
||||
available, missing = _dependency_closure(
|
||||
available_requested | forced_requested | protected,
|
||||
manifests,
|
||||
)
|
||||
forced, forced_missing = _dependency_closure(
|
||||
forced_requested | protected,
|
||||
manifests,
|
||||
)
|
||||
if missing or forced_missing:
|
||||
missing_text = ", ".join(sorted(missing | forced_missing))
|
||||
raise ModuleEntitlementError(
|
||||
f"Module policy references dependencies that are not installed: {missing_text}"
|
||||
)
|
||||
unavailable_enabled = enabled_requested - available
|
||||
if unavailable_enabled:
|
||||
raise ModuleEntitlementError(
|
||||
"Tenant selection contains modules unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable_enabled))
|
||||
)
|
||||
_validate_enabled_dependencies(enabled_requested | forced, available, manifests)
|
||||
updated = _write_document(
|
||||
settings,
|
||||
revision=current.revision + 1,
|
||||
available_modules=available,
|
||||
forced_modules=forced,
|
||||
enabled_modules=enabled_requested,
|
||||
)
|
||||
return updated, tenant_module_entitlement_state(
|
||||
updated,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
|
||||
|
||||
def update_tenant_module_selection(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
enabled_modules: Iterable[str],
|
||||
expected_revision: int | None,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||
current = tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
_check_revision(current.revision, expected_revision)
|
||||
enabled = _validated_requested_ids(
|
||||
enabled_modules,
|
||||
known=set(manifests),
|
||||
field="enabled_modules",
|
||||
)
|
||||
unavailable = enabled - set(current.available_modules)
|
||||
if unavailable:
|
||||
raise ModuleEntitlementError(
|
||||
"Tenant selection contains modules unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable))
|
||||
)
|
||||
_validate_enabled_dependencies(
|
||||
enabled | set(current.forced_modules),
|
||||
set(current.available_modules),
|
||||
manifests,
|
||||
)
|
||||
updated = _write_document(
|
||||
settings,
|
||||
revision=current.revision + 1,
|
||||
available_modules=current.available_modules,
|
||||
forced_modules=current.forced_modules,
|
||||
enabled_modules=enabled,
|
||||
)
|
||||
return updated, tenant_module_entitlement_state(
|
||||
updated,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
|
||||
|
||||
def module_entitlement_payload(
|
||||
tenant_id: str,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"revision": state.revision,
|
||||
"configured": state.configured,
|
||||
"available_modules": list(state.available_modules),
|
||||
"forced_modules": list(state.forced_modules),
|
||||
"selected_modules": list(state.selected_modules),
|
||||
"effective_modules": list(state.effective_modules),
|
||||
"derived_dependencies": list(state.derived_dependencies),
|
||||
"modules": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"dependencies": list(item.dependencies),
|
||||
"runtime_active": item.runtime_active,
|
||||
"availability": item.availability,
|
||||
"selected": item.selected,
|
||||
"effective": item.effective,
|
||||
"forced": item.forced,
|
||||
"derived_dependency": item.derived_dependency,
|
||||
"tenant_can_toggle": item.tenant_can_toggle,
|
||||
"reason": item.reason,
|
||||
}
|
||||
for item in state.modules
|
||||
],
|
||||
"diagnostics": [dict(item) for item in state.diagnostics],
|
||||
}
|
||||
|
||||
|
||||
def _write_document(
|
||||
settings: Mapping[str, object] | None,
|
||||
*,
|
||||
revision: int,
|
||||
available_modules: Iterable[str],
|
||||
forced_modules: Iterable[str],
|
||||
enabled_modules: Iterable[str],
|
||||
) -> dict[str, object]:
|
||||
updated = dict(settings or {})
|
||||
updated[MODULE_ENTITLEMENTS_KEY] = {
|
||||
"schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION,
|
||||
"revision": revision,
|
||||
"system_policy": {
|
||||
"available_modules": sorted(set(available_modules)),
|
||||
"forced_modules": sorted(set(forced_modules)),
|
||||
},
|
||||
"tenant_selection": {
|
||||
"enabled_modules": sorted(set(enabled_modules)),
|
||||
},
|
||||
}
|
||||
return updated
|
||||
|
||||
|
||||
def _validate_enabled_dependencies(
|
||||
enabled: set[str],
|
||||
available: set[str],
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
) -> None:
|
||||
closure, missing = _dependency_closure(enabled, manifests)
|
||||
if missing:
|
||||
raise ModuleEntitlementError(
|
||||
"Selected modules require dependencies that are not installed: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
unavailable = closure - available
|
||||
if unavailable:
|
||||
raise ModuleEntitlementError(
|
||||
"Selected modules require dependencies unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable))
|
||||
)
|
||||
|
||||
|
||||
def _dependency_closure(
|
||||
requested: Iterable[str],
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
) -> tuple[set[str], set[str]]:
|
||||
closure: set[str] = set()
|
||||
missing: set[str] = set()
|
||||
pending = list(_normalized_ids(requested))
|
||||
while pending:
|
||||
module_id = pending.pop()
|
||||
if module_id in closure:
|
||||
continue
|
||||
manifest = manifests.get(module_id)
|
||||
if manifest is None:
|
||||
missing.add(module_id)
|
||||
continue
|
||||
closure.add(module_id)
|
||||
pending.extend(manifest.dependencies)
|
||||
return closure, missing
|
||||
|
||||
|
||||
def _configured_ids(
|
||||
value: object,
|
||||
*,
|
||||
field: str,
|
||||
known: set[str],
|
||||
fallback: Iterable[str],
|
||||
diagnostics: list[dict[str, str]],
|
||||
) -> set[str]:
|
||||
if not isinstance(value, list | tuple):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_field",
|
||||
f"{field} is malformed and was evaluated with a restrictive fallback.",
|
||||
)
|
||||
)
|
||||
return set(fallback)
|
||||
values = _normalized_ids(value)
|
||||
unknown = values - known
|
||||
if unknown:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.unknown_module",
|
||||
f"{field} references unknown modules: {', '.join(sorted(unknown))}.",
|
||||
)
|
||||
)
|
||||
return values.intersection(known)
|
||||
|
||||
|
||||
def _validated_requested_ids(
|
||||
values: Iterable[str],
|
||||
*,
|
||||
known: set[str],
|
||||
field: str,
|
||||
) -> set[str]:
|
||||
normalized = _normalized_ids(values)
|
||||
unknown = normalized - known
|
||||
if unknown:
|
||||
raise ModuleEntitlementError(
|
||||
f"{field} contains unknown modules: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_ids(values: Iterable[object]) -> set[str]:
|
||||
return {
|
||||
clean
|
||||
for value in values
|
||||
if (clean := str(value).strip())
|
||||
}
|
||||
|
||||
|
||||
def _revision(value: object, diagnostics: list[dict[str, str]]) -> int:
|
||||
if isinstance(value, int) and value >= 0:
|
||||
return value
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_revision",
|
||||
"The module entitlement revision is invalid; concurrent updates will require a reload.",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _check_revision(current: int, expected: int | None) -> None:
|
||||
if expected is not None and expected != current:
|
||||
raise ModuleEntitlementConflict(
|
||||
f"Module entitlement revision changed from {expected} to {current}; reload before saving."
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic(code: str, message: str) -> dict[str, str]:
|
||||
return {"code": code, "message": message, "severity": "warning"}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODULE_ENTITLEMENTS_KEY",
|
||||
"MODULE_ENTITLEMENT_SCHEMA_VERSION",
|
||||
"TENANT_PROTECTED_MODULES",
|
||||
"ModuleEntitlementConflict",
|
||||
"ModuleEntitlementError",
|
||||
"ModuleEntitlementResolutionError",
|
||||
"TenantExecutionContext",
|
||||
"TenantModuleAdmission",
|
||||
"TenantModuleEntitlementResolver",
|
||||
"TenantModuleEntitlementState",
|
||||
"TenantModuleItem",
|
||||
"TenantModuleOperatorActionRequired",
|
||||
"TenantModuleUnavailable",
|
||||
"TenantWorkState",
|
||||
"current_tenant_execution_context",
|
||||
"module_entitlement_payload",
|
||||
"tenant_execution_scope",
|
||||
"tenant_module_entitlement_state",
|
||||
"update_system_tenant_module_policy",
|
||||
"update_tenant_module_selection",
|
||||
]
|
||||
@@ -27,6 +27,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.events import current_event_trace
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
ModuleLifecycleRecoveryError,
|
||||
begin_module_installer_recovery,
|
||||
canonical_sha256,
|
||||
)
|
||||
from govoplan_core.core.module_management import (
|
||||
PROTECTED_MODULES,
|
||||
ModuleInstallPlan,
|
||||
@@ -268,6 +274,11 @@ class ModuleInstallerRunResult:
|
||||
return_code: int = 0
|
||||
error: str | None = None
|
||||
rollback: dict[str, object] | None = None
|
||||
recovery: ModuleLifecycleRecovery | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
compare=False,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
@@ -293,6 +304,7 @@ class _ModuleInstallRunState:
|
||||
result_commands: tuple[str, ...]
|
||||
record_redactions: tuple[str, ...]
|
||||
record: dict[str, Any]
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
|
||||
|
||||
def default_installer_runtime_dir(database_url: str | None = None, *, cwd: Path | None = None) -> Path:
|
||||
@@ -503,6 +515,7 @@ def run_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired: bool = True,
|
||||
dry_run: bool = False,
|
||||
request_context: Mapping[str, object] | None = None,
|
||||
finalize_recovery: bool = True,
|
||||
) -> ModuleInstallerRunResult:
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
|
||||
@@ -520,6 +533,7 @@ def run_module_install_plan(
|
||||
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
|
||||
|
||||
state = _prepare_module_install_run(
|
||||
session=session,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
database_url=database_url,
|
||||
@@ -550,6 +564,7 @@ def run_module_install_plan(
|
||||
|
||||
if failed_error is not None:
|
||||
return _failed_module_install_run_result(
|
||||
session=session,
|
||||
state=state,
|
||||
plan=plan,
|
||||
executed=executed,
|
||||
@@ -569,11 +584,13 @@ def run_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
executed=executed,
|
||||
state=state,
|
||||
finalize_recovery=finalize_recovery,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_module_install_run(
|
||||
*,
|
||||
session: Session,
|
||||
plan: ModuleInstallPlan,
|
||||
preflight: ModuleInstallerPreflight,
|
||||
database_url: str,
|
||||
@@ -605,13 +622,39 @@ def _prepare_module_install_run(
|
||||
verify_modules=True,
|
||||
)
|
||||
record_redactions = _installer_secret_redactions(database_url)
|
||||
record = _initial_module_install_record(
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if not dry_run:
|
||||
try:
|
||||
recovery = begin_module_installer_recovery(
|
||||
session,
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
snapshot=_snapshot_environment(
|
||||
plan=tuple(item.as_dict() for item in plan.items),
|
||||
command_count=len(commands),
|
||||
migrate_database=migrate_database,
|
||||
destructive_retirement=_destructive_retirement_requested(plan),
|
||||
snapshot_sha256=None,
|
||||
backup_reference=(
|
||||
f"module-installer:{run_id}:database-backup"
|
||||
if _destructive_retirement_requested(plan)
|
||||
else None
|
||||
),
|
||||
request_context_sha256=canonical_sha256(dict(request_context or {})),
|
||||
)
|
||||
recovery.checkpoint(
|
||||
kind="snapshot-started",
|
||||
summary="Installer environment snapshot started before package effects",
|
||||
evidence={
|
||||
"run_id": run_id,
|
||||
"database_backup_expected": bool(
|
||||
migrate_database or _destructive_retirement_requested(plan)
|
||||
),
|
||||
},
|
||||
)
|
||||
except ModuleLifecycleRecoveryError as exc:
|
||||
raise ModuleInstallerError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
snapshot = _snapshot_environment(
|
||||
run_dir,
|
||||
webui_root=webui_root,
|
||||
database_url=database_url,
|
||||
@@ -619,7 +662,32 @@ def _prepare_module_install_run(
|
||||
database_backup_command=database_backup_command,
|
||||
database_restore_command=database_restore_command,
|
||||
database_restore_check_command=database_restore_check_command,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
if recovery is not None:
|
||||
recovery.unresolved(
|
||||
summary="Installer snapshot preparation failed before package effects",
|
||||
evidence={"snapshot_error_type": type(exc).__name__},
|
||||
outcome_unknown=False,
|
||||
)
|
||||
raise
|
||||
|
||||
if recovery is not None:
|
||||
recovery.checkpoint(
|
||||
kind="snapshot-verified",
|
||||
summary="Installer environment snapshot and backup evidence were verified",
|
||||
evidence={
|
||||
"snapshot_sha256": canonical_sha256(snapshot),
|
||||
**_database_backup_recovery_evidence(snapshot),
|
||||
},
|
||||
)
|
||||
record = _initial_module_install_record(
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
snapshot=snapshot,
|
||||
build_webui=build_webui,
|
||||
migrate_database=migrate_database,
|
||||
activate_installed_modules=activate_installed_modules,
|
||||
@@ -627,6 +695,8 @@ def _prepare_module_install_run(
|
||||
dry_run=dry_run,
|
||||
request_context=request_context,
|
||||
)
|
||||
if recovery is not None:
|
||||
record["recovery"] = _module_lifecycle_recovery_record(recovery)
|
||||
record_path = run_dir / "record.json"
|
||||
_write_json(record_path, record)
|
||||
return _ModuleInstallRunState(
|
||||
@@ -637,6 +707,7 @@ def _prepare_module_install_run(
|
||||
result_commands=_command_displays(commands, redactions=record_redactions),
|
||||
record_redactions=record_redactions,
|
||||
record=record,
|
||||
recovery=recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -673,6 +744,40 @@ def _initial_module_install_record(
|
||||
return record
|
||||
|
||||
|
||||
def _module_lifecycle_recovery_record(
|
||||
recovery: ModuleLifecycleRecovery,
|
||||
*,
|
||||
status: str = "running",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"operation_id": recovery.operation_id,
|
||||
"operation_type": recovery.operation_type,
|
||||
"mode": recovery.mode.value,
|
||||
"plan_sha256": recovery.plan_sha256,
|
||||
"replayed": recovery.replayed,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _database_backup_recovery_evidence(
|
||||
snapshot: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
backup = snapshot.get("database_backup")
|
||||
if not isinstance(backup, Mapping):
|
||||
return {"database_backup_present": False}
|
||||
sha256 = str(backup.get("artifact_sha256") or "").strip()
|
||||
return {
|
||||
"database_backup_present": True,
|
||||
"database_backup_type": str(backup.get("type") or "unknown"),
|
||||
"database_backup_sha256": sha256 or "unavailable",
|
||||
"database_backup_size_bytes": int(backup.get("size_bytes") or 0),
|
||||
"database_backup_reference": (
|
||||
f"sha256:{sha256}" if sha256 else "unavailable"
|
||||
),
|
||||
"restore_check_sha256": canonical_sha256(backup.get("restore_check")),
|
||||
}
|
||||
|
||||
|
||||
def _execute_module_install_run(
|
||||
*,
|
||||
session: Session,
|
||||
@@ -685,14 +790,91 @@ def _execute_module_install_run(
|
||||
failed_error: str | None = None
|
||||
with _installer_lock(effective_runtime_dir):
|
||||
try:
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="effects-starting",
|
||||
summary="Installer acquired local and distributed execution fences",
|
||||
evidence={
|
||||
"command_count": len(state.commands),
|
||||
"destructive_retirement": _destructive_retirement_requested(plan),
|
||||
},
|
||||
)
|
||||
if _destructive_retirement_requested(plan):
|
||||
state.recovery.checkpoint(
|
||||
kind="retirement-effect-started",
|
||||
summary="Destructive module retirement entered its effect boundary",
|
||||
evidence={
|
||||
"retirement_plan_sha256": canonical_sha256(
|
||||
[
|
||||
item.as_dict()
|
||||
for item in plan.items
|
||||
if item.destroy_data
|
||||
]
|
||||
),
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
_execute_module_install_retirements(session=session, plan=plan, available=available, state=state)
|
||||
for command in state.commands:
|
||||
executed.append(_run_module_install_command(command, state=state))
|
||||
for index, command in enumerate(state.commands):
|
||||
if state.recovery is not None:
|
||||
command_record = _command_record(
|
||||
command,
|
||||
redactions=state.record_redactions,
|
||||
)
|
||||
state.recovery.checkpoint(
|
||||
kind="command-effect-started",
|
||||
summary="Installer command entered its effect boundary",
|
||||
evidence={
|
||||
"command_index": index,
|
||||
"command_source": str(command.get("source") or "unknown"),
|
||||
"command_sha256": canonical_sha256(command_record),
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
command_result = _run_module_install_command(command, state=state)
|
||||
executed.append(command_result)
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="command-result-verified",
|
||||
summary="Installer command returned a conclusive successful result",
|
||||
evidence={
|
||||
"command_index": index,
|
||||
"return_code": int(command_result["return_code"]),
|
||||
"result_sha256": canonical_sha256(command_result),
|
||||
},
|
||||
)
|
||||
state.record["commands"] = executed
|
||||
_write_json(state.record_path, state.record)
|
||||
except Exception as exc:
|
||||
failed_error = _redact_installer_text(str(exc), redactions=state.record_redactions)
|
||||
_rollback_session_after_module_install_error(session, exc)
|
||||
if state.recovery is not None:
|
||||
outcome_unknown = not isinstance(exc, ModuleInstallerError)
|
||||
try:
|
||||
state.recovery.unresolved(
|
||||
summary="Module installer effects did not reach verified completion",
|
||||
evidence={
|
||||
"error_type": type(exc).__name__,
|
||||
"completed_command_count": len(executed),
|
||||
},
|
||||
outcome_unknown=outcome_unknown,
|
||||
)
|
||||
state.record["recovery"] = _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status=(
|
||||
"outcome_unknown"
|
||||
if outcome_unknown
|
||||
else "recovery_required"
|
||||
if state.recovery.effect_started
|
||||
else "failed"
|
||||
),
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
state.record["recovery_error"] = type(recovery_exc).__name__
|
||||
failed_error = (
|
||||
f"{failed_error}; recovery ledger transition failed: "
|
||||
f"{type(recovery_exc).__name__}"
|
||||
)
|
||||
return executed, failed_error
|
||||
|
||||
|
||||
@@ -740,6 +922,7 @@ def _rollback_session_after_module_install_error(session: Session, exc: Exceptio
|
||||
|
||||
def _failed_module_install_run_result(
|
||||
*,
|
||||
session: Session,
|
||||
state: _ModuleInstallRunState,
|
||||
plan: ModuleInstallPlan,
|
||||
executed: list[dict[str, object]],
|
||||
@@ -765,6 +948,7 @@ def _failed_module_install_run_result(
|
||||
commands=state.result_commands,
|
||||
return_code=1,
|
||||
error=failed_error,
|
||||
recovery=state.recovery,
|
||||
)
|
||||
rollback = rollback_module_install_run(
|
||||
run_id=state.run_id,
|
||||
@@ -775,6 +959,30 @@ def _failed_module_install_run_result(
|
||||
database_url=database_url,
|
||||
)
|
||||
_update_run_record(state.record_path, {"destructive_retirement_rollback": rollback.as_dict()})
|
||||
if rollback.return_code == 0 and state.recovery is not None:
|
||||
try:
|
||||
state.recovery.recovered(
|
||||
session,
|
||||
evidence={
|
||||
"rollback_return_code": rollback.return_code,
|
||||
"rollback_sha256": canonical_sha256(rollback.as_dict()),
|
||||
},
|
||||
summary="Verified rollback restored the pre-install module state",
|
||||
)
|
||||
_update_run_record(
|
||||
state.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status="recovered",
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
_update_run_record(
|
||||
state.record_path,
|
||||
{"recovery_error": type(recovery_exc).__name__},
|
||||
)
|
||||
return ModuleInstallerRunResult(
|
||||
run_id=state.run_id,
|
||||
status="rolled-back" if rollback.return_code == 0 else "failed",
|
||||
@@ -783,6 +991,7 @@ def _failed_module_install_run_result(
|
||||
return_code=1,
|
||||
error=failed_error,
|
||||
rollback=rollback.as_dict(),
|
||||
recovery=state.recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -795,6 +1004,7 @@ def _applied_module_install_run_result(
|
||||
remove_uninstalled_modules_from_desired: bool,
|
||||
executed: list[dict[str, object]],
|
||||
state: _ModuleInstallRunState,
|
||||
finalize_recovery: bool,
|
||||
) -> ModuleInstallerRunResult:
|
||||
save_module_install_plan(session, tuple(_mark_applied(item) for item in plan.items))
|
||||
if activate_installed_modules or remove_uninstalled_modules_from_desired:
|
||||
@@ -806,14 +1016,50 @@ def _applied_module_install_run_result(
|
||||
)
|
||||
save_desired_enabled_modules(session, next_desired)
|
||||
state.record["desired_enabled_after"] = list(next_desired)
|
||||
recovery_evidence = {
|
||||
"command_count": len(executed),
|
||||
"command_results_sha256": canonical_sha256(executed),
|
||||
"desired_graph_sha256": canonical_sha256(
|
||||
state.record.get("desired_enabled_after", list(desired_enabled))
|
||||
),
|
||||
"plan_projection_sha256": canonical_sha256(
|
||||
[item.as_dict() for item in plan.items]
|
||||
),
|
||||
}
|
||||
if state.recovery is not None and finalize_recovery:
|
||||
state.recovery.succeed(
|
||||
session,
|
||||
evidence=recovery_evidence,
|
||||
commit_projection=True,
|
||||
)
|
||||
recovery_status = "succeeded"
|
||||
else:
|
||||
session.commit()
|
||||
recovery_status = "awaiting_supervisor" if state.recovery is not None else None
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="local-projection-committed",
|
||||
summary="Package and desired-graph projections await runtime health verification",
|
||||
evidence=recovery_evidence,
|
||||
)
|
||||
state.record.update({
|
||||
"status": "applied",
|
||||
"finished_at": datetime.now(tz=UTC).isoformat(),
|
||||
"commands": executed,
|
||||
})
|
||||
if state.recovery is not None and recovery_status is not None:
|
||||
state.record["recovery"] = _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status=recovery_status,
|
||||
)
|
||||
_write_json(state.record_path, state.record)
|
||||
return ModuleInstallerRunResult(run_id=state.run_id, status="applied", record_path=state.record_path, commands=state.result_commands)
|
||||
return ModuleInstallerRunResult(
|
||||
run_id=state.run_id,
|
||||
status="applied",
|
||||
record_path=state.record_path,
|
||||
commands=state.result_commands,
|
||||
recovery=state.recovery,
|
||||
)
|
||||
|
||||
|
||||
def supervise_module_install_plan(
|
||||
@@ -864,6 +1110,7 @@ def supervise_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
dry_run=False,
|
||||
request_context=request_context,
|
||||
finalize_recovery=False,
|
||||
)
|
||||
supervisor: dict[str, object] = {
|
||||
"started_at": datetime.now(tz=UTC).isoformat(),
|
||||
@@ -938,6 +1185,27 @@ def supervise_module_install_plan(
|
||||
"status": "ok",
|
||||
"finished_at": datetime.now(tz=UTC).isoformat(),
|
||||
})
|
||||
if result.recovery is not None:
|
||||
result.recovery.succeed(
|
||||
session,
|
||||
evidence={
|
||||
"restart_results_sha256": canonical_sha256(restart_results),
|
||||
"health_results_sha256": canonical_sha256(supervisor.get("health")),
|
||||
"runtime_health_verified": True,
|
||||
},
|
||||
commit_projection=False,
|
||||
)
|
||||
supervisor["recovery_operation_id"] = result.recovery.operation_id
|
||||
supervisor["recovery_status"] = "succeeded"
|
||||
_update_run_record(
|
||||
result.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
result.recovery,
|
||||
status="succeeded",
|
||||
)
|
||||
},
|
||||
)
|
||||
_update_run_record(result.record_path, {"supervisor": supervisor})
|
||||
return result
|
||||
|
||||
@@ -3264,6 +3532,31 @@ def _rollback_after_supervisor_failure(
|
||||
session.commit()
|
||||
supervisor["rollback"] = rollback.as_dict()
|
||||
|
||||
if rollback.return_code == 0 and result.recovery is not None:
|
||||
try:
|
||||
result.recovery.recovered(
|
||||
session,
|
||||
evidence={
|
||||
"rollback_sha256": canonical_sha256(rollback.as_dict()),
|
||||
"desired_graph_restored": True,
|
||||
},
|
||||
summary="Supervisor rollback restored package and desired module state",
|
||||
)
|
||||
supervisor["recovery_operation_id"] = result.recovery.operation_id
|
||||
supervisor["recovery_status"] = "recovered"
|
||||
_update_run_record(
|
||||
result.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
result.recovery,
|
||||
status="recovered",
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
supervisor["recovery_status"] = "reconciliation-failed"
|
||||
supervisor["recovery_error"] = type(recovery_exc).__name__
|
||||
|
||||
rollback_restart = _run_restart_commands(restart_commands)
|
||||
if rollback_restart:
|
||||
supervisor["rollback_restart_commands"] = rollback_restart
|
||||
@@ -3284,6 +3577,7 @@ def _rollback_after_supervisor_failure(
|
||||
return_code=1,
|
||||
error=reason,
|
||||
rollback=rollback.as_dict(),
|
||||
recovery=result.recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -3698,10 +3992,13 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
|
||||
raise ModuleInstallerError(
|
||||
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
|
||||
)
|
||||
artifact_sha256 = _sha256_file(backup_path)
|
||||
return {
|
||||
"type": "sqlite",
|
||||
"source": str(db_path),
|
||||
"path": backup_path.name,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"size_bytes": backup_path.stat().st_size,
|
||||
"restore_check": {
|
||||
"type": "sqlite_integrity_check",
|
||||
"result": integrity,
|
||||
@@ -3744,6 +4041,12 @@ def _snapshot_external_database(
|
||||
payload["database_url_secret"] = database_url_secret
|
||||
if result.returncode != 0:
|
||||
raise ModuleInstallerError(f"Database backup command failed ({result.returncode}): {_redact_installer_text(backup_command, redactions=redactions)}")
|
||||
if not backup_path.is_file() or backup_path.stat().st_size <= 0:
|
||||
raise ModuleInstallerError(
|
||||
"Database backup command did not create a non-empty backup artifact."
|
||||
)
|
||||
payload["artifact_sha256"] = _sha256_file(backup_path)
|
||||
payload["size_bytes"] = backup_path.stat().st_size
|
||||
if restore_check_command:
|
||||
restore_check = _run_database_hook(
|
||||
restore_check_command,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
claim_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
|
||||
|
||||
class ModuleLifecycleRecoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleLifecycleRecoveryDeclaration:
|
||||
operation_type: str
|
||||
mode: RecoveryMode
|
||||
resources: tuple[str, ...]
|
||||
verification: tuple[str, ...]
|
||||
|
||||
|
||||
MODULE_LIFECYCLE_RECOVERY_OPERATIONS = (
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-lifecycle.pre-migration",
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
resources=("postgresql", "package-environment", "webui-bundle", "filesystem"),
|
||||
verification=(
|
||||
"verify the canonical install plan and immutable package references",
|
||||
"verify the package and WebUI snapshots before mutation",
|
||||
"verify the installed manifests and desired module graph",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-lifecycle.post-migration",
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
resources=(
|
||||
"postgresql",
|
||||
"package-environment",
|
||||
"webui-bundle",
|
||||
"runtime-nodes",
|
||||
),
|
||||
verification=(
|
||||
"verify the backup reference and migration execution evidence",
|
||||
"verify migration heads and installed module manifests",
|
||||
"verify the desired graph and runtime health before completion",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-retirement.destroy-data",
|
||||
mode=RecoveryMode.SNAPSHOT_RESTORE,
|
||||
resources=("postgresql", "object-storage", "package-environment"),
|
||||
verification=(
|
||||
"verify the pinned backup artifact and restore-readiness evidence",
|
||||
"verify the retirement provider result and remaining migration state",
|
||||
"verify the installed manifests and desired module graph",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-runtime.apply-graph",
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
resources=("postgresql", "runtime-nodes", "module-registry"),
|
||||
verification=(
|
||||
"verify the requested graph against available module contracts",
|
||||
"verify activation and deactivation hooks completed",
|
||||
"verify the active graph and workflow contribution reconciliation",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_DECLARATIONS = {
|
||||
item.operation_type: item for item in MODULE_LIFECYCLE_RECOVERY_OPERATIONS
|
||||
}
|
||||
|
||||
|
||||
def canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def lifecycle_session_factory(session: Session) -> sessionmaker[Session]:
|
||||
bind = session.get_bind()
|
||||
if bind is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery requires a database bind"
|
||||
)
|
||||
return sessionmaker(bind=bind, expire_on_commit=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModuleLifecycleRecovery:
|
||||
operation: DurableRecoveryOperation | None
|
||||
operation_id: str
|
||||
operation_type: str
|
||||
mode: RecoveryMode
|
||||
plan_sha256: str
|
||||
replayed: bool
|
||||
effect_started: bool = False
|
||||
|
||||
def checkpoint(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object],
|
||||
effect_started: bool = False,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.effect_started = self.effect_started or effect_started
|
||||
self.operation.checkpoint(
|
||||
kind=kind,
|
||||
summary=summary,
|
||||
evidence={
|
||||
**dict(evidence),
|
||||
"effect_started": self.effect_started,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: Mapping[str, object],
|
||||
commit_projection: bool,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
terminal = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"plan_sha256": self.plan_sha256,
|
||||
"effect_started": self.effect_started,
|
||||
},
|
||||
}
|
||||
if commit_projection:
|
||||
self.operation.commit_verified_success(session, evidence=terminal)
|
||||
else:
|
||||
self.operation.succeed(evidence=terminal)
|
||||
|
||||
def unresolved(
|
||||
self,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object],
|
||||
outcome_unknown: bool,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
if not self.effect_started:
|
||||
self.operation.fail(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"effect_started": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
self.operation.unresolved(
|
||||
status=(
|
||||
RecoveryStatus.OUTCOME_UNKNOWN
|
||||
if outcome_unknown
|
||||
else RecoveryStatus.RECOVERY_REQUIRED
|
||||
),
|
||||
summary=summary,
|
||||
evidence={
|
||||
**dict(evidence),
|
||||
"effect_started": True,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
failure_summary=(
|
||||
"Inspect the installer run record and affected state services "
|
||||
"before retrying or restoring"
|
||||
),
|
||||
)
|
||||
|
||||
def recovered(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: Mapping[str, object],
|
||||
summary: str,
|
||||
) -> None:
|
||||
state = session.get(RecoveryOperation, self.operation_id)
|
||||
if state is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery operation is unavailable"
|
||||
)
|
||||
if state.status == RecoveryStatus.RECOVERED.value:
|
||||
return
|
||||
try:
|
||||
handle = claim_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
operation_id=self.operation_id,
|
||||
lease_ttl_seconds=900,
|
||||
)
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
if exc.status == RecoveryStatus.RECOVERED.value:
|
||||
return
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
f"Module lifecycle recovery is already {exc.status}"
|
||||
) from exc
|
||||
except (RecoveryOperationBusy, RecoveryGuaranteeError, RuntimeError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery authority is unavailable"
|
||||
) from exc
|
||||
session.expire_all()
|
||||
state = session.get(RecoveryOperation, self.operation_id)
|
||||
if state is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery operation is unavailable"
|
||||
)
|
||||
recovery_evidence = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
}
|
||||
if state.status == RecoveryStatus.OUTCOME_UNKNOWN.value:
|
||||
handle.resolve_unknown(
|
||||
effect_occurred=False,
|
||||
evidence=recovery_evidence,
|
||||
summary=summary,
|
||||
)
|
||||
else:
|
||||
handle.compensate(
|
||||
failure_summary=summary,
|
||||
failure_evidence={
|
||||
"effect_started": self.effect_started,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
recovery_evidence=recovery_evidence,
|
||||
)
|
||||
|
||||
|
||||
def begin_module_installer_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
run_id: str,
|
||||
plan: Sequence[Mapping[str, object]],
|
||||
command_count: int,
|
||||
migrate_database: bool,
|
||||
destructive_retirement: bool,
|
||||
snapshot_sha256: str | None,
|
||||
backup_reference: str | None,
|
||||
request_context_sha256: str,
|
||||
) -> ModuleLifecycleRecovery:
|
||||
operation_type = (
|
||||
"module-retirement.destroy-data"
|
||||
if destructive_retirement
|
||||
else "module-lifecycle.post-migration"
|
||||
if migrate_database
|
||||
else "module-lifecycle.pre-migration"
|
||||
)
|
||||
declaration = _DECLARATIONS[operation_type]
|
||||
plan_sha256 = canonical_sha256([dict(item) for item in plan])
|
||||
recovery_plan = RecoveryPlan(
|
||||
mode=declaration.mode,
|
||||
preconditions=(
|
||||
"maintenance mode and installer preflight are current",
|
||||
"package references and the requested module graph are pinned",
|
||||
"the deployment-wide module lifecycle fence is owned",
|
||||
),
|
||||
compensation_steps=(
|
||||
"restore the Python and WebUI package snapshots",
|
||||
"restore the prior desired module graph",
|
||||
"verify installed manifests and runtime health",
|
||||
)
|
||||
if declaration.mode == RecoveryMode.COMPENSATION
|
||||
else (),
|
||||
forward_recovery_steps=(
|
||||
"inspect migration task and command evidence",
|
||||
"complete or repair migrations under the same deployment fence",
|
||||
"verify migration heads, manifests, desired graph, and runtime health",
|
||||
)
|
||||
if declaration.mode == RecoveryMode.FORWARD_RECOVERY
|
||||
else (),
|
||||
verification_steps=declaration.verification,
|
||||
backup_reference=(
|
||||
backup_reference
|
||||
if declaration.mode == RecoveryMode.SNAPSHOT_RESTORE
|
||||
else None
|
||||
),
|
||||
)
|
||||
if declaration.mode == RecoveryMode.SNAPSHOT_RESTORE and not backup_reference:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Destructive module retirement requires verified backup evidence"
|
||||
)
|
||||
session.commit()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="core",
|
||||
operation_type=operation_type,
|
||||
idempotency_key=f"module-installer:{run_id}",
|
||||
request={
|
||||
"run_id": run_id,
|
||||
"plan_sha256": plan_sha256,
|
||||
"command_count": command_count,
|
||||
"migrate_database": migrate_database,
|
||||
"destructive_retirement": destructive_retirement,
|
||||
"snapshot_expected": True,
|
||||
"request_context_sha256": request_context_sha256,
|
||||
},
|
||||
recovery_plan=recovery_plan,
|
||||
precondition_evidence={
|
||||
"plan_sha256": plan_sha256,
|
||||
"snapshot_sha256": snapshot_sha256 or "pending",
|
||||
"request_context_sha256": request_context_sha256,
|
||||
"command_count": command_count,
|
||||
"backup_reference_present": bool(backup_reference),
|
||||
},
|
||||
lease_resource_key="core:module-lifecycle:deployment",
|
||||
lease_ttl_seconds=900,
|
||||
resource_type="module_installer_run",
|
||||
resource_id=run_id,
|
||||
metadata={
|
||||
"resources": list(declaration.resources),
|
||||
"migrate_database": migrate_database,
|
||||
"destructive_retirement": destructive_retirement,
|
||||
},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except RecoveryOperationBusy as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Another runtime owns the deployment module lifecycle fence"
|
||||
) from exc
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
f"Module installer recovery is already {exc.status}"
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError, SQLAlchemyError, ValueError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"The recovery ledger is unavailable; module mutation did not start"
|
||||
) from exc
|
||||
return ModuleLifecycleRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
operation_type=operation_type,
|
||||
mode=declaration.mode,
|
||||
plan_sha256=plan_sha256,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
def begin_runtime_graph_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
previous_modules: Sequence[str],
|
||||
requested_modules: Sequence[str],
|
||||
migrate: bool,
|
||||
) -> ModuleLifecycleRecovery:
|
||||
declaration = _DECLARATIONS["module-runtime.apply-graph"]
|
||||
plan = {
|
||||
"previous_modules": sorted(set(previous_modules)),
|
||||
"requested_modules": sorted(set(requested_modules)),
|
||||
"migrate": migrate,
|
||||
}
|
||||
plan_sha256 = canonical_sha256(plan)
|
||||
session.commit()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="core",
|
||||
operation_type=declaration.operation_type,
|
||||
idempotency_key=f"module-runtime:{uuid4()}",
|
||||
request={**plan, "plan_sha256": plan_sha256},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=declaration.mode,
|
||||
preconditions=(
|
||||
"the requested graph passed module contract validation",
|
||||
"the deployment-wide module lifecycle fence is owned",
|
||||
),
|
||||
compensation_steps=(
|
||||
"restore the previous in-process active registry",
|
||||
"reconfigure capability contexts from the previous graph",
|
||||
),
|
||||
verification_steps=declaration.verification,
|
||||
),
|
||||
precondition_evidence={
|
||||
"plan_sha256": plan_sha256,
|
||||
"previous_graph_sha256": canonical_sha256(
|
||||
sorted(set(previous_modules))
|
||||
),
|
||||
"requested_graph_sha256": canonical_sha256(
|
||||
sorted(set(requested_modules))
|
||||
),
|
||||
},
|
||||
lease_resource_key="core:module-lifecycle:deployment",
|
||||
lease_ttl_seconds=300,
|
||||
resource_type="module_runtime_graph",
|
||||
resource_id=plan_sha256,
|
||||
metadata={"resources": list(declaration.resources)},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Another lifecycle mutation is active or unresolved"
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError, SQLAlchemyError, ValueError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"The recovery ledger is unavailable; the active graph was unchanged"
|
||||
) from exc
|
||||
return ModuleLifecycleRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
operation_type=declaration.operation_type,
|
||||
mode=declaration.mode,
|
||||
plan_sha256=plan_sha256,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODULE_LIFECYCLE_RECOVERY_OPERATIONS",
|
||||
"ModuleLifecycleRecovery",
|
||||
"ModuleLifecycleRecoveryDeclaration",
|
||||
"ModuleLifecycleRecoveryError",
|
||||
"begin_module_installer_recovery",
|
||||
"begin_runtime_graph_recovery",
|
||||
"canonical_sha256",
|
||||
"lifecycle_session_factory",
|
||||
]
|
||||
@@ -408,6 +408,7 @@ class DeleteVetoProviderRegistration:
|
||||
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
PublicTenantResolver = Callable[[object, object], str | None]
|
||||
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
||||
LifecycleHook = Callable[[ModuleContext], None]
|
||||
|
||||
@@ -426,6 +427,7 @@ class ModuleManifest:
|
||||
permissions: tuple[PermissionDefinition, ...] = ()
|
||||
role_templates: tuple[RoleTemplate, ...] = ()
|
||||
route_factory: RouteFactory | None = None
|
||||
public_tenant_resolver: PublicTenantResolver | None = None
|
||||
migration_spec: MigrationSpec | None = None
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
frontend: FrontendModule | None = None
|
||||
|
||||
@@ -33,6 +33,14 @@ class NotificationDispatchRequest:
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationDispatchProvider(Protocol):
|
||||
def tenant_id_for_notification(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
notification_id: str,
|
||||
) -> str | None:
|
||||
...
|
||||
|
||||
def enqueue_notification(
|
||||
self,
|
||||
session: object,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from heapq import nsmallest
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -27,6 +28,7 @@ class StorageObjectMissing(StorageBackendError):
|
||||
class StorageObjectInfo:
|
||||
key: str
|
||||
size_bytes: int
|
||||
modified_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -165,9 +167,14 @@ class LocalFilesystemStorageBackend:
|
||||
|
||||
def stat(self, key: str) -> StorageObjectInfo:
|
||||
path = self._readable_path(key)
|
||||
metadata = path.stat()
|
||||
return StorageObjectInfo(
|
||||
key=normalize_storage_key(key),
|
||||
size_bytes=path.stat().st_size,
|
||||
size_bytes=metadata.st_size,
|
||||
modified_at=datetime.fromtimestamp(
|
||||
metadata.st_mtime,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
@@ -188,9 +195,14 @@ class LocalFilesystemStorageBackend:
|
||||
normalized_after is not None and key <= normalized_after
|
||||
):
|
||||
continue
|
||||
metadata = path.stat()
|
||||
yield StorageObjectInfo(
|
||||
key=key,
|
||||
size_bytes=path.stat().st_size,
|
||||
size_bytes=metadata.st_size,
|
||||
modified_at=datetime.fromtimestamp(
|
||||
metadata.st_mtime,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
candidates = nsmallest(
|
||||
@@ -381,7 +393,11 @@ class S3StorageBackend:
|
||||
raise StorageBackendError(
|
||||
"S3 object metadata did not include a valid size"
|
||||
) from exc
|
||||
return StorageObjectInfo(key=normalized, size_bytes=size)
|
||||
return StorageObjectInfo(
|
||||
key=normalized,
|
||||
size_bytes=size,
|
||||
modified_at=_storage_modified_at(response.get("LastModified")),
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
@@ -407,6 +423,7 @@ class S3StorageBackend:
|
||||
StorageObjectInfo(
|
||||
key=str(item["Key"]),
|
||||
size_bytes=int(item.get("Size") or 0),
|
||||
modified_at=_storage_modified_at(item.get("LastModified")),
|
||||
)
|
||||
for item in response.get("Contents", ())
|
||||
if isinstance(item, dict) and item.get("Key")
|
||||
@@ -418,6 +435,14 @@ class S3StorageBackend:
|
||||
)
|
||||
|
||||
|
||||
def _storage_modified_at(value: object) -> datetime | None:
|
||||
if not isinstance(value, datetime):
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def configured_storage_backend(settings: object) -> StorageBackend:
|
||||
"""Build the deployment-wide object store from Core settings.
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Literal, Mapping, Sequence
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.views import (
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
)
|
||||
|
||||
|
||||
PLATFORM_INTERFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PlatformInterfaceKind = Literal[
|
||||
"backend_capability",
|
||||
"frontend_route",
|
||||
"navigation",
|
||||
"permission",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
"search_source",
|
||||
"settings_route",
|
||||
"view_surface",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlatformInterfaceDeclaration:
|
||||
"""A sanitized, stable declaration from a module manifest.
|
||||
|
||||
The declaration contains identifiers and authorization metadata only. It
|
||||
deliberately excludes factories, executable callbacks, credentials, and
|
||||
mutable module state.
|
||||
"""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
kind: PlatformInterfaceKind
|
||||
label: str | None = None
|
||||
path: str | None = None
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.id}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"id": self.id,
|
||||
"module_id": self.module_id,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
"path": self.path,
|
||||
"required_all": list(self.required_all),
|
||||
"required_any": list(self.required_any),
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
def manifest_interface_declarations(
|
||||
manifest: ModuleManifest,
|
||||
) -> tuple[PlatformInterfaceDeclaration, ...]:
|
||||
"""Normalize the typed public declarations owned by one module manifest."""
|
||||
|
||||
declarations: list[PlatformInterfaceDeclaration] = []
|
||||
|
||||
for capability_name in sorted(manifest.capability_factories):
|
||||
documentation = manifest.capability_documentation.get(capability_name)
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=capability_name,
|
||||
module_id=manifest.id,
|
||||
kind="backend_capability",
|
||||
label=documentation.label if documentation is not None else None,
|
||||
metadata={
|
||||
"contract_version": (
|
||||
documentation.contract_version
|
||||
if documentation is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for interface in manifest.provides_interfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=interface.name,
|
||||
module_id=manifest.id,
|
||||
kind="provided_interface",
|
||||
metadata={"version": interface.version},
|
||||
)
|
||||
)
|
||||
|
||||
for permission in manifest.permissions:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=permission.scope,
|
||||
module_id=manifest.id,
|
||||
kind="permission",
|
||||
label=permission.label,
|
||||
metadata={
|
||||
"category": permission.category,
|
||||
"level": permission.level,
|
||||
"deprecated": permission.deprecated,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for registration in manifest.search_providers:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=registration.id,
|
||||
module_id=manifest.id,
|
||||
kind="search_provider",
|
||||
metadata={
|
||||
"role": "provider",
|
||||
"resource_types": list(registration.resource_types),
|
||||
},
|
||||
)
|
||||
)
|
||||
for registration in manifest.search_sources:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=registration.id,
|
||||
module_id=manifest.id,
|
||||
kind="search_source",
|
||||
metadata={"role": "source"},
|
||||
)
|
||||
)
|
||||
|
||||
frontend = manifest.frontend
|
||||
if frontend is not None:
|
||||
for route in frontend.routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="frontend_route",
|
||||
path=route.path,
|
||||
required_all=route.required_all,
|
||||
required_any=route.required_any,
|
||||
metadata={
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
"surface_id": route.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for route in frontend.public_routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.public.{_path_slug(route.path)}",
|
||||
module_id=manifest.id,
|
||||
kind="public_route",
|
||||
path=route.path,
|
||||
metadata={"component": route.component, "order": route.order},
|
||||
)
|
||||
)
|
||||
for route in frontend.settings_routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="settings_route",
|
||||
path=route.path,
|
||||
required_all=route.required_all,
|
||||
required_any=route.required_any,
|
||||
metadata={
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
"surface_id": route.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for item in frontend.nav_items:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
path=item.path,
|
||||
required_all=item.required_all,
|
||||
required_any=item.required_any,
|
||||
metadata={
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"order": item.order,
|
||||
"surface_id": item.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.view_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=surface.id,
|
||||
module_id=manifest.id,
|
||||
kind="view_surface",
|
||||
label=surface.label,
|
||||
metadata={
|
||||
"surface_kind": surface.kind,
|
||||
"parent_id": surface.parent_id,
|
||||
"description": surface.description,
|
||||
"order": surface.order,
|
||||
"default_visible": surface.default_visible,
|
||||
"required": surface.required,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
frontend_navigation = {
|
||||
declaration.id: declaration
|
||||
for declaration in declarations
|
||||
if declaration.kind == "navigation"
|
||||
}
|
||||
for item in manifest.nav_items:
|
||||
declaration = PlatformInterfaceDeclaration(
|
||||
id=navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
path=item.path,
|
||||
required_all=item.required_all,
|
||||
required_any=item.required_any,
|
||||
metadata={
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"order": item.order,
|
||||
"surface_id": item.surface_id,
|
||||
},
|
||||
)
|
||||
frontend_declaration = frontend_navigation.get(declaration.id)
|
||||
if frontend_declaration is not None and frontend_declaration == declaration:
|
||||
continue
|
||||
declarations.append(declaration)
|
||||
|
||||
return tuple(sorted(declarations, key=lambda item: (item.kind, item.id)))
|
||||
|
||||
|
||||
def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None:
|
||||
seen: set[str] = set()
|
||||
for declaration in manifest_interface_declarations(manifest):
|
||||
if declaration.key in seen:
|
||||
raise ValueError(
|
||||
f"Module {manifest.id!r} declares duplicate platform interface "
|
||||
f"{declaration.key!r}"
|
||||
)
|
||||
seen.add(declaration.key)
|
||||
|
||||
|
||||
def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]:
|
||||
declarations = manifest_interface_declarations(manifest)
|
||||
serialized = [item.to_dict() for item in declarations]
|
||||
canonical = json.dumps(
|
||||
serialized,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||
"module_id": manifest.id,
|
||||
"module_version": manifest.version,
|
||||
"digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
|
||||
"counts": dict(sorted(Counter(item.kind for item in declarations).items())),
|
||||
"declarations": serialized,
|
||||
}
|
||||
|
||||
|
||||
def platform_interface_catalog(
|
||||
manifests: Sequence[ModuleManifest],
|
||||
) -> dict[str, Any]:
|
||||
modules = [manifest_interface_catalog(manifest) for manifest in manifests]
|
||||
return {
|
||||
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||
"modules": modules,
|
||||
}
|
||||
|
||||
|
||||
def _path_slug(path: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".")
|
||||
return slug or "root"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PLATFORM_INTERFACE_CONTRACT_VERSION",
|
||||
"PlatformInterfaceDeclaration",
|
||||
"PlatformInterfaceKind",
|
||||
"manifest_interface_catalog",
|
||||
"manifest_interface_declarations",
|
||||
"platform_interface_catalog",
|
||||
"validate_manifest_interface_declarations",
|
||||
]
|
||||
@@ -129,6 +129,16 @@ class PollParticipationContextRef:
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollPublicInvitationRef:
|
||||
"""Non-sensitive routing identity for one valid governed invitation."""
|
||||
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
@@ -156,6 +166,17 @@ class PollParticipationGatewayProvider(Protocol):
|
||||
|
||||
...
|
||||
|
||||
def resolve_public_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
) -> PollPublicInvitationRef:
|
||||
"""Resolve tenant routing without disclosing participant details."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
@@ -266,6 +287,7 @@ __all__ = [
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollPublicInvitationRef",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
|
||||
@@ -41,6 +41,7 @@ class RecoveryStatus(StrEnum):
|
||||
PREPARED = "prepared"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
REJECTED = "rejected"
|
||||
FAILED = "failed"
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
RECOVERY_REQUIRED = "recovery_required"
|
||||
@@ -52,6 +53,7 @@ class RecoveryStatus(StrEnum):
|
||||
TERMINAL_RECOVERY_STATUSES = frozenset(
|
||||
{
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.RECOVERED.value,
|
||||
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||
@@ -69,6 +71,7 @@ _TRANSITIONS: dict[str, frozenset[str]] = {
|
||||
RecoveryStatus.RUNNING.value: frozenset(
|
||||
{
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.OUTCOME_UNKNOWN.value,
|
||||
RecoveryStatus.RECOVERY_REQUIRED.value,
|
||||
@@ -431,7 +434,11 @@ def transition_recovery_operation(
|
||||
elif status == RecoveryStatus.RECOVERED:
|
||||
locked.recovered_at = observed_at
|
||||
locked.completed_at = observed_at
|
||||
elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
|
||||
elif status in {
|
||||
RecoveryStatus.REJECTED,
|
||||
RecoveryStatus.FAILED,
|
||||
RecoveryStatus.MANUAL_INTERVENTION,
|
||||
}:
|
||||
locked.completed_at = observed_at
|
||||
session.add(locked)
|
||||
record_recovery_checkpoint(
|
||||
@@ -591,7 +598,11 @@ def _validate_transition_evidence(
|
||||
evidence: dict[str, Any],
|
||||
failure_summary: str | None,
|
||||
) -> None:
|
||||
if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
|
||||
if status in {
|
||||
RecoveryStatus.SUCCEEDED,
|
||||
RecoveryStatus.REJECTED,
|
||||
RecoveryStatus.RECOVERED,
|
||||
}:
|
||||
checks = evidence.get("checks")
|
||||
if (
|
||||
evidence.get("verified") is not True
|
||||
@@ -602,7 +613,7 @@ def _validate_transition_evidence(
|
||||
or not checks
|
||||
):
|
||||
raise RecoveryGuaranteeError(
|
||||
"Successful recovery transitions require verified evidence and check results"
|
||||
"Verified terminal transitions require verified evidence and check results"
|
||||
)
|
||||
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
|
||||
raise RecoveryGuaranteeError(
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
plan_recovery_operation,
|
||||
prepare_recovery_operation,
|
||||
record_recovery_checkpoint,
|
||||
start_recovery_operation,
|
||||
transition_recovery_operation,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
LeaseClaim,
|
||||
RuntimeIdentity,
|
||||
acquire_lease,
|
||||
release_lease,
|
||||
renew_lease,
|
||||
)
|
||||
|
||||
|
||||
SessionFactory = Callable[[], Session]
|
||||
|
||||
|
||||
class RecoveryOperationBusy(RecoveryGuaranteeError):
|
||||
pass
|
||||
|
||||
|
||||
class RecoveryOperationStateConflict(RecoveryGuaranteeError):
|
||||
def __init__(self, operation_id: str, status: str) -> None:
|
||||
self.operation_id = operation_id
|
||||
self.status = status
|
||||
super().__init__(
|
||||
f"Recovery operation {operation_id} is already {status}; "
|
||||
"reconcile it before starting another effect"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DurableRecoveryStart:
|
||||
operation_id: str
|
||||
status: str
|
||||
replayed: bool
|
||||
operation: DurableRecoveryOperation | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DurableRecoveryOperation:
|
||||
"""Append checkpoints in independent, committed transactions.
|
||||
|
||||
The caller's business transaction may roll back without erasing evidence
|
||||
that an object, queue, filesystem, or provider effect already occurred.
|
||||
"""
|
||||
|
||||
session_factory: SessionFactory
|
||||
operation_id: str
|
||||
lease_claim: LeaseClaim
|
||||
lease_ttl_seconds: int
|
||||
closed: bool = False
|
||||
|
||||
def checkpoint(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
summary: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
record_recovery_checkpoint(
|
||||
session,
|
||||
operation,
|
||||
kind=kind,
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
session.commit()
|
||||
|
||||
def succeed(self, *, evidence: dict[str, Any]) -> None:
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
kind="verified-success",
|
||||
summary="Operation effects and authoritative state were verified",
|
||||
evidence=evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def commit_atomic_success(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit domain writes and verified success in one DB transaction."""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
summary="Operation effects and authoritative state were verified",
|
||||
kind="verified-success",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=True,
|
||||
)
|
||||
|
||||
def commit_verified_success(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit a verified success projection and checkpoint together.
|
||||
|
||||
Non-atomic operations use this only after their external effect has a
|
||||
conclusive provider result. It does not make that effect atomic; it
|
||||
prevents local success from outrunning its durable verification.
|
||||
"""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
summary="Operation effects and authoritative state were verified",
|
||||
kind="verified-success",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=False,
|
||||
)
|
||||
|
||||
def commit_atomic_failure(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit domain failure evidence and the terminal state atomically."""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.FAILED,
|
||||
summary=summary,
|
||||
kind="verified-failure",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=True,
|
||||
)
|
||||
|
||||
def commit_atomic_rejection(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit a definitive rejection and its domain evidence atomically."""
|
||||
|
||||
self._commit_terminal(
|
||||
session,
|
||||
status=RecoveryStatus.REJECTED,
|
||||
summary=summary,
|
||||
kind="verified-rejection",
|
||||
evidence=evidence,
|
||||
require_atomic_mode=True,
|
||||
)
|
||||
|
||||
def fail(self, *, summary: str, evidence: dict[str, Any]) -> None:
|
||||
"""Finish a verified, ordinary failure that needs no recovery."""
|
||||
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.FAILED,
|
||||
kind="verified-failure",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=summary,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def reject(self, *, summary: str, evidence: dict[str, Any]) -> None:
|
||||
"""Finish an operation with a verified definitive rejection."""
|
||||
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.REJECTED,
|
||||
kind="verified-rejection",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=summary,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def compensate(
|
||||
self,
|
||||
*,
|
||||
failure_summary: str,
|
||||
failure_evidence: dict[str, Any],
|
||||
recovery_evidence: dict[str, Any],
|
||||
) -> None:
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
if operation.status == RecoveryStatus.RUNNING.value:
|
||||
operation = transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
kind="compensation-required",
|
||||
summary="The started operation requires explicit compensation",
|
||||
evidence=failure_evidence,
|
||||
failure_summary=failure_summary,
|
||||
lease_claim=claim,
|
||||
)
|
||||
if operation.status == RecoveryStatus.RECOVERY_REQUIRED.value:
|
||||
operation = transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERING,
|
||||
kind="compensation-started",
|
||||
summary="Compensation started",
|
||||
evidence={"failure_summary": failure_summary},
|
||||
lease_claim=claim,
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERED,
|
||||
kind="compensation-verified",
|
||||
summary="Compensation restored the declared invariant",
|
||||
evidence=recovery_evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def unresolved(
|
||||
self,
|
||||
*,
|
||||
status: RecoveryStatus,
|
||||
summary: str,
|
||||
evidence: dict[str, Any],
|
||||
failure_summary: str,
|
||||
) -> None:
|
||||
if status not in {
|
||||
RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
RecoveryStatus.RECOVERY_REQUIRED,
|
||||
}:
|
||||
raise ValueError("Unresolved operations require an unresolved status")
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=status,
|
||||
kind="unresolved-effect",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=failure_summary,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def resolve_unknown(
|
||||
self,
|
||||
*,
|
||||
effect_occurred: bool,
|
||||
evidence: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""Resolve an externally verified operation with an unknown outcome.
|
||||
|
||||
A confirmed provider effect is a verified success. A confirmed absence
|
||||
of the effect is recorded as forward recovery: the declared invariant
|
||||
is restored and the original effect may be attempted again under a new
|
||||
idempotency key.
|
||||
"""
|
||||
|
||||
with self.session_factory() as session:
|
||||
try:
|
||||
self._transition_unknown_resolution(
|
||||
session,
|
||||
effect_occurred=effect_occurred,
|
||||
evidence=evidence,
|
||||
summary=summary,
|
||||
)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self.closed = True
|
||||
|
||||
def commit_unknown_resolution(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
effect_occurred: bool,
|
||||
evidence: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""Commit an operator reconciliation and its domain projection together."""
|
||||
|
||||
try:
|
||||
self._transition_unknown_resolution(
|
||||
session,
|
||||
effect_occurred=effect_occurred,
|
||||
evidence=evidence,
|
||||
summary=summary,
|
||||
)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self.closed = True
|
||||
|
||||
def release_unresolved(self) -> None:
|
||||
"""Release authority after a process-local exception.
|
||||
|
||||
This does not alter the operation state. A later recovery claim treats a
|
||||
stale `running` operation according to its declared recovery mode.
|
||||
"""
|
||||
|
||||
if self.closed:
|
||||
return
|
||||
with self.session_factory() as session:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
record_recovery_checkpoint(
|
||||
session,
|
||||
operation,
|
||||
kind="authority-released",
|
||||
summary="Execution authority was released without a terminal claim",
|
||||
evidence={"status": operation.status},
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
self.closed = True
|
||||
|
||||
def _locked_and_renewed(
|
||||
self,
|
||||
session: Session,
|
||||
) -> tuple[RecoveryOperation, LeaseClaim]:
|
||||
if self.closed:
|
||||
raise RecoveryGuaranteeError("Recovery operation handle is closed")
|
||||
claim = renew_lease(
|
||||
session,
|
||||
self.lease_claim,
|
||||
ttl_seconds=self.lease_ttl_seconds,
|
||||
)
|
||||
operation = session.execute(
|
||||
select(RecoveryOperation)
|
||||
.where(RecoveryOperation.id == self.operation_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).scalar_one()
|
||||
self.lease_claim = claim
|
||||
return operation, claim
|
||||
|
||||
def _commit_terminal(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
status: RecoveryStatus,
|
||||
summary: str,
|
||||
kind: str,
|
||||
evidence: dict[str, Any],
|
||||
require_atomic_mode: bool,
|
||||
) -> None:
|
||||
if status not in {
|
||||
RecoveryStatus.SUCCEEDED,
|
||||
RecoveryStatus.FAILED,
|
||||
RecoveryStatus.REJECTED,
|
||||
}:
|
||||
raise ValueError("Unsupported terminal recovery status")
|
||||
try:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
if (
|
||||
require_atomic_mode
|
||||
and operation.mode != RecoveryMode.ATOMIC.value
|
||||
):
|
||||
raise RecoveryGuaranteeError(
|
||||
"Atomic terminal commits require an atomic recovery plan"
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=status,
|
||||
kind=kind,
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary=(
|
||||
summary
|
||||
if status in {RecoveryStatus.FAILED, RecoveryStatus.REJECTED}
|
||||
else None
|
||||
),
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self.closed = True
|
||||
|
||||
def _transition_unknown_resolution(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
effect_occurred: bool,
|
||||
evidence: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
operation, claim = self._locked_and_renewed(session)
|
||||
if operation.status != RecoveryStatus.OUTCOME_UNKNOWN.value:
|
||||
raise RecoveryOperationStateConflict(operation.id, operation.status)
|
||||
if effect_occurred:
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.SUCCEEDED,
|
||||
kind="unknown-outcome-verified-success",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
else:
|
||||
operation = transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
kind="unknown-outcome-recovery-required",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
failure_summary="The external effect was verified absent",
|
||||
lease_claim=claim,
|
||||
)
|
||||
operation = transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERING,
|
||||
kind="unknown-outcome-recovery-started",
|
||||
summary="Recording the verified absence of the external effect",
|
||||
evidence={"effect_occurred": False},
|
||||
lease_claim=claim,
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERED,
|
||||
kind="unknown-outcome-verified-absent",
|
||||
summary=summary,
|
||||
evidence=evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
self._verify_chain(session)
|
||||
release_lease(session, claim)
|
||||
|
||||
def _verify_chain(self, session: Session) -> None:
|
||||
if not verify_recovery_evidence_chain(session, self.operation_id):
|
||||
raise RecoveryGuaranteeError(
|
||||
"Recovery checkpoint chain verification failed"
|
||||
)
|
||||
|
||||
|
||||
def begin_durable_recovery_operation(
|
||||
session_factory: SessionFactory,
|
||||
*,
|
||||
identity: RuntimeIdentity,
|
||||
module_id: str,
|
||||
operation_type: str,
|
||||
idempotency_key: str,
|
||||
request: dict[str, Any],
|
||||
recovery_plan: RecoveryPlan,
|
||||
precondition_evidence: dict[str, Any],
|
||||
lease_resource_key: str,
|
||||
lease_ttl_seconds: int = 300,
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
block_unresolved_resource: bool = False,
|
||||
) -> DurableRecoveryStart:
|
||||
if lease_ttl_seconds < 1:
|
||||
raise ValueError("Recovery lease TTL must be at least one second")
|
||||
with session_factory() as session:
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
installation_id=identity.installation_id,
|
||||
resource_key=lease_resource_key,
|
||||
holder_node_id=identity.node_id,
|
||||
holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=lease_ttl_seconds,
|
||||
metadata={
|
||||
"module_id": module_id,
|
||||
"operation_type": operation_type,
|
||||
},
|
||||
)
|
||||
if claim is None:
|
||||
raise RecoveryOperationBusy(
|
||||
f"Another runtime owns the recovery fence for {lease_resource_key}"
|
||||
)
|
||||
existing = session.execute(
|
||||
select(RecoveryOperation).where(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.module_id == module_id,
|
||||
RecoveryOperation.idempotency_key == idempotency_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if block_unresolved_resource:
|
||||
blocking = session.execute(
|
||||
select(RecoveryOperation).where(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.lease_resource_key == lease_resource_key,
|
||||
RecoveryOperation.status.not_in(
|
||||
(
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.RECOVERED.value,
|
||||
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||
)
|
||||
),
|
||||
)
|
||||
).scalars().first()
|
||||
if blocking is not None and (
|
||||
existing is None or blocking.id != existing.id
|
||||
):
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
raise RecoveryOperationStateConflict(
|
||||
blocking.id,
|
||||
blocking.status,
|
||||
)
|
||||
operation = plan_recovery_operation(
|
||||
session,
|
||||
installation_id=identity.installation_id,
|
||||
module_id=module_id,
|
||||
operation_type=operation_type,
|
||||
idempotency_key=idempotency_key,
|
||||
request=request,
|
||||
recovery_plan=recovery_plan,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
lease_claim=claim,
|
||||
metadata=metadata,
|
||||
)
|
||||
if existing is not None:
|
||||
if operation.status == RecoveryStatus.SUCCEEDED.value:
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
return DurableRecoveryStart(
|
||||
operation_id=operation.id,
|
||||
status=operation.status,
|
||||
replayed=True,
|
||||
operation=None,
|
||||
)
|
||||
session.rollback()
|
||||
raise RecoveryOperationStateConflict(operation.id, operation.status)
|
||||
prepare_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
evidence=precondition_evidence,
|
||||
lease_claim=claim,
|
||||
)
|
||||
start_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
evidence={"lease_resource_key": lease_resource_key},
|
||||
lease_claim=claim,
|
||||
)
|
||||
if not verify_recovery_evidence_chain(session, operation.id):
|
||||
raise RecoveryGuaranteeError(
|
||||
"Recovery checkpoint chain verification failed before side effects"
|
||||
)
|
||||
session.commit()
|
||||
return DurableRecoveryStart(
|
||||
operation_id=operation.id,
|
||||
status=operation.status,
|
||||
replayed=False,
|
||||
operation=DurableRecoveryOperation(
|
||||
session_factory=session_factory,
|
||||
operation_id=operation.id,
|
||||
lease_claim=claim,
|
||||
lease_ttl_seconds=lease_ttl_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def claim_durable_recovery_operation(
|
||||
session_factory: SessionFactory,
|
||||
*,
|
||||
identity: RuntimeIdentity,
|
||||
operation_id: str,
|
||||
lease_ttl_seconds: int = 300,
|
||||
) -> DurableRecoveryOperation:
|
||||
with session_factory() as session:
|
||||
candidate = session.get(RecoveryOperation, operation_id)
|
||||
if candidate is None:
|
||||
raise RecoveryGuaranteeError("Recovery operation was not found")
|
||||
if candidate.status in {
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
RecoveryStatus.REJECTED.value,
|
||||
RecoveryStatus.FAILED.value,
|
||||
RecoveryStatus.RECOVERED.value,
|
||||
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||
}:
|
||||
raise RecoveryOperationStateConflict(candidate.id, candidate.status)
|
||||
if not candidate.lease_resource_key:
|
||||
raise RecoveryGuaranteeError(
|
||||
"Recovery takeover requires an operation-bound lease resource"
|
||||
)
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
installation_id=identity.installation_id,
|
||||
resource_key=candidate.lease_resource_key,
|
||||
holder_node_id=identity.node_id,
|
||||
holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=lease_ttl_seconds,
|
||||
metadata={"recovery_operation_id": candidate.id},
|
||||
)
|
||||
if claim is None:
|
||||
raise RecoveryOperationBusy(
|
||||
f"Another runtime owns recovery operation {candidate.id}"
|
||||
)
|
||||
operation = session.execute(
|
||||
select(RecoveryOperation)
|
||||
.where(RecoveryOperation.id == operation_id)
|
||||
.with_for_update()
|
||||
).scalar_one()
|
||||
previous_fence = {
|
||||
"holder_node_id": operation.holder_node_id,
|
||||
"holder_incarnation": operation.holder_incarnation,
|
||||
"fence_number": operation.fencing_token,
|
||||
}
|
||||
operation.holder_node_id = claim.holder_node_id
|
||||
operation.holder_incarnation = claim.holder_incarnation
|
||||
operation.fencing_token = claim.fencing_token
|
||||
session.add(operation)
|
||||
record_recovery_checkpoint(
|
||||
session,
|
||||
operation,
|
||||
kind="fence-takeover",
|
||||
summary="A new runtime claimed explicit recovery authority",
|
||||
evidence={
|
||||
"previous_fence": previous_fence,
|
||||
"new_fence_number": claim.fencing_token,
|
||||
},
|
||||
lease_claim=claim,
|
||||
)
|
||||
if operation.status == RecoveryStatus.RUNNING.value:
|
||||
mode = RecoveryMode(operation.mode)
|
||||
if mode == RecoveryMode.ATOMIC:
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.FAILED,
|
||||
kind="stale-atomic-operation",
|
||||
summary="The stale database-only transaction rolled back",
|
||||
evidence={"previous_fence": previous_fence},
|
||||
failure_summary="Execution authority expired before commit",
|
||||
lease_claim=claim,
|
||||
)
|
||||
elif mode in {RecoveryMode.COMPENSATION, RecoveryMode.SNAPSHOT_RESTORE}:
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
kind="stale-effect-requires-recovery",
|
||||
summary="Execution authority expired after effects may have started",
|
||||
evidence={"previous_fence": previous_fence},
|
||||
failure_summary="Execution authority expired during a non-atomic operation",
|
||||
lease_claim=claim,
|
||||
)
|
||||
else:
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
kind="stale-effect-outcome-unknown",
|
||||
summary="Execution authority expired after an external effect may have started",
|
||||
evidence={"previous_fence": previous_fence},
|
||||
failure_summary="External effect outcome requires reconciliation",
|
||||
lease_claim=claim,
|
||||
)
|
||||
if not verify_recovery_evidence_chain(session, operation.id):
|
||||
raise RecoveryGuaranteeError("Recovery checkpoint chain verification failed")
|
||||
if operation.status == RecoveryStatus.FAILED.value:
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
raise RecoveryOperationStateConflict(operation.id, operation.status)
|
||||
session.commit()
|
||||
return DurableRecoveryOperation(
|
||||
session_factory=session_factory,
|
||||
operation_id=operation.id,
|
||||
lease_claim=claim,
|
||||
lease_ttl_seconds=lease_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DurableRecoveryOperation",
|
||||
"DurableRecoveryStart",
|
||||
"RecoveryOperationBusy",
|
||||
"RecoveryOperationStateConflict",
|
||||
"begin_durable_recovery_operation",
|
||||
"claim_durable_recovery_operation",
|
||||
]
|
||||
@@ -25,10 +25,20 @@ from govoplan_core.core.modules import (
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
TenantModuleEntitlementResolver,
|
||||
TenantModuleUnavailable,
|
||||
TenantWorkState,
|
||||
current_tenant_execution_context,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.ownership import (
|
||||
OwnershipProviderRegistration,
|
||||
ResourceOwnershipProvider,
|
||||
)
|
||||
from govoplan_core.core.platform_interfaces import (
|
||||
validate_manifest_interface_declarations,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
@@ -80,8 +90,10 @@ class PlatformRegistry:
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capability_factory_owners: dict[str, str] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
|
||||
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||
self._search_providers: dict[str, SearchProvider] = {}
|
||||
self._search_source_registrations: list[
|
||||
@@ -139,6 +151,9 @@ class PlatformRegistry:
|
||||
})
|
||||
self._ownership_providers = dict(replacement._ownership_providers)
|
||||
self._capability_factories = dict(replacement._capability_factories)
|
||||
self._capability_factory_owners = dict(
|
||||
replacement._capability_factory_owners
|
||||
)
|
||||
self._search_provider_registrations = list(
|
||||
replacement._search_provider_registrations
|
||||
)
|
||||
@@ -148,6 +163,7 @@ class PlatformRegistry:
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
self._tenant_entitlement_resolver.invalidate()
|
||||
return snapshot
|
||||
|
||||
def get(self, module_id: str) -> ModuleManifest | None:
|
||||
@@ -255,6 +271,23 @@ class PlatformRegistry:
|
||||
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(
|
||||
self,
|
||||
ttl_seconds=float(
|
||||
getattr(
|
||||
context.settings,
|
||||
"tenant_module_entitlement_cache_ttl_seconds",
|
||||
5.0,
|
||||
)
|
||||
),
|
||||
max_entries=int(
|
||||
getattr(
|
||||
context.settings,
|
||||
"tenant_module_entitlement_cache_max_entries",
|
||||
2048,
|
||||
)
|
||||
),
|
||||
)
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
@@ -263,6 +296,7 @@ class PlatformRegistry:
|
||||
if name in self._capability_factories:
|
||||
raise RegistryError(f"Duplicate capability: {name}")
|
||||
self._capability_factories[name] = factory
|
||||
self._capability_factory_owners[name] = module_id
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self._capability_factories
|
||||
@@ -270,7 +304,69 @@ class PlatformRegistry:
|
||||
def capability_names(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self._capability_factories))
|
||||
|
||||
def capability_owner(self, name: str) -> str | None:
|
||||
return self._capability_factory_owners.get(name)
|
||||
|
||||
def public_tenant_resolver(self, module_id: str):
|
||||
manifest = self.get(module_id)
|
||||
return manifest.public_tenant_resolver if manifest is not None else None
|
||||
|
||||
def tenant_entitlement_resolver(self) -> TenantModuleEntitlementResolver:
|
||||
return self._tenant_entitlement_resolver
|
||||
|
||||
def invalidate_tenant_entitlement(self, tenant_id: str | None = None) -> None:
|
||||
self._tenant_entitlement_resolver.invalidate(tenant_id)
|
||||
|
||||
def tenant_capability(
|
||||
self,
|
||||
name: str,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> object | None:
|
||||
with tenant_execution_scope(
|
||||
self._tenant_entitlement_resolver,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state=work_state,
|
||||
):
|
||||
return self.capability(name)
|
||||
|
||||
def require_tenant_capability(
|
||||
self,
|
||||
name: str,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> object:
|
||||
owner = self.capability_owner(name)
|
||||
if owner is not None:
|
||||
self._tenant_entitlement_resolver.require(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=owner,
|
||||
work_state=work_state,
|
||||
)
|
||||
capability = self.tenant_capability(
|
||||
name,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state=work_state,
|
||||
)
|
||||
if capability is None:
|
||||
raise RegistryError(f"Required capability is not available: {name}")
|
||||
return capability
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
execution = current_tenant_execution_context()
|
||||
owner = self._capability_factory_owners.get(name)
|
||||
if execution is not None and owner is not None:
|
||||
try:
|
||||
execution.require_module(owner)
|
||||
except TenantModuleUnavailable:
|
||||
return None
|
||||
if name in self._capabilities:
|
||||
return self._capabilities[name]
|
||||
factory = self._capability_factories.get(name)
|
||||
@@ -311,6 +407,12 @@ class PlatformRegistry:
|
||||
return ()
|
||||
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
|
||||
for registered in self.search_provider_registrations():
|
||||
execution = current_tenant_execution_context()
|
||||
if execution is not None:
|
||||
try:
|
||||
execution.require_module(registered.module_id)
|
||||
except TenantModuleUnavailable:
|
||||
continue
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_providers.get(key)
|
||||
if provider is None:
|
||||
@@ -349,6 +451,12 @@ class PlatformRegistry:
|
||||
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||
] = []
|
||||
for registered in self.search_source_registrations():
|
||||
execution = current_tenant_execution_context()
|
||||
if execution is not None:
|
||||
try:
|
||||
execution.require_module(registered.module_id)
|
||||
except TenantModuleUnavailable:
|
||||
continue
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_sources.get(key)
|
||||
if provider is None:
|
||||
@@ -646,6 +754,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_manifest_overlaps(manifest)
|
||||
_validate_manifest_migration_spec(manifest)
|
||||
_validate_manifest_frontend(manifest)
|
||||
try:
|
||||
validate_manifest_interface_declarations(manifest)
|
||||
except ValueError as exc:
|
||||
raise RegistryError(str(exc)) from exc
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
for topic in manifest.documentation:
|
||||
@@ -1013,6 +1125,11 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
if frontend.public_routes and manifest.public_tenant_resolver is None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} exposes public frontend routes without a "
|
||||
"public tenant resolver"
|
||||
)
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
|
||||
@@ -122,6 +122,26 @@ class RuntimeIdentity:
|
||||
queues: tuple[str, ...] = ()
|
||||
|
||||
|
||||
_process_runtime_identity: RuntimeIdentity | None = None
|
||||
|
||||
|
||||
def bind_process_runtime_identity(identity: RuntimeIdentity | None) -> None:
|
||||
"""Bind the authority identity used by effects in this OS process."""
|
||||
|
||||
global _process_runtime_identity
|
||||
_process_runtime_identity = identity
|
||||
|
||||
|
||||
def process_runtime_identity() -> RuntimeIdentity:
|
||||
"""Return the process authority or fail before a consequential effect."""
|
||||
|
||||
if _process_runtime_identity is None:
|
||||
raise RuntimeCoordinationError(
|
||||
"No runtime identity is bound to the current process"
|
||||
)
|
||||
return _process_runtime_identity
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LeaseClaim:
|
||||
installation_id: str
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.external_references import ExternalObjectReference
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
|
||||
@@ -379,6 +380,43 @@ class SearchSourceProvider(Protocol):
|
||||
"""Return an explicit decision for every requested reference key."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchEventSourceProvider(Protocol):
|
||||
"""Optional source extension for committed, idempotent index deltas."""
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
"""Translate one committed event into authoritative index changes."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchIndexCoordinator(Protocol):
|
||||
"""Worker-facing orchestration surface exposed by the Search module."""
|
||||
|
||||
def ingest_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
def process_changes(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
limit: int = 100,
|
||||
tenant_id: str | None = None,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
|
||||
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
||||
SearchSourceProviderFactory = Callable[
|
||||
[ModuleContext],
|
||||
@@ -455,8 +493,10 @@ __all__ = [
|
||||
"SearchBackfillRequest",
|
||||
"SearchContextKind",
|
||||
"SearchDocument",
|
||||
"SearchEventSourceProvider",
|
||||
"SearchIndexChange",
|
||||
"SearchIndexChangeKind",
|
||||
"SearchIndexCoordinator",
|
||||
"SearchIndexWriter",
|
||||
"SearchProvider",
|
||||
"SearchProviderFactory",
|
||||
|
||||
@@ -6,11 +6,17 @@ import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
|
||||
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
|
||||
DEFAULT_PREVIEW_BYTES = 1_000_000
|
||||
DEFAULT_PREVIEW_TIMEOUT_MS = 2_000
|
||||
|
||||
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
|
||||
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
|
||||
TabularDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||
|
||||
|
||||
class TabularSourceError(ValueError):
|
||||
@@ -29,6 +35,10 @@ class TabularSourceValidationError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
class TabularSourceUnavailableError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
def parse_tabular_csv(
|
||||
csv_text: str,
|
||||
*,
|
||||
@@ -131,6 +141,32 @@ class TabularColumn:
|
||||
nullable: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularPushdown:
|
||||
projections: bool = False
|
||||
pagination: bool = False
|
||||
filters: tuple[str, ...] = ()
|
||||
aggregations: tuple[str, ...] = ()
|
||||
sorting: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularSourceHealth:
|
||||
status: TabularHealthStatus = "unknown"
|
||||
code: str = "source.health_unknown"
|
||||
summary: str = "Source health has not been checked."
|
||||
checked_at: datetime | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularPreviewDiagnostic:
|
||||
severity: TabularDiagnosticSeverity
|
||||
code: str
|
||||
message: str
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularSource:
|
||||
"""Opaque, policy-filtered source reference exposed to consuming modules."""
|
||||
@@ -148,6 +184,9 @@ class TabularSource:
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
source_mode: TabularSourceMode = "cached"
|
||||
pushdown: TabularPushdown = field(default_factory=TabularPushdown)
|
||||
health: TabularSourceHealth = field(default_factory=TabularSourceHealth)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -157,6 +196,8 @@ class TabularReadRequest:
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -165,6 +206,12 @@ class TabularReadResult:
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
returned_bytes: int = 0
|
||||
elapsed_ms: int = 0
|
||||
effective_row_limit: int = 0
|
||||
effective_byte_limit: int = 0
|
||||
effective_timeout_ms: int = 0
|
||||
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -247,7 +294,11 @@ def _capability(registry: object | None, name: str) -> object | None:
|
||||
__all__ = [
|
||||
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
|
||||
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||
"DEFAULT_PREVIEW_BYTES",
|
||||
"DEFAULT_PREVIEW_TIMEOUT_MS",
|
||||
"TabularColumn",
|
||||
"TabularPreviewDiagnostic",
|
||||
"TabularPushdown",
|
||||
"TabularReadRequest",
|
||||
"TabularReadResult",
|
||||
"TabularSnapshotInput",
|
||||
@@ -257,6 +308,9 @@ __all__ = [
|
||||
"TabularSourceError",
|
||||
"TabularSourceNotFoundError",
|
||||
"TabularSourceProvider",
|
||||
"TabularSourceHealth",
|
||||
"TabularSourceMode",
|
||||
"TabularSourceUnavailableError",
|
||||
"TabularSourceValidationError",
|
||||
"parse_tabular_csv",
|
||||
"tabular_snapshot_writer",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_TEMPLATE_CATALOG = "templates.catalog"
|
||||
CAPABILITY_TEMPLATE_RENDERER = "templates.renderer"
|
||||
|
||||
TemplateType = Literal[
|
||||
"label",
|
||||
"label_sheet",
|
||||
"envelope",
|
||||
"serial_letter",
|
||||
"form_letter",
|
||||
"list_layout",
|
||||
"email",
|
||||
"generic",
|
||||
]
|
||||
TemplateOutputFormat = Literal["html", "text"]
|
||||
TemplateRenderMode = Literal["preview", "final"]
|
||||
|
||||
|
||||
class TemplateContractError(ValueError):
|
||||
"""Stable base error for provider-neutral template operations."""
|
||||
|
||||
|
||||
class TemplateNotFoundError(TemplateContractError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateCompatibilityError(TemplateContractError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateRenderError(TemplateContractError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateFieldRequirement:
|
||||
path: str
|
||||
value_type: Literal[
|
||||
"string",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
"datetime",
|
||||
"object",
|
||||
"array",
|
||||
] = "string"
|
||||
label: str | None = None
|
||||
required: bool = True
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateOutputProfile:
|
||||
id: str
|
||||
label: str
|
||||
output_format: TemplateOutputFormat
|
||||
media_type: str
|
||||
channel: str = "print"
|
||||
capabilities: tuple[str, ...] = ()
|
||||
page: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateRevisionRef:
|
||||
id: str
|
||||
template_id: str
|
||||
revision: int
|
||||
definition_hash: str
|
||||
template_type: TemplateType
|
||||
usages: tuple[str, ...]
|
||||
locale: str
|
||||
required_fields: tuple[TemplateFieldRequirement, ...]
|
||||
output_profiles: tuple[TemplateOutputProfile, ...]
|
||||
published_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
slug: str
|
||||
template_type: TemplateType
|
||||
status: str
|
||||
current_revision: int
|
||||
current_revision_id: str
|
||||
published_revision_id: str | None
|
||||
description: str | None = None
|
||||
scope_type: str = "tenant"
|
||||
scope_id: str | None = None
|
||||
read_only: bool = False
|
||||
updated_at: datetime | None = None
|
||||
revision: TemplateRevisionRef | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateCompatibility:
|
||||
compatible: bool
|
||||
template_id: str
|
||||
revision_id: str
|
||||
usage: str | None
|
||||
output_format: str | None
|
||||
missing_fields: tuple[str, ...] = ()
|
||||
incompatible_fields: tuple[str, ...] = ()
|
||||
diagnostics: tuple[Mapping[str, object], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateRenderRequest:
|
||||
template_id: str
|
||||
revision: int | None = None
|
||||
usage: str | None = None
|
||||
locale: str | None = None
|
||||
output_format: TemplateOutputFormat = "html"
|
||||
profile_id: str | None = None
|
||||
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||
items: tuple[Mapping[str, object], ...] = ()
|
||||
input_snapshot: Mapping[str, object] = field(default_factory=dict)
|
||||
mode: TemplateRenderMode = "preview"
|
||||
idempotency_key: str | None = None
|
||||
persist_to_files: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateArtifactRef:
|
||||
kind: Literal["managed_file", "bounded_download"]
|
||||
filename: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
file_asset_id: str | None = None
|
||||
file_version_id: str | None = None
|
||||
download_path: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateRenderResult:
|
||||
render_id: str
|
||||
template_id: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
template_hash: str
|
||||
input_hash: str
|
||||
renderer_version: str
|
||||
output_format: TemplateOutputFormat
|
||||
content_type: str
|
||||
filename: str
|
||||
item_count: int
|
||||
page_count: int
|
||||
output_sha256: str
|
||||
output_size_bytes: int
|
||||
diagnostics: tuple[Mapping[str, object], ...] = ()
|
||||
artifact: TemplateArtifactRef | None = None
|
||||
generated_at: datetime | None = None
|
||||
payload: bytes | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TemplateCatalogProvider(Protocol):
|
||||
def list_templates(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
usage: str | None = None,
|
||||
template_type: str | None = None,
|
||||
locale: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> Sequence[TemplateRef]: ...
|
||||
|
||||
def get_template(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
revision: int | None = None,
|
||||
) -> TemplateRef | None: ...
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
revision: int | None = None,
|
||||
usage: str | None = None,
|
||||
output_format: str | None = None,
|
||||
available_fields: Mapping[str, str] | Sequence[str] = (),
|
||||
) -> TemplateCompatibility: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TemplateRendererProvider(Protocol):
|
||||
def render(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TemplateRenderRequest,
|
||||
) -> TemplateRenderResult: ...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_TEMPLATE_CATALOG",
|
||||
"CAPABILITY_TEMPLATE_RENDERER",
|
||||
"TemplateArtifactRef",
|
||||
"TemplateCatalogProvider",
|
||||
"TemplateCompatibility",
|
||||
"TemplateCompatibilityError",
|
||||
"TemplateContractError",
|
||||
"TemplateFieldRequirement",
|
||||
"TemplateNotFoundError",
|
||||
"TemplateOutputFormat",
|
||||
"TemplateOutputProfile",
|
||||
"TemplateRef",
|
||||
"TemplateRenderError",
|
||||
"TemplateRenderMode",
|
||||
"TemplateRenderRequest",
|
||||
"TemplateRenderResult",
|
||||
"TemplateRendererProvider",
|
||||
"TemplateRevisionRef",
|
||||
"TemplateType",
|
||||
]
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
@@ -15,6 +15,20 @@ VOTING_ASSURANCE_CONFIDENTIAL = "confidential"
|
||||
VOTING_ASSURANCE_SECRET = "secret"
|
||||
VOTING_ASSURANCE_EXTERNAL_CERTIFIED = "external_certified"
|
||||
|
||||
VOTING_CERTIFICATION_NOT_CERTIFIED = "not_certified"
|
||||
VOTING_CERTIFICATION_IN_EVALUATION = "in_evaluation"
|
||||
VOTING_CERTIFICATION_CERTIFIED = "certified"
|
||||
VOTING_CERTIFICATION_EXPIRED = "expired"
|
||||
VOTING_CERTIFICATION_REVOKED = "revoked"
|
||||
|
||||
VotingProviderCertificationState = Literal[
|
||||
"not_certified",
|
||||
"in_evaluation",
|
||||
"certified",
|
||||
"expired",
|
||||
"revoked",
|
||||
]
|
||||
|
||||
|
||||
class VotingCapabilityError(ValueError):
|
||||
"""Stable error raised by Voting capability implementations."""
|
||||
@@ -30,6 +44,119 @@ def voting_provider_capability(provider_id: str) -> str:
|
||||
return f"{CAPABILITY_VOTING_PROVIDER_PREFIX}{normalized}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VotingProviderAssuranceDeclaration:
|
||||
"""Pinned assurance and certification claim made by a Voting provider."""
|
||||
|
||||
provider_id: str
|
||||
implementation_ref: str
|
||||
supported_assurance_profiles: tuple[
|
||||
Literal["confidential", "secret", "external_certified"], ...
|
||||
]
|
||||
certification_state: VotingProviderCertificationState
|
||||
protocol_ref: str
|
||||
protocol_version: str
|
||||
certification_authority: str | None = None
|
||||
certification_reference: str | None = None
|
||||
certification_evidence_ref: str | None = None
|
||||
certification_valid_from: datetime | None = None
|
||||
certification_valid_until: datetime | None = None
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
normalized_id = str(self.provider_id or "").strip().lower()
|
||||
voting_provider_capability(normalized_id)
|
||||
if normalized_id != self.provider_id:
|
||||
raise ValueError("Voting provider assurance id must be normalized.")
|
||||
for field_name in ("implementation_ref", "protocol_ref", "protocol_version"):
|
||||
if not str(getattr(self, field_name) or "").strip():
|
||||
raise ValueError(
|
||||
f"Voting provider assurance {field_name} is required."
|
||||
)
|
||||
profiles = tuple(self.supported_assurance_profiles)
|
||||
allowed_profiles = {
|
||||
VOTING_ASSURANCE_CONFIDENTIAL,
|
||||
VOTING_ASSURANCE_SECRET,
|
||||
VOTING_ASSURANCE_EXTERNAL_CERTIFIED,
|
||||
}
|
||||
if (
|
||||
not profiles
|
||||
or len(set(profiles)) != len(profiles)
|
||||
or not set(profiles) <= allowed_profiles
|
||||
):
|
||||
raise ValueError(
|
||||
"Voting provider assurance profiles must be unique supported external profiles."
|
||||
)
|
||||
if self.certification_state not in {
|
||||
VOTING_CERTIFICATION_NOT_CERTIFIED,
|
||||
VOTING_CERTIFICATION_IN_EVALUATION,
|
||||
VOTING_CERTIFICATION_CERTIFIED,
|
||||
VOTING_CERTIFICATION_EXPIRED,
|
||||
VOTING_CERTIFICATION_REVOKED,
|
||||
}:
|
||||
raise ValueError("Voting provider certification state is invalid.")
|
||||
valid_from = _aware_datetime(
|
||||
self.certification_valid_from,
|
||||
field_name="certification_valid_from",
|
||||
)
|
||||
valid_until = _aware_datetime(
|
||||
self.certification_valid_until,
|
||||
field_name="certification_valid_until",
|
||||
)
|
||||
if valid_from and valid_until and valid_until <= valid_from:
|
||||
raise ValueError(
|
||||
"Voting provider certification validity must end after it starts."
|
||||
)
|
||||
if self.certification_state == VOTING_CERTIFICATION_CERTIFIED:
|
||||
required = (
|
||||
self.certification_authority,
|
||||
self.certification_reference,
|
||||
self.certification_evidence_ref,
|
||||
valid_from,
|
||||
valid_until,
|
||||
)
|
||||
if any(value is None or value == "" for value in required):
|
||||
raise ValueError(
|
||||
"Certified Voting providers require authority, reference, evidence, and a validity window."
|
||||
)
|
||||
if len(self.notes) > 16 or any(not str(item or "").strip() for item in self.notes):
|
||||
raise ValueError("Voting provider assurance notes must be bounded non-empty text.")
|
||||
|
||||
def is_currently_certified(self, *, at: datetime | None = None) -> bool:
|
||||
if self.certification_state != VOTING_CERTIFICATION_CERTIFIED:
|
||||
return False
|
||||
moment = _aware_datetime(at or datetime.now(UTC), field_name="at")
|
||||
valid_from = _aware_datetime(
|
||||
self.certification_valid_from,
|
||||
field_name="certification_valid_from",
|
||||
)
|
||||
valid_until = _aware_datetime(
|
||||
self.certification_valid_until,
|
||||
field_name="certification_valid_until",
|
||||
)
|
||||
return bool(valid_from and valid_until and valid_from <= moment < valid_until)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"implementation_ref": self.implementation_ref,
|
||||
"supported_assurance_profiles": list(self.supported_assurance_profiles),
|
||||
"certification_state": self.certification_state,
|
||||
"protocol_ref": self.protocol_ref,
|
||||
"protocol_version": self.protocol_version,
|
||||
"certification_authority": self.certification_authority,
|
||||
"certification_reference": self.certification_reference,
|
||||
"certification_evidence_ref": self.certification_evidence_ref,
|
||||
"certification_valid_from": _datetime_text(
|
||||
self.certification_valid_from
|
||||
),
|
||||
"certification_valid_until": _datetime_text(
|
||||
self.certification_valid_until
|
||||
),
|
||||
"notes": list(self.notes),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VotingOption:
|
||||
key: str
|
||||
@@ -184,6 +311,8 @@ class ExternalVotingProvider(Protocol):
|
||||
provider credentials must not cross this boundary.
|
||||
"""
|
||||
|
||||
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration: ...
|
||||
|
||||
def finalize_ballot(
|
||||
self,
|
||||
session: object,
|
||||
@@ -283,6 +412,45 @@ class VotingBallotProvider(Protocol):
|
||||
) -> VotingBallotRef: ...
|
||||
|
||||
|
||||
def require_voting_provider_assurance(
|
||||
provider: object,
|
||||
*,
|
||||
provider_id: str,
|
||||
assurance_profile: str,
|
||||
at: datetime | None = None,
|
||||
) -> VotingProviderAssuranceDeclaration:
|
||||
"""Validate and return the provider claim required for a frozen ballot."""
|
||||
|
||||
if not isinstance(provider, ExternalVotingProvider):
|
||||
raise VotingCapabilityError("Voting provider does not implement the contract.")
|
||||
try:
|
||||
declaration = provider.assurance_declaration()
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise VotingCapabilityError(
|
||||
"Voting provider assurance declaration was rejected."
|
||||
) from exc
|
||||
if not isinstance(declaration, VotingProviderAssuranceDeclaration):
|
||||
raise VotingCapabilityError(
|
||||
"Voting provider returned an invalid assurance declaration."
|
||||
)
|
||||
normalized_provider_id = str(provider_id or "").strip().lower()
|
||||
if declaration.provider_id != normalized_provider_id:
|
||||
raise VotingCapabilityError(
|
||||
"Voting provider assurance declaration does not match the selected provider."
|
||||
)
|
||||
if assurance_profile not in declaration.supported_assurance_profiles:
|
||||
raise VotingCapabilityError(
|
||||
"Voting provider does not support the selected assurance profile."
|
||||
)
|
||||
if (
|
||||
assurance_profile == VOTING_ASSURANCE_EXTERNAL_CERTIFIED
|
||||
and not declaration.is_currently_certified(at=at)
|
||||
):
|
||||
raise VotingCapabilityError(
|
||||
"Externally certified Voting requires a currently valid provider certification."
|
||||
)
|
||||
return declaration
|
||||
|
||||
def _validate_provider_evidence(
|
||||
evidence: Sequence[Mapping[str, object]],
|
||||
) -> None:
|
||||
@@ -330,6 +498,25 @@ def _reject_sensitive_evidence(value: object) -> None:
|
||||
_reject_sensitive_evidence(nested)
|
||||
|
||||
|
||||
def _aware_datetime(
|
||||
value: datetime | None,
|
||||
*,
|
||||
field_name: str,
|
||||
) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(
|
||||
f"Voting provider assurance {field_name} must be timezone-aware."
|
||||
)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
aware = _aware_datetime(value, field_name="datetime")
|
||||
return aware.isoformat() if aware is not None else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_VOTING_BALLOTS",
|
||||
"CAPABILITY_VOTING_PROVIDER_PREFIX",
|
||||
@@ -343,6 +530,11 @@ __all__ = [
|
||||
"VOTING_ASSURANCE_EXTERNAL_CERTIFIED",
|
||||
"VOTING_ASSURANCE_RECORDED",
|
||||
"VOTING_ASSURANCE_SECRET",
|
||||
"VOTING_CERTIFICATION_CERTIFIED",
|
||||
"VOTING_CERTIFICATION_EXPIRED",
|
||||
"VOTING_CERTIFICATION_IN_EVALUATION",
|
||||
"VOTING_CERTIFICATION_NOT_CERTIFIED",
|
||||
"VOTING_CERTIFICATION_REVOKED",
|
||||
"VotingBallotCreateCommand",
|
||||
"VotingBallotProvider",
|
||||
"VotingBallotRef",
|
||||
@@ -350,7 +542,10 @@ __all__ = [
|
||||
"VotingCastCommand",
|
||||
"VotingElector",
|
||||
"VotingOption",
|
||||
"VotingProviderAssuranceDeclaration",
|
||||
"VotingProviderCertificationState",
|
||||
"VotingReceipt",
|
||||
"VotingResult",
|
||||
"require_voting_provider_assurance",
|
||||
"voting_provider_capability",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.module_management import (
|
||||
load_startup_enabled_modules,
|
||||
startup_candidate_module_ids,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.server.registry import (
|
||||
available_module_manifests,
|
||||
build_platform_registry,
|
||||
)
|
||||
|
||||
|
||||
def build_worker_platform_registry(settings: object) -> PlatformRegistry:
|
||||
"""Build the active capability graph used by an out-of-process worker."""
|
||||
|
||||
configured_modules = getattr(settings, "enabled_modules", "")
|
||||
raw_enabled_modules = load_startup_enabled_modules(configured_modules)
|
||||
candidate_modules = startup_candidate_module_ids(
|
||||
configured_modules,
|
||||
raw_enabled_modules,
|
||||
)
|
||||
available_modules = available_module_manifests(
|
||||
enabled_modules=candidate_modules,
|
||||
ignore_load_errors=True,
|
||||
)
|
||||
enabled_modules = load_startup_enabled_modules(
|
||||
configured_modules,
|
||||
available=available_modules,
|
||||
)
|
||||
registry = build_platform_registry(enabled_modules)
|
||||
context = ModuleContext(registry=registry, settings=settings)
|
||||
configure_runtime(context)
|
||||
registry.configure_capability_context(context)
|
||||
return registry
|
||||
|
||||
|
||||
__all__ = ["build_worker_platform_registry"]
|
||||
@@ -127,6 +127,7 @@ class WorkflowRuntimeWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]: ...
|
||||
@@ -140,6 +141,7 @@ class WorkflowTriggerDispatcher(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
@@ -32,6 +32,7 @@ def create_all_tables() -> None:
|
||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
import json
|
||||
@@ -18,6 +19,7 @@ from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
@@ -574,9 +576,73 @@ def alembic_config(
|
||||
config.attributes["enabled_modules"] = tuple(enabled_modules)
|
||||
if manifest_factories:
|
||||
config.attributes["manifest_factories"] = tuple(manifest_factories)
|
||||
validate_unique_migration_revisions(config)
|
||||
return config
|
||||
|
||||
|
||||
def validate_unique_migration_revisions(config: Config) -> None:
|
||||
"""Reject duplicate revision IDs before Alembic assembles the shared graph.
|
||||
|
||||
Module migrations use separate version directories, but Alembic revision IDs
|
||||
still occupy one global namespace. Alembic can otherwise resolve a duplicate
|
||||
to the wrong module and report a misleading ancestor/head overlap.
|
||||
"""
|
||||
|
||||
locations = tuple(
|
||||
Path(value).resolve()
|
||||
for value in config.get_main_option("version_locations", "").split(os.pathsep)
|
||||
if value.strip()
|
||||
)
|
||||
owners: dict[str, list[Path]] = {}
|
||||
for location in locations:
|
||||
if not location.is_dir():
|
||||
continue
|
||||
for path in sorted(location.glob("*.py")):
|
||||
revision = _literal_migration_revision(path)
|
||||
if revision:
|
||||
owners.setdefault(revision, []).append(path)
|
||||
|
||||
duplicates = {
|
||||
revision: paths
|
||||
for revision, paths in owners.items()
|
||||
if len(paths) > 1
|
||||
}
|
||||
if not duplicates:
|
||||
return
|
||||
|
||||
details = "; ".join(
|
||||
f"{revision}: {', '.join(str(path) for path in paths)}"
|
||||
for revision, paths in sorted(duplicates.items())
|
||||
)
|
||||
raise ValueError(
|
||||
"Alembic revision IDs are global across enabled modules; duplicate "
|
||||
f"revision declarations found: {details}"
|
||||
)
|
||||
|
||||
|
||||
def _literal_migration_revision(path: Path) -> str | None:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
except (OSError, SyntaxError, UnicodeError):
|
||||
return None
|
||||
for statement in tree.body:
|
||||
value: ast.expr | None = None
|
||||
if isinstance(statement, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id == "revision"
|
||||
for target in statement.targets
|
||||
):
|
||||
value = statement.value
|
||||
elif (
|
||||
isinstance(statement, ast.AnnAssign)
|
||||
and isinstance(statement.target, ast.Name)
|
||||
and statement.target.id == "revision"
|
||||
):
|
||||
value = statement.value
|
||||
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
||||
return value.value.strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def database_revision(database_url: str | None = None) -> str | None:
|
||||
url = database_url or settings.database_url
|
||||
engine = create_engine(url)
|
||||
|
||||
@@ -69,6 +69,8 @@ TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
|
||||
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
|
||||
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
|
||||
PermissionDefinition("admin:module:read", "View tenant modules", "Inspect module availability, requirements, and effective state for the active tenant.", "Tenant administration"),
|
||||
PermissionDefinition("admin:module:write", "Manage tenant modules", "Enable or disable modules for the active tenant within system policy.", "Tenant administration"),
|
||||
)
|
||||
|
||||
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
|
||||
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.bootstrap import create_bootstrap_router
|
||||
from govoplan_core.server.credentials import router as credential_router
|
||||
from govoplan_core.server.ownership import router as ownership_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
@@ -69,6 +70,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
||||
for router in server_config.base_routers:
|
||||
api_router.include_router(router)
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
api_router.include_router(create_bootstrap_router(server_config.settings))
|
||||
api_router.include_router(credential_router)
|
||||
api_router.include_router(ownership_router)
|
||||
for router in server_config.post_module_routers:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
FirstAdminProvisioner,
|
||||
)
|
||||
from govoplan_core.core.first_admin import (
|
||||
FirstAdminEnrollmentConflict,
|
||||
FirstAdminEnrollmentCredentialError,
|
||||
FirstAdminEnrollmentUnavailable,
|
||||
consume_first_admin_credential,
|
||||
first_admin_enrollment_status,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
class FirstAdminReadinessResponse(BaseModel):
|
||||
enrollment_required: bool
|
||||
credential_active: bool
|
||||
state: str
|
||||
generation: int = 0
|
||||
expires_at: datetime | None = None
|
||||
readiness: dict[str, bool] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FirstAdminEnrollmentRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
password: SecretStr = Field(min_length=12, max_length=1024)
|
||||
tenant_slug: str = Field(default="default", min_length=1, max_length=100)
|
||||
tenant_name: str = Field(default="Default Tenant", min_length=1, max_length=255)
|
||||
|
||||
|
||||
class FirstAdminEnrollmentResponse(BaseModel):
|
||||
account_id: str
|
||||
membership_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
replayed: bool = False
|
||||
bootstrap_retired: bool = True
|
||||
|
||||
|
||||
def create_bootstrap_router(settings: object) -> APIRouter:
|
||||
router = APIRouter(prefix="/bootstrap", tags=["bootstrap"])
|
||||
|
||||
@router.get("/status", response_model=FirstAdminReadinessResponse)
|
||||
def bootstrap_status(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> FirstAdminReadinessResponse:
|
||||
provisioner = _first_admin_provisioner(request, required=False)
|
||||
if provisioner is None:
|
||||
return FirstAdminReadinessResponse(
|
||||
enrollment_required=False,
|
||||
credential_active=False,
|
||||
state="not_ready",
|
||||
readiness={
|
||||
"database": True,
|
||||
"access_capability": False,
|
||||
"administrator_absent": False,
|
||||
},
|
||||
)
|
||||
enrollment = first_admin_enrollment_status(
|
||||
session,
|
||||
installation_id=str(getattr(settings, "installation_id", "govoplan-local")),
|
||||
provisioner=provisioner,
|
||||
)
|
||||
return FirstAdminReadinessResponse(
|
||||
enrollment_required=enrollment.enrollment_required,
|
||||
credential_active=enrollment.credential_active,
|
||||
state=enrollment.state,
|
||||
generation=enrollment.generation,
|
||||
expires_at=enrollment.expires_at,
|
||||
readiness=enrollment.readiness,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/first-admin",
|
||||
response_model=FirstAdminEnrollmentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def enroll_first_admin(
|
||||
payload: FirstAdminEnrollmentRequest,
|
||||
request: Request,
|
||||
x_govoplan_enrollment_token: str = Header(
|
||||
min_length=32,
|
||||
max_length=512,
|
||||
alias="X-GovOPlaN-Enrollment-Token",
|
||||
),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FirstAdminEnrollmentResponse:
|
||||
provisioner = _first_admin_provisioner(request, required=True)
|
||||
assert provisioner is not None
|
||||
try:
|
||||
result = consume_first_admin_credential(
|
||||
session,
|
||||
installation_id=str(getattr(settings, "installation_id", "govoplan-local")),
|
||||
provisioner=provisioner,
|
||||
secret=x_govoplan_enrollment_token,
|
||||
email=payload.email,
|
||||
display_name=payload.display_name,
|
||||
password=payload.password.get_secret_value(),
|
||||
tenant_slug=payload.tenant_slug,
|
||||
tenant_name=payload.tenant_name,
|
||||
)
|
||||
session.commit()
|
||||
except FirstAdminEnrollmentCredentialError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
except FirstAdminEnrollmentUnavailable as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail=str(exc)) from exc
|
||||
except FirstAdminEnrollmentConflict as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
administrator = result.administrator
|
||||
return FirstAdminEnrollmentResponse(
|
||||
account_id=administrator.account_id,
|
||||
membership_id=administrator.membership_id,
|
||||
tenant_id=administrator.tenant_id,
|
||||
email=administrator.email,
|
||||
display_name=administrator.display_name,
|
||||
replayed=result.replayed,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _first_admin_provisioner(
|
||||
request: Request,
|
||||
*,
|
||||
required: bool,
|
||||
) -> FirstAdminProvisioner | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry):
|
||||
if required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The module registry is not ready.",
|
||||
)
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
|
||||
if required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Install and enable the Access module before enrolling the first administrator.",
|
||||
)
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
|
||||
if not isinstance(capability, FirstAdminProvisioner):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="The Access first-administrator capability is invalid.",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FirstAdminEnrollmentRequest",
|
||||
"FirstAdminEnrollmentResponse",
|
||||
"FirstAdminReadinessResponse",
|
||||
"create_bootstrap_router",
|
||||
]
|
||||
@@ -12,6 +12,11 @@ from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
runtime_identity,
|
||||
)
|
||||
from govoplan_core.settings import Settings, settings
|
||||
|
||||
|
||||
@@ -49,11 +54,19 @@ async def lifespan(app: FastAPI):
|
||||
if registry is not None
|
||||
else ()
|
||||
)
|
||||
configured_identity = getattr(
|
||||
app.state,
|
||||
"govoplan_runtime_identity",
|
||||
None,
|
||||
)
|
||||
runtime_agent = RuntimeNodeAgent(
|
||||
settings=settings,
|
||||
software_version=app.version,
|
||||
module_ids=module_ids,
|
||||
metadata={"process": "api"},
|
||||
identity=configured_identity
|
||||
if isinstance(configured_identity, RuntimeIdentity)
|
||||
else None,
|
||||
)
|
||||
await runtime_agent.start()
|
||||
app.state.govoplan_runtime_agent = runtime_agent
|
||||
@@ -73,6 +86,17 @@ def register_health_details(
|
||||
active_settings = (
|
||||
config_settings if isinstance(config_settings, Settings) else settings
|
||||
)
|
||||
module_ids = tuple(manifest.id for manifest in registry.manifests())
|
||||
if not isinstance(
|
||||
getattr(app.state, "govoplan_runtime_identity", None),
|
||||
RuntimeIdentity,
|
||||
):
|
||||
app.state.govoplan_runtime_identity = runtime_identity(
|
||||
active_settings,
|
||||
software_version=app.version,
|
||||
module_ids=module_ids,
|
||||
)
|
||||
bind_process_runtime_identity(app.state.govoplan_runtime_identity)
|
||||
|
||||
@app.get("/health/details")
|
||||
def health_details(
|
||||
|
||||
@@ -5,9 +5,17 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
module_entitlement_payload,
|
||||
tenant_module_entitlement_state,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.platform_interfaces import (
|
||||
manifest_interface_catalog,
|
||||
platform_interface_catalog,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||
from govoplan_core.core.views import (
|
||||
VIEW_SURFACE_CONTRACT_VERSION,
|
||||
@@ -17,6 +25,7 @@ from govoplan_core.core.views import (
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
def _registry(request: Request) -> PlatformRegistry:
|
||||
@@ -26,6 +35,49 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
return registry
|
||||
|
||||
|
||||
def _effective_manifest_state(
|
||||
request: Request,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[PlatformRegistry, tuple[ModuleManifest, ...], object | None]:
|
||||
"""Resolve only manifests available in the principal's active context."""
|
||||
|
||||
registry = _registry(request)
|
||||
manifests = tuple(registry.manifests())
|
||||
entitlement = None
|
||||
principal_ref = getattr(principal, "principal", None)
|
||||
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||
if tenant_id is not None:
|
||||
try:
|
||||
with get_database().session() as session:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="The active tenant is unavailable.",
|
||||
)
|
||||
manifest_map = {manifest.id: manifest for manifest in manifests}
|
||||
entitlement = tenant_module_entitlement_state(
|
||||
tenant.settings or {},
|
||||
manifest_map,
|
||||
runtime_active_modules=manifest_map,
|
||||
)
|
||||
except (RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Tenant module entitlement could not be resolved.",
|
||||
) from exc
|
||||
effective_ids = (
|
||||
set(entitlement.effective_modules)
|
||||
if entitlement is not None
|
||||
else {manifest.id for manifest in manifests}
|
||||
)
|
||||
return (
|
||||
registry,
|
||||
tuple(manifest for manifest in manifests if manifest.id in effective_ids),
|
||||
entitlement,
|
||||
)
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": item.path,
|
||||
@@ -156,9 +208,14 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
@router.get("/modules")
|
||||
def modules(
|
||||
request: Request,
|
||||
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
registry = _registry(request)
|
||||
registry, manifests, entitlement = _effective_manifest_state(
|
||||
request,
|
||||
principal,
|
||||
)
|
||||
principal_ref = getattr(principal, "principal", None)
|
||||
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
@@ -178,13 +235,36 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
for declaration in manifest.external_providers
|
||||
],
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"interface_catalog": {
|
||||
key: value
|
||||
for key, value in manifest_interface_catalog(manifest).items()
|
||||
if key != "declarations"
|
||||
},
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
for manifest in manifests
|
||||
],
|
||||
"module_entitlement": (
|
||||
module_entitlement_payload(tenant_id, entitlement)
|
||||
if tenant_id is not None and entitlement is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
@router.get("/interface-catalog")
|
||||
def interface_catalog(
|
||||
request: Request,
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_any_scope("admin:module:read", "system:settings:read")
|
||||
),
|
||||
):
|
||||
_registry_item, manifests, _entitlement = _effective_manifest_state(
|
||||
request,
|
||||
principal,
|
||||
)
|
||||
return platform_interface_catalog(manifests)
|
||||
|
||||
@router.get("/public-modules")
|
||||
def public_modules(request: Request):
|
||||
registry = _registry(request)
|
||||
|
||||
@@ -17,6 +17,21 @@ from govoplan_core.db.session import get_database
|
||||
logger = logging.getLogger("govoplan.runtime")
|
||||
|
||||
|
||||
def application_runtime_identity(app: object) -> RuntimeIdentity:
|
||||
"""Return the registered identity used to fence request-owned effects."""
|
||||
|
||||
state = getattr(app, "state", None)
|
||||
agent = getattr(state, "govoplan_runtime_agent", None)
|
||||
identity = getattr(agent, "identity", None) or getattr(
|
||||
state,
|
||||
"govoplan_runtime_identity",
|
||||
None,
|
||||
)
|
||||
if not isinstance(identity, RuntimeIdentity):
|
||||
raise RuntimeError("The application runtime identity is not available")
|
||||
return identity
|
||||
|
||||
|
||||
class RuntimeNodeAgent:
|
||||
"""Register one process in the shared runtime directory and heartbeat it."""
|
||||
|
||||
@@ -30,9 +45,10 @@ class RuntimeNodeAgent:
|
||||
node_id: str | None = None,
|
||||
queues: tuple[str, ...] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
identity: RuntimeIdentity | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.identity: RuntimeIdentity = runtime_identity(
|
||||
self.identity: RuntimeIdentity = identity or runtime_identity(
|
||||
settings,
|
||||
software_version=software_version,
|
||||
module_ids=module_ids,
|
||||
@@ -126,4 +142,4 @@ class RuntimeNodeAgent:
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["RuntimeNodeAgent"]
|
||||
__all__ = ["RuntimeNodeAgent", "application_runtime_identity"]
|
||||
|
||||
@@ -107,7 +107,7 @@ class Settings(BaseSettings):
|
||||
default=(
|
||||
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
||||
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
||||
"datasources,dataflow,dist_lists,workflow_engine,workflow,views,search,risk_compliance,"
|
||||
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,"
|
||||
"postbox,notifications,docs,ops"
|
||||
),
|
||||
alias="ENABLED_MODULES",
|
||||
@@ -115,6 +115,12 @@ class Settings(BaseSettings):
|
||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
celery_visibility_timeout_seconds: int = Field(
|
||||
default=3600,
|
||||
ge=30,
|
||||
le=7 * 24 * 60 * 60,
|
||||
alias="CELERY_VISIBILITY_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
|
||||
s3_region: str = Field(default="garage", alias="S3_REGION")
|
||||
@@ -192,6 +198,18 @@ class Settings(BaseSettings):
|
||||
le=100_000,
|
||||
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
tenant_module_entitlement_cache_ttl_seconds: int = Field(
|
||||
default=5,
|
||||
ge=0,
|
||||
le=300,
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS",
|
||||
)
|
||||
tenant_module_entitlement_cache_max_entries: int = Field(
|
||||
default=2048,
|
||||
ge=1,
|
||||
le=100_000,
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
@@ -253,6 +271,19 @@ class Settings(BaseSettings):
|
||||
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
|
||||
dev_mailbox_api_enabled: bool = Field(default=False, alias="DEV_MAILBOX_API_ENABLED")
|
||||
|
||||
# Production first-administrator enrollment. The credential is issued only
|
||||
# by the local operator command and is unrelated to development bootstrap.
|
||||
first_admin_enrollment_ttl_seconds: int = Field(
|
||||
default=30 * 60,
|
||||
ge=60,
|
||||
le=24 * 60 * 60,
|
||||
alias="FIRST_ADMIN_ENROLLMENT_TTL_SECONDS",
|
||||
)
|
||||
first_admin_enrollment_file: str = Field(
|
||||
default="/run/govoplan/first-admin-enrollment.json",
|
||||
alias="FIRST_ADMIN_ENROLLMENT_FILE",
|
||||
)
|
||||
|
||||
# Comma-separated list. Use * only for local development.
|
||||
cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS")
|
||||
|
||||
|
||||
@@ -426,6 +426,10 @@ class _FakeCampaignPolicyContextProvider:
|
||||
|
||||
|
||||
class _FakeCampaignDeliveryTaskProvider:
|
||||
def tenant_id_for_job(self, session: object, *, job_id: str):
|
||||
del session, job_id
|
||||
return "tenant-1"
|
||||
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True):
|
||||
del session
|
||||
return {"job_id": job_id, "enqueue_imap_task": enqueue_imap_task}
|
||||
|
||||
+67
-9
@@ -44,6 +44,11 @@ from govoplan_core.db.migrations import alembic_config
|
||||
from govoplan_core.db.session import configure_database, set_database
|
||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
||||
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryOperation,
|
||||
RecoveryStatus,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.tenancy.scope import create_scope_tables, scope_registry
|
||||
from govoplan_access.backend.permissions.catalog import permission_catalog as access_permission_catalog
|
||||
|
||||
@@ -101,6 +106,14 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
payload = response.json()
|
||||
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
||||
|
||||
def _stored_campaign_eml(self, job: object) -> bytes:
|
||||
from govoplan_files.backend.storage.backends import get_storage_backend
|
||||
|
||||
self.assertIsNone(getattr(job, "eml_local_path", None))
|
||||
storage_key = getattr(job, "eml_storage_key", None)
|
||||
self.assertTrue(storage_key)
|
||||
return get_storage_backend().get_bytes(str(storage_key))
|
||||
|
||||
def _create_test_mail_profile(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
@@ -3304,6 +3317,29 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
json={"write_eml": False},
|
||||
)
|
||||
self.assertEqual(built.status_code, 200, built.text)
|
||||
replayed_build = self.client.post(
|
||||
f"/api/v1/campaigns/versions/{version_id}/build",
|
||||
headers=headers,
|
||||
json={"write_eml": False},
|
||||
)
|
||||
self.assertEqual(replayed_build.status_code, 200, replayed_build.text)
|
||||
with SessionLocal() as session:
|
||||
operations = (
|
||||
session.query(RecoveryOperation)
|
||||
.filter(
|
||||
RecoveryOperation.module_id == "campaigns",
|
||||
RecoveryOperation.resource_id == version_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(1, len(operations))
|
||||
self.assertEqual(
|
||||
RecoveryStatus.SUCCEEDED.value,
|
||||
operations[0].status,
|
||||
)
|
||||
self.assertTrue(
|
||||
verify_recovery_evidence_chain(session, operations[0].id)
|
||||
)
|
||||
self.assertEqual(built.json()["built_count"], 1)
|
||||
|
||||
mocked = self.client.post(
|
||||
@@ -3742,7 +3778,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertTrue(job.eml_local_path)
|
||||
self.assertIsNone(job.eml_local_path)
|
||||
self.assertTrue(job.eml_storage_key)
|
||||
built_use = (
|
||||
session.query(CampaignAttachmentUse)
|
||||
.filter(
|
||||
@@ -3979,7 +4016,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id).one()
|
||||
self.assertEqual([item["email"] for item in job.resolved_recipients["from_all"]], ["local-from@example.org"])
|
||||
self.assertEqual([item["email"] for item in job.resolved_recipients["to"]], ["global-to@example.org", "local-to@example.org"])
|
||||
message = BytesParser(policy=policy.default).parsebytes(Path(job.eml_local_path).read_bytes())
|
||||
message = BytesParser(policy=policy.default).parsebytes(
|
||||
self._stored_campaign_eml(job)
|
||||
)
|
||||
self.assertIsNone(message["Sender"])
|
||||
self.assertEqual([address.addr_spec for address in message["From"].addresses], ["local-from@example.org"])
|
||||
self.assertEqual([address.addr_spec for address in message["To"].addresses], ["global-to@example.org", "local-to@example.org"])
|
||||
@@ -4229,8 +4268,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id).one()
|
||||
eml_path = Path(job.eml_local_path)
|
||||
message = BytesParser(policy=policy.default).parsebytes(eml_path.read_bytes())
|
||||
message = BytesParser(policy=policy.default).parsebytes(
|
||||
self._stored_campaign_eml(job)
|
||||
)
|
||||
uses = (
|
||||
session.query(CampaignAttachmentUse)
|
||||
.filter(
|
||||
@@ -4337,8 +4377,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertIsNotNone(job.eml_local_path)
|
||||
generated_eml = Path(job.eml_local_path).read_bytes()
|
||||
job_id = job.id
|
||||
generated_eml = self._stored_campaign_eml(job)
|
||||
|
||||
sent = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/send-now",
|
||||
@@ -4359,6 +4399,18 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertTrue(raw_filename)
|
||||
captured_eml = (_TEST_ROOT / "mock-mailbox" / "messages" / str(raw_filename)).read_bytes()
|
||||
self.assertEqual(captured_eml, generated_eml)
|
||||
with SessionLocal() as session:
|
||||
operation = (
|
||||
session.query(RecoveryOperation)
|
||||
.filter(
|
||||
RecoveryOperation.operation_type
|
||||
== "external-channel-delivery",
|
||||
RecoveryOperation.resource_id == job_id,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(operation.status, RecoveryStatus.SUCCEEDED.value)
|
||||
self.assertTrue(verify_recovery_evidence_chain(session, operation.id))
|
||||
|
||||
def test_send_now_rejects_modified_generated_eml_before_delivery(self) -> None:
|
||||
headers, _ = self._login()
|
||||
@@ -4368,13 +4420,19 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignJob
|
||||
from govoplan_files.backend.storage.backends import get_storage_backend
|
||||
from govoplan_mail.backend.dev.mock_mailbox import list_records
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertIsNotNone(job.eml_local_path)
|
||||
eml_path = Path(job.eml_local_path)
|
||||
eml_path.write_bytes(eml_path.read_bytes() + b"\r\nX-Tampered: true\r\n")
|
||||
storage_key = job.eml_storage_key
|
||||
generated_eml = self._stored_campaign_eml(job)
|
||||
assert storage_key is not None
|
||||
get_storage_backend().put_bytes(
|
||||
storage_key,
|
||||
generated_eml + b"\r\nX-Tampered: true\r\n",
|
||||
content_type="message/rfc822",
|
||||
)
|
||||
|
||||
sent = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/send-now",
|
||||
|
||||
@@ -181,6 +181,14 @@ class AutomationContractTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(preview.allowed)
|
||||
self.assertEqual("compensatable", preview.reversibility)
|
||||
self.assertEqual("forward_recovery", provider.action.recovery_mode)
|
||||
self.assertEqual(
|
||||
(
|
||||
"verify the provider result and every announced effect "
|
||||
"before continuation",
|
||||
),
|
||||
provider.action.recovery_verification,
|
||||
)
|
||||
self.assertEqual("completed", result.state)
|
||||
self.assertEqual(
|
||||
"postbox-message:1",
|
||||
@@ -234,6 +242,22 @@ class AutomationContractTests(unittest.TestCase):
|
||||
description="Test effect",
|
||||
contract_version="2",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "recovery verification"):
|
||||
ActionDefinition(
|
||||
action_key="invalid.recovery",
|
||||
owner_module="test",
|
||||
description="Invalid recovery declaration",
|
||||
input_schema_ref="schema:invalid.recovery@1",
|
||||
recovery_verification=(),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "recovery mode"):
|
||||
ActionDefinition(
|
||||
action_key="invalid.recovery-mode",
|
||||
owner_module="test",
|
||||
description="Invalid recovery mode",
|
||||
input_schema_ref="schema:invalid.recovery-mode@1",
|
||||
recovery_mode="best_effort", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||
@@ -22,6 +23,10 @@ class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||
|
||||
with (
|
||||
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||
):
|
||||
result = dispatch_calendar_outbox.run("tenant-1", 25)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.celery_app import celery
|
||||
from govoplan_core.celery_app import celery, worker_acceptance_probe
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
@@ -26,9 +27,33 @@ class CeleryQueueContractTests(unittest.TestCase):
|
||||
def test_delivery_tasks_keep_worker_loss_protection(self) -> None:
|
||||
self.assertTrue(celery.conf.task_acks_late)
|
||||
self.assertTrue(celery.conf.task_reject_on_worker_lost)
|
||||
self.assertTrue(celery.conf.task_track_started)
|
||||
self.assertEqual(1, celery.conf.worker_prefetch_multiplier)
|
||||
self.assertEqual(
|
||||
settings.celery_visibility_timeout_seconds,
|
||||
celery.conf.broker_transport_options["visibility_timeout"],
|
||||
)
|
||||
|
||||
def test_worker_acceptance_probe_is_bounded_and_side_effect_free(self) -> None:
|
||||
with patch.object(worker_acceptance_probe, "update_state") as update_state:
|
||||
result = worker_acceptance_probe.run(
|
||||
"probe-1",
|
||||
mode="complete",
|
||||
delay_seconds=0,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"probe_id": "probe-1",
|
||||
"mode": "complete",
|
||||
"retries": 0,
|
||||
"redelivered": False,
|
||||
"delivery_count": None,
|
||||
},
|
||||
result,
|
||||
)
|
||||
update_state.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.connector_runtime import (
|
||||
CONNECTOR_RUNTIME_CONTRACT_VERSION,
|
||||
ConnectorContractError,
|
||||
ConnectorDryRunResult,
|
||||
ConnectorEffectPreview,
|
||||
ConnectorEndpoint,
|
||||
summarize_connector_effects,
|
||||
)
|
||||
|
||||
|
||||
class ConnectorRuntimeContractTests(unittest.TestCase):
|
||||
def test_endpoint_rejects_embedded_credentials(self) -> None:
|
||||
with self.assertRaisesRegex(ConnectorContractError, "must not contain credentials"):
|
||||
ConnectorEndpoint(url="ldaps://user:secret@directory.example.test")
|
||||
|
||||
def test_dry_run_is_bounded_consistent_and_apply_gated(self) -> None:
|
||||
effects = (
|
||||
ConnectorEffectPreview(
|
||||
effect="create",
|
||||
source_object_ref="ldap:uid=one",
|
||||
sample={"display_name": "Example Person"},
|
||||
),
|
||||
ConnectorEffectPreview(
|
||||
effect="unchanged",
|
||||
source_object_ref="ldap:uid=two",
|
||||
target_object_ref="contact-2",
|
||||
),
|
||||
)
|
||||
result = ConnectorDryRunResult(
|
||||
contract_version=CONNECTOR_RUNTIME_CONTRACT_VERSION,
|
||||
source_ref="source-1",
|
||||
source_revision="revision-1",
|
||||
source_fingerprint="a" * 64,
|
||||
input_hash="b" * 64,
|
||||
generated_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
summary=summarize_connector_effects(effects),
|
||||
effects=effects,
|
||||
apply_token="plan-1",
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.summary.creates)
|
||||
self.assertEqual(1, result.summary.unchanged)
|
||||
self.assertTrue(result.can_apply)
|
||||
|
||||
def test_summary_must_describe_exact_returned_effects(self) -> None:
|
||||
with self.assertRaisesRegex(ConnectorContractError, "summary counts"):
|
||||
ConnectorDryRunResult(
|
||||
contract_version=CONNECTOR_RUNTIME_CONTRACT_VERSION,
|
||||
source_ref="source-1",
|
||||
source_revision="revision-1",
|
||||
source_fingerprint="a" * 64,
|
||||
input_hash="b" * 64,
|
||||
generated_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
summary=summarize_connector_effects(()),
|
||||
effects=(
|
||||
ConnectorEffectPreview(
|
||||
effect="create",
|
||||
source_object_ref="source:one",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.contact_points import (
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||
CONTACT_POINT_CONTRACT_VERSION,
|
||||
ContactPointResolutionRequest,
|
||||
contact_point_resolution_provider,
|
||||
)
|
||||
from govoplan_core.core.distribution_lists import DistributionSourceReference
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None = None) -> None:
|
||||
self._capability = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION
|
||||
and self._capability is not None
|
||||
)
|
||||
|
||||
def capability(self, _name: str) -> object:
|
||||
return self._capability
|
||||
|
||||
|
||||
class ContactPointContractTests(unittest.TestCase):
|
||||
def test_request_is_provider_neutral_and_versioned(self) -> None:
|
||||
request = ContactPointResolutionRequest(
|
||||
tenant_id="tenant-1",
|
||||
subject=DistributionSourceReference(
|
||||
provider="idm",
|
||||
resource_type="identity",
|
||||
resource_id="identity-1",
|
||||
),
|
||||
effective_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
requested_channels=("email", "postal"),
|
||||
address_purpose="official",
|
||||
fallback_rule="primary",
|
||||
postal_format="international",
|
||||
)
|
||||
|
||||
self.assertEqual("1.0", CONTACT_POINT_CONTRACT_VERSION)
|
||||
self.assertEqual("idm", request.subject.provider)
|
||||
self.assertEqual(("email", "postal"), request.requested_channels)
|
||||
|
||||
def test_accessor_rejects_objects_that_do_not_implement_the_protocol(self) -> None:
|
||||
self.assertIsNone(contact_point_resolution_provider(None))
|
||||
self.assertIsNone(contact_point_resolution_provider(_Registry(object())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
@@ -16,6 +18,7 @@ from govoplan_core.db.migrations import (
|
||||
migrate_database,
|
||||
reconcile_change_sequence_retention_floor_drift,
|
||||
reconcile_namespace_table_drift,
|
||||
validate_unique_migration_revisions,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +43,25 @@ def database_migration_heads(connection) -> set[str]:
|
||||
|
||||
|
||||
class DatabaseMigrationTests(unittest.TestCase):
|
||||
def test_duplicate_module_revision_ids_are_rejected_with_file_provenance(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-duplicate-revision-test-") as directory:
|
||||
root = Path(directory)
|
||||
first = root / "first"
|
||||
second = root / "second"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
(first / "first.py").write_text('revision = "duplicate123"\n', encoding="utf-8")
|
||||
(second / "second.py").write_text('revision: str = "duplicate123"\n', encoding="utf-8")
|
||||
config = Config()
|
||||
config.set_main_option("version_locations", os.pathsep.join((str(first), str(second))))
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "duplicate123") as raised:
|
||||
validate_unique_migration_revisions(config)
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertIn("first.py", message)
|
||||
self.assertIn("second.py", message)
|
||||
|
||||
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
previous_disabled = logger.disabled
|
||||
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.celery_app import (
|
||||
dispatch_dataflow_runs,
|
||||
purge_dataflow_runs,
|
||||
)
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class DataflowRunWorkerTests(unittest.TestCase):
|
||||
@@ -30,11 +31,16 @@ class DataflowRunWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_dataflow_runs.run(7)
|
||||
|
||||
provider.dispatch_pending.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=7,
|
||||
worker_id=ANY,
|
||||
)
|
||||
@@ -57,10 +63,18 @@ class DataflowRunWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = purge_dataflow_runs.run(25)
|
||||
|
||||
provider.purge_expired.assert_called_once_with(session, limit=25)
|
||||
provider.purge_expired.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(2, result["purged"])
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||
@@ -30,10 +31,18 @@ class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_dataflow_triggers.run(25)
|
||||
|
||||
provider.dispatch_due.assert_called_once_with(session, limit=25)
|
||||
provider.dispatch_due.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(result["succeeded"], 1)
|
||||
|
||||
|
||||
@@ -95,6 +95,17 @@ class _Provider:
|
||||
del session, principal, datasource_ref
|
||||
return self.promote_stage(object(), object(), stage_ref="stage:1")
|
||||
|
||||
def update_datasource_governance(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
datasource_ref,
|
||||
governance,
|
||||
):
|
||||
del session, principal, datasource_ref, governance
|
||||
return self.descriptor
|
||||
|
||||
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
|
||||
del session, principal, datasource_ref, label
|
||||
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_core.commands.first_admin import _write_private_json
|
||||
from govoplan_core.core.access import (
|
||||
FirstAdminProvisioner,
|
||||
FirstAdminProvisioningError,
|
||||
FirstSystemAdministratorRef,
|
||||
)
|
||||
from govoplan_core.core.first_admin import (
|
||||
FirstAdminEnrollment,
|
||||
FirstAdminEnrollmentConflict,
|
||||
FirstAdminEnrollmentCredentialError,
|
||||
FirstAdminEnrollmentEvent,
|
||||
FirstAdminEnrollmentState,
|
||||
FirstAdminEnrollmentUnavailable,
|
||||
consume_first_admin_credential,
|
||||
first_admin_enrollment_status,
|
||||
issue_first_admin_credential,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.server.bootstrap import create_bootstrap_router
|
||||
from govoplan_core.tenancy.scope import scope_registry
|
||||
|
||||
|
||||
class _Provisioner(FirstAdminProvisioner):
|
||||
def __init__(self, *, administrator_exists: bool = False, fail_create: bool = False) -> None:
|
||||
self.administrator_exists = administrator_exists
|
||||
self.fail_create = fail_create
|
||||
self.create_count = 0
|
||||
|
||||
def has_durable_system_administrator(self, session: object) -> bool:
|
||||
del session
|
||||
return self.administrator_exists
|
||||
|
||||
def create_first_system_administrator(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
) -> FirstSystemAdministratorRef:
|
||||
del session, password
|
||||
if self.fail_create:
|
||||
raise FirstAdminProvisioningError("simulated authority failure")
|
||||
self.create_count += 1
|
||||
self.administrator_exists = True
|
||||
return FirstSystemAdministratorRef(
|
||||
account_id="account-1",
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
membership_id="membership-1",
|
||||
tenant_id=str(getattr(tenant, "id")),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
scope_registry.metadata.create_all(engine)
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
FirstAdminEnrollment.__table__,
|
||||
FirstAdminEnrollmentEvent.__table__,
|
||||
],
|
||||
)
|
||||
with Session(engine, expire_on_commit=False) as item:
|
||||
yield item
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_single_use_enrollment_creates_authority_and_replays_idempotently(
|
||||
session: Session,
|
||||
) -> None:
|
||||
provisioner = _Provisioner()
|
||||
now = datetime(2026, 8, 4, 12, tzinfo=timezone.utc)
|
||||
with patch("govoplan_core.core.first_admin.audit_event"):
|
||||
issued = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=900,
|
||||
reason="initial installation",
|
||||
now=now,
|
||||
)
|
||||
session.commit()
|
||||
result = consume_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
secret=issued.secret,
|
||||
email="owner@example.test",
|
||||
display_name="System Owner",
|
||||
password="a-production-password",
|
||||
tenant_slug="default",
|
||||
tenant_name="Default Tenant",
|
||||
now=now + timedelta(minutes=1),
|
||||
)
|
||||
session.commit()
|
||||
replay = consume_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
secret=issued.secret,
|
||||
email="owner@example.test",
|
||||
display_name="System Owner",
|
||||
password="a-production-password",
|
||||
tenant_slug="default",
|
||||
tenant_name="Default Tenant",
|
||||
now=now + timedelta(minutes=2),
|
||||
)
|
||||
|
||||
assert not result.replayed
|
||||
assert replay.replayed
|
||||
assert replay.administrator.account_id == "account-1"
|
||||
assert provisioner.create_count == 1
|
||||
enrollment = session.get(FirstAdminEnrollment, "installation-1")
|
||||
assert enrollment is not None
|
||||
assert enrollment.state == FirstAdminEnrollmentState.CONSUMED.value
|
||||
assert enrollment.consumed_account_id == "account-1"
|
||||
assert enrollment.token_sha256 != issued.secret
|
||||
evidence = session.scalars(
|
||||
select(FirstAdminEnrollmentEvent).order_by(FirstAdminEnrollmentEvent.sequence)
|
||||
).all()
|
||||
assert [item.event_type for item in evidence] == [
|
||||
"credential_issued",
|
||||
"administrator_created",
|
||||
]
|
||||
assert evidence[1].previous_sha256 == evidence[0].event_sha256
|
||||
assert issued.secret not in json.dumps([item.evidence for item in evidence])
|
||||
|
||||
|
||||
def test_consumed_credential_rejects_a_different_request(session: Session) -> None:
|
||||
provisioner = _Provisioner()
|
||||
with patch("govoplan_core.core.first_admin.audit_event"):
|
||||
issued = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=900,
|
||||
reason="initial installation",
|
||||
)
|
||||
consume_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
secret=issued.secret,
|
||||
email="owner@example.test",
|
||||
display_name=None,
|
||||
password="a-production-password",
|
||||
tenant_slug="default",
|
||||
tenant_name="Default Tenant",
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(FirstAdminEnrollmentCredentialError, match="already been used"):
|
||||
consume_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
secret=issued.secret,
|
||||
email="other@example.test",
|
||||
display_name=None,
|
||||
password="a-production-password",
|
||||
tenant_slug="default",
|
||||
tenant_name="Default Tenant",
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_rotates_lost_or_expired_material(session: Session) -> None:
|
||||
provisioner = _Provisioner()
|
||||
now = datetime(2026, 8, 4, 12, tzinfo=timezone.utc)
|
||||
with patch("govoplan_core.core.first_admin.audit_event"):
|
||||
first = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=60,
|
||||
reason="initial installation",
|
||||
now=now,
|
||||
)
|
||||
with pytest.raises(FirstAdminEnrollmentConflict, match="already exists"):
|
||||
issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=60,
|
||||
reason="duplicate issue",
|
||||
now=now + timedelta(seconds=30),
|
||||
)
|
||||
replacement = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=300,
|
||||
reason="lost credential",
|
||||
replace_active=True,
|
||||
now=now + timedelta(seconds=30),
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(FirstAdminEnrollmentCredentialError, match="invalid"):
|
||||
consume_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
secret=first.secret,
|
||||
email="owner@example.test",
|
||||
display_name=None,
|
||||
password="a-production-password",
|
||||
tenant_slug="default",
|
||||
tenant_name="Default Tenant",
|
||||
now=now + timedelta(seconds=40),
|
||||
)
|
||||
assert replacement.generation == 2
|
||||
|
||||
|
||||
def test_failed_authority_creation_rolls_back_without_consuming_secret(
|
||||
session: Session,
|
||||
) -> None:
|
||||
provisioner = _Provisioner(fail_create=True)
|
||||
with patch("govoplan_core.core.first_admin.audit_event"):
|
||||
issued = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=900,
|
||||
reason="initial installation",
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(FirstAdminEnrollmentConflict, match="simulated"):
|
||||
consume_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
secret=issued.secret,
|
||||
email="owner@example.test",
|
||||
display_name=None,
|
||||
password="a-production-password",
|
||||
tenant_slug="default",
|
||||
tenant_name="Default Tenant",
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
enrollment = session.get(FirstAdminEnrollment, "installation-1")
|
||||
assert enrollment is not None
|
||||
assert enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
assert enrollment.consumed_account_id is None
|
||||
|
||||
|
||||
def test_enrollment_is_unavailable_after_durable_admin_exists(session: Session) -> None:
|
||||
provisioner = _Provisioner(administrator_exists=True)
|
||||
with patch("govoplan_core.core.first_admin.audit_event"):
|
||||
with pytest.raises(FirstAdminEnrollmentUnavailable):
|
||||
issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=900,
|
||||
reason="must fail",
|
||||
)
|
||||
readiness = first_admin_enrollment_status(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
)
|
||||
assert not readiness.enrollment_required
|
||||
assert readiness.state == "completed"
|
||||
|
||||
|
||||
def test_operator_artifact_is_owner_readable_only(tmp_path: Path) -> None:
|
||||
output = tmp_path / "bootstrap" / "first-admin.json"
|
||||
_write_private_json(output, {"enrollment_token": "never-print-this"})
|
||||
|
||||
assert stat.S_IMODE(output.stat().st_mode) == 0o600
|
||||
assert output.read_text(encoding="utf-8").endswith("\n")
|
||||
assert os.geteuid() == output.stat().st_uid
|
||||
|
||||
|
||||
def test_public_api_is_limited_to_readiness_and_single_enrollment(
|
||||
session: Session,
|
||||
) -> None:
|
||||
provisioner = _Provisioner()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="test",
|
||||
capability_factories={
|
||||
"access.firstAdminProvisioner": lambda _context: provisioner,
|
||||
},
|
||||
)
|
||||
)
|
||||
settings = SimpleNamespace(installation_id="installation-1")
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=settings)
|
||||
)
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_bootstrap_router(settings), prefix="/api/v1")
|
||||
|
||||
def _session_override():
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = _session_override
|
||||
with patch("govoplan_core.core.first_admin.audit_event"):
|
||||
issued = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=900,
|
||||
reason="API test",
|
||||
)
|
||||
session.commit()
|
||||
with TestClient(app) as client:
|
||||
ready = client.get("/api/v1/bootstrap/status")
|
||||
enrolled = client.post(
|
||||
"/api/v1/bootstrap/first-admin",
|
||||
headers={"X-GovOPlaN-Enrollment-Token": issued.secret},
|
||||
json={
|
||||
"email": "owner@example.test",
|
||||
"display_name": "System Owner",
|
||||
"password": "a-production-password",
|
||||
"tenant_slug": "default",
|
||||
"tenant_name": "Default Tenant",
|
||||
},
|
||||
)
|
||||
completed = client.get("/api/v1/bootstrap/status")
|
||||
|
||||
assert ready.status_code == 200
|
||||
assert ready.json()["credential_active"] is True
|
||||
assert enrolled.status_code == 201
|
||||
assert enrolled.json()["bootstrap_retired"] is True
|
||||
assert completed.json()["state"] == "completed"
|
||||
|
||||
|
||||
def test_readiness_reports_missing_access_without_exposing_an_enrollment_api(
|
||||
session: Session,
|
||||
) -> None:
|
||||
registry = PlatformRegistry()
|
||||
settings = SimpleNamespace(installation_id="installation-1")
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_bootstrap_router(settings), prefix="/api/v1")
|
||||
|
||||
def _session_override():
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = _session_override
|
||||
with TestClient(app) as client:
|
||||
ready = client.get("/api/v1/bootstrap/status")
|
||||
rejected = client.post(
|
||||
"/api/v1/bootstrap/first-admin",
|
||||
headers={"X-GovOPlaN-Enrollment-Token": "x" * 48},
|
||||
json={
|
||||
"email": "owner@example.test",
|
||||
"password": "a-production-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert ready.json()["state"] == "not_ready"
|
||||
assert ready.json()["readiness"]["access_capability"] is False
|
||||
assert rejected.status_code == 503
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.core.idm import (
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _Lifecycle:
|
||||
@@ -70,6 +71,10 @@ class IdmAssignmentLifecycleWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = expire_idm_assignments.run("tenant-1", 25)
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_core.core.idm import (
|
||||
IdentityRelationshipDecisionRef,
|
||||
IdentityRelationshipRef,
|
||||
IdmRelationshipDirectory,
|
||||
TypedGroupMembershipResolutionRef,
|
||||
TypedGroupRef,
|
||||
)
|
||||
|
||||
|
||||
class RelationshipDirectoryStub:
|
||||
def get_typed_group(self, group_id, *, tenant_id=None):
|
||||
return None
|
||||
|
||||
def list_typed_groups(self, **kwargs):
|
||||
return ()
|
||||
|
||||
def identity_relationships_for_identity(self, identity_id, **kwargs):
|
||||
return ()
|
||||
|
||||
def identity_relationships_for_identities(self, identity_ids, **kwargs):
|
||||
return {identity_id: () for identity_id in identity_ids}
|
||||
|
||||
def identity_relationships_for_group(self, group_id, **kwargs):
|
||||
return ()
|
||||
|
||||
def identity_relationships_for_groups(self, group_ids, **kwargs):
|
||||
return {group_id: () for group_id in group_ids}
|
||||
|
||||
def resolve_typed_group_memberships(self, group_ids, **kwargs):
|
||||
return {}
|
||||
|
||||
|
||||
class IdmRelationshipContractTests(unittest.TestCase):
|
||||
def test_runtime_protocol_and_resolution_identity_projection(self) -> None:
|
||||
self.assertIsInstance(RelationshipDirectoryStub(), IdmRelationshipDirectory)
|
||||
relationship = IdentityRelationshipRef(
|
||||
id="relationship-1",
|
||||
tenant_id="tenant-1",
|
||||
relationship_kind="member",
|
||||
subject_identity_id="identity-1",
|
||||
target_group_id="group-1",
|
||||
)
|
||||
resolution = TypedGroupMembershipResolutionRef(
|
||||
group=TypedGroupRef(
|
||||
id="group-1",
|
||||
tenant_id="tenant-1",
|
||||
key="group",
|
||||
name="Group",
|
||||
group_type="business_status",
|
||||
),
|
||||
effective_at=datetime(2026, 8, 2, tzinfo=timezone.utc),
|
||||
decisions=(
|
||||
IdentityRelationshipDecisionRef(
|
||||
relationship=relationship,
|
||||
included=True,
|
||||
code="relationship.effective",
|
||||
explanation="The relationship is effective.",
|
||||
identity_status="active",
|
||||
),
|
||||
IdentityRelationshipDecisionRef(
|
||||
relationship=relationship,
|
||||
included=False,
|
||||
code="relationship.revoked",
|
||||
explanation="The relationship was revoked.",
|
||||
identity_status="active",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(("identity-1",), resolution.identity_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,6 +10,7 @@ from govoplan_core.celery_app import (
|
||||
dispatch_mail_outbox,
|
||||
purge_mail_outbox,
|
||||
)
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _Provider:
|
||||
@@ -35,6 +36,10 @@ class MailDeliveryWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.celery_app._mail_delivery_outbox",
|
||||
return_value=_Provider(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_mail_outbox.run("tenant-1", 7)
|
||||
|
||||
@@ -56,10 +61,15 @@ class MailDeliveryWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.celery_app._mail_delivery_outbox",
|
||||
return_value=_Provider(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = purge_mail_outbox.run(19)
|
||||
|
||||
self.assertIs(result["session"], session)
|
||||
self.assertEqual(result["tenant_id"], "tenant-1")
|
||||
self.assertEqual(result["limit"], 19)
|
||||
|
||||
def test_worker_routes_and_schedules_are_declared(self) -> None:
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.celery_app import _run_tenant_worker_batches
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.lifecycle import require_module_active
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementConflict,
|
||||
ModuleEntitlementError,
|
||||
TenantModuleEntitlementResolver,
|
||||
TenantModuleOperatorActionRequired,
|
||||
TenantModuleUnavailable,
|
||||
tenant_module_entitlement_state,
|
||||
update_system_tenant_module_policy,
|
||||
update_tenant_module_selection,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
|
||||
class TenantModuleEntitlementTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.manifests = {
|
||||
"access": ModuleManifest(id="access", name="Access", version="test"),
|
||||
"admin": ModuleManifest(
|
||||
id="admin",
|
||||
name="Admin",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
"files": ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
"campaigns": ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="test",
|
||||
dependencies=("access", "files"),
|
||||
),
|
||||
"encryption": ModuleManifest(
|
||||
id="encryption",
|
||||
name="Encryption",
|
||||
version="test",
|
||||
),
|
||||
}
|
||||
|
||||
def test_unconfigured_tenant_preserves_current_module_visibility(self) -> None:
|
||||
state = tenant_module_entitlement_state({}, self.manifests)
|
||||
|
||||
self.assertFalse(state.configured)
|
||||
self.assertEqual(set(self.manifests), set(state.effective_modules))
|
||||
self.assertEqual({"access", "admin"}, set(state.forced_modules))
|
||||
|
||||
def test_system_policy_closes_dependencies_and_tenant_selection(self) -> None:
|
||||
settings, state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("campaigns", "encryption"),
|
||||
forced_modules=("campaigns",),
|
||||
enabled_modules=("encryption",),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
self.assertEqual(1, state.revision)
|
||||
self.assertEqual(
|
||||
{"access", "admin", "files", "campaigns", "encryption"},
|
||||
set(state.available_modules),
|
||||
)
|
||||
self.assertEqual(
|
||||
{"access", "admin", "files", "campaigns"},
|
||||
set(state.forced_modules),
|
||||
)
|
||||
self.assertEqual({"encryption"}, set(state.selected_modules))
|
||||
self.assertEqual(set(self.manifests), set(state.effective_modules))
|
||||
self.assertIn("module_entitlements", settings)
|
||||
|
||||
def test_tenant_cannot_enable_system_unavailable_module(self) -> None:
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("files",),
|
||||
forced_modules=(),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ModuleEntitlementError,
|
||||
"unavailable by system policy: encryption",
|
||||
):
|
||||
update_tenant_module_selection(
|
||||
settings,
|
||||
self.manifests,
|
||||
enabled_modules=("encryption",),
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
def test_forced_modules_remain_effective_when_tenant_selection_is_empty(self) -> None:
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("campaigns",),
|
||||
forced_modules=("campaigns",),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
_settings, state = update_tenant_module_selection(
|
||||
settings,
|
||||
self.manifests,
|
||||
enabled_modules=(),
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{"access", "admin", "files", "campaigns"},
|
||||
set(state.effective_modules),
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
not item.tenant_can_toggle
|
||||
for item in state.modules
|
||||
if item.id in state.forced_modules
|
||||
)
|
||||
)
|
||||
|
||||
def test_inactive_runtime_module_is_selected_but_not_effective(self) -> None:
|
||||
settings, state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("files", "encryption"),
|
||||
forced_modules=(),
|
||||
enabled_modules=("encryption",),
|
||||
expected_revision=0,
|
||||
runtime_active_modules=("access", "admin", "files"),
|
||||
)
|
||||
|
||||
self.assertIn("encryption", state.selected_modules)
|
||||
self.assertNotIn("encryption", state.effective_modules)
|
||||
encryption = next(item for item in state.modules if item.id == "encryption")
|
||||
self.assertIn("not active in the deployment", encryption.reason or "")
|
||||
self.assertIn("module_entitlements", settings)
|
||||
|
||||
def test_stale_revision_is_rejected(self) -> None:
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("files",),
|
||||
forced_modules=(),
|
||||
enabled_modules=("files",),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
with self.assertRaises(ModuleEntitlementConflict):
|
||||
update_tenant_module_selection(
|
||||
settings,
|
||||
self.manifests,
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
def test_malformed_document_fails_closed_to_protected_modules(self) -> None:
|
||||
state = tenant_module_entitlement_state(
|
||||
{"module_entitlements": {"revision": "invalid"}},
|
||||
self.manifests,
|
||||
)
|
||||
|
||||
self.assertEqual({"access", "admin"}, set(state.effective_modules))
|
||||
self.assertTrue(state.diagnostics)
|
||||
|
||||
def test_resolver_caches_and_invalidates_tenant_state(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for manifest in self.manifests.values():
|
||||
registry.register(manifest)
|
||||
tenant = SimpleNamespace(id="tenant-1", is_active=True, settings={})
|
||||
|
||||
class CountingSession:
|
||||
calls = 0
|
||||
|
||||
def get(self, _model, _tenant_id):
|
||||
self.calls += 1
|
||||
return tenant
|
||||
|
||||
session = CountingSession()
|
||||
resolver = TenantModuleEntitlementResolver(
|
||||
registry,
|
||||
ttl_seconds=60,
|
||||
max_entries=2,
|
||||
)
|
||||
|
||||
resolver.resolve(session, "tenant-1")
|
||||
resolver.resolve(session, "tenant-1")
|
||||
self.assertEqual(1, session.calls)
|
||||
|
||||
resolver.invalidate("tenant-1")
|
||||
resolver.resolve(session, "tenant-1")
|
||||
self.assertEqual(2, session.calls)
|
||||
|
||||
def test_new_and_accepted_work_have_distinct_disable_semantics(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for manifest in self.manifests.values():
|
||||
registry.register(manifest)
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=(),
|
||||
forced_modules=(),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
tenant = SimpleNamespace(
|
||||
id="tenant-1",
|
||||
is_active=True,
|
||||
settings=settings,
|
||||
)
|
||||
session = SimpleNamespace(get=lambda _model, _tenant_id: tenant)
|
||||
resolver = TenantModuleEntitlementResolver(registry, ttl_seconds=0)
|
||||
|
||||
with self.assertRaises(TenantModuleUnavailable) as rejected:
|
||||
resolver.require(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
work_state="new",
|
||||
)
|
||||
self.assertEqual("rejected", rejected.exception.admission.disposition)
|
||||
|
||||
with self.assertRaises(TenantModuleOperatorActionRequired) as preserved:
|
||||
resolver.require(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
work_state="accepted",
|
||||
)
|
||||
self.assertEqual(
|
||||
"operator_action_required",
|
||||
preserved.exception.admission.disposition,
|
||||
)
|
||||
|
||||
|
||||
class TenantModuleEntitlementRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
|
||||
configure_database(f"sqlite:///{root / 'test.db'}")
|
||||
create_scope_tables(get_database().engine)
|
||||
self.manifests = (
|
||||
ModuleManifest(id="access", name="Access", version="test"),
|
||||
ModuleManifest(
|
||||
id="admin",
|
||||
name="Admin",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
)
|
||||
self.registry = PlatformRegistry()
|
||||
for manifest in self.manifests:
|
||||
self.registry.register(manifest)
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
{manifest.id: manifest for manifest in self.manifests},
|
||||
available_modules=(),
|
||||
forced_modules=(),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
with get_database().session() as session:
|
||||
session.add(
|
||||
Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
settings=settings,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
def test_platform_metadata_excludes_tenant_unavailable_module(self) -> None:
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = self.registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual(
|
||||
{"access", "admin"},
|
||||
{item["id"] for item in response.json()["modules"]},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"files",
|
||||
response.json()["module_entitlement"]["effective_modules"],
|
||||
)
|
||||
|
||||
def test_authenticated_module_route_is_hidden_when_tenant_unavailable(self) -> None:
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = self.registry
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||
|
||||
@guarded.get("/files")
|
||||
def files_route():
|
||||
return {"ok": True}
|
||||
|
||||
app.include_router(guarded)
|
||||
with patch(
|
||||
"govoplan_core.core.lifecycle.get_api_principal",
|
||||
return_value=self.principal,
|
||||
), TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/files",
|
||||
headers={"Authorization": "Bearer test"},
|
||||
)
|
||||
|
||||
self.assertEqual(404, response.status_code, response.text)
|
||||
self.assertEqual(
|
||||
"Module is unavailable in the active tenant: files",
|
||||
response.json()["detail"],
|
||||
)
|
||||
|
||||
def test_unauthenticated_public_route_is_not_turned_into_login(self) -> None:
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = self.registry
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||
|
||||
@guarded.get("/public-files")
|
||||
def public_files_route():
|
||||
return {"ok": True}
|
||||
|
||||
app.include_router(guarded)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/public-files")
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_public_tenant_route_enforces_module_entitlement(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for manifest in self.manifests:
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
dependencies=manifest.dependencies,
|
||||
public_tenant_resolver=(
|
||||
(lambda _request, _session: "tenant-1")
|
||||
if manifest.id == "files"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||
|
||||
@guarded.get("/public-files/{token}")
|
||||
def public_files_route(token: str):
|
||||
return {"token": token}
|
||||
|
||||
app.include_router(guarded)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/public-files/example")
|
||||
|
||||
self.assertEqual(404, response.status_code, response.text)
|
||||
self.assertEqual(
|
||||
"Module is unavailable in the active tenant: files",
|
||||
response.json()["detail"],
|
||||
)
|
||||
|
||||
def test_tenant_capability_rejects_unavailable_provider(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for manifest in self.manifests:
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
dependencies=manifest.dependencies,
|
||||
capability_factories=(
|
||||
{"files.example": lambda _context: object()}
|
||||
if manifest.id == "files"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=SimpleNamespace())
|
||||
)
|
||||
with get_database().session() as session:
|
||||
with self.assertRaises(TenantModuleUnavailable):
|
||||
registry.require_tenant_capability(
|
||||
"files.example",
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
def test_request_context_treats_unavailable_optional_capability_as_absent(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for manifest in self.manifests:
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
dependencies=manifest.dependencies,
|
||||
capability_factories=(
|
||||
{"files.example": lambda _context: object()}
|
||||
if manifest.id == "files"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=SimpleNamespace())
|
||||
)
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active("admin"))])
|
||||
|
||||
@guarded.get("/admin-capability")
|
||||
def admin_capability_route():
|
||||
return {"files_available": registry.capability("files.example") is not None}
|
||||
|
||||
app.include_router(guarded)
|
||||
with patch(
|
||||
"govoplan_core.core.lifecycle.get_api_principal",
|
||||
return_value=self.principal,
|
||||
), TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/admin-capability",
|
||||
headers={"Authorization": "Bearer test"},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertFalse(response.json()["files_available"])
|
||||
|
||||
def test_worker_preserves_accepted_work_for_operator_when_disabled(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for manifest in self.manifests:
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
dependencies=manifest.dependencies,
|
||||
capability_factories=(
|
||||
{"files.worker": lambda _context: object()}
|
||||
if manifest.id == "files"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=SimpleNamespace())
|
||||
)
|
||||
invoked: list[str] = []
|
||||
with get_database().session() as session:
|
||||
result = _run_tenant_worker_batches(
|
||||
registry,
|
||||
session,
|
||||
capability_name="files.worker",
|
||||
tenant_id="tenant-1",
|
||||
operation=lambda tenant_id: invoked.append(tenant_id) or {},
|
||||
defaults={"processed": 0},
|
||||
)
|
||||
|
||||
self.assertEqual([], invoked)
|
||||
self.assertEqual(1, result["operator_action_required"])
|
||||
self.assertEqual(
|
||||
"operator_action_required",
|
||||
result["operator_actions"][0]["disposition"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+147
-4
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
|
||||
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Keep the default app import side effect from bootstrapping a development DB.
|
||||
@@ -93,6 +93,13 @@ from govoplan_core.core.configuration_packages import (
|
||||
validate_configuration_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.module_license import issue_module_license, module_license_decision, module_license_diagnostics, validate_module_license
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryCheckpoint,
|
||||
RecoveryOperation,
|
||||
RecoveryStatus,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
module_package_catalog,
|
||||
record_module_package_catalog_acceptance,
|
||||
@@ -237,9 +244,13 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
"mail",
|
||||
"notifications",
|
||||
"addresses",
|
||||
"dist_lists",
|
||||
"templates",
|
||||
"calendar",
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
),
|
||||
)
|
||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||
@@ -509,6 +520,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
public_tenant_resolver=lambda _request, _session: "tenant-1",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
@@ -525,6 +537,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
public_tenant_resolver=lambda _request, _session: "tenant-1",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
@@ -573,6 +586,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
public_tenant_resolver=lambda _request, _session: "tenant-1",
|
||||
frontend=FrontendModule(
|
||||
module_id=module_id,
|
||||
public_routes=(
|
||||
@@ -2604,7 +2618,15 @@ finally:
|
||||
settings = _settings(root)
|
||||
configure_database(settings.database_url)
|
||||
database = get_database()
|
||||
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
|
||||
Base.metadata.create_all(
|
||||
bind=database.engine,
|
||||
tables=[
|
||||
SystemSettings.__table__,
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
metadata = MetaData()
|
||||
table = Table("retirement_example", metadata, Column("id", Integer, primary_key=True))
|
||||
metadata.create_all(bind=database.engine)
|
||||
@@ -2655,6 +2677,9 @@ finally:
|
||||
database_url=settings.database_url,
|
||||
runtime_dir=root / "installer",
|
||||
)
|
||||
recovery = session.execute(select(RecoveryOperation)).scalar_one()
|
||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, recovery.status)
|
||||
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
|
||||
|
||||
self.assertEqual("applied", result.status)
|
||||
self.assertFalse(inspect(database.engine).has_table("retirement_example"))
|
||||
@@ -2810,7 +2835,15 @@ finally:
|
||||
settings = _settings(root)
|
||||
configure_database(settings.database_url)
|
||||
database = get_database()
|
||||
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
|
||||
Base.metadata.create_all(
|
||||
bind=database.engine,
|
||||
tables=[
|
||||
SystemSettings.__table__,
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
|
||||
def fake_run(*_args, **kwargs):
|
||||
argv = tuple(_args[0]) if _args else ()
|
||||
@@ -2843,11 +2876,78 @@ finally:
|
||||
|
||||
restored_desired = saved_desired_enabled_modules(session, ("tenancy", "access"))
|
||||
restored_plan = saved_module_install_plan(session)
|
||||
recovery = session.execute(select(RecoveryOperation)).scalar_one()
|
||||
self.assertEqual(RecoveryStatus.RECOVERED.value, recovery.status)
|
||||
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
|
||||
|
||||
self.assertEqual("rolled-back", result.status)
|
||||
self.assertEqual(("tenancy", "access"), restored_desired)
|
||||
self.assertEqual(("planned",), tuple(item.status for item in restored_plan.items))
|
||||
|
||||
def test_module_installer_blocks_after_unresolved_package_effect(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unresolved-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
configure_database(settings.database_url)
|
||||
database = get_database()
|
||||
Base.metadata.create_all(
|
||||
bind=database.engine,
|
||||
tables=[
|
||||
SystemSettings.__table__,
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
|
||||
def fail_package_install(*args, **_kwargs):
|
||||
argv = tuple(args[0]) if args else ()
|
||||
if any("govoplan-example==0.1.4" in str(item) for item in argv):
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="install failed")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
with database.session() as session:
|
||||
save_maintenance_mode(session, MaintenanceMode(enabled=True))
|
||||
plan = save_module_install_plan(session, [{
|
||||
"module_id": "example",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-example",
|
||||
"python_ref": "govoplan-example==0.1.4",
|
||||
}])
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_installer.subprocess.run",
|
||||
side_effect=fail_package_install,
|
||||
):
|
||||
result = run_module_install_plan(
|
||||
session=session,
|
||||
plan=plan,
|
||||
available=available_module_manifests(),
|
||||
current_enabled=("tenancy", "access"),
|
||||
desired_enabled=("tenancy", "access"),
|
||||
database_url=settings.database_url,
|
||||
runtime_dir=root / "installer",
|
||||
)
|
||||
|
||||
self.assertEqual("failed", result.status)
|
||||
recovery = session.execute(select(RecoveryOperation)).scalar_one()
|
||||
self.assertEqual(RecoveryStatus.RECOVERY_REQUIRED.value, recovery.status)
|
||||
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
module_installer_module.ModuleInstallerError,
|
||||
"already recovery_required",
|
||||
):
|
||||
run_module_install_plan(
|
||||
session=session,
|
||||
plan=plan,
|
||||
available=available_module_manifests(),
|
||||
current_enabled=("tenancy", "access"),
|
||||
desired_enabled=("tenancy", "access"),
|
||||
database_url=settings.database_url,
|
||||
runtime_dir=root / "installer",
|
||||
)
|
||||
|
||||
def test_module_installer_external_database_backup_command_is_recorded(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-external-backup-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
@@ -3421,7 +3521,7 @@ finally:
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["files"]["requires_interfaces"])
|
||||
self.assertEqual(
|
||||
["campaigns", "encryption"],
|
||||
["campaigns", "encryption", "search"],
|
||||
modules["files"]["optional_dependencies"],
|
||||
)
|
||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
||||
@@ -3443,15 +3543,49 @@ finally:
|
||||
"version_min": "0.2.0",
|
||||
"version_max_exclusive": "0.3.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "dist_lists.source",
|
||||
"optional": True,
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "dist_lists.expand",
|
||||
"optional": True,
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "templates.catalog",
|
||||
"optional": True,
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "templates.renderer",
|
||||
"optional": True,
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "calendar.invitations",
|
||||
"optional": True,
|
||||
"version_min": "0.2.0",
|
||||
"version_max_exclusive": "0.3.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertEqual(
|
||||
[
|
||||
"files",
|
||||
"mail",
|
||||
"notifications",
|
||||
"addresses",
|
||||
"dist_lists",
|
||||
"templates",
|
||||
"calendar",
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
],
|
||||
modules["campaigns"]["optional_dependencies"],
|
||||
)
|
||||
@@ -4180,6 +4314,15 @@ finally:
|
||||
app, _settings_obj = self._app_for_modules(())
|
||||
lifecycle = getattr(app.state, "govoplan_lifecycle", None)
|
||||
self.assertIsNotNone(lifecycle)
|
||||
database = get_database()
|
||||
Base.metadata.create_all(
|
||||
bind=database.engine,
|
||||
tables=[
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -26,10 +27,12 @@ class _S3Error(RuntimeError):
|
||||
class _FakeS3Client:
|
||||
def __init__(self) -> None:
|
||||
self.objects: dict[str, bytes] = {}
|
||||
self.modified_at: dict[str, datetime] = {}
|
||||
self.head_error: Exception | None = None
|
||||
|
||||
def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
|
||||
self.objects[Key] = Body
|
||||
self.modified_at[Key] = datetime.now(timezone.utc)
|
||||
|
||||
def get_object(self, *, Key: str, **_kwargs):
|
||||
try:
|
||||
@@ -45,10 +48,40 @@ class _FakeS3Client:
|
||||
payload = self.objects[Key]
|
||||
except KeyError as exc:
|
||||
raise _S3Error("NotFound", 404) from exc
|
||||
return {"ContentLength": len(payload)}
|
||||
return {
|
||||
"ContentLength": len(payload),
|
||||
"LastModified": self.modified_at[Key],
|
||||
}
|
||||
|
||||
def delete_object(self, *, Key: str, **_kwargs) -> None:
|
||||
self.objects.pop(Key, None)
|
||||
self.modified_at.pop(Key, None)
|
||||
|
||||
def list_objects_v2(
|
||||
self,
|
||||
*,
|
||||
Prefix: str,
|
||||
MaxKeys: int,
|
||||
StartAfter: str | None = None,
|
||||
**_kwargs,
|
||||
):
|
||||
keys = [
|
||||
key
|
||||
for key in sorted(self.objects)
|
||||
if key.startswith(Prefix) and (StartAfter is None or key > StartAfter)
|
||||
]
|
||||
selected = keys[:MaxKeys]
|
||||
return {
|
||||
"Contents": [
|
||||
{
|
||||
"Key": key,
|
||||
"Size": len(self.objects[key]),
|
||||
"LastModified": self.modified_at[key],
|
||||
}
|
||||
for key in selected
|
||||
],
|
||||
"IsTruncated": len(keys) > len(selected),
|
||||
}
|
||||
|
||||
|
||||
class ObjectStorageTests(unittest.TestCase):
|
||||
@@ -67,6 +100,7 @@ class ObjectStorageTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
|
||||
self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
|
||||
self.assertIsNotNone(backend.stat("campaign/a.eml").modified_at)
|
||||
self.assertEqual(
|
||||
("campaign/a.eml",), tuple(item.key for item in first.objects)
|
||||
)
|
||||
@@ -134,6 +168,10 @@ class ObjectStorageTests(unittest.TestCase):
|
||||
backend.put_bytes("campaign/message.eml", b"message/rfc822")
|
||||
self.assertTrue(backend.exists("campaign/message.eml"))
|
||||
self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml"))
|
||||
self.assertIsNotNone(backend.stat("campaign/message.eml").modified_at)
|
||||
self.assertIsNotNone(
|
||||
backend.list_objects(prefix="campaign/").objects[0].modified_at
|
||||
)
|
||||
self.assertFalse(backend.exists("campaign/missing.eml"))
|
||||
|
||||
client.head_error = _S3Error("AccessDenied", 403)
|
||||
|
||||
@@ -10,6 +10,10 @@ from govoplan_core.celery_app import (
|
||||
purge_platform_events,
|
||||
)
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
|
||||
from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX
|
||||
from govoplan_core.core.search import CAPABILITY_SEARCH_INDEX_WRITER
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class PlatformEventWorkerTests(unittest.TestCase):
|
||||
@@ -18,16 +22,30 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.dispatch_pending.return_value = {
|
||||
outbox.dispatch_pending.side_effect = (
|
||||
{
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
}
|
||||
},
|
||||
{
|
||||
"selected": 0,
|
||||
"delivered": 0,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 0,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
)
|
||||
dataflow = MagicMock()
|
||||
registry = MagicMock()
|
||||
registry.has_capability.side_effect = lambda name: name in {
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX,
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -46,16 +64,29 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.celery_app._workflow_trigger_dispatcher",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._search_index_coordinator",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_platform_events.run(25)
|
||||
|
||||
call = outbox.dispatch_pending.call_args
|
||||
self.assertEqual(2, outbox.dispatch_pending.call_count)
|
||||
call = outbox.dispatch_pending.call_args_list[0]
|
||||
self.assertEqual(session, call.args[0])
|
||||
self.assertEqual(25, call.kwargs["limit"])
|
||||
self.assertEqual("tenant-1", call.kwargs["tenant_id"])
|
||||
system_call = outbox.dispatch_pending.call_args_list[1]
|
||||
self.assertTrue(system_call.kwargs["tenantless_only"])
|
||||
self.assertIsNone(system_call.kwargs["tenant_id"])
|
||||
consumer = call.kwargs["consumers"][0]
|
||||
self.assertEqual(
|
||||
"dataflow.event-triggers.v1",
|
||||
@@ -77,14 +108,114 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(1, result["delivered"])
|
||||
|
||||
def test_dispatch_uses_a_durable_search_consumer_and_processes_changes(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.dispatch_pending.side_effect = (
|
||||
{
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
{
|
||||
"selected": 0,
|
||||
"delivered": 0,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 0,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
)
|
||||
search = MagicMock()
|
||||
search.process_changes.return_value = {
|
||||
"selected": 1,
|
||||
"applied": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
}
|
||||
registry = MagicMock()
|
||||
registry.has_capability.side_effect = lambda name: name in {
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX,
|
||||
CAPABILITY_SEARCH_INDEX_WRITER,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_event_outbox",
|
||||
return_value=outbox,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._workflow_trigger_dispatcher",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._search_index_coordinator",
|
||||
return_value=search,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_platform_events.run(25)
|
||||
|
||||
consumer = outbox.dispatch_pending.call_args_list[0].kwargs[
|
||||
"consumers"
|
||||
][0]
|
||||
self.assertEqual("search.indexing.v1", consumer.consumer_id)
|
||||
self.assertEqual(frozenset({"*"}), consumer.event_types)
|
||||
event = PlatformEvent(type="files.file.updated", module_id="files")
|
||||
delivery_key = consumer.delivery_key(event)
|
||||
consumer.handler(event, delivery_key)
|
||||
search.ingest_event.assert_called_once_with(
|
||||
session,
|
||||
event=event,
|
||||
delivery_key=delivery_key,
|
||||
)
|
||||
search.process_changes.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
self.assertEqual(1, result["search_changes"]["applied"])
|
||||
session.commit.assert_called_once_with()
|
||||
|
||||
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.purge_terminal.return_value = {"deleted": 2}
|
||||
outbox.purge_terminal.side_effect = (
|
||||
{"deleted": 2},
|
||||
{"deleted": 1},
|
||||
)
|
||||
registry = MagicMock()
|
||||
registry.has_capability.side_effect = lambda name: (
|
||||
name == CAPABILITY_PLATFORM_EVENT_OUTBOX
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_event_outbox",
|
||||
return_value=outbox,
|
||||
@@ -98,17 +229,25 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
"platform_event_outbox_terminal_retention_days",
|
||||
30,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = purge_platform_events.run(75)
|
||||
|
||||
call = outbox.purge_terminal.call_args
|
||||
self.assertEqual(2, outbox.purge_terminal.call_count)
|
||||
call = outbox.purge_terminal.call_args_list[0]
|
||||
self.assertEqual(session, call.args[0])
|
||||
self.assertEqual(75, call.kwargs["limit"])
|
||||
self.assertEqual("tenant-1", call.kwargs["tenant_id"])
|
||||
system_call = outbox.purge_terminal.call_args_list[1]
|
||||
self.assertTrue(system_call.kwargs["tenantless_only"])
|
||||
before = call.kwargs["before"]
|
||||
self.assertIsInstance(before, datetime)
|
||||
self.assertEqual(timezone.utc, before.tzinfo)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual({"deleted": 2}, result)
|
||||
self.assertEqual(3, result["deleted"])
|
||||
|
||||
def test_worker_routes_and_periodic_tasks_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
)
|
||||
from govoplan_core.core.platform_interfaces import (
|
||||
manifest_interface_catalog,
|
||||
manifest_interface_declarations,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
|
||||
|
||||
def _principal(*scopes: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id=None,
|
||||
tenant_id=None,
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
def _manifest() -> ModuleManifest:
|
||||
navigation = NavItem(path="/example", label="Example", icon="box")
|
||||
return ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="example.reader", version="1.0.0"),
|
||||
),
|
||||
capability_factories={"example.reader": lambda _context: object()},
|
||||
nav_items=(navigation,),
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(path="/example", component="ExamplePage"),
|
||||
),
|
||||
nav_items=(navigation,),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlatformInterfaceCatalogTests(unittest.TestCase):
|
||||
def test_manifest_declarations_have_stable_typed_keys(self) -> None:
|
||||
declarations = manifest_interface_declarations(_manifest())
|
||||
keys = {item.key for item in declarations}
|
||||
|
||||
self.assertIn("backend_capability:example.reader", keys)
|
||||
self.assertIn("provided_interface:example.reader", keys)
|
||||
self.assertIn("frontend_route:example.route.example", keys)
|
||||
self.assertIn("navigation:example.nav.example", keys)
|
||||
self.assertEqual(
|
||||
1,
|
||||
sum(item.key == "navigation:example.nav.example" for item in declarations),
|
||||
)
|
||||
|
||||
def test_catalog_digest_is_deterministic(self) -> None:
|
||||
first = manifest_interface_catalog(_manifest())
|
||||
second = manifest_interface_catalog(_manifest())
|
||||
|
||||
self.assertEqual(first["digest"], second["digest"])
|
||||
self.assertEqual("1", first["contract_version"])
|
||||
|
||||
def test_registry_rejects_conflicting_duplicate_navigation(self) -> None:
|
||||
manifest = _manifest()
|
||||
manifest = ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
nav_items=manifest.nav_items,
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
nav_items=(NavItem(path="/example", label="Other"),),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(manifest)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "duplicate platform interface"):
|
||||
registry.validate()
|
||||
|
||||
def test_read_only_endpoint_requires_administrator_scope(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(_manifest())
|
||||
registry.validate()
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
|
||||
app.dependency_overrides[get_api_principal] = lambda: _principal()
|
||||
with TestClient(app) as client:
|
||||
denied = client.get("/api/v1/platform/interface-catalog")
|
||||
self.assertEqual(403, denied.status_code)
|
||||
|
||||
app.dependency_overrides[get_api_principal] = lambda: _principal(
|
||||
"admin:module:read"
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/interface-catalog")
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertEqual("1", payload["contract_version"])
|
||||
self.assertEqual(["example"], [item["module_id"] for item in payload["modules"]])
|
||||
self.assertIn(
|
||||
"frontend_route:example.route.example",
|
||||
{
|
||||
item["key"]
|
||||
for item in payload["modules"][0]["declarations"]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,6 +18,9 @@ class _CompleteGateway:
|
||||
def resolve_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_public_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_governed_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.core.postbox import (
|
||||
postbox_routing_provider,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _RoutingProvider:
|
||||
@@ -68,6 +69,10 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_postbox_routes.run("tenant-1", 25)
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, MetaData, String, Table, create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryCheckpoint,
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
claim_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeIdentity
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _fixture():
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _identity(node: str, incarnation: str) -> RuntimeIdentity:
|
||||
return RuntimeIdentity(
|
||||
installation_id="installation-1",
|
||||
node_id=node,
|
||||
incarnation=incarnation,
|
||||
role="worker",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _start(
|
||||
factory,
|
||||
identity,
|
||||
*,
|
||||
key: str = "build-1",
|
||||
block_unresolved_resource: bool = False,
|
||||
):
|
||||
return begin_durable_recovery_operation(
|
||||
factory,
|
||||
identity=identity,
|
||||
module_id="campaigns",
|
||||
operation_type="build-artifacts",
|
||||
idempotency_key=key,
|
||||
request={"version_id": "version-1", "write_eml": True},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
preconditions=("validated version is locked",),
|
||||
compensation_steps=("delete build object prefix",),
|
||||
verification_steps=("compare database and object manifests",),
|
||||
),
|
||||
precondition_evidence={"validation_sha256": "b" * 64},
|
||||
lease_resource_key="campaign:build:version-1",
|
||||
resource_type="campaign_version",
|
||||
resource_id="version-1",
|
||||
block_unresolved_resource=block_unresolved_resource,
|
||||
)
|
||||
|
||||
|
||||
def _start_atomic(factory, identity, *, key: str = "sync-1"):
|
||||
return begin_durable_recovery_operation(
|
||||
factory,
|
||||
identity=identity,
|
||||
module_id="connectors",
|
||||
operation_type="read-snapshot",
|
||||
idempotency_key=key,
|
||||
request={"provider_id": "provider-1", "cursor": "revision-1"},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.ATOMIC,
|
||||
preconditions=("the provider read is non-mutating",),
|
||||
verification_steps=("compare the committed projection",),
|
||||
),
|
||||
precondition_evidence={"provider_mutation": False},
|
||||
lease_resource_key="connectors:provider-1",
|
||||
)
|
||||
|
||||
|
||||
def test_durable_operation_commits_before_caller_effect_and_replays_success() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
with factory() as session:
|
||||
persisted = session.get(RecoveryOperation, started.operation_id)
|
||||
assert persisted is not None
|
||||
assert persisted.status == RecoveryStatus.RUNNING.value
|
||||
assert persisted.checkpoint_count == 3
|
||||
|
||||
started.operation.checkpoint(
|
||||
kind="object-prefix-reserved",
|
||||
summary="Build object prefix reserved",
|
||||
evidence={"prefix": "campaign-artifacts/build-1/"},
|
||||
)
|
||||
started.operation.succeed(
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {"database_manifest": "matched", "object_manifest": "matched"},
|
||||
}
|
||||
)
|
||||
|
||||
replay = _start(factory, _identity("worker-2", "incarnation-2"))
|
||||
assert replay.replayed is True
|
||||
assert replay.operation is None
|
||||
with factory() as session:
|
||||
assert verify_recovery_evidence_chain(session, started.operation_id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_atomic_terminal_commits_domain_rows_and_recovery_evidence_together() -> None:
|
||||
engine, factory = _fixture()
|
||||
metadata = MetaData()
|
||||
projection = Table(
|
||||
"test_recovery_projection",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
try:
|
||||
started = _start_atomic(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
with factory() as session:
|
||||
session.execute(projection.insert().values(id="projection-1"))
|
||||
started.operation.commit_atomic_success(
|
||||
session,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {"projection_id": "projection-1"},
|
||||
},
|
||||
)
|
||||
|
||||
with factory() as session:
|
||||
assert session.scalar(select(projection.c.id)) == "projection-1"
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.status == RecoveryStatus.SUCCEEDED.value
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_verified_external_success_commits_projection_and_evidence_together() -> None:
|
||||
engine, factory = _fixture()
|
||||
metadata = MetaData()
|
||||
projection = Table(
|
||||
"test_verified_external_projection",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
with factory() as session:
|
||||
session.execute(projection.insert().values(id="projection-1"))
|
||||
started.operation.commit_verified_success(
|
||||
session,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"provider_result": "accepted",
|
||||
"projection_id": "projection-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with factory() as session:
|
||||
assert session.scalar(select(projection.c.id)) == "projection-1"
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.mode == RecoveryMode.COMPENSATION.value
|
||||
assert operation.status == RecoveryStatus.SUCCEEDED.value
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_failed_atomic_commit_rolls_back_domain_and_terminal_checkpoint() -> None:
|
||||
engine, factory = _fixture()
|
||||
metadata = MetaData()
|
||||
projection = Table(
|
||||
"test_recovery_projection_rollback",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
try:
|
||||
started = _start_atomic(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
with factory() as session:
|
||||
session.execute(projection.insert().values(id="rolled-back"))
|
||||
with (
|
||||
patch.object(session, "commit", side_effect=RuntimeError("commit failed")),
|
||||
pytest.raises(RuntimeError, match="commit failed"),
|
||||
):
|
||||
started.operation.commit_atomic_success(
|
||||
session,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {"projection_id": "rolled-back"},
|
||||
},
|
||||
)
|
||||
|
||||
with factory() as session:
|
||||
assert session.execute(select(projection.c.id)).all() == []
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.status == RecoveryStatus.RUNNING.value
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_same_fence_cannot_start_duplicate_running_operation() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
identity = _identity("worker-1", "incarnation-1")
|
||||
started = _start(factory, identity)
|
||||
with pytest.raises(RecoveryOperationStateConflict, match="already running"):
|
||||
_start(factory, identity)
|
||||
assert started.operation is not None
|
||||
started.operation.release_unresolved()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_verified_provider_rejection_is_terminal_without_recovery() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
started.operation.reject(
|
||||
summary="Provider definitively rejected the request",
|
||||
evidence={
|
||||
"verified": True,
|
||||
"provider_outcome": "rejected",
|
||||
"checks": {"provider_response": "definitive-rejection"},
|
||||
},
|
||||
)
|
||||
with factory() as session:
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.status == RecoveryStatus.REJECTED.value
|
||||
assert operation.completed_at is not None
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_other_runtime_cannot_use_an_active_fence() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
with pytest.raises(RecoveryOperationBusy):
|
||||
_start(factory, _identity("worker-2", "incarnation-2"), key="build-2")
|
||||
assert started.operation is not None
|
||||
started.operation.release_unresolved()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_unresolved_predecessor_can_block_new_effects_on_same_resource() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(
|
||||
factory,
|
||||
_identity("worker-1", "incarnation-1"),
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
assert started.operation is not None
|
||||
started.operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Provider outcome is unknown",
|
||||
evidence={"request_sent": True},
|
||||
failure_summary="Reconcile before retry",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RecoveryOperationStateConflict,
|
||||
match="outcome_unknown",
|
||||
):
|
||||
_start(
|
||||
factory,
|
||||
_identity("worker-2", "incarnation-2"),
|
||||
key="build-2",
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_expired_crash_fence_is_taken_over_as_recovery_required() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
with factory() as session:
|
||||
lease = session.execute(select(DistributedLease)).scalar_one()
|
||||
lease.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
session.add(lease)
|
||||
session.commit()
|
||||
|
||||
recovery = claim_durable_recovery_operation(
|
||||
factory,
|
||||
identity=_identity("worker-2", "incarnation-2"),
|
||||
operation_id=started.operation_id,
|
||||
)
|
||||
with factory() as session:
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.status == RecoveryStatus.RECOVERY_REQUIRED.value
|
||||
assert operation.fencing_token == 2
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
recovery.compensate(
|
||||
failure_summary="worker stopped during object publication",
|
||||
failure_evidence={"object_prefix": "campaign-artifacts/build-1/"},
|
||||
recovery_evidence={
|
||||
"verified": True,
|
||||
"checks": {"object_prefix_empty": True},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_tampered_checkpoint_blocks_verified_success() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
with factory() as session:
|
||||
checkpoint = session.execute(
|
||||
select(RecoveryCheckpoint).order_by(RecoveryCheckpoint.sequence)
|
||||
).scalars().first()
|
||||
assert checkpoint is not None
|
||||
checkpoint.summary = "tampered"
|
||||
session.add(checkpoint)
|
||||
session.commit()
|
||||
with pytest.raises(RecoveryGuaranteeError, match="chain verification failed"):
|
||||
started.operation.succeed(
|
||||
evidence={"verified": True, "checks": {"objects": "matched"}}
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("effect_occurred", "expected_status"),
|
||||
[
|
||||
(True, RecoveryStatus.SUCCEEDED.value),
|
||||
(False, RecoveryStatus.RECOVERED.value),
|
||||
],
|
||||
)
|
||||
def test_unknown_provider_outcome_can_be_resolved_from_external_evidence(
|
||||
effect_occurred: bool,
|
||||
expected_status: str,
|
||||
) -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
started.operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Provider outcome is unknown",
|
||||
evidence={"effect_started": True},
|
||||
failure_summary="Inspect the provider before retrying",
|
||||
)
|
||||
recovery = claim_durable_recovery_operation(
|
||||
factory,
|
||||
identity=_identity("worker-2", "incarnation-2"),
|
||||
operation_id=started.operation_id,
|
||||
)
|
||||
recovery.resolve_unknown(
|
||||
effect_occurred=effect_occurred,
|
||||
summary="Operator verified the provider outcome",
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {"provider_evidence": "case-1"},
|
||||
"effect_occurred": effect_occurred,
|
||||
"reference": "case-1",
|
||||
},
|
||||
)
|
||||
|
||||
with factory() as session:
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.status == expected_status
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_unknown_resolution_commits_domain_projection_and_evidence_together() -> None:
|
||||
engine, factory = _fixture()
|
||||
metadata = MetaData()
|
||||
projection = Table(
|
||||
"test_unknown_resolution_projection",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
try:
|
||||
started = _start(factory, _identity("worker-1", "incarnation-1"))
|
||||
assert started.operation is not None
|
||||
started.operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Provider outcome is unknown",
|
||||
evidence={"effect_started": True},
|
||||
failure_summary="Inspect the provider before retrying",
|
||||
)
|
||||
recovery = claim_durable_recovery_operation(
|
||||
factory,
|
||||
identity=_identity("worker-2", "incarnation-2"),
|
||||
operation_id=started.operation_id,
|
||||
)
|
||||
with factory() as session:
|
||||
session.execute(projection.insert().values(id="confirmed-effect"))
|
||||
recovery.commit_unknown_resolution(
|
||||
session,
|
||||
effect_occurred=True,
|
||||
summary="Operator verified the provider outcome",
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {"provider_evidence": "case-1"},
|
||||
"effect_occurred": True,
|
||||
},
|
||||
)
|
||||
|
||||
with factory() as session:
|
||||
assert session.scalar(select(projection.c.id)) == "confirmed-effect"
|
||||
operation = session.get(RecoveryOperation, started.operation_id)
|
||||
assert operation is not None
|
||||
assert operation.status == RecoveryStatus.SUCCEEDED.value
|
||||
assert verify_recovery_evidence_chain(session, operation.id)
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -6,6 +6,8 @@ from types import SimpleNamespace
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
RuntimeCoordinationError,
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
process_runtime_identity,
|
||||
)
|
||||
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
|
||||
|
||||
@@ -64,6 +66,32 @@ def test_api_runtime_agent_fails_readiness_on_heartbeat_error() -> None:
|
||||
assert agent.coordination_healthy is True
|
||||
|
||||
|
||||
def test_process_runtime_identity_is_explicit_and_replaceable() -> None:
|
||||
from govoplan_core.core import runtime_coordination
|
||||
|
||||
previous = runtime_coordination._process_runtime_identity
|
||||
identity = RuntimeIdentity(
|
||||
installation_id="installation-1",
|
||||
node_id="api-1",
|
||||
incarnation="incarnation-1",
|
||||
role="api",
|
||||
software_version="0.1.14",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
try:
|
||||
bind_process_runtime_identity(None)
|
||||
try:
|
||||
process_runtime_identity()
|
||||
except RuntimeCoordinationError:
|
||||
pass
|
||||
else: # pragma: no cover - assertion branch
|
||||
raise AssertionError("An unbound process identity must fail closed")
|
||||
bind_process_runtime_identity(identity)
|
||||
assert process_runtime_identity() is identity
|
||||
finally:
|
||||
bind_process_runtime_identity(previous)
|
||||
|
||||
|
||||
def test_worker_disables_consumers_without_reclaiming_stale_identity(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -127,8 +155,19 @@ def test_worker_disables_consumers_without_reclaiming_stale_identity(
|
||||
|
||||
def test_worker_child_replaces_inherited_database_pool(monkeypatch) -> None:
|
||||
from govoplan_core import celery_app
|
||||
from govoplan_core.core import runtime_coordination
|
||||
|
||||
calls: list[tuple[str, bool]] = []
|
||||
previous_process_identity = runtime_coordination._process_runtime_identity
|
||||
previous_worker_identity = celery_app._worker_identity
|
||||
inherited = RuntimeIdentity(
|
||||
installation_id="installation-1",
|
||||
node_id="parent-worker",
|
||||
incarnation="parent-incarnation",
|
||||
role="worker",
|
||||
software_version="0.1.14",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
celery_app,
|
||||
"configure_database",
|
||||
@@ -136,7 +175,19 @@ def test_worker_child_replaces_inherited_database_pool(monkeypatch) -> None:
|
||||
(url, dispose_previous)
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
celery_app._worker_identity = inherited
|
||||
bind_process_runtime_identity(inherited)
|
||||
celery_app._reset_worker_process_database()
|
||||
|
||||
assert calls == [(celery_app.settings.database_url, True)]
|
||||
assert celery_app._worker_identity is None
|
||||
try:
|
||||
process_runtime_identity()
|
||||
except RuntimeCoordinationError:
|
||||
pass
|
||||
else: # pragma: no cover - assertion branch
|
||||
raise AssertionError("A worker child must discard inherited authority")
|
||||
finally:
|
||||
celery_app._worker_identity = previous_worker_identity
|
||||
bind_process_runtime_identity(previous_process_identity)
|
||||
|
||||
@@ -2,17 +2,25 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.datasources import (
|
||||
DatasourceDescriptor,
|
||||
DatasourceReadRequest,
|
||||
DatasourceReadResult,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER,
|
||||
CAPABILITY_CONNECTORS_TABULAR_SOURCES,
|
||||
TabularColumn,
|
||||
TabularPreviewDiagnostic,
|
||||
TabularPushdown,
|
||||
TabularReadRequest,
|
||||
TabularReadResult,
|
||||
TabularSnapshotInput,
|
||||
TabularSnapshotWriter,
|
||||
TabularSource,
|
||||
TabularSourceHealth,
|
||||
TabularSourceProvider,
|
||||
TabularSourceValidationError,
|
||||
parse_tabular_csv,
|
||||
@@ -30,6 +38,13 @@ class _TabularProvider:
|
||||
schema=(TabularColumn(name="case_id", data_type="string", nullable=False),),
|
||||
fingerprint="abc123",
|
||||
row_count=1,
|
||||
source_mode="cached",
|
||||
pushdown=TabularPushdown(projections=True, pagination=True),
|
||||
health=TabularSourceHealth(
|
||||
status="healthy",
|
||||
code="snapshot.ready",
|
||||
summary="Immutable snapshot is ready.",
|
||||
),
|
||||
)
|
||||
|
||||
def list_sources(self, session, principal, *, query="", limit=100):
|
||||
@@ -51,6 +66,18 @@ class _TabularProvider:
|
||||
rows=selected,
|
||||
total_rows=len(rows),
|
||||
truncated=len(selected) < len(rows),
|
||||
returned_bytes=18,
|
||||
elapsed_ms=1,
|
||||
effective_row_limit=request.limit,
|
||||
effective_byte_limit=request.max_bytes,
|
||||
effective_timeout_ms=request.timeout_ms,
|
||||
diagnostics=(
|
||||
TabularPreviewDiagnostic(
|
||||
severity="info",
|
||||
code="preview.bounded",
|
||||
message="The preview used explicit budgets.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def create_snapshot(self, session, principal, *, snapshot):
|
||||
@@ -96,8 +123,47 @@ class TabularSourceContractTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(({"case_id": "A-1"},), result.rows)
|
||||
self.assertEqual("cached", result.source.source_mode)
|
||||
self.assertTrue(result.source.pushdown.projections)
|
||||
self.assertEqual("healthy", result.source.health.status)
|
||||
self.assertEqual("preview.bounded", result.diagnostics[0].code)
|
||||
self.assertEqual(1_000_000, request.max_bytes)
|
||||
self.assertEqual(2_000, request.timeout_ms)
|
||||
self.assertEqual(provider.source, provider.create_snapshot(object(), object(), snapshot=snapshot))
|
||||
|
||||
def test_datasource_read_contract_preserves_live_preview_evidence(self) -> None:
|
||||
request = DatasourceReadRequest(datasource_ref="datasource:monthly-cases")
|
||||
result = DatasourceReadResult(
|
||||
datasource=DatasourceDescriptor(
|
||||
ref=request.datasource_ref,
|
||||
source_name="monthly_cases",
|
||||
name="Monthly cases",
|
||||
kind="database",
|
||||
mode="live",
|
||||
shape="tabular",
|
||||
),
|
||||
rows=(),
|
||||
total_rows=0,
|
||||
truncated=False,
|
||||
returned_bytes=2,
|
||||
elapsed_ms=3,
|
||||
effective_row_limit=request.limit,
|
||||
effective_byte_limit=request.max_bytes,
|
||||
effective_timeout_ms=request.timeout_ms,
|
||||
diagnostics=(
|
||||
TabularPreviewDiagnostic(
|
||||
severity="info",
|
||||
code="preview.complete",
|
||||
message="The bounded preview completed.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(1_000_000, request.max_bytes)
|
||||
self.assertEqual(2_000, request.timeout_ms)
|
||||
self.assertEqual(2, result.returned_bytes)
|
||||
self.assertEqual("preview.complete", result.diagnostics[0].code)
|
||||
|
||||
def test_shared_csv_parser_preserves_identifier_zeroes_and_rejects_extra_values(self) -> None:
|
||||
rows = parse_tabular_csv(
|
||||
"case_id;amount;active\n0012;7.5;true\n\n",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.files import (
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
ManagedArtifactRef,
|
||||
ManagedArtifactStore,
|
||||
ManagedArtifactWriteRequest,
|
||||
)
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
TemplateCatalogProvider,
|
||||
TemplateRenderRequest,
|
||||
TemplateRendererProvider,
|
||||
)
|
||||
|
||||
|
||||
class _Catalog:
|
||||
def list_templates(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return ()
|
||||
|
||||
def get_template(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return None
|
||||
|
||||
def check_compatibility(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return None
|
||||
|
||||
|
||||
class _Renderer:
|
||||
def render(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return None
|
||||
|
||||
|
||||
class _Store:
|
||||
def store_artifact(self, session, principal, *, request):
|
||||
del session, principal
|
||||
return ManagedArtifactRef(
|
||||
file_asset_id="file-1",
|
||||
file_version_id="version-1",
|
||||
filename=request.filename,
|
||||
display_path=request.filename,
|
||||
content_type=request.content_type,
|
||||
size_bytes=len(request.payload),
|
||||
sha256="0" * 64,
|
||||
)
|
||||
|
||||
|
||||
class TemplateContractTests(unittest.TestCase):
|
||||
def test_capability_names_and_runtime_protocols_are_stable(self) -> None:
|
||||
self.assertEqual("templates.catalog", CAPABILITY_TEMPLATE_CATALOG)
|
||||
self.assertEqual("templates.renderer", CAPABILITY_TEMPLATE_RENDERER)
|
||||
self.assertEqual("files.artifact_store", CAPABILITY_FILES_ARTIFACT_STORE)
|
||||
self.assertIsInstance(_Catalog(), TemplateCatalogProvider)
|
||||
self.assertIsInstance(_Renderer(), TemplateRendererProvider)
|
||||
self.assertIsInstance(_Store(), ManagedArtifactStore)
|
||||
|
||||
def test_requests_do_not_expose_consumer_or_files_models(self) -> None:
|
||||
render = TemplateRenderRequest(template_id="template-1")
|
||||
artifact = ManagedArtifactWriteRequest(
|
||||
filename="result.html",
|
||||
payload=b"result",
|
||||
content_type="text/html",
|
||||
)
|
||||
self.assertEqual((), render.items)
|
||||
self.assertEqual("preview", render.mode)
|
||||
self.assertEqual("Generated", artifact.folder)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.voting import VotingResult
|
||||
from govoplan_core.core.voting import (
|
||||
VOTING_CERTIFICATION_CERTIFIED,
|
||||
VOTING_CERTIFICATION_IN_EVALUATION,
|
||||
VotingCapabilityError,
|
||||
VotingProviderAssuranceDeclaration,
|
||||
VotingResult,
|
||||
require_voting_provider_assurance,
|
||||
)
|
||||
|
||||
|
||||
def result_with_evidence(*evidence):
|
||||
@@ -23,7 +31,64 @@ def result_with_evidence(*evidence):
|
||||
)
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
def __init__(self, declaration: VotingProviderAssuranceDeclaration) -> None:
|
||||
self.declaration = declaration
|
||||
|
||||
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration:
|
||||
return self.declaration
|
||||
|
||||
def finalize_ballot(self, session, principal, *, request):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class VotingContractTests(unittest.TestCase):
|
||||
def test_external_certification_requires_current_evidence_backed_claim(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
declaration = VotingProviderAssuranceDeclaration(
|
||||
provider_id="certified_provider",
|
||||
implementation_ref="certified-provider/adapter@1",
|
||||
supported_assurance_profiles=("external_certified",),
|
||||
certification_state=VOTING_CERTIFICATION_CERTIFIED,
|
||||
protocol_ref="vendor:certified-ballot",
|
||||
protocol_version="3.0",
|
||||
certification_authority="Independent authority",
|
||||
certification_reference="certificate-2026-1",
|
||||
certification_evidence_ref="evidence://certificate-2026-1",
|
||||
certification_valid_from=now - timedelta(days=1),
|
||||
certification_valid_until=now + timedelta(days=1),
|
||||
)
|
||||
|
||||
selected = require_voting_provider_assurance(
|
||||
FakeProvider(declaration),
|
||||
provider_id="certified_provider",
|
||||
assurance_profile="external_certified",
|
||||
at=now,
|
||||
)
|
||||
|
||||
self.assertEqual("certificate-2026-1", selected.certification_reference)
|
||||
self.assertEqual(
|
||||
(now - timedelta(days=1)).isoformat(),
|
||||
selected.to_dict()["certification_valid_from"],
|
||||
)
|
||||
|
||||
def test_external_certification_rejects_evaluation_only_provider(self) -> None:
|
||||
declaration = VotingProviderAssuranceDeclaration(
|
||||
provider_id="candidate_provider",
|
||||
implementation_ref="candidate-provider/adapter@1",
|
||||
supported_assurance_profiles=("external_certified",),
|
||||
certification_state=VOTING_CERTIFICATION_IN_EVALUATION,
|
||||
protocol_ref="vendor:candidate-ballot",
|
||||
protocol_version="1.0",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(VotingCapabilityError, "currently valid"):
|
||||
require_voting_provider_assurance(
|
||||
FakeProvider(declaration),
|
||||
provider_id="candidate_provider",
|
||||
assurance_profile="external_certified",
|
||||
)
|
||||
|
||||
def test_accepts_sanitized_provider_evidence(self) -> None:
|
||||
value = result_with_evidence(
|
||||
{
|
||||
|
||||
@@ -69,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase):
|
||||
self.assertNotEqual(repository_root, runtime_root)
|
||||
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
||||
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
||||
self.assertEqual(["d03a7b9c1e5f"], result["heads"])
|
||||
self.assertEqual(["e14b8c2d6f90"], result["heads"])
|
||||
self.assertIn("core_scopes", result["tables"])
|
||||
self.assertIn("core_system_settings", result["tables"])
|
||||
|
||||
|
||||
@@ -10,11 +10,18 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.workflows import (
|
||||
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
|
||||
WorkflowDefinitionContribution,
|
||||
)
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, get_database, reset_database
|
||||
|
||||
|
||||
class _ContributionProvider:
|
||||
@@ -30,8 +37,28 @@ class _ContributionProvider:
|
||||
class WorkflowContributionLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
configure_database("sqlite:///:memory:")
|
||||
database = get_database()
|
||||
Base.metadata.create_all(
|
||||
bind=database.engine,
|
||||
tables=[
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="test-node",
|
||||
incarnation="test-incarnation",
|
||||
role="test",
|
||||
software_version="test",
|
||||
composition_hash="0" * 64,
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
bind_process_runtime_identity(None)
|
||||
reset_database(dispose=True)
|
||||
|
||||
def test_active_graph_change_reconciles_module_workflow_baselines(self) -> None:
|
||||
|
||||
@@ -22,6 +22,7 @@ from govoplan_core.core.workflows import (
|
||||
workflow_runtime_worker,
|
||||
workflow_trigger_dispatcher,
|
||||
)
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _Worker:
|
||||
@@ -141,10 +142,18 @@ class WorkflowRuntimeWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = reconcile_workflow_instances.run(25)
|
||||
|
||||
worker.reconcile_pending.assert_called_once_with(session, limit=25)
|
||||
worker.reconcile_pending.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(1, result["advanced"])
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
TenantModuleAdmission,
|
||||
TenantWorkState,
|
||||
)
|
||||
|
||||
|
||||
def allowed_worker_admissions(
|
||||
_registry,
|
||||
_session,
|
||||
*,
|
||||
capability_name: str,
|
||||
tenant_id: str | None,
|
||||
work_state: TenantWorkState = "accepted",
|
||||
) -> tuple[TenantModuleAdmission, ...]:
|
||||
return (
|
||||
TenantModuleAdmission(
|
||||
tenant_id=tenant_id or "tenant-1",
|
||||
module_id=capability_name.split(".", 1)[0],
|
||||
revision=1,
|
||||
work_state=work_state,
|
||||
allowed=True,
|
||||
disposition="allowed",
|
||||
reason="Test tenant permits the worker capability.",
|
||||
),
|
||||
)
|
||||
Generated
+132
-70
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -22,9 +22,11 @@
|
||||
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||
"@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui",
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/encryption-webui": "file:../../govoplan-encryption/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
|
||||
"@govoplan/forms-webui": "file:../../govoplan-forms/webui",
|
||||
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||
@@ -38,6 +40,7 @@
|
||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
|
||||
@@ -70,12 +73,12 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.15",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -89,9 +92,9 @@
|
||||
},
|
||||
"../../govoplan-addresses/webui": {
|
||||
"name": "@govoplan/addresses-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -105,12 +108,12 @@
|
||||
},
|
||||
"../../govoplan-admin/webui": {
|
||||
"name": "@govoplan/admin-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -124,9 +127,9 @@
|
||||
},
|
||||
"../../govoplan-approvals/webui": {
|
||||
"name": "@govoplan/approvals-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -139,9 +142,9 @@
|
||||
},
|
||||
"../../govoplan-audit/webui": {
|
||||
"name": "@govoplan/audit-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -155,9 +158,9 @@
|
||||
},
|
||||
"../../govoplan-calendar/webui": {
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -174,7 +177,7 @@
|
||||
},
|
||||
"../../govoplan-campaign/webui": {
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -182,7 +185,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -196,9 +199,9 @@
|
||||
},
|
||||
"../../govoplan-cases/webui": {
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -212,9 +215,9 @@
|
||||
},
|
||||
"../../govoplan-committee/webui": {
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -228,9 +231,9 @@
|
||||
},
|
||||
"../../govoplan-dashboard/webui": {
|
||||
"name": "@govoplan/dashboard-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -244,9 +247,9 @@
|
||||
},
|
||||
"../../govoplan-dataflow/webui": {
|
||||
"name": "@govoplan/dataflow-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -262,9 +265,9 @@
|
||||
},
|
||||
"../../govoplan-datasources/webui": {
|
||||
"name": "@govoplan/datasources-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -279,9 +282,9 @@
|
||||
},
|
||||
"../../govoplan-dist-lists/webui": {
|
||||
"name": "@govoplan/dist-lists-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -296,9 +299,9 @@
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -313,11 +316,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-encryption/webui": {
|
||||
"name": "@govoplan/encryption-webui",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-files/webui": {
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -334,9 +352,9 @@
|
||||
},
|
||||
"../../govoplan-forms-runtime/webui": {
|
||||
"name": "@govoplan/forms-runtime-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -350,9 +368,24 @@
|
||||
},
|
||||
"../../govoplan-forms/webui": {
|
||||
"name": "@govoplan/forms-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-identity-trust/webui": {
|
||||
"name": "@govoplan/identity-trust-webui",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -365,9 +398,9 @@
|
||||
},
|
||||
"../../govoplan-idm/webui": {
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -384,12 +417,12 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.15",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -403,9 +436,9 @@
|
||||
},
|
||||
"../../govoplan-notifications/webui": {
|
||||
"name": "@govoplan/notifications-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -422,9 +455,9 @@
|
||||
},
|
||||
"../../govoplan-ops/webui": {
|
||||
"name": "@govoplan/ops-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -441,9 +474,9 @@
|
||||
},
|
||||
"../../govoplan-organizations/webui": {
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -460,9 +493,9 @@
|
||||
},
|
||||
"../../govoplan-policy/webui": {
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -476,9 +509,9 @@
|
||||
},
|
||||
"../../govoplan-portal/webui": {
|
||||
"name": "@govoplan/portal-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -492,9 +525,9 @@
|
||||
},
|
||||
"../../govoplan-postbox/webui": {
|
||||
"name": "@govoplan/postbox-webui",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -508,9 +541,9 @@
|
||||
},
|
||||
"../../govoplan-projects/webui": {
|
||||
"name": "@govoplan/projects-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -524,9 +557,9 @@
|
||||
},
|
||||
"../../govoplan-reporting/webui": {
|
||||
"name": "@govoplan/reporting-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -540,9 +573,9 @@
|
||||
},
|
||||
"../../govoplan-risk-compliance/webui": {
|
||||
"name": "@govoplan/risk-compliance-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -556,9 +589,9 @@
|
||||
},
|
||||
"../../govoplan-scheduling/webui": {
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -575,9 +608,9 @@
|
||||
},
|
||||
"../../govoplan-search/webui": {
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -589,11 +622,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-templates/webui": {
|
||||
"name": "@govoplan/templates-webui",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-tenancy/webui": {
|
||||
"name": "@govoplan/tenancy-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -606,9 +656,9 @@
|
||||
},
|
||||
"../../govoplan-views/webui": {
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -622,9 +672,9 @@
|
||||
},
|
||||
"../../govoplan-voting/webui": {
|
||||
"name": "@govoplan/voting-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -637,9 +687,9 @@
|
||||
},
|
||||
"../../govoplan-workflow/webui": {
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -1461,6 +1511,10 @@
|
||||
"resolved": "../../govoplan-docs/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/encryption-webui": {
|
||||
"resolved": "../../govoplan-encryption/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/files-webui": {
|
||||
"resolved": "../../govoplan-files/webui",
|
||||
"link": true
|
||||
@@ -1473,6 +1527,10 @@
|
||||
"resolved": "../../govoplan-forms/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/identity-trust-webui": {
|
||||
"resolved": "../../govoplan-identity-trust/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/idm-webui": {
|
||||
"resolved": "../../govoplan-idm/webui",
|
||||
"link": true
|
||||
@@ -1525,6 +1583,10 @@
|
||||
"resolved": "../../govoplan-search/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/templates-webui": {
|
||||
"resolved": "../../govoplan-templates/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/tenancy-webui": {
|
||||
"resolved": "../../govoplan-tenancy/webui",
|
||||
"link": true
|
||||
|
||||
+1116
-466
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user