Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ba1c772d | ||
|
|
7c689b6939 |
@@ -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
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# GovOPlaN DMS
|
# GovOPlaN DMS
|
||||||
|
|
||||||
|
Optional integration module for external document-management systems. The first concrete product profile targets d.velop d3 DMSApp and provides repository discovery plus digest-bound request planning. Real target writes remain disabled until a configured d3 tenant passes mapping, custody, reconciliation, and recovery tests.
|
||||||
|
|
||||||
|
See [the DMS boundary](docs/DMS_BOUNDARY.md) and [the d.velop d3 integration profile](docs/DVELOP_D3_INTEGRATION.md).
|
||||||
|
|
||||||
<!-- govoplan-repository-type:start -->
|
<!-- govoplan-repository-type:start -->
|
||||||
**Repository type:** module (domain).
|
**Repository type:** module (domain).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# d.velop d3 integration profile
|
||||||
|
|
||||||
|
GovOPlaN's first concrete DMS product profile targets the d.velop d3 DMSApp HTTP interface. The slice deliberately separates discovery and request planning from effects.
|
||||||
|
|
||||||
|
## Configuration boundary
|
||||||
|
|
||||||
|
A tenant binding contains the HTTPS API base URL, repository ID, calling `Origin`, d3 source category, stable source ID, mapping revision, and an Access credential-envelope reference. The bearer token is resolved only for an outbound call and is never persisted in the DMS binding, plan, evidence, or diagnostics.
|
||||||
|
|
||||||
|
The source category and source properties must be mapped by the d3 tenant administrator. GovOPlaN does not infer a document type from a repository response or silently adapt when that mapping changes.
|
||||||
|
|
||||||
|
## Preflight and plan
|
||||||
|
|
||||||
|
Preflight reads the DMSApp repository catalog, the selected repository, and its object definitions. It records observation time and canonical response digests. A successful preflight means that discovery and authentication worked; it does not prove that a source mapping accepts GovOPlaN data.
|
||||||
|
|
||||||
|
The store-plan builder produces the documented `o2m` request shape with stable GovOPlaN package, record, revision, manifest digest, purpose, and mapping-revision properties. It never sends the request. The plan digest is the review boundary for a later target-tested write implementation.
|
||||||
|
|
||||||
|
## Enablement gates
|
||||||
|
|
||||||
|
Real Records dispatch remains fail-closed until target evidence demonstrates all of the following:
|
||||||
|
|
||||||
|
- repository and object-definition discovery through the deployed gateway;
|
||||||
|
- administrator-reviewed source-property mapping;
|
||||||
|
- successful storage and correlation lookup by package ID and manifest digest;
|
||||||
|
- an unambiguous custody receipt or an explicit statement that d3 is only a DMS copy target;
|
||||||
|
- timeout and unknown-outcome reconciliation before retry;
|
||||||
|
- correction, quarantine, and recovery exercises; and
|
||||||
|
- credential rotation without exposing bearer values.
|
||||||
|
|
||||||
|
Until those gates are met, the `records.archive.dvelop_d3` capability reports unhealthy and rejects dispatch. Records retains its approved, digest-bound package and does not claim external custody.
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-dms"
|
||||||
|
version = "0.1.20"
|
||||||
|
description = "GovOPlaN document-management integration module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core>=0.1.37",
|
||||||
|
"govoplan-access>=0.1.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_dms = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
dms = "govoplan_dms.backend.manifest:get_manifest"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN DMS integration module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.20"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Backend integration profiles for document-management systems."""
|
||||||
|
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Protocol
|
||||||
|
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from govoplan_core.core.records import (
|
||||||
|
RecordArchiveProviderState,
|
||||||
|
RecordArchiveReceipt,
|
||||||
|
RecordArchiveTransferRequest,
|
||||||
|
RecordContractError,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.http_fetch import HttpFetchResponse, fetch_http, validate_http_url
|
||||||
|
|
||||||
|
|
||||||
|
DVELOP_D3_PROVIDER_ID = "dvelop_d3"
|
||||||
|
DVELOP_D3_ARCHIVE_PROFILE = "dvelop-d3-record-transfer-v1"
|
||||||
|
DVELOP_D3_EXTERNAL_PROVIDER_ID = "dms.dvelop_d3"
|
||||||
|
MAX_D3_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class DvelopD3Error(RuntimeError):
|
||||||
|
"""Stable, sanitized d.velop d3 integration error."""
|
||||||
|
|
||||||
|
|
||||||
|
class DvelopD3Transport(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: bytes | None,
|
||||||
|
) -> HttpFetchResponse: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DvelopD3Profile:
|
||||||
|
"""Non-secret tenant binding for one d.velop d3 DMSApp repository."""
|
||||||
|
|
||||||
|
api_base_url: str
|
||||||
|
repository_id: str
|
||||||
|
origin: str
|
||||||
|
source_category: str
|
||||||
|
source_id: str
|
||||||
|
mapping_revision: str
|
||||||
|
credential_ref: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_base = validate_http_url(self.api_base_url, label="d.velop d3 API base URL")
|
||||||
|
normalized_origin = validate_http_url(self.origin, label="d.velop d3 Origin")
|
||||||
|
origin_parts = urlsplit(normalized_origin)
|
||||||
|
if origin_parts.path not in {"", "/"} or origin_parts.query or origin_parts.fragment:
|
||||||
|
raise ValueError("d.velop d3 Origin must contain only scheme, host, and port.")
|
||||||
|
for name in (
|
||||||
|
"repository_id",
|
||||||
|
"source_category",
|
||||||
|
"source_id",
|
||||||
|
"mapping_revision",
|
||||||
|
"credential_ref",
|
||||||
|
):
|
||||||
|
value = str(getattr(self, name) or "").strip()
|
||||||
|
if not value or len(value) > 255:
|
||||||
|
raise ValueError(f"d.velop d3 {name.replace('_', ' ')} is required and limited to 255 characters.")
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
object.__setattr__(self, "api_base_url", normalized_base.rstrip("/"))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"origin",
|
||||||
|
urlunsplit((origin_parts.scheme, origin_parts.netloc, "", "", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DvelopD3PreflightResult:
|
||||||
|
repository_id: str
|
||||||
|
observed_at: datetime
|
||||||
|
repository_catalog_sha256: str
|
||||||
|
repository_sha256: str
|
||||||
|
object_definitions_sha256: str
|
||||||
|
mapping_revision: str
|
||||||
|
ready_for_mapping_test: bool
|
||||||
|
dispatch_ready: bool = False
|
||||||
|
limitations: tuple[str, ...] = (
|
||||||
|
"Repository discovery does not prove a configured source mapping.",
|
||||||
|
"Archive custody and recovery conformance require separate target evidence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DvelopD3StorePlan:
|
||||||
|
"""Reviewable DMSApp o2m request; executing it is a separate governed effect."""
|
||||||
|
|
||||||
|
repository_id: str
|
||||||
|
endpoint: str
|
||||||
|
origin: str
|
||||||
|
idempotency_key: str
|
||||||
|
body: Mapping[str, object]
|
||||||
|
body_sha256: str
|
||||||
|
mapping_revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DvelopD3Client:
|
||||||
|
profile: DvelopD3Profile
|
||||||
|
bearer_token: str | None = field(default=None, repr=False)
|
||||||
|
transport: DvelopD3Transport | None = field(default=None, repr=False)
|
||||||
|
timeout_seconds: int = 30
|
||||||
|
|
||||||
|
def preflight(self) -> DvelopD3PreflightResult:
|
||||||
|
catalog = self._get_json("dms/r")
|
||||||
|
repository = self._get_json(
|
||||||
|
f"dms/r/{quote(self.profile.repository_id, safe='')}"
|
||||||
|
)
|
||||||
|
object_definitions = self._get_json(
|
||||||
|
f"dms/r/{quote(self.profile.repository_id, safe='')}/objdef"
|
||||||
|
)
|
||||||
|
return DvelopD3PreflightResult(
|
||||||
|
repository_id=self.profile.repository_id,
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
repository_catalog_sha256=_json_sha256(catalog),
|
||||||
|
repository_sha256=_json_sha256(repository),
|
||||||
|
object_definitions_sha256=_json_sha256(object_definitions),
|
||||||
|
mapping_revision=self.profile.mapping_revision,
|
||||||
|
ready_for_mapping_test=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_record_store_plan(
|
||||||
|
self,
|
||||||
|
request: RecordArchiveTransferRequest,
|
||||||
|
*,
|
||||||
|
content_location_uri: str,
|
||||||
|
) -> DvelopD3StorePlan:
|
||||||
|
if request.package.profile != DVELOP_D3_ARCHIVE_PROFILE:
|
||||||
|
raise DvelopD3Error("The transfer package does not use the d.velop d3 profile.")
|
||||||
|
content_uri = validate_http_url(
|
||||||
|
content_location_uri,
|
||||||
|
label="d.velop d3 content location URI",
|
||||||
|
)
|
||||||
|
body: dict[str, object] = {
|
||||||
|
"sourceCategory": self.profile.source_category,
|
||||||
|
"sourceId": self.profile.source_id,
|
||||||
|
"sourceProperties": {
|
||||||
|
"govoplanPackageId": request.package.package_id,
|
||||||
|
"govoplanRecordId": request.package.record_id,
|
||||||
|
"govoplanRecordRevision": str(request.package.record_revision),
|
||||||
|
"govoplanManifestSha256": request.package.manifest_sha256,
|
||||||
|
"govoplanPurpose": request.purpose,
|
||||||
|
"govoplanMappingRevision": self.profile.mapping_revision,
|
||||||
|
},
|
||||||
|
"contentLocationUri": content_uri,
|
||||||
|
}
|
||||||
|
encoded = _canonical_json(body)
|
||||||
|
return DvelopD3StorePlan(
|
||||||
|
repository_id=self.profile.repository_id,
|
||||||
|
endpoint=self._url(
|
||||||
|
f"dms/r/{quote(self.profile.repository_id, safe='')}/o2m"
|
||||||
|
),
|
||||||
|
origin=self.profile.origin,
|
||||||
|
idempotency_key=request.idempotency_key,
|
||||||
|
body=body,
|
||||||
|
body_sha256=hashlib.sha256(encoded).hexdigest(),
|
||||||
|
mapping_revision=self.profile.mapping_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_json(self, relative_path: str) -> object:
|
||||||
|
response = self._request(relative_path, method="GET", body=None)
|
||||||
|
if response.status != 200:
|
||||||
|
raise DvelopD3Error(
|
||||||
|
f"d.velop d3 discovery returned HTTP {response.status}."
|
||||||
|
)
|
||||||
|
content_type = next(
|
||||||
|
(
|
||||||
|
value
|
||||||
|
for key, value in response.headers.items()
|
||||||
|
if key.casefold() == "content-type"
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
).partition(";")[0].strip().casefold()
|
||||||
|
if content_type not in {"application/json", "application/hal+json"}:
|
||||||
|
raise DvelopD3Error("d.velop d3 discovery did not return JSON or HAL+JSON.")
|
||||||
|
if len(response.body) > MAX_D3_RESPONSE_BYTES:
|
||||||
|
raise DvelopD3Error("d.velop d3 discovery response exceeded the safety limit.")
|
||||||
|
try:
|
||||||
|
return json.loads(response.body)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise DvelopD3Error("d.velop d3 discovery returned malformed JSON.") from exc
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
relative_path: str,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
body: bytes | None,
|
||||||
|
) -> HttpFetchResponse:
|
||||||
|
url = self._url(relative_path)
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/hal+json, application/json",
|
||||||
|
"Origin": self.profile.origin,
|
||||||
|
}
|
||||||
|
if self.bearer_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||||
|
if self.transport is not None:
|
||||||
|
return self.transport(url, method=method, headers=headers, body=body)
|
||||||
|
if not self.bearer_token:
|
||||||
|
raise DvelopD3Error(
|
||||||
|
"d.velop d3 authentication is unavailable; resolve the configured credential envelope first."
|
||||||
|
)
|
||||||
|
return fetch_http(
|
||||||
|
url,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
body=body,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
max_bytes=MAX_D3_RESPONSE_BYTES,
|
||||||
|
label="d.velop d3 DMSApp",
|
||||||
|
redirect_sensitive_headers=("Authorization",),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _url(self, relative_path: str) -> str:
|
||||||
|
url = urljoin(f"{self.profile.api_base_url}/", relative_path.lstrip("/"))
|
||||||
|
base = urlsplit(self.profile.api_base_url)
|
||||||
|
candidate = urlsplit(url)
|
||||||
|
if _origin(base) != _origin(candidate):
|
||||||
|
raise DvelopD3Error("d.velop d3 endpoint escaped the configured API origin.")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class UnconfiguredDvelopD3ArchiveProvider:
|
||||||
|
"""Advertise the real-product profile without enabling an untested effect."""
|
||||||
|
|
||||||
|
provider_id = DVELOP_D3_PROVIDER_ID
|
||||||
|
|
||||||
|
def state(self) -> RecordArchiveProviderState:
|
||||||
|
return RecordArchiveProviderState(
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
label="d.velop d3 DMS",
|
||||||
|
profiles=(DVELOP_D3_ARCHIVE_PROFILE,),
|
||||||
|
authority_modes=("external_authoritative",),
|
||||||
|
healthy=False,
|
||||||
|
checked_at=datetime.now(UTC),
|
||||||
|
limitations=(
|
||||||
|
"A tenant repository, credential envelope, source mapping, and target test are required.",
|
||||||
|
"Records dispatch remains disabled until custody and recovery are evidenced.",
|
||||||
|
),
|
||||||
|
simulated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: RecordArchiveTransferRequest,
|
||||||
|
) -> RecordArchiveReceipt:
|
||||||
|
del session, principal, request
|
||||||
|
raise RecordContractError(
|
||||||
|
"d.velop d3 dispatch is disabled until the configured repository passes mapping, custody, and recovery target tests."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(parts) -> tuple[str, str, int | None]:
|
||||||
|
return (
|
||||||
|
parts.scheme.casefold(),
|
||||||
|
(parts.hostname or "").casefold(),
|
||||||
|
parts.port or (443 if parts.scheme.casefold() == "https" else 80),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_sha256(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DVELOP_D3_ARCHIVE_PROFILE",
|
||||||
|
"DVELOP_D3_EXTERNAL_PROVIDER_ID",
|
||||||
|
"DVELOP_D3_PROVIDER_ID",
|
||||||
|
"DvelopD3Client",
|
||||||
|
"DvelopD3Error",
|
||||||
|
"DvelopD3PreflightResult",
|
||||||
|
"DvelopD3Profile",
|
||||||
|
"DvelopD3StorePlan",
|
||||||
|
"UnconfiguredDvelopD3ArchiveProvider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleManifest,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderDeclaration,
|
||||||
|
ExternalProviderRuntimeState,
|
||||||
|
ExternalProviderStateContext,
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
ProviderBehaviorDeclaration,
|
||||||
|
ProviderObjectDeclaration,
|
||||||
|
declared_module_architecture,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.records import record_archive_capability
|
||||||
|
from govoplan_dms.backend.dvelop_d3 import (
|
||||||
|
DVELOP_D3_EXTERNAL_PROVIDER_ID,
|
||||||
|
DVELOP_D3_PROVIDER_ID,
|
||||||
|
UnconfiguredDvelopD3ArchiveProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "dms"
|
||||||
|
MODULE_VERSION = "0.1.20"
|
||||||
|
READ_SCOPE = "dms:integration:read"
|
||||||
|
ADMIN_SCOPE = "dms:integration:admin"
|
||||||
|
D3_ARCHIVE_CAPABILITY = record_archive_capability(DVELOP_D3_PROVIDER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
|
module_id, resource, action = scope.split(":", 2)
|
||||||
|
return PermissionDefinition(
|
||||||
|
scope=scope,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
category="DMS",
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
D3_PROVIDER = ExternalProviderDeclaration(
|
||||||
|
id=DVELOP_D3_EXTERNAL_PROVIDER_ID,
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="d.velop d3 DMSApp repository",
|
||||||
|
maturity="read",
|
||||||
|
operations=("discover", "read", "preview", "dry_run"),
|
||||||
|
objects=(
|
||||||
|
ProviderObjectDeclaration(
|
||||||
|
object_type="repository",
|
||||||
|
field_groups=("identity", "object_definitions", "source_mapping"),
|
||||||
|
authority_modes=("external_authoritative", "linked_reference"),
|
||||||
|
default_authority_mode="external_authoritative",
|
||||||
|
),
|
||||||
|
ProviderObjectDeclaration(
|
||||||
|
object_type="record_transfer",
|
||||||
|
field_groups=("manifest", "content_location", "mapping_revision"),
|
||||||
|
authority_modes=("external_authoritative",),
|
||||||
|
default_authority_mode="external_authoritative",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
behavior=ProviderBehaviorDeclaration(
|
||||||
|
revision_tokens="Repository and object-definition digests plus the configured mapping revision are retained.",
|
||||||
|
concurrency="A reviewed plan is bound to the exact GovOPlaN manifest and mapping revision.",
|
||||||
|
freshness="Repository discovery records its observation time and response digests.",
|
||||||
|
health="Discovery, authentication, repository lookup, object definitions, mapping, custody, and recovery are distinct gates.",
|
||||||
|
max_read_items=1000,
|
||||||
|
idempotency="The Records package id and manifest digest form the target correlation key.",
|
||||||
|
retry="Discovery is retryable; writes remain disabled until target-specific reconciliation is proven.",
|
||||||
|
timeout_seconds=30,
|
||||||
|
conflicts="Mapping changes invalidate an earlier store plan and require a new review.",
|
||||||
|
outcome_unknown="A timed-out write must be reconciled by package correlation before retry.",
|
||||||
|
outcome_unknown_supported=True,
|
||||||
|
evidence="Endpoint, repository, mapping revision, response digests, package digest, and target receipts are evidence; secrets are excluded.",
|
||||||
|
correction="Correct repository or mapping configuration and produce a newly digest-bound plan.",
|
||||||
|
rollback="Repository effects are not assumed to be transactionally reversible.",
|
||||||
|
compensation="Target-specific recovery may supersede or quarantine an erroneous object after reconciliation.",
|
||||||
|
reconciliation="Look up the stable package correlation and compare the exact manifest digest before any retry.",
|
||||||
|
outage="Records retains its prepared package and never infers custody from an unavailable target.",
|
||||||
|
classifications=("confidential", "restricted"),
|
||||||
|
purposes=("document management", "governed record transfer"),
|
||||||
|
retention="Records and target DMS retention policies remain explicit and independently evidenced.",
|
||||||
|
secret_handling="Only credential-envelope references are configured; bearer values are never persisted or returned.",
|
||||||
|
),
|
||||||
|
capability_names=(D3_ARCHIVE_CAPABILITY,),
|
||||||
|
documentation_topic_ids=("dms.dvelop-d3",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _d3_archive_provider(context: ModuleContext) -> UnconfiguredDvelopD3ArchiveProvider:
|
||||||
|
del context
|
||||||
|
return UnconfiguredDvelopD3ArchiveProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _d3_provider_states(
|
||||||
|
context: ExternalProviderStateContext,
|
||||||
|
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||||
|
del context
|
||||||
|
return (
|
||||||
|
ExternalProviderRuntimeState(
|
||||||
|
provider_id=DVELOP_D3_EXTERNAL_PROVIDER_ID,
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
configured=False,
|
||||||
|
active=False,
|
||||||
|
health="inactive",
|
||||||
|
freshness="not_applicable",
|
||||||
|
conflict="not_applicable",
|
||||||
|
recovery="unsupported",
|
||||||
|
detail="No target-tested d.velop d3 tenant binding is configured; dispatch is disabled.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name="DMS",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("access",),
|
||||||
|
optional_dependencies=("records", "files", "audit", "policy"),
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
_permission(READ_SCOPE, "View DMS integration", "Inspect configured DMS bindings and non-secret target health."),
|
||||||
|
_permission(ADMIN_SCOPE, "Administer DMS integration", "Configure and test tenant DMS repositories, mappings, and recovery evidence."),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="dms_integration_administrator",
|
||||||
|
name="DMS integration administrator",
|
||||||
|
description="Configure and verify governed external document-management bindings.",
|
||||||
|
permissions=(READ_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={D3_ARCHIVE_CAPABILITY: _d3_archive_provider},
|
||||||
|
external_providers=(D3_PROVIDER,),
|
||||||
|
external_provider_state_providers=(
|
||||||
|
ExternalProviderStateProviderRegistration(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
provider_id=DVELOP_D3_EXTERNAL_PROVIDER_ID,
|
||||||
|
provider=_d3_provider_states,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="dms.boundary",
|
||||||
|
title="DMS integration boundary",
|
||||||
|
summary="Connect external document-management products without moving Files storage or Records lifecycle ownership into the connector.",
|
||||||
|
body=(
|
||||||
|
"DMS owns product-specific repository discovery, source mapping, version and reference semantics, and target receipts. Files continues to own binary storage, while Records owns eAkte filing, retention, holds, disposition, and archive handoff. A provider declaration is not proof that a configured target is healthy or conformant."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "records_manager"),
|
||||||
|
related_modules=("files", "records", "access"),
|
||||||
|
links=(DocumentationLink(label="DMS boundary", href="docs/DMS_BOUNDARY.md", kind="repository"),),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Integrationsgrenze des DMS-Moduls",
|
||||||
|
"summary": "Externe Dokumentenmanagementprodukte anbinden, ohne Dateiablage oder Aktenlebenszyklus in den Konnektor zu verlagern.",
|
||||||
|
"body": "DMS verantwortet produktspezifische Repository-Erkennung, Quellzuordnung, Versions- und Referenzsemantik sowie Zielbelege. Files bleibt für Binärdaten zuständig; Records verwaltet Veraktung, Aufbewahrung, Sperren, Aussonderung und Archivübergabe. Eine Anbieterdeklaration beweist weder Gesundheit noch Konformität eines konfigurierten Ziels.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={"kind": "reference"},
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="dms.dvelop-d3",
|
||||||
|
title="Configure and verify d.velop d3 DMSApp",
|
||||||
|
summary="Discover a tenant repository, bind an administrator-owned source mapping, and review a digest-bound transfer plan before effects are enabled.",
|
||||||
|
body=(
|
||||||
|
"Configure the HTTPS API base, repository id, calling Origin, d3 source category and source id, mapping revision, and a reusable Access credential envelope. Preflight reads the repository catalog, selected repository, and object definitions through DMSApp and retains only response digests. A store plan binds the exact Records package, content location, purpose, idempotency key, and mapping revision. Dispatch stays unavailable until a real target test proves authentication, source mapping, correlation lookup, custody receipt, unknown-outcome reconciliation, and recovery."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "records_manager", "auditor"),
|
||||||
|
related_modules=("records", "files", "access", "audit"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),
|
||||||
|
),
|
||||||
|
links=(DocumentationLink(label="d.velop d3 integration profile", href="docs/DVELOP_D3_INTEGRATION.md", kind="repository"),),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "d.velop d3 DMSApp konfigurieren und prüfen",
|
||||||
|
"summary": "Ein Mandanten-Repository erkennen, eine administrativ verantwortete Quellzuordnung binden und vor Wirkungen einen prüfsummengebundenen Übergabeplan kontrollieren.",
|
||||||
|
"body": "Konfigurieren Sie HTTPS-API-Basis, Repository-ID, aufrufenden Origin, d3-Quellkategorie und -Quell-ID, Mapping-Revision sowie einen wiederverwendbaren Access-Berechtigungsnachweis. Der Vorabtest liest Repository-Katalog, ausgewähltes Repository und Objektdefinitionen über DMSApp und bewahrt nur Antwortprüfsummen. Ein Ablageplan bindet das exakte Records-Paket, die Inhaltsadresse, den Zweck, den Idempotenzschlüssel und die Mapping-Revision. Die Übergabe bleibt gesperrt, bis ein echter Zieltest Authentifizierung, Quellzuordnung, Korrelationssuche, Verwahrungsbeleg, Abgleich unbekannter Ergebnisse und Wiederherstellung nachweist.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"prerequisites": [
|
||||||
|
"A d.velop d3 tenant and DMSApp endpoint are available.",
|
||||||
|
"The tenant administrator has created and reviewed the source mapping.",
|
||||||
|
"Authentication is held in a scoped Access credential envelope.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Run repository and object-definition preflight.",
|
||||||
|
"Review the configured source category, source id, and mapping revision.",
|
||||||
|
"Build and compare the digest-bound store plan without executing it.",
|
||||||
|
"Complete target custody, reconciliation, and recovery evidence before enabling dispatch.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"This release does not enable real Records dispatch.",
|
||||||
|
"Repository discovery alone does not prove source mapping or archival custody.",
|
||||||
|
],
|
||||||
|
"consequences": [
|
||||||
|
"Changing the mapping revision invalidates an earlier plan.",
|
||||||
|
"A timeout never implies success and requires target reconciliation.",
|
||||||
|
"No secret is included in plans, diagnostics, or retained evidence.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"prerequisites": [
|
||||||
|
"Ein d.velop-d3-Mandant und ein DMSApp-Endpunkt sind verfügbar.",
|
||||||
|
"Die Mandantenadministration hat die Quellzuordnung erstellt und geprüft.",
|
||||||
|
"Die Authentifizierung liegt in einem bereichsbegrenzten Access-Berechtigungsnachweis.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Vorabprüfung für Repository und Objektdefinitionen ausführen.",
|
||||||
|
"Konfigurierte Quellkategorie, Quell-ID und Mapping-Revision prüfen.",
|
||||||
|
"Den prüfsummengebundenen Ablageplan ohne Ausführung erzeugen und vergleichen.",
|
||||||
|
"Nachweise zu Zielverwahrung, Abgleich und Wiederherstellung abschließen, bevor die Übergabe aktiviert wird.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Diese Version aktiviert keine reale Records-Übergabe.",
|
||||||
|
"Die Repository-Erkennung allein beweist weder Quellzuordnung noch Archivverwahrung.",
|
||||||
|
],
|
||||||
|
"consequences": [
|
||||||
|
"Eine geänderte Mapping-Revision macht einen früheren Plan ungültig.",
|
||||||
|
"Eine Zeitüberschreitung bedeutet niemals Erfolg und erfordert einen Zielabgleich.",
|
||||||
|
"Pläne, Diagnosen und aufbewahrte Nachweise enthalten keine Geheimnisse.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order=110,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="data_reporting_integration",
|
||||||
|
kind="integration",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/DVELOP_D3_INTEGRATION.md",
|
||||||
|
test_ref="tests/test_dvelop_d3.py",
|
||||||
|
known_limits=("Real d.velop d3 writes require configured-target conformance and recovery evidence.",),
|
||||||
|
supported_authority_modes=("external_authoritative", "linked_reference"),
|
||||||
|
owned_concepts=("DMS product binding", "DMS source mapping", "DMS target receipt"),
|
||||||
|
non_owned_concepts=("file blob", "record lifecycle", "archive disposition"),
|
||||||
|
recovery_docs=("docs/DVELOP_D3_INTEGRATION.md",),
|
||||||
|
security_docs=("docs/DVELOP_D3_INTEGRATION.md",),
|
||||||
|
operations_docs=("docs/DVELOP_D3_INTEGRATION.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import documentation_structured_translation_issues
|
||||||
|
from govoplan_dms.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_dms_german_reference_contract_is_complete() -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
assert topics["dms.boundary"].metadata["kind"] == "reference"
|
||||||
|
workflow = topics["dms.dvelop-d3"]
|
||||||
|
assert workflow.metadata["kind"] == "workflow"
|
||||||
|
assert workflow.structured_translation_version == "1"
|
||||||
|
assert "de" in workflow.structured_translations
|
||||||
|
assert documentation_structured_translation_issues(workflow) == ()
|
||||||
|
for topic in topics.values():
|
||||||
|
assert all(topic.translations["de"].get(key) for key in ("title", "summary", "body"))
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_core.core.records import (
|
||||||
|
RecordArchiveTransferRequest,
|
||||||
|
RecordTransferPackage,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.http_fetch import HttpFetchResponse
|
||||||
|
from govoplan_dms.backend.dvelop_d3 import (
|
||||||
|
DVELOP_D3_ARCHIVE_PROFILE,
|
||||||
|
DvelopD3Client,
|
||||||
|
DvelopD3Profile,
|
||||||
|
UnconfiguredDvelopD3ArchiveProvider,
|
||||||
|
)
|
||||||
|
from govoplan_dms.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _profile() -> DvelopD3Profile:
|
||||||
|
return DvelopD3Profile(
|
||||||
|
api_base_url="https://d3.example.test/api",
|
||||||
|
repository_id="repo-1",
|
||||||
|
origin="https://govoplan.example.test",
|
||||||
|
source_category="govoplan-record",
|
||||||
|
source_id="tenant-a",
|
||||||
|
mapping_revision="mapping-7",
|
||||||
|
credential_ref="core-credential:d3-tenant-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_is_digest_bound_and_does_not_claim_dispatch_readiness() -> None:
|
||||||
|
calls: list[tuple[str, MappingLike]] = []
|
||||||
|
|
||||||
|
def transport(url, *, method, headers, body):
|
||||||
|
calls.append((url, dict(headers)))
|
||||||
|
assert method == "GET"
|
||||||
|
assert body is None
|
||||||
|
return HttpFetchResponse(
|
||||||
|
status=200,
|
||||||
|
headers={"Content-Type": "application/hal+json"},
|
||||||
|
body=json.dumps({"_links": {"self": {"href": url}}}).encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = DvelopD3Client(_profile(), transport=transport).preflight()
|
||||||
|
|
||||||
|
assert result.ready_for_mapping_test is True
|
||||||
|
assert result.dispatch_ready is False
|
||||||
|
assert len(calls) == 3
|
||||||
|
assert all(headers["Origin"] == "https://govoplan.example.test" for _, headers in calls)
|
||||||
|
assert all("Authorization" not in headers for _, headers in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_plan_binds_exact_record_package_without_effect() -> None:
|
||||||
|
package = RecordTransferPackage(
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
package_id="package-1",
|
||||||
|
record_id="record-1",
|
||||||
|
record_revision=3,
|
||||||
|
profile=DVELOP_D3_ARCHIVE_PROFILE,
|
||||||
|
manifest_sha256="a" * 64,
|
||||||
|
manifest={"version": 1},
|
||||||
|
)
|
||||||
|
request = RecordArchiveTransferRequest(
|
||||||
|
package=package,
|
||||||
|
purpose="approved transfer",
|
||||||
|
idempotency_key="transfer-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = DvelopD3Client(_profile(), transport=lambda *args, **kwargs: None).build_record_store_plan(
|
||||||
|
request,
|
||||||
|
content_location_uri="https://files.example.test/content/package-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.body["sourceProperties"]["govoplanManifestSha256"] == "a" * 64
|
||||||
|
assert plan.body["sourceProperties"]["govoplanMappingRevision"] == "mapping-7"
|
||||||
|
canonical = json.dumps(plan.body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||||
|
assert plan.body_sha256 == hashlib.sha256(canonical).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_provider_fails_closed() -> None:
|
||||||
|
provider = UnconfiguredDvelopD3ArchiveProvider()
|
||||||
|
assert provider.state().healthy is False
|
||||||
|
with pytest.raises(Exception, match="dispatch is disabled"):
|
||||||
|
provider.dispatch(None, None, request=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_rejects_origin_paths_and_embedded_credentials() -> None:
|
||||||
|
with pytest.raises(ValueError, match="Origin"):
|
||||||
|
DvelopD3Profile(
|
||||||
|
api_base_url="https://d3.example.test/api",
|
||||||
|
repository_id="repo",
|
||||||
|
origin="https://govoplan.example.test/path",
|
||||||
|
source_category="category",
|
||||||
|
source_id="source",
|
||||||
|
mapping_revision="one",
|
||||||
|
credential_ref="credential",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="credentials"):
|
||||||
|
DvelopD3Profile(
|
||||||
|
api_base_url="https://user:secret@d3.example.test/api",
|
||||||
|
repository_id="repo",
|
||||||
|
origin="https://govoplan.example.test",
|
||||||
|
source_category="category",
|
||||||
|
source_id="source",
|
||||||
|
mapping_revision="one",
|
||||||
|
credential_ref="credential",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_declares_d3_without_claiming_target_test() -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
assert manifest.version == "0.1.20"
|
||||||
|
assert manifest.external_providers[0].id == "dms.dvelop_d3"
|
||||||
|
assert manifest.architecture is not None
|
||||||
|
assert manifest.architecture.target_tested_providers == ()
|
||||||
|
|
||||||
|
|
||||||
|
MappingLike = dict[str, str]
|
||||||
Reference in New Issue
Block a user