Compare commits
32
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 |
@@ -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
|
||||
@@ -300,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.
|
||||
@@ -431,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.
|
||||
@@ -738,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
|
||||
@@ -827,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`
|
||||
@@ -1232,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
|
||||
@@ -1339,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+672
-193
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",
|
||||
|
||||
@@ -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]:
|
||||
...
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,11 +40,74 @@ 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
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
||||
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,30 +176,110 @@ 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
|
||||
|
||||
if migrate:
|
||||
self._migrate(plan.enabled_modules)
|
||||
with get_database().session() as recovery_session:
|
||||
recovery = begin_runtime_graph_recovery(
|
||||
recovery_session,
|
||||
previous_modules=previous,
|
||||
requested_modules=plan.enabled_modules,
|
||||
migrate=migrate,
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
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:
|
||||
hook(self.context)
|
||||
if migrate:
|
||||
self._migrate(plan.enabled_modules)
|
||||
|
||||
self.registry.replace(self.available_modules[module_id] for module_id in plan.enabled_modules)
|
||||
self.configure_runtime()
|
||||
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
|
||||
|
||||
for module_id in activated:
|
||||
hook = self.available_modules[module_id].on_activate
|
||||
if hook is not None:
|
||||
hook(self.context)
|
||||
for module_id in deactivated:
|
||||
hook = old_manifests[module_id].on_deactivate
|
||||
if hook is not None:
|
||||
hook(self.context)
|
||||
|
||||
self.reconcile_workflow_definitions()
|
||||
self.registry.replace(self.available_modules[module_id] for module_id in plan.enabled_modules)
|
||||
self.configure_runtime()
|
||||
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
for module_id in activated:
|
||||
hook = self.available_modules[module_id].on_activate
|
||||
if hook is not None:
|
||||
hook(self.context)
|
||||
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
snapshot=_snapshot_environment(
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if not dry_run:
|
||||
try:
|
||||
recovery = begin_module_installer_recovery(
|
||||
session,
|
||||
run_id=run_id,
|
||||
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)
|
||||
session.commit()
|
||||
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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -198,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,
|
||||
@@ -259,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,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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
+111
-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,
|
||||
@@ -243,6 +250,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
),
|
||||
)
|
||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||
@@ -512,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",
|
||||
@@ -528,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",
|
||||
@@ -576,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=(
|
||||
@@ -2607,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)
|
||||
@@ -2658,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"))
|
||||
@@ -2813,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 ()
|
||||
@@ -2846,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)
|
||||
@@ -3424,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"])
|
||||
@@ -3488,6 +3585,7 @@ finally:
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
],
|
||||
modules["campaigns"]["optional_dependencies"],
|
||||
)
|
||||
@@ -4216,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 = {
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
}
|
||||
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()
|
||||
|
||||
celery_app._reset_worker_process_database()
|
||||
|
||||
assert calls == [(celery_app.settings.database_url, True)]
|
||||
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",
|
||||
|
||||
@@ -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
+112
-72
@@ -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",
|
||||
@@ -71,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",
|
||||
@@ -90,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",
|
||||
@@ -106,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",
|
||||
@@ -125,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"
|
||||
@@ -140,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",
|
||||
@@ -156,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",
|
||||
@@ -175,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"
|
||||
},
|
||||
@@ -183,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",
|
||||
@@ -197,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",
|
||||
@@ -213,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",
|
||||
@@ -229,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",
|
||||
@@ -245,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",
|
||||
@@ -263,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",
|
||||
@@ -280,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",
|
||||
@@ -297,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",
|
||||
@@ -314,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",
|
||||
@@ -335,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",
|
||||
@@ -351,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"
|
||||
@@ -366,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",
|
||||
@@ -385,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",
|
||||
@@ -404,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",
|
||||
@@ -423,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",
|
||||
@@ -442,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",
|
||||
@@ -461,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",
|
||||
@@ -477,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",
|
||||
@@ -493,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",
|
||||
@@ -509,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",
|
||||
@@ -525,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",
|
||||
@@ -541,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",
|
||||
@@ -557,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",
|
||||
@@ -576,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",
|
||||
@@ -592,9 +624,9 @@
|
||||
},
|
||||
"../../govoplan-templates/webui": {
|
||||
"name": "@govoplan/templates-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",
|
||||
@@ -609,9 +641,9 @@
|
||||
},
|
||||
"../../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"
|
||||
@@ -624,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",
|
||||
@@ -640,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"
|
||||
@@ -655,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",
|
||||
@@ -1479,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
|
||||
@@ -1491,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
|
||||
|
||||
+1116
-466
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,13 +26,14 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract",
|
||||
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract && npm run test:core-interface-patterns",
|
||||
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
||||
"test:theme-contract": "node scripts/test-theme-contract.mjs",
|
||||
"test:core-interface-patterns": "node scripts/test-core-interface-patterns.mjs",
|
||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
@@ -43,7 +44,10 @@
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
|
||||
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
||||
"test:password-field": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/password-generator.test.js",
|
||||
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
||||
"test:action-blocker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/action-blocker-hint.test.js",
|
||||
"test:documentation-help": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/documentation-help-link.test.js",
|
||||
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js",
|
||||
"test:wysiwyg-editor": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/wysiwyg-editor-utils.test.js"
|
||||
},
|
||||
@@ -62,10 +66,12 @@
|
||||
"@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-webui": "file:../../govoplan-forms/webui",
|
||||
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,19 +26,19 @@
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.8",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.8",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.8",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.8",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.10",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8",
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.15",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.15",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.15",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.15",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.15",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.15",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.15",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.15",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.15",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.15",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.15",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.15",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.15",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
|
||||
@@ -40,7 +40,7 @@ function scanStructuralSourcePositions(roots) {
|
||||
}
|
||||
if (ts.isPropertyAssignment(node)) {
|
||||
const name = nodeName(node.name);
|
||||
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`);
|
||||
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer) && !isPresentationalBlockerProperty(node)) report(node, `object ${name}`);
|
||||
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
|
||||
@@ -221,6 +221,16 @@ function isStructuralJsxValue(node) {
|
||||
return parent.tagName.getText() === "option";
|
||||
}
|
||||
|
||||
function isPresentationalBlockerProperty(node) {
|
||||
if (nodeName(node.name) !== "target" || !ts.isObjectLiteralExpression(node.parent)) return false;
|
||||
const propertyNames = new Set(
|
||||
node.parent.properties
|
||||
.filter((property) => ts.isPropertyAssignment(property))
|
||||
.map((property) => nodeName(property.name))
|
||||
);
|
||||
return propertyNames.has("actor") && propertyNames.has("requiredAction");
|
||||
}
|
||||
|
||||
function hasAncestorVariable(node, variableName) {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8");
|
||||
|
||||
const settings = read("src/features/settings/SettingsPage.tsx");
|
||||
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
|
||||
const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
||||
const iconRail = read("src/layout/IconRail.tsx");
|
||||
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
|
||||
const titlebar = read("src/layout/Titlebar.tsx");
|
||||
const layoutStyles = read("src/styles/layout.css");
|
||||
|
||||
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
|
||||
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
|
||||
assert.match(settings, /There are no unsaved interface changes\./, "preference save explains its clean state");
|
||||
|
||||
assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker");
|
||||
assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation");
|
||||
assert.match(retention, /locked by platform configuration/, "retention explains platform locks");
|
||||
assert.doesNotMatch(retention, /<textarea/, "retention does not use raw text or JSON as its primary editor");
|
||||
|
||||
assert.match(credentials, /<ActionBlockerHint/, "credentials render the shared actionable blocker");
|
||||
assert.match(credentials, /contextId: "access\.credentials"/, "credentials expose stable admin documentation");
|
||||
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
|
||||
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
||||
|
||||
assert.match(iconRail, /<div className="icon-rail-scroll">\s*<nav className="icon-nav">/, "the module navigation has a dedicated scroll viewport");
|
||||
assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 1 auto;[^}]*overflow-y: auto;/, "only the middle rail region scrolls");
|
||||
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
|
||||
assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed");
|
||||
|
||||
assert.match(titlebar, /className="titlebar-status-pattern"/, "shell state uses a titlebar background pattern");
|
||||
assert.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell");
|
||||
assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state");
|
||||
assert.doesNotMatch(layoutStyles, /\.maintenance-topbar-link[^}]*position: absolute;/, "maintenance state does not occupy the centered search position");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
|
||||
assert.match(moduleLoadBoundary, /<DismissibleAlert tone="danger" compact/, "module failures use the compact shared alert");
|
||||
|
||||
console.log("Core interface-pattern contracts passed.");
|
||||
@@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
const packageByModule = {
|
||||
access: "@govoplan/access-webui",
|
||||
approvals: "@govoplan/approvals-webui",
|
||||
identity_trust: "@govoplan/identity-trust-webui",
|
||||
admin: "@govoplan/admin-webui",
|
||||
addresses: "@govoplan/addresses-webui",
|
||||
audit: "@govoplan/audit-webui",
|
||||
@@ -16,6 +17,7 @@ const packageByModule = {
|
||||
datasources: "@govoplan/datasources-webui",
|
||||
dist_lists: "@govoplan/dist-lists-webui",
|
||||
docs: "@govoplan/docs-webui",
|
||||
encryption: "@govoplan/encryption-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
forms: "@govoplan/forms-webui",
|
||||
forms_runtime: "@govoplan/forms-runtime-webui",
|
||||
@@ -70,6 +72,10 @@ const cases = [
|
||||
{ name: "notifications-only", modules: ["notifications"] },
|
||||
{ name: "organizations-only", modules: ["organizations"] },
|
||||
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
||||
{ name: "identity-trust-only", modules: ["identity_trust"] },
|
||||
{ name: "identity-trust-with-access", modules: ["access", "identity_trust"] },
|
||||
{ name: "encryption-only", modules: ["encryption"] },
|
||||
{ name: "encryption-with-trust", modules: ["access", "identity_trust", "encryption"] },
|
||||
{ name: "postbox-only", modules: ["postbox"] },
|
||||
{ name: "portal-only", modules: ["portal"] },
|
||||
{ name: "projects-only", modules: ["projects"] },
|
||||
@@ -85,7 +91,7 @@ const cases = [
|
||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||
{ name: "approvals-only", modules: ["access", "approvals"] },
|
||||
{ name: "voting-only", modules: ["access", "voting"] },
|
||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search", "voting"] }
|
||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search", "voting"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
|
||||
@@ -21,6 +21,8 @@ import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
||||
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
||||
import { hasAnyScope } from "./utils/permissions";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -494,6 +496,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
||||
const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) &&
|
||||
webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
||||
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
@@ -514,6 +518,7 @@ export default function App() {
|
||||
onLanguageChange={persistLanguagePreference}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
|
||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
@@ -561,6 +566,7 @@ export default function App() {
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</DocumentationHelpProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||
import type { PlatformInterfaceCatalog, PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||
@@ -50,3 +50,7 @@ export async function fetchPlatformStatus(settings: ApiSettings): Promise<Platfo
|
||||
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
|
||||
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
|
||||
}
|
||||
|
||||
export async function fetchPlatformInterfaceCatalog(settings: ApiSettings): Promise<PlatformInterfaceCatalog> {
|
||||
return apiFetch<PlatformInterfaceCatalog>(settings, "/api/v1/platform/interface-catalog");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import AdvancedOptionsPanel from "./AdvancedOptionsPanel";
|
||||
import DocumentationHelpLink from "./help/DocumentationHelpLink";
|
||||
import type { DocumentationHelpReference } from "./help/documentationHelp";
|
||||
|
||||
export type ActionBlockerReason = {
|
||||
summary: ReactNode;
|
||||
@@ -11,17 +13,32 @@ export type ActionBlockerReason = {
|
||||
technicalDetails?: ReactNode;
|
||||
};
|
||||
|
||||
export type ActionBlockerLabels = {
|
||||
requiredAction?: ReactNode;
|
||||
actor?: ReactNode;
|
||||
target?: ReactNode;
|
||||
technicalDetails?: ReactNode;
|
||||
};
|
||||
|
||||
type ActionBlockerHintProps = {
|
||||
reason: ActionBlockerReason;
|
||||
tone?: "info" | "warning" | "danger";
|
||||
className?: string;
|
||||
labels?: ActionBlockerLabels;
|
||||
documentation?: DocumentationHelpReference;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function ActionBlockerHint({ reason, tone = "warning", className = "" }: ActionBlockerHintProps) {
|
||||
export default function ActionBlockerHint({
|
||||
reason,
|
||||
tone = "warning",
|
||||
className = "",
|
||||
labels = {},
|
||||
documentation
|
||||
}: ActionBlockerHintProps) {
|
||||
const Icon = tone === "info" ? Info : AlertTriangle;
|
||||
const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target);
|
||||
|
||||
@@ -35,29 +52,30 @@ export default function ActionBlockerHint({ reason, tone = "warning", className
|
||||
<dl>
|
||||
{reason.requiredAction && (
|
||||
<>
|
||||
<dt>Required action</dt>
|
||||
<dt>{labels.requiredAction ?? "Required action"}</dt>
|
||||
<dd>{reason.requiredAction}</dd>
|
||||
</>
|
||||
)}
|
||||
{reason.actor && (
|
||||
<>
|
||||
<dt>Who can fix it</dt>
|
||||
<dt>{labels.actor ?? "Who can fix it"}</dt>
|
||||
<dd>{reason.actor}</dd>
|
||||
</>
|
||||
)}
|
||||
{reason.target && (
|
||||
<>
|
||||
<dt>Where to go</dt>
|
||||
<dt>{labels.target ?? "Where to go"}</dt>
|
||||
<dd>{reason.target}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{reason.technicalDetails && (
|
||||
<AdvancedOptionsPanel title="Technical details" className="action-blocker-technical">
|
||||
<AdvancedOptionsPanel title={labels.technicalDetails ?? "Technical details"} className="action-blocker-technical">
|
||||
<div>{reason.technicalDetails}</div>
|
||||
</AdvancedOptionsPanel>
|
||||
)}
|
||||
{documentation && <DocumentationHelpLink reference={documentation} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import DisabledActionTooltip from "./DisabledActionTooltip";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInterfaceIdentityProps & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
disabledReason?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
|
||||
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
|
||||
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type {
|
||||
CredentialReferenceSelectorsUiCapability
|
||||
} from "./ReferenceSelect";
|
||||
import ActionBlockerHint, { type ActionBlockerReason } from "./ActionBlockerHint";
|
||||
import DocumentationHelpLink from "./help/DocumentationHelpLink";
|
||||
|
||||
export type CredentialEnvelopeTargetOption = {
|
||||
id: string;
|
||||
@@ -95,6 +97,11 @@ const EMPTY_DRAFT: CredentialDraft = {
|
||||
retainedPublicData: {}
|
||||
};
|
||||
|
||||
const CREDENTIAL_DOCUMENTATION = {
|
||||
contextId: "access.credentials",
|
||||
documentationType: "admin" as const
|
||||
};
|
||||
|
||||
export default function CredentialEnvelopeManager({
|
||||
settings,
|
||||
scopeType,
|
||||
@@ -377,6 +384,27 @@ export default function CredentialEnvelopeManager({
|
||||
const saveDisabled = saving || !canWrite || !draft.name.trim() ||
|
||||
(editing === "new" && !draft.secret.trim()) ||
|
||||
(kindChanged && !draft.secret.trim());
|
||||
const managerBlocker = credentialManagerBlocker({ canWrite, scopeReady, scopeType });
|
||||
const writeDisabledReason = saving
|
||||
? "A credential change is already being saved."
|
||||
: !scopeReady
|
||||
? "Select a credential owner before changing credentials."
|
||||
: !canWrite
|
||||
? "Your account may inspect credentials at this scope but cannot change them."
|
||||
: undefined;
|
||||
const saveDisabledReason = saving
|
||||
? "The credential is already being saved."
|
||||
: !canWrite
|
||||
? "Your account may inspect this credential but cannot change it."
|
||||
: !scopeReady
|
||||
? "Select a credential owner before saving."
|
||||
: !draft.name.trim()
|
||||
? "Enter a credential name before saving."
|
||||
: editing === "new" && !draft.secret.trim()
|
||||
? "Enter a secret before creating the credential."
|
||||
: kindChanged && !draft.secret.trim()
|
||||
? "Enter a replacement secret when changing the credential type."
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="credential-envelope-manager">
|
||||
@@ -397,16 +425,24 @@ export default function CredentialEnvelopeManager({
|
||||
)}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
{managerBlocker && (
|
||||
<ActionBlockerHint
|
||||
reason={managerBlocker}
|
||||
documentation={CREDENTIAL_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||
<Button
|
||||
type="button"
|
||||
title="Reload credentials"
|
||||
aria-label="Reload credentials"
|
||||
onClick={() => void loadCredentials()}
|
||||
disabled={loading}
|
||||
disabled={loading || !scopeReady}
|
||||
disabledReason={loading ? "Credentials are already loading." : !scopeReady ? "Select a credential owner before reloading." : undefined}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
@@ -414,7 +450,8 @@ export default function CredentialEnvelopeManager({
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={openCreate}
|
||||
disabled={!canWrite || !scopeReady || loading}
|
||||
disabled={Boolean(writeDisabledReason) || loading}
|
||||
disabledReason={loading ? "Wait until credentials have loaded." : writeDisabledReason}
|
||||
>
|
||||
<Plus size={16} /> Add credential
|
||||
</Button>
|
||||
@@ -435,7 +472,8 @@ export default function CredentialEnvelopeManager({
|
||||
label: `Edit ${credential.name}`,
|
||||
icon: <Pencil size={15} />,
|
||||
onClick: () => openEdit(credential),
|
||||
disabled: !canWrite || saving
|
||||
disabled: !canWrite || saving,
|
||||
disabledReason: writeDisabledReason
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
@@ -443,6 +481,7 @@ export default function CredentialEnvelopeManager({
|
||||
icon: <Trash2 size={15} />,
|
||||
onClick: () => setDeleting(credential),
|
||||
disabled: !canWrite || saving,
|
||||
disabledReason: writeDisabledReason,
|
||||
variant: "danger"
|
||||
}
|
||||
]}
|
||||
@@ -461,8 +500,8 @@ export default function CredentialEnvelopeManager({
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled}>
|
||||
<Button onClick={closeEditor} disabled={saving} disabledReason={saving ? "Wait for the credential save to finish." : undefined}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled} disabledReason={saveDisabledReason}>
|
||||
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
|
||||
</Button>
|
||||
</>
|
||||
@@ -495,7 +534,7 @@ export default function CredentialEnvelopeManager({
|
||||
label={secretFieldLabel(draft.credentialKind)}
|
||||
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
|
||||
>
|
||||
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" />
|
||||
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator />
|
||||
</FormField>
|
||||
{editing !== "new" && (
|
||||
<ToggleSwitch
|
||||
@@ -555,6 +594,35 @@ export default function CredentialEnvelopeManager({
|
||||
);
|
||||
}
|
||||
|
||||
function credentialManagerBlocker({
|
||||
canWrite,
|
||||
scopeReady,
|
||||
scopeType
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
scopeReady: boolean;
|
||||
scopeType: CredentialEnvelopeManagerProps["scopeType"];
|
||||
}): ActionBlockerReason | null {
|
||||
if (!scopeReady) {
|
||||
return {
|
||||
summary: "A credential owner is required.",
|
||||
details: `No ${scopeType} target is currently selected.`,
|
||||
requiredAction: "Select the user or group that will own the reusable credential.",
|
||||
actor: "An administrator with access to this settings surface",
|
||||
target: "The target selector above"
|
||||
};
|
||||
}
|
||||
if (!canWrite) {
|
||||
return {
|
||||
summary: "You can inspect reusable credentials but cannot change them.",
|
||||
requiredAction: "Ask for credential-management authority if a change is required.",
|
||||
actor: "A scope or access administrator",
|
||||
target: "Access and role administration"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function credentialDraft(credential: CredentialEnvelopeSummary): CredentialDraft {
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
|
||||
@@ -23,6 +23,7 @@ export type CredentialFieldsProps = {
|
||||
passwordPlaceholder?: string;
|
||||
usernameAutoComplete?: string;
|
||||
passwordAutoComplete?: string;
|
||||
passwordGenerator?: boolean;
|
||||
showUsername?: boolean;
|
||||
showPassword?: boolean;
|
||||
};
|
||||
@@ -49,6 +50,7 @@ export function CredentialFields({
|
||||
passwordPlaceholder,
|
||||
usernameAutoComplete = "username",
|
||||
passwordAutoComplete = "new-password",
|
||||
passwordGenerator = true,
|
||||
showUsername = true,
|
||||
showPassword = true
|
||||
}: CredentialFieldsProps) {
|
||||
@@ -74,6 +76,7 @@ export function CredentialFields({
|
||||
savedPlaceholder={savedPasswordPlaceholder}
|
||||
placeholder={passwordPlaceholder}
|
||||
autoComplete={passwordAutoComplete}
|
||||
generator={passwordGenerator}
|
||||
onValueChange={(password) => onChange({ password })} />
|
||||
</FormField>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & PlatformInterfaceIdentityProps & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
min?: string;
|
||||
@@ -49,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
|
||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||
}
|
||||
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||
const selectedDate = parseDate(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||
@@ -93,7 +94,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`date-field ${className}`.trim()}>
|
||||
<div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
@@ -145,7 +146,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
||||
|
||||
}
|
||||
|
||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", ...props }: BaseProps) {
|
||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -158,7 +159,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
||||
}, [value, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`time-field ${className}`.trim()}>
|
||||
<div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
@@ -174,7 +175,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
||||
|
||||
}
|
||||
|
||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", ...props }: BaseProps) {
|
||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||
const parts = datePartsFromDateTime(value);
|
||||
const minParts = datePartsFromDateTime(min || "");
|
||||
const maxParts = datePartsFromDateTime(max || "");
|
||||
@@ -188,7 +189,7 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`date-time-field ${className}`.trim()}>
|
||||
<div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<DateField
|
||||
{...props}
|
||||
value={parts.date}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user