Compare commits
5
Commits
aebe94ecc2
...
v0.1.16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6bde385ca | ||
|
|
101eafbcf6 | ||
|
|
fc8cf4b914 | ||
|
|
4c192c1a2f | ||
|
|
4e0238f701 |
@@ -14,6 +14,8 @@ on:
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
@@ -29,7 +31,6 @@ jobs:
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
@@ -43,24 +44,6 @@ jobs:
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
python - "$tag" <<'PY'
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
tag = sys.argv[1]
|
||||
repository = os.environ["GITEA_REPOSITORY"]
|
||||
request = urllib.request.Request(
|
||||
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
|
||||
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
protections = json.load(response)
|
||||
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
||||
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
|
||||
PY
|
||||
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"
|
||||
@@ -130,7 +113,7 @@ jobs:
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/GovOPlaN/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
@@ -180,6 +163,78 @@ jobs:
|
||||
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:
|
||||
@@ -189,13 +244,17 @@ jobs:
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
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
|
||||
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[@]} )); then
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
@@ -203,7 +262,9 @@ jobs:
|
||||
'@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]}" \
|
||||
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
|
||||
|
||||
@@ -27,6 +27,10 @@ published by a source-owning module. Do not expose another module's ORM or an
|
||||
unbounded SQL connection as a report source. Configure an explicit schema,
|
||||
freshness policy, source fingerprint expectations, purpose, privacy,
|
||||
retention, and a row-policy provider where source access alone is not enough.
|
||||
For a Dataflow source, `source_run_ref` optionally pins one successful run that
|
||||
published an immutable Datasource materialization. Reporting passes this pin
|
||||
through rather than re-executing the pipeline. The current principal must still
|
||||
be authorized for the Dataflow definition and the exact Datasource output.
|
||||
|
||||
On PostgreSQL installations, Reporting compiles bounded semantic filters,
|
||||
grouping, measures, calculated measures, ordering, offsets, and limits into a
|
||||
|
||||
@@ -28,6 +28,11 @@ the exact definition and output. Warnings explain freshness, inferred schema,
|
||||
or provider diagnostics. A failed quality gate records a failed execution and
|
||||
does not publish a result.
|
||||
|
||||
A report configured against an exact published Dataflow run reads that run's
|
||||
immutable Datasource materialization. It does not rerun the flow with current
|
||||
inputs. The evidence identifies the Dataflow run and materialization, and access
|
||||
to both is checked again when the report runs.
|
||||
|
||||
The **Effective access** explanation states when dimensions, measures, source
|
||||
rows, or actions were removed by Policy. A result with no hidden elements says
|
||||
so explicitly; catalogue visibility never grants access to protected detail.
|
||||
|
||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-reporting"
|
||||
version = "0.1.14"
|
||||
version = "0.1.16"
|
||||
description = "GovOPlaN governed reporting and semantic BI module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.14",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-core>=0.1.16",
|
||||
"govoplan-access>=0.1.16",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Reporting module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
__version__ = "0.1.16"
|
||||
|
||||
@@ -501,6 +501,7 @@ def _read_dataset(
|
||||
request=DataflowDatasetRequest(
|
||||
pipeline_ref=dataset.source_ref,
|
||||
revision=dataset.source_revision or 0,
|
||||
run_ref=dataset.source_run_ref,
|
||||
parameters=source_parameters,
|
||||
row_limit=2_000,
|
||||
expected_definition_hash=dataset.definition_hash,
|
||||
|
||||
@@ -77,7 +77,7 @@ from govoplan_reporting.backend.search_source import create_reporting_search_sou
|
||||
|
||||
MODULE_ID = "reporting"
|
||||
MODULE_NAME = "Reporting"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
MODULE_VERSION = "0.1.16"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
@@ -488,7 +488,7 @@ manifest = ModuleManifest(
|
||||
"schedules, exports, and publication providers replace unchecked SQL in the "
|
||||
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
|
||||
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
|
||||
"adapters retain idempotent evidence. Dataflow and module read models remain source owners."
|
||||
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
|
||||
@@ -101,6 +101,7 @@ class DatasetDefinition(BaseModel):
|
||||
source_kind: Literal["dataflow", "read_model", "static"]
|
||||
source_ref: str = Field(min_length=1, max_length=500)
|
||||
source_revision: int | None = Field(default=None, ge=1)
|
||||
source_run_ref: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
definition_hash: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
source_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
static_rows: list[dict[str, Any]] = Field(default_factory=list, max_length=2_000)
|
||||
@@ -130,6 +131,8 @@ class DatasetDefinition(BaseModel):
|
||||
def validate_source_pin(self) -> "DatasetDefinition":
|
||||
if self.source_kind == "dataflow" and self.source_revision is None:
|
||||
raise ValueError("Dataflow datasets require a pinned source revision.")
|
||||
if self.source_run_ref is not None and self.source_kind != "dataflow":
|
||||
raise ValueError("Only Dataflow datasets can pin a source run.")
|
||||
if self.source_kind == "static" and not self.static_rows:
|
||||
raise ValueError("Static analytical datasets require static_rows.")
|
||||
names = [item.name for item in self.fields]
|
||||
|
||||
@@ -8,6 +8,10 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||
DataflowDatasetResult,
|
||||
)
|
||||
from govoplan_core.core.files import (
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
ManagedArtifactRef,
|
||||
@@ -132,6 +136,34 @@ class ArtifactStore:
|
||||
)
|
||||
|
||||
|
||||
class DataflowOutput:
|
||||
def __init__(self, rows: list[dict[str, object]]) -> None:
|
||||
self.rows = tuple(dict(item) for item in rows)
|
||||
self.last_request = None
|
||||
|
||||
def list_outputs(self, *_args, **_kwargs):
|
||||
return ()
|
||||
|
||||
def read_output(self, _session, _principal, *, request):
|
||||
self.last_request = request
|
||||
return DataflowDatasetResult(
|
||||
pipeline_ref=request.pipeline_ref,
|
||||
revision=request.revision,
|
||||
definition_hash=request.expected_definition_hash or "pipeline-hash",
|
||||
rows=self.rows,
|
||||
total_rows=len(self.rows),
|
||||
truncated=False,
|
||||
output_hash="d" * 64,
|
||||
executor_version="duckdb-v1",
|
||||
run_ref=request.run_ref,
|
||||
source_fingerprints=(
|
||||
{"node_id": "source", "fingerprint": "source-v1"},
|
||||
),
|
||||
generated_at=NOW,
|
||||
provenance={"immutable_run": bool(request.run_ref)},
|
||||
)
|
||||
|
||||
|
||||
class ReportingServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
@@ -352,6 +384,44 @@ class ReportingServiceTests(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(raised.exception.execution_id)
|
||||
|
||||
def test_dataflow_dataset_can_pin_an_exact_published_run(self) -> None:
|
||||
payload = dataset_payload()
|
||||
rows = list(payload.pop("static_rows"))
|
||||
payload.update(
|
||||
{
|
||||
"source_kind": "dataflow",
|
||||
"source_ref": "pipeline:monthly-comparison",
|
||||
"source_revision": 4,
|
||||
"source_run_ref": "dataflow-run:published-july",
|
||||
"definition_hash": "pipeline-hash",
|
||||
}
|
||||
)
|
||||
self._create("dataset", "dataset-1", payload)
|
||||
self._create("semantic_model", "semantic-1", semantic_payload())
|
||||
self._create("report", "report-1", report_payload())
|
||||
provider = DataflowOutput(rows)
|
||||
registry = CapabilityRegistry()
|
||||
registry.providers[CAPABILITY_DATAFLOW_DATASET_OUTPUT] = provider
|
||||
|
||||
result = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=registry,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="published-dataflow-run",
|
||||
)
|
||||
|
||||
self.assertEqual("succeeded", result["status"])
|
||||
self.assertIsNotNone(provider.last_request)
|
||||
self.assertEqual(
|
||||
"dataflow-run:published-july",
|
||||
provider.last_request.run_ref,
|
||||
)
|
||||
self.assertTrue(result["provenance"]["source"]["immutable_run"])
|
||||
|
||||
def test_restricted_access_and_service_scope_guards(self) -> None:
|
||||
self._create_report_graph(
|
||||
report_access={
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/reporting-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
Reference in New Issue
Block a user