Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ba1c772d | ||
|
|
7c689b6939 | ||
|
|
76c2fbd654 | ||
|
|
460569cae7 | ||
|
|
2ae5fa0930 |
@@ -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
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
*.db
|
||||
|
||||
# GovOPlaN local runtime state
|
||||
runtime/
|
||||
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.module-test-build/
|
||||
webui/.component-test-build/
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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 -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-dms` owns document lifecycle and collaborative document-management
|
||||
semantics. It builds on files/storage providers but does not own low-level file
|
||||
storage or file connector implementations.
|
||||
|
||||
This repository is currently a tag-only scaffold. It should gain package
|
||||
metadata and module manifests only after the first backend or WebUI slice is
|
||||
designed.
|
||||
|
||||
See [docs/DMS_BOUNDARY.md](docs/DMS_BOUNDARY.md) for the boundary decision.
|
||||
@@ -0,0 +1,70 @@
|
||||
# DMS Module Boundary
|
||||
|
||||
`govoplan-dms` owns document lifecycle, collaboration, document evidence, and
|
||||
document governance. Files remain lower-level storage and provider integration.
|
||||
|
||||
The core boundary decision register is in
|
||||
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
|
||||
|
||||
## Ownership
|
||||
|
||||
DMS owns:
|
||||
|
||||
- document records, versions, renditions, and document metadata
|
||||
- collaborative editing links and external office-suite session references
|
||||
- comments, reviews, approvals, locks, signatures, and checkout/checkin
|
||||
- document classification, retention inputs, legal hold, and archive handoff
|
||||
- document evidence references for cases, workflow, templates, forms, mail, and
|
||||
reporting
|
||||
- external DMS/e-file/archive links and selected metadata synchronization where
|
||||
a connector provides the protocol boundary
|
||||
|
||||
## Boundary With Files
|
||||
|
||||
`govoplan-files` owns:
|
||||
|
||||
- stored file objects and blob/provider storage
|
||||
- file upload/download, file permissions, file spaces, and provider connectors
|
||||
- checksums, MIME type, file size, and low-level file metadata
|
||||
|
||||
DMS should reference file objects through file capabilities and DTOs. It must
|
||||
not import file connector internals. A document version may point to a
|
||||
GovOPlaN-managed file, an external DMS object, or both.
|
||||
|
||||
## External Collaboration
|
||||
|
||||
Office/collaboration suites such as Collabora, OnlyOffice, Nextcloud Office, or
|
||||
OpenDesk document services should be integrated through connectors or
|
||||
capabilities. DMS owns the document session semantics and evidence; connectors
|
||||
own endpoint/profile health and protocol-specific clients.
|
||||
|
||||
## Integrations
|
||||
|
||||
- `govoplan-templates`: generates initial document content or rendered outputs
|
||||
- `govoplan-forms`: supplies submission data and attachment references
|
||||
- `govoplan-workflow` and `govoplan-cases`: attach documents to process state
|
||||
and evidence
|
||||
- `govoplan-mail` and `govoplan-postbox`: send or expose document references
|
||||
- `govoplan-reporting`: exports reports as document evidence where needed
|
||||
- `govoplan-records`: classifies closed documents into file plans and archive
|
||||
handoff
|
||||
|
||||
## Candidate Capabilities
|
||||
|
||||
- `dms.documents`
|
||||
- `dms.versions`
|
||||
- `dms.collaboration`
|
||||
- `dms.approvals`
|
||||
- `dms.locks`
|
||||
- `dms.legalHold`
|
||||
- `dms.externalReferences`
|
||||
|
||||
## First Implementation Slice
|
||||
|
||||
1. Define manifest metadata, permissions, and capability names.
|
||||
2. Add document/version/reference DTOs.
|
||||
3. Add one file-backed document version path through the files capability.
|
||||
4. Add locks and approval status as explicit document state, even if the first
|
||||
implementation is simple.
|
||||
5. Add tests that DMS can run without external office-suite connectors and that
|
||||
optional file integration uses capabilities.
|
||||
@@ -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