From 2ca792bd71c7fc968207d410fd022ae6eadb07cd Mon Sep 17 00:00:00 2001 From: Erik Jaaniso Date: Thu, 29 Oct 2026 19:24:19 +0200 Subject: [PATCH 1/3] fix(fairdp): give each distribution a completed status Datasets carry adms:status COMPLETED, but distributions carried none, so a harvester recorded no status for them. Use the distribution-status value that gdi-metadata's DistributionShape accepts. The conformance harness now checks those constraints instead of skipping them. --- conformance/README.md | 5 ++--- conformance/check_fdp_negative.py | 12 ++++++------ crates/fairdp/src/graph.rs | 8 +++++++- crates/fairdp/src/vocab.rs | 6 ++++++ .../snapshots/render__distribution_ntriples.snap | 1 + 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 029784e..8e4d3d3 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -91,9 +91,8 @@ into `target/conformance/union.ttl`. It then runs: skip. A value constraint is reported and skipped, for one of two reasons the output distinguishes: - - the property is optional and the corpus does not populate it (`dct:type`, - `Distribution`'s `adms:status`). Closable by extending the corpus, at the cost of a - fixture less like a real node. + - the property is optional and the corpus does not populate it (`dct:type`). Closable + by extending the corpus, at the cost of a fixture less like a real node. - the site is structurally unexercisable. Every remaining `sh:uniqueLang` skip is this: the node emits those paths as plain literals because the model behind them is a string, not a language map (`Agent.name` and `OtherIdentifier.name` for `foaf:name`, the diff --git a/conformance/check_fdp_negative.py b/conformance/check_fdp_negative.py index d6d6c3b..247c51c 100755 --- a/conformance/check_fdp_negative.py +++ b/conformance/check_fdp_negative.py @@ -866,9 +866,9 @@ def main(argv: list[str]) -> int: return 1 # Value-constraint skips are reported, not fatal: unlike the mandatory predicates above, # an `sh:in`/`sh:pattern` site can legitimately target an optional property the corpus - # does not populate (`adms:status`, `dct:type` on a non-synthetic dataset). Making these - # fatal would force the corpus to carry every optional field, giving a fixture less like - # the real node the crawl is meant to represent. + # does not populate (`dct:type` on a non-synthetic dataset). Making these fatal would + # force the corpus to carry every optional field, giving a fixture less like the real + # node the crawl is meant to represent. if value_skipped: print( f"\nnote: {len(value_skipped)} value-constraint site(s) not exercised by this " @@ -876,9 +876,9 @@ def main(argv: list[str]) -> int: "is a corpus gap:" ) print( - " (a) optional property the corpus does not populate — `dct:type` (set only on " - "synthetic datasets) and `Distribution`'s `adms:status`. Closable by extending " - "the fixture corpus, at the cost of a corpus less like a real node." + " (a) optional property the corpus does not populate — `dct:type`, set only on " + "synthetic datasets. Closable by extending the fixture corpus, at the cost of a " + "corpus less like a real node." ) print( " (b) structurally unexercisable — every remaining `sh:uniqueLang` site. The " diff --git a/crates/fairdp/src/graph.rs b/crates/fairdp/src/graph.rs index 6df1d96..ceaee40 100644 --- a/crates/fairdp/src/graph.rs +++ b/crates/fairdp/src/graph.rs @@ -338,7 +338,7 @@ pub(crate) fn add_agent( /// `dcatap:applicableLegislation` and `dct:license` are inherited from the parent /// dataset. Its title and `dcat:accessURL` (where a user asks for access) come from /// `[fairdp.distribution]`; the inline `dcat:accessService` `dcat:DataService` is the -/// aggregated Beacon. +/// aggregated Beacon. Its `adms:status` is constant, as on the dataset. #[must_use] pub fn distribution_graph(entry: &DatasetEntry, ctx: &FdpContext) -> Graph { let mut b = GraphBuilder::new(); @@ -374,6 +374,12 @@ pub fn distribution_graph(entry: &DatasetEntry, ctx: &FdpContext) -> Graph { } b.add_iri(subj.clone(), vocab::DCT_LICENSE, &entry.metadata.license); + b.add_iri( + subj.clone(), + vocab::ADMS_STATUS, + vocab::DISTRIBUTION_STATUS_COMPLETED, + ); + // Inline DataService blank node under dcat:accessService. let service_subj = b.add_blank_node(&subj, vocab::DCAT_ACCESS_SERVICE, vocab::DCAT_DATA_SERVICE); diff --git a/crates/fairdp/src/vocab.rs b/crates/fairdp/src/vocab.rs index 9eb9ed2..e622267 100644 --- a/crates/fairdp/src/vocab.rs +++ b/crates/fairdp/src/vocab.rs @@ -215,6 +215,12 @@ pub(crate) const FDP_SPEC_V1_2: &str = "https://specs.fairdatapoint.org/v1.2/fdp pub(crate) const DATASET_STATUS_COMPLETED: &str = "http://publications.europa.eu/resource/authority/dataset-status/COMPLETED"; +/// `adms:status` constant on a distribution: the EU distribution-status `COMPLETED` +/// value. Distributions have their own status table, the only one gdi-metadata's +/// `DistributionShape` accepts. +pub(crate) const DISTRIBUTION_STATUS_COMPLETED: &str = + "http://publications.europa.eu/resource/authority/distribution-status/COMPLETED"; + // --- XSD datatype IRIs --- /// `xsd:string`. diff --git a/crates/fairdp/tests/snapshots/render__distribution_ntriples.snap b/crates/fairdp/tests/snapshots/render__distribution_ntriples.snap index 2487e0b..d8504f2 100644 --- a/crates/fairdp/tests/snapshots/render__distribution_ntriples.snap +++ b/crates/fairdp/tests/snapshots/render__distribution_ntriples.snap @@ -8,6 +8,7 @@ expression: nt "GDI User Portal" + _:c14n0 From 13cb83a54c82b1bd90711ce99846fd05221255b4 Mon Sep 17 00:00:00 2001 From: Erik Jaaniso Date: Thu, 29 Oct 2026 19:25:22 +0200 Subject: [PATCH 2/3] fix(node): create a missing inbox owner-only A node that created its own inbox left it at the umask default (0755) and then warned that others could read it. Create it 0700 like the rest of the data path; an existing inbox keeps its mode. The tool's hint, its docs and the quickstart now say mkdir -p -m 0700. --- README.md | 2 +- clippy.toml | 2 +- crates/gdi-dataset-tool/src/state.rs | 2 +- crates/gdi-node-standalone/src/main.rs | 11 +++-------- docs/gdi-dataset-tool.md | 4 ++-- 5 files changed, 8 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fd4a012..2cfabbb 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ A keyless node reading a local inbox, serving the bundled single-variant COVID allele-frequency sample. Nothing to author, no keys, no Docker. First the node: ```bash -mkdir -p ~/gdi-demo/inbox ~/gdi-demo/datasets +mkdir -p -m 0700 ~/gdi-demo/inbox ~/gdi-demo/datasets # keep the inbox owner-only, or the node warns cat > ~/gdi-demo/node.toml < Result<(), ToolError> { "inbox {} does not exist. The node creates its inbox at startup, so an absent \ one means this is not that node's inbox path. Check the profile's `inbox` \ or `--inbox` against the node's `[service].inbox`. If you are staging a drop \ - before the node's first start, create it first: mkdir -p {}", + before the node's first start, create it first: mkdir -p -m 0700 {}", inbox.display(), inbox.display() ))), diff --git a/crates/gdi-node-standalone/src/main.rs b/crates/gdi-node-standalone/src/main.rs index 1ae7dea..57b07ae 100644 --- a/crates/gdi-node-standalone/src/main.rs +++ b/crates/gdi-node-standalone/src/main.rs @@ -1171,14 +1171,9 @@ async fn run(cli: Cli) -> Result<()> { // Inbox watcher + periodic rescan, if an inbox is configured. if let Some(inbox) = state.config.service.inbox.clone() { - // The inbox is an operator-provisioned ingress a provider drops into, so it keeps - // its configured permissions. Unlike the rest of the data path, it is not forced - // 0o700. - #[expect( - clippy::disallowed_methods, - reason = "the inbox is an operator-provisioned ingress that keeps its configured permissions" - )] - std::fs::create_dir_all(&inbox) + // A pre-existing inbox, such as a mounted volume or one the operator made, keeps its + // mode. One the node creates is owner-only, like the rest of the data path. + gdi_node_standalone_core::util::create_private_dir(&inbox) .with_context(|| format!("creating inbox {}", inbox.display()))?; // An inbox writable by more than its owner is a multi-tenant plaintext inbox, an // unsupported topology: a co-tenant can swap a just-validated staging file for a diff --git a/docs/gdi-dataset-tool.md b/docs/gdi-dataset-tool.md index 4e4aaad..fde3564 100644 --- a/docs/gdi-dataset-tool.md +++ b/docs/gdi-dataset-tool.md @@ -1830,8 +1830,8 @@ asynchronously: a successful `deploy` only drops the artifact into the inbox, so > wrong, and `deploy`, `publish`, `unpublish` and `delete` all refuse rather than create > it. A created-on-demand inbox would turn the one signal a mistyped path gives, `ENOENT`, > into a phantom inbox that accepts every drop while the node reads the real one and sees -> nothing. If you are staging a drop before the node's first start, `mkdir -p ` -> first; the error message names that command. +> nothing. If you are staging a drop before the node's first start, run +> `mkdir -p -m 0700 ` first; the error message names that command. ```bash gdi-dataset-tool deploy GDI-EE-EXAMPLE-20260409143052837.tar.c4gh --inbox /var/inbox From 798e227821b45106476bdbf8a0406d7eea206abd Mon Sep 17 00:00:00 2001 From: Erik Jaaniso Date: Thu, 29 Oct 2026 19:26:50 +0200 Subject: [PATCH 3/3] docs(deploy): fix the network policy's selector comment Splitting the selectors into two from elements would admit every pod in monitoring, not every labelled pod in the cluster. --- deploy/kubernetes/base/networkpolicy.yaml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/deploy/kubernetes/base/networkpolicy.yaml b/deploy/kubernetes/base/networkpolicy.yaml index d83c54f..de84283 100644 --- a/deploy/kubernetes/base/networkpolicy.yaml +++ b/deploy/kubernetes/base/networkpolicy.yaml @@ -36,12 +36,10 @@ spec: protocol: TCP # Management plane: the monitoring namespace's scraper only. # - # `namespaceSelector` and `podSelector` sit in the same `from` element: one - # element ANDs its selectors, whereas two elements OR them — which would widen this - # rule to every pod in the cluster carrying the label, the exact opposite of the - # intent. A bare `namespaceSelector` would admit every pod in `monitoring` (Grafana, - # Alertmanager, any sidecar, anything that later lands there) to the unauthenticated - # dataset-state oracle, so it is worth the extra selector. + # `namespaceSelector` and `podSelector` sit in the same `from` element, so both + # must match. Split into two elements, either would do, and every pod in + # `monitoring` (Grafana, Alertmanager, anything that later lands there) would + # reach the unauthenticated dataset-state oracle. # # Adjust the pod label to match your scraper — `app.kubernetes.io/name: prometheus` is # the prometheus-operator convention, not a universal one. Getting it wrong blocks the