From 59180e8835f029f0889006bf93e67d7b6f0c14e3 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:37:19 +1200 Subject: [PATCH 001/130] spec deployment-scoped artefacts --- .workhorse/specs/platform/artifacts.md | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .workhorse/specs/platform/artifacts.md diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md new file mode 100644 index 000000000..e3731eb84 --- /dev/null +++ b/.workhorse/specs/platform/artifacts.md @@ -0,0 +1,68 @@ +--- +id: ART +--- + +# Version artefacts + +An artefact is a file published for a version that Canopy records the location of and does not hold: an installer, a package, a set of migrations, a manifest. +Canopy is the index rather than the store: a server or the infrastructure acting on a version's behalf learns from Canopy where a file is and fetches it from there, and where the location is not one that caller may read, Canopy passes the bytes through on request rather than keeping a copy. + +## What an artefact belongs to + +An artefact belongs to one exact version, or to a range of versions given as a semver pattern, and never to both. +A range artefact covers every version its pattern matches, so a file that does not change between releases is published once instead of per release. + +An artefact carries a type and a platform, which together say what it is and what it is for. +A version's artefacts are the exact-version ones plus every range artefact whose pattern matches it. + +## What a version offers + +Canopy offers one artefact per type and platform, choosing the most specific of those that match. +An exact-version artefact is more specific than any range artefact; between two ranges, the narrower is more specific. +A pattern Canopy cannot parse matches nothing rather than everything, so a malformed range withholds a file instead of offering it to the whole fleet. + +The full set, including the artefacts specificity passed over, is available to operators. +What resolution hides is a fact about how a version was published and an operator has to be able to see it. + +## Group-scoped artefacts + +An artefact may belong to a group, and one that does is offered only to that group. +An artefact belonging to no group is offered to every group, which is what a version's installers, migrations, and manifests are: properties of the version and of nothing narrower. + +Belonging to a group is therefore an addition rather than a requirement, and the artefacts published today are unaffected. +Resolution keys on type, platform, and group together, so a group-scoped artefact and an unscoped one of the same type do not displace each other and each group is offered its own. + +A group-scoped artefact is offered only where the caller's group is known. +A caller whose credential carries a group, a server device being the case that matters, never names one and is answered for its own group alone, so a server cannot ask what another group is offered. +A caller carrying no group of its own names the group it asks about and is answered only for a group it is authorised for, which is how a build reads what it is about to replace and how an operator reads the fleet. +A read carrying no identity at all is answered with the unscoped artefacts alone, so giving an artefact a group narrows who is offered it rather than widening what an open path serves. +A group-scoped artefact's existence is disclosed only to a caller it is offered to: a caller that names or guesses one it is not offered is answered as though it did not exist, so which groups hold one is not enumerable through the artefact surface. + +A group scope exists because some artefacts are derived from a group's own data and are wrong for anyone else, a reporting schema being the case that motivates it (see [RPT](../public-server/reporting-schemas.md)). +Such an artefact is published into that group's own object storage, over a credential Canopy issues the publisher for the run, and is read back through Canopy, which passes it only to a caller the artefact is offered to. +The boundary is therefore enforced on the read rather than resting on a location being hard to guess. + +## Registration + +A releaser device registers artefacts against a version over its own credential, which is what a product's release automation uses when it publishes. +Registering a group-scoped artefact requires being authorised for that group, and the group is named on the registration rather than inferred from the caller. +A restore consumer registers the artefacts its builds produce for the groups its own declarations authorise it to read, so a build needs no releaser credential to publish what it made. +A releaser credential carries no group and registers unscoped artefacts alone, which is what a version's installers, migrations, and manifests are. +An operator may also record one directly. +Canopy records which device registered an artefact and, where the registration names one, the run that produced it, so an artefact that arrived by automation is distinguishable from one entered by hand and traceable to what made it. + +## What Canopy does not know + +Canopy holds where an artefact is published and not what it contains. +It does not go looking for the file to check it, so an artefact whose location stops resolving is not detected until something tries to read it, and two artefacts published to the same location with no digest between them are the same artefact to Canopy however their contents differ. + +An artefact carries a digest where whoever registers it records one, and a group-scoped artefact carries one always, because what a server has applied is graded against the artefact Canopy holds and successive builds for one version are otherwise indistinguishable (see [RPT](../public-server/reporting-schemas.md)). +Where Canopy passes an artefact through and holds a digest for it, it verifies the bytes against that digest and refuses them on a mismatch, so a file replaced at its published location fails the read rather than reaching a server as the artefact it is not. + +A version's publication is corroborated against the artefacts Canopy holds for it rather than against the files themselves, which is enough to say a version has published what it needs to and not enough to say those files are good. + +## Out of scope + +- Hosting or retaining the files themselves: Canopy passes bytes through on request and keeps no copy of them. +- What any artefact contains, and whether it is fit for what fetches it. +- Which artefacts a product must publish to be considered released. From 70fc85077a09fa683616d085acf2c858c3faf4f4 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:36:00 +1200 Subject: [PATCH 002/130] spec reporting schemas --- .../specs/public-server/reporting-schemas.md | 152 ++++++++++++++++++ .../specs/public-server/restore-replicas.md | 2 +- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 .workhorse/specs/public-server/reporting-schemas.md diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md new file mode 100644 index 000000000..ceb855615 --- /dev/null +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -0,0 +1,152 @@ +--- +id: RPT +--- + +# Reporting schemas + +A deployment's reporting schema is the set of database views its reports read from. +Canopy decides which deployments owe a reporting schema for which version, supplies the database a build needs, holds the artefact a build produces, and grades whether what a deployment is running is the schema it should have. + +## Scope + +This spec covers the reporting schema as an artefact Canopy tracks per deployment and version: what obliges one to exist, what a build is given, what it reports, and how a deployment's own schema is graded against it. + +It does not cover what the schema contains. +Which views exist, what each one selects, and how they are derived from a database are the build's business, and a re-implementation would be free to produce different views from the same inputs. + +It does not cover applying a schema to a deployment. +Applying one is a change to a live database, made by whoever is upgrading the deployment, under the deployment's own change control. +Canopy's part is to hold the artefact that should be applied and to say when what is applied is not it. + +## Why it exists + +A reporting schema is derived from two things that no single actor holds. +Half of it follows from a Tamanu version's database schema and is the same for every deployment on that version. +The other half follows from the deployment's own configuration, and can only be produced from a database carrying that configuration. + +Canopy already holds both sides. +It tracks Tamanu's releases and which version each deployment runs (see [APP](../servers/products.md)), it knows which version a deployment is planning to move to (see [UPG](../private-server/upgrade-plans.md)), and it can have a replica of that deployment's data restored and migrated to that version (see [RST](restore-replicas.md)). +Nothing joins them, so the artefact each deployment needs is produced against a database nobody records, on a cadence nobody can see, and a deployment can run for a year on a schema built for a version it has long left. + +The gap is visible in the fleet: deployments carry reporting schemas several minor versions behind the release they run, and Canopy has no way to say so. + +## Actors + +A **schema builder** is first-party infrastructure that produces a reporting schema from a database and publishes the result. +It holds no list of what to build: it asks Canopy what schemas are owed, builds them, and reports back. +It owns the mechanics of the build, and it is the only actor that needs to understand what the schema contains. + +An **operator** declares which deployments are covered, and reads the currency of each. + +Canopy owns which deployment owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a deployment actually runs. +The builder owns how the schema is produced. +This boundary is the same one the restore path draws, and for the same reason: Canopy is the only actor with fleet-wide knowledge, and the only one that must not need to know how a build works. + +Whether the schema builder is also the restore consumer that produced its source database, or a separate actor that asks for one, is a deployment choice and not a contract Canopy holds either way. + +## What a build is entitled to + +A build requires a database at the version being built for, carrying the deployment's own configuration. + +Both halves are load-bearing. +A database at the right version with no configuration in it produces only the version-generic part of the schema, which is the same for every deployment and answers nobody's question. +A database carrying the configuration but sitting at the deployment's current version produces a schema for a version the deployment is leaving. + +The configuration a build reads is a deployment's configured surveys, the screens that compose them, and the data elements they collect. +It is reference data, present in every copy of the deployment's database and carrying no patient content. + +A build therefore needs no patient data, and a source database whose patient data has been de-identified is sufficient. +Canopy prefers a de-identified source where one can be had, since a build that never sees patient data is a build whose infrastructure need not be trusted with it. +A de-identified source is sufficient only while the product's masking manifest leaves configuration legible: a manifest that masked a survey's identifiers rather than its answers would defeat the build while appearing to succeed. + +Canopy does not run the build and does not hold the database. +What it holds is the requirement, and the means to have a conforming database produced: a managed restore replica of the deployment, restored from a recent snapshot, migrated to the version being built for, and de-identified. + +## What is owed + +Canopy derives what is owed rather than an operator naming each pair of deployment and version. + +A deployment owes a reporting schema for its current version, and for the version its group's open plan moves it to. +The current version is what its reports are running against now. +The planned version is what they will run against, and a schema that does not exist by the time the upgrade lands is an outage of every report the deployment has. + +Only a published version is buildable, for the same reason it is testable: a version's schema reaches a builder as its published artefacts, and an unpublished version has none to fetch. + +Only a deployment an operator has declared as covered is owed anything. +A deployment whose reports are maintained elsewhere, or which has no reporting at all, should not accrue findings for a schema nobody wants. +Coverage is declared per group, is enabled or disabled, and is audited (see [ADM](../private-server/admin-access.md)). + +A build is owed once per pair of deployment and version, and is settled by a successful build for that pair. +A pair is reinstated when the version's own artefacts change, since a schema built from a superseded release of the same version is not the schema that version now describes. +Changes to a deployment's configuration do not reinstate a settled pair on their own; the schema a deployment has is the schema it asked for at the time it was built, and refreshing it is an operator's decision. + +## The worklist + +A schema builder fetches what it is currently owed in one request, scoped to the calling builder. +Canopy returns one entry per unsettled pair: + +- the **group** the schema is for, and the **server** whose configuration it is built from; +- the **version** to build for; +- where the source database is to be found, or what the builder must ask for to obtain one; +- whether the source is required to be de-identified. + +The worklist carries no credentials. +A builder reconciles the worklist against what it has already produced and converges over time; entries are the latest state rather than a queue to drain. + +## What a build reports + +A builder reports the outcome of each build back to Canopy. +A report carries: + +- the **group**, **server**, and **version** it concerns; +- the **outcome** — built, or failed — and, on failure, a description of what went wrong; +- the **snapshot** the source database was restored from, where the source was a restored replica, joining the schema to the data it was derived from; +- a reference to the **published artefact**, where the build produced one; +- **how much of the deployment's configuration was covered**: the number of configured surveys the schema addresses, and the number it could not; +- when the build was observed. + +Coverage is a primary result rather than diagnostic detail. +A build that succeeds while silently skipping half a deployment's surveys produces an artefact that looks complete and leaves reports missing, and the count is what makes that visible without reading the schema. + +Reports are retained indefinitely as an audit trail. + +## The artefact + +A reporting schema is published per version and deployment, alongside the other artefacts a version publishes. +Canopy records where each one is published rather than storing it, as it does for a version's other artefacts, and corroborates a reported artefact against the published artefacts it already holds for that version. + +A deployment's artefact for a version supersedes any earlier artefact for the same pair. +Earlier artefacts are retained and remain addressable, because a deployment that has not yet applied the newest one is running an older one and its currency has to be gradeable against something. + +## Currency + +Canopy grades a deployment's reporting schema as current, behind, or unknown. + +A deployment's schema is current when the schema it reports running is the artefact Canopy holds for the version it reports running. +It is behind when the two differ. +It is unknown when the deployment reports no schema at all, which is the state of a deployment whose schema was applied before Canopy tracked any. + +The schema a deployment is running is reported by the deployment, alongside the other facts its sources report about it (see [STA](statuses.md)). +Canopy does not read it out of the deployment's database, and treats a deployment that reports no schema as unknown rather than assuming it has none. + +Currency is presented per group, so whether a deployment's reports are running against the right schema is answered in one place. + +## Alerting + +A deployment whose schema is behind raises a reporting-schema check on its central server (see [CHK](../monitoring/checks.md)). +A deployment that owes a build which has not been produced within its bound degrades the same check, because owed-and-unbuilt and built-but-unapplied are both "this deployment's reports do not match the version it runs". + +The check is a warning rather than a failure, and does not escalate. +The deployment is serving patients and its reports are returning rows; what is wrong is that some of those rows are computed by a schema written for a different version, and the people who need to know are the ones who maintain the reports, not whoever is on call for outages. + +A failed build raises the same check on the affected server, carrying the failure description. +A build failing against a fixed version and a fixed configuration fails the same way every time, so a failure settles the pair rather than leaving it to retry, and a new version or a rebuild is what clears it. + +## Out of scope + +- What a reporting schema contains, and what each view in it means. +- How a build is run, where it runs, and what it costs to run. +- The report definitions, documentation, and translations a build may produce alongside the schema. Canopy tracks them as published artefacts of the same version and deployment without interpreting them. +- Applying a schema to a deployment's databases, and the change control around doing so. +- Deciding when a deployment upgrades: an owed schema informs that decision without making it. +- Producing a source database. A build's source is a managed restore replica or is not Canopy's concern at all (see [RST](restore-replicas.md)). diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index 0f8486fbe..34cbe1a85 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -405,7 +405,7 @@ That is for whoever gave out the replica to act on, not for whoever is on call f - How a consumer provisions, runs, names, or tears down a replica, or how it applies migrations or a masking manifest to one. - A consumer's runtime placement, storage sizing, or scheduling. -- Producing reporting schemas, or any other artefact, from a migrated replica. +- Producing reporting schemas, or any other artefact, from a migrated replica (see [RPT](reporting-schemas.md)). - The contents of a masking manifest, and what each masking it names does to a value. - Deciding or scheduling when a deployment upgrades: verdicts inform that decision without making it. - Scoping object-storage credentials below the granularity of a group's repo: one repo holds all of a group's servers' snapshots, so credentials are necessarily group-wide while targeting and reporting are per-server. From b6eb71ee52046315ad8a7988746923298c54085a Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:39:23 +1200 Subject: [PATCH 003/130] tighten reporting spec --- .../specs/public-server/reporting-schemas.md | 79 ++++++------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index ceb855615..9eb5fd163 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -5,80 +5,56 @@ id: RPT # Reporting schemas A deployment's reporting schema is the set of database views its reports read from. -Canopy decides which deployments owe a reporting schema for which version, supplies the database a build needs, holds the artefact a build produces, and grades whether what a deployment is running is the schema it should have. - -## Scope - -This spec covers the reporting schema as an artefact Canopy tracks per deployment and version: what obliges one to exist, what a build is given, what it reports, and how a deployment's own schema is graded against it. - -It does not cover what the schema contains. -Which views exist, what each one selects, and how they are derived from a database are the build's business, and a re-implementation would be free to produce different views from the same inputs. - -It does not cover applying a schema to a deployment. -Applying one is a change to a live database, made by whoever is upgrading the deployment, under the deployment's own change control. -Canopy's part is to hold the artefact that should be applied and to say when what is applied is not it. +Canopy decides which deployments owe a reporting schema for which version, supplies what a build needs to produce one, holds the artefact that results, and grades whether what a deployment runs is the schema it should have. ## Why it exists -A reporting schema is derived from two things that no single actor holds. -Half of it follows from a Tamanu version's database schema and is the same for every deployment on that version. -The other half follows from the deployment's own configuration, and can only be produced from a database carrying that configuration. +A reporting schema is derived from two things no single actor holds. +Half of it follows from a Tamanu version's database schema and is the same for every deployment on that version; the other half follows from the deployment's own configuration, and can only be produced from a database carrying it. Canopy already holds both sides. It tracks Tamanu's releases and which version each deployment runs (see [APP](../servers/products.md)), it knows which version a deployment is planning to move to (see [UPG](../private-server/upgrade-plans.md)), and it can have a replica of that deployment's data restored and migrated to that version (see [RST](restore-replicas.md)). -Nothing joins them, so the artefact each deployment needs is produced against a database nobody records, on a cadence nobody can see, and a deployment can run for a year on a schema built for a version it has long left. - -The gap is visible in the fleet: deployments carry reporting schemas several minor versions behind the release they run, and Canopy has no way to say so. +Nothing joins them, so each deployment's artefact is produced against a database nobody records, on a cadence nobody can see, and a deployment can run for a year on a schema built for a version it has long left. ## Actors A **schema builder** is first-party infrastructure that produces a reporting schema from a database and publishes the result. It holds no list of what to build: it asks Canopy what schemas are owed, builds them, and reports back. -It owns the mechanics of the build, and it is the only actor that needs to understand what the schema contains. +It is the only actor that needs to understand what the schema contains. An **operator** declares which deployments are covered, and reads the currency of each. Canopy owns which deployment owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a deployment actually runs. The builder owns how the schema is produced. -This boundary is the same one the restore path draws, and for the same reason: Canopy is the only actor with fleet-wide knowledge, and the only one that must not need to know how a build works. -Whether the schema builder is also the restore consumer that produced its source database, or a separate actor that asks for one, is a deployment choice and not a contract Canopy holds either way. +Whether the schema builder is also the restore consumer that produced its source database, or a separate actor that asks for one, is a deployment choice rather than a contract Canopy holds either way. ## What a build is entitled to A build requires a database at the version being built for, carrying the deployment's own configuration. - -Both halves are load-bearing. -A database at the right version with no configuration in it produces only the version-generic part of the schema, which is the same for every deployment and answers nobody's question. -A database carrying the configuration but sitting at the deployment's current version produces a schema for a version the deployment is leaving. +Neither half stands alone: the right version without the configuration produces only the part of the schema every deployment on that version shares, and the configuration at the wrong version produces a schema for the version the deployment is leaving. The configuration a build reads is a deployment's configured surveys, the screens that compose them, and the data elements they collect. -It is reference data, present in every copy of the deployment's database and carrying no patient content. +It is reference data, present in every copy of the deployment's database and carrying no patient content, so a source whose patient data has been de-identified is sufficient and is what Canopy asks for where one can be had. +That holds only while the product's masking manifest leaves configuration legible: a manifest masking a survey's identifiers rather than its answers would defeat the build while appearing to succeed. -A build therefore needs no patient data, and a source database whose patient data has been de-identified is sufficient. -Canopy prefers a de-identified source where one can be had, since a build that never sees patient data is a build whose infrastructure need not be trusted with it. -A de-identified source is sufficient only while the product's masking manifest leaves configuration legible: a manifest that masked a survey's identifiers rather than its answers would defeat the build while appearing to succeed. - -Canopy does not run the build and does not hold the database. -What it holds is the requirement, and the means to have a conforming database produced: a managed restore replica of the deployment, restored from a recent snapshot, migrated to the version being built for, and de-identified. +Canopy holds the requirement rather than the database, and the means to have a conforming one produced: a managed restore replica of the deployment, restored from a recent snapshot, migrated to the version being built for, and de-identified. ## What is owed Canopy derives what is owed rather than an operator naming each pair of deployment and version. -A deployment owes a reporting schema for its current version, and for the version its group's open plan moves it to. -The current version is what its reports are running against now. -The planned version is what they will run against, and a schema that does not exist by the time the upgrade lands is an outage of every report the deployment has. +A deployment owes a reporting schema for the version it runs and for the version its group's open plan moves it to. +A schema that does not exist by the time an upgrade lands is an outage of every report the deployment has. Only a published version is buildable, for the same reason it is testable: a version's schema reaches a builder as its published artefacts, and an unpublished version has none to fetch. -Only a deployment an operator has declared as covered is owed anything. -A deployment whose reports are maintained elsewhere, or which has no reporting at all, should not accrue findings for a schema nobody wants. +Only a deployment an operator has declared as covered is owed anything, so a deployment whose reports are maintained elsewhere accrues no findings for a schema nobody wants. Coverage is declared per group, is enabled or disabled, and is audited (see [ADM](../private-server/admin-access.md)). A build is owed once per pair of deployment and version, and is settled by a successful build for that pair. A pair is reinstated when the version's own artefacts change, since a schema built from a superseded release of the same version is not the schema that version now describes. -Changes to a deployment's configuration do not reinstate a settled pair on their own; the schema a deployment has is the schema it asked for at the time it was built, and refreshing it is an operator's decision. +A change to a deployment's configuration does not reinstate a settled pair: the schema a deployment has is the one it asked for when it was built, and refreshing it is an operator's decision. ## The worklist @@ -91,7 +67,7 @@ Canopy returns one entry per unsettled pair: - whether the source is required to be de-identified. The worklist carries no credentials. -A builder reconciles the worklist against what it has already produced and converges over time; entries are the latest state rather than a queue to drain. +Entries are the latest state rather than a queue to drain, and a builder converges on them over time. ## What a build reports @@ -100,53 +76,48 @@ A report carries: - the **group**, **server**, and **version** it concerns; - the **outcome** — built, or failed — and, on failure, a description of what went wrong; -- the **snapshot** the source database was restored from, where the source was a restored replica, joining the schema to the data it was derived from; +- the **snapshot** the source database was restored from, where it was a restored replica, joining the schema to the data it came from; - a reference to the **published artefact**, where the build produced one; - **how much of the deployment's configuration was covered**: the number of configured surveys the schema addresses, and the number it could not; - when the build was observed. Coverage is a primary result rather than diagnostic detail. -A build that succeeds while silently skipping half a deployment's surveys produces an artefact that looks complete and leaves reports missing, and the count is what makes that visible without reading the schema. +A build that silently skips half a deployment's surveys still succeeds and still publishes, and the count is what makes that visible without reading the schema. Reports are retained indefinitely as an audit trail. ## The artefact A reporting schema is published per version and deployment, alongside the other artefacts a version publishes. -Canopy records where each one is published rather than storing it, as it does for a version's other artefacts, and corroborates a reported artefact against the published artefacts it already holds for that version. +Canopy records where each is published rather than storing it, and corroborates a reported artefact against the published artefacts it already holds for that version. -A deployment's artefact for a version supersedes any earlier artefact for the same pair. -Earlier artefacts are retained and remain addressable, because a deployment that has not yet applied the newest one is running an older one and its currency has to be gradeable against something. +A deployment's artefact for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a deployment that has not applied the newest is running an older one and its currency has to be gradeable against something. ## Currency Canopy grades a deployment's reporting schema as current, behind, or unknown. - -A deployment's schema is current when the schema it reports running is the artefact Canopy holds for the version it reports running. -It is behind when the two differ. -It is unknown when the deployment reports no schema at all, which is the state of a deployment whose schema was applied before Canopy tracked any. +It is current when the schema the deployment reports running is the artefact Canopy holds for the version it reports running, behind when the two differ, and unknown when the deployment reports no schema at all. The schema a deployment is running is reported by the deployment, alongside the other facts its sources report about it (see [STA](statuses.md)). -Canopy does not read it out of the deployment's database, and treats a deployment that reports no schema as unknown rather than assuming it has none. +Canopy does not read it out of the deployment's database, so a deployment that reports nothing is unknown rather than assumed bare. Currency is presented per group, so whether a deployment's reports are running against the right schema is answered in one place. ## Alerting A deployment whose schema is behind raises a reporting-schema check on its central server (see [CHK](../monitoring/checks.md)). -A deployment that owes a build which has not been produced within its bound degrades the same check, because owed-and-unbuilt and built-but-unapplied are both "this deployment's reports do not match the version it runs". +A deployment that owes a build not produced within its bound degrades the same check, since owed-and-unbuilt and built-but-unapplied both leave the deployment's reports mismatched to the version it runs. The check is a warning rather than a failure, and does not escalate. -The deployment is serving patients and its reports are returning rows; what is wrong is that some of those rows are computed by a schema written for a different version, and the people who need to know are the ones who maintain the reports, not whoever is on call for outages. +The deployment is serving patients and its reports are returning rows; the fault is that some of those rows are computed by a schema written for a different version, and that belongs to whoever maintains the reports rather than whoever is on call for outages. -A failed build raises the same check on the affected server, carrying the failure description. +A failed build raises the same check, carrying the failure description. A build failing against a fixed version and a fixed configuration fails the same way every time, so a failure settles the pair rather than leaving it to retry, and a new version or a rebuild is what clears it. ## Out of scope - What a reporting schema contains, and what each view in it means. -- How a build is run, where it runs, and what it costs to run. +- How a build is run, where it runs, and what it costs. - The report definitions, documentation, and translations a build may produce alongside the schema. Canopy tracks them as published artefacts of the same version and deployment without interpreting them. - Applying a schema to a deployment's databases, and the change control around doing so. - Deciding when a deployment upgrades: an owed schema informs that decision without making it. -- Producing a source database. A build's source is a managed restore replica or is not Canopy's concern at all (see [RST](restore-replicas.md)). From 1aa19421eacb685a610642645faa4c7f987c3d6d Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:42:26 +1200 Subject: [PATCH 004/130] pin artefact and source --- .workhorse/specs/public-server/reporting-schemas.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 9eb5fd163..8acbe9185 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -35,6 +35,7 @@ A build requires a database at the version being built for, carrying the deploym Neither half stands alone: the right version without the configuration produces only the part of the schema every deployment on that version shares, and the configuration at the wrong version produces a schema for the version the deployment is leaving. The configuration a build reads is a deployment's configured surveys, the screens that compose them, and the data elements they collect. +It is held centrally and synced down, so a schema is built from a deployment's central server whatever else its group contains. It is reference data, present in every copy of the deployment's database and carrying no patient content, so a source whose patient data has been de-identified is sufficient and is what Canopy asks for where one can be had. That holds only while the product's masking manifest leaves configuration legible: a manifest masking a survey's identifiers rather than its answers would defeat the build while appearing to succeed. @@ -61,7 +62,7 @@ A change to a deployment's configuration does not reinstate a settled pair: the A schema builder fetches what it is currently owed in one request, scoped to the calling builder. Canopy returns one entry per unsettled pair: -- the **group** the schema is for, and the **server** whose configuration it is built from; +- the **group** the schema is for, and its **central server**, whose configuration the schema is built from; - the **version** to build for; - where the source database is to be found, or what the builder must ask for to obtain one; - whether the source is required to be de-identified. @@ -91,14 +92,19 @@ Reports are retained indefinitely as an audit trail. A reporting schema is published per version and deployment, alongside the other artefacts a version publishes. Canopy records where each is published rather than storing it, and corroborates a reported artefact against the published artefacts it already holds for that version. +A version's artefacts are otherwise identified by their type, which cannot tell one deployment's reporting schema from another's. +An artefact may therefore name the deployment it belongs to, and one that does is offered only for that deployment. +Two deployments on the same version have two reporting schemas, and neither stands in for the other; a version's artefacts that name no deployment stay available to all of them. + A deployment's artefact for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a deployment that has not applied the newest is running an older one and its currency has to be gradeable against something. +Supersession is by publication rather than by content, since Canopy holds where an artefact is published and not what it contains. ## Currency Canopy grades a deployment's reporting schema as current, behind, or unknown. It is current when the schema the deployment reports running is the artefact Canopy holds for the version it reports running, behind when the two differ, and unknown when the deployment reports no schema at all. -The schema a deployment is running is reported by the deployment, alongside the other facts its sources report about it (see [STA](statuses.md)). +The schema a deployment is running is reported by the deployment as the version it was built for, alongside the other facts its sources report about it (see [STA](statuses.md)). Canopy does not read it out of the deployment's database, so a deployment that reports nothing is unknown rather than assumed bare. Currency is presented per group, so whether a deployment's reports are running against the right schema is answered in one place. From 8c46775d7a443c9c59be50e550d1aaa33ce7ce1e Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:46:59 +1200 Subject: [PATCH 005/130] adopt group terminology --- .../specs/public-server/reporting-schemas.md | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 8acbe9185..6da17bde0 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -4,17 +4,17 @@ id: RPT # Reporting schemas -A deployment's reporting schema is the set of database views its reports read from. -Canopy decides which deployments owe a reporting schema for which version, supplies what a build needs to produce one, holds the artefact that results, and grades whether what a deployment runs is the schema it should have. +A reporting schema is the set of database views a group's reports read from. +Canopy decides which groups owe a reporting schema for which version, supplies what a build needs to produce one, holds the artefact that results, and grades whether what a group runs is the schema it should have. ## Why it exists A reporting schema is derived from two things no single actor holds. -Half of it follows from a Tamanu version's database schema and is the same for every deployment on that version; the other half follows from the deployment's own configuration, and can only be produced from a database carrying it. +Half of it follows from a Tamanu version's database schema and is the same for every group on that version; the other half follows from the group's own configuration, and can only be produced from a database carrying it. Canopy already holds both sides. -It tracks Tamanu's releases and which version each deployment runs (see [APP](../servers/products.md)), it knows which version a deployment is planning to move to (see [UPG](../private-server/upgrade-plans.md)), and it can have a replica of that deployment's data restored and migrated to that version (see [RST](restore-replicas.md)). -Nothing joins them, so each deployment's artefact is produced against a database nobody records, on a cadence nobody can see, and a deployment can run for a year on a schema built for a version it has long left. +It tracks Tamanu's releases and which version each server runs (see [APP](../servers/products.md)), it knows which version a group is planning to move to (see [UPG](../private-server/upgrade-plans.md)), and it can have a replica of that group's data restored and migrated to that version (see [RST](restore-replicas.md)). +Nothing joins them, so each group's artefact is produced against a database nobody records, on a cadence nobody can see, and a group can run for a year on a schema built for a version it has long left. ## Actors @@ -22,47 +22,47 @@ A **schema builder** is first-party infrastructure that produces a reporting sch It holds no list of what to build: it asks Canopy what schemas are owed, builds them, and reports back. It is the only actor that needs to understand what the schema contains. -An **operator** declares which deployments are covered, and reads the currency of each. +An **operator** declares which groups are covered, and reads the currency of each. -Canopy owns which deployment owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a deployment actually runs. +Canopy owns which group owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a group actually runs. The builder owns how the schema is produced. -Whether the schema builder is also the restore consumer that produced its source database, or a separate actor that asks for one, is a deployment choice rather than a contract Canopy holds either way. +Whether the schema builder is also the restore consumer that produced its source database, or a separate actor that asks for one, is an operational choice rather than a contract Canopy holds either way. ## What a build is entitled to -A build requires a database at the version being built for, carrying the deployment's own configuration. -Neither half stands alone: the right version without the configuration produces only the part of the schema every deployment on that version shares, and the configuration at the wrong version produces a schema for the version the deployment is leaving. +A build requires a database at the version being built for, carrying the group's own configuration. +Neither half stands alone: the right version without the configuration produces only the part of the schema every group on that version shares, and the configuration at the wrong version produces a schema for the version the group is leaving. -The configuration a build reads is a deployment's configured surveys, the screens that compose them, and the data elements they collect. -It is held centrally and synced down, so a schema is built from a deployment's central server whatever else its group contains. -It is reference data, present in every copy of the deployment's database and carrying no patient content, so a source whose patient data has been de-identified is sufficient and is what Canopy asks for where one can be had. +The configuration a build reads is a group's configured surveys, the screens that compose them, and the data elements they collect. +It is held centrally and synced down, so a schema is built from a central server, and a group holding more than one owes a schema for each, since each carries its own configuration. +It is reference data, present in every copy of the database and carrying no patient content, so a source whose patient data has been de-identified is sufficient and is what Canopy asks for where one can be had. That holds only while the product's masking manifest leaves configuration legible: a manifest masking a survey's identifiers rather than its answers would defeat the build while appearing to succeed. -Canopy holds the requirement rather than the database, and the means to have a conforming one produced: a managed restore replica of the deployment, restored from a recent snapshot, migrated to the version being built for, and de-identified. +Canopy holds the requirement rather than the database, and the means to have a conforming one produced: a managed restore replica of the group, restored from a recent snapshot, migrated to the version being built for, and de-identified. ## What is owed -Canopy derives what is owed rather than an operator naming each pair of deployment and version. +Canopy derives what is owed rather than an operator naming each pair of central server and version. -A deployment owes a reporting schema for the version it runs and for the version its group's open plan moves it to. -A schema that does not exist by the time an upgrade lands is an outage of every report the deployment has. +A central server owes a reporting schema for the version it runs and for the version its group's open plan moves it to. +A schema that does not exist by the time an upgrade lands is an outage of every report the group has. Only a published version is buildable, for the same reason it is testable: a version's schema reaches a builder as its published artefacts, and an unpublished version has none to fetch. -Only a deployment an operator has declared as covered is owed anything, so a deployment whose reports are maintained elsewhere accrues no findings for a schema nobody wants. -Coverage is declared per group, is enabled or disabled, and is audited (see [ADM](../private-server/admin-access.md)). +Only a group an operator has declared as covered is owed anything, so a group whose reports are maintained elsewhere accrues no findings for a schema nobody wants. +Coverage is declared per group, expands over that group's central servers, is enabled or disabled, and is audited (see [ADM](../private-server/admin-access.md)). -A build is owed once per pair of deployment and version, and is settled by a successful build for that pair. +A build is owed once per pair of central server and version, and is settled by a successful build for that pair. A pair is reinstated when the version's own artefacts change, since a schema built from a superseded release of the same version is not the schema that version now describes. -A change to a deployment's configuration does not reinstate a settled pair: the schema a deployment has is the one it asked for when it was built, and refreshing it is an operator's decision. +A change to a group's configuration does not reinstate a settled pair: the schema a group has is the one it asked for when it was built, and refreshing it is an operator's decision. ## The worklist A schema builder fetches what it is currently owed in one request, scoped to the calling builder. Canopy returns one entry per unsettled pair: -- the **group** the schema is for, and its **central server**, whose configuration the schema is built from; +- the **group** the schema is for, and the **central server** whose configuration it is built from; - the **version** to build for; - where the source database is to be found, or what the builder must ask for to obtain one; - whether the source is required to be de-identified. @@ -79,43 +79,43 @@ A report carries: - the **outcome** — built, or failed — and, on failure, a description of what went wrong; - the **snapshot** the source database was restored from, where it was a restored replica, joining the schema to the data it came from; - a reference to the **published artefact**, where the build produced one; -- **how much of the deployment's configuration was covered**: the number of configured surveys the schema addresses, and the number it could not; +- **how much of the configuration was covered**: the number of configured surveys the schema addresses, and the number it could not; - when the build was observed. Coverage is a primary result rather than diagnostic detail. -A build that silently skips half a deployment's surveys still succeeds and still publishes, and the count is what makes that visible without reading the schema. +A build that silently skips half a group's surveys still succeeds and still publishes, and the count is what makes that visible without reading the schema. Reports are retained indefinitely as an audit trail. ## The artefact -A reporting schema is published per version and deployment, alongside the other artefacts a version publishes. +A reporting schema is published per version and group, alongside the other artefacts a version publishes. Canopy records where each is published rather than storing it, and corroborates a reported artefact against the published artefacts it already holds for that version. -A version's artefacts are otherwise identified by their type, which cannot tell one deployment's reporting schema from another's. -An artefact may therefore name the deployment it belongs to, and one that does is offered only for that deployment. -Two deployments on the same version have two reporting schemas, and neither stands in for the other; a version's artefacts that name no deployment stay available to all of them. +A version's artefacts are otherwise identified by their type, which cannot tell one group's reporting schema from another's. +An artefact may therefore name the group it belongs to, and one that does is offered only for that group. +Two groups on the same version have two reporting schemas, and neither stands in for the other; a version's artefacts that name no group stay available to all of them. -A deployment's artefact for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a deployment that has not applied the newest is running an older one and its currency has to be gradeable against something. +A group's artefact for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. Supersession is by publication rather than by content, since Canopy holds where an artefact is published and not what it contains. ## Currency -Canopy grades a deployment's reporting schema as current, behind, or unknown. -It is current when the schema the deployment reports running is the artefact Canopy holds for the version it reports running, behind when the two differ, and unknown when the deployment reports no schema at all. +Canopy grades a central server's reporting schema as current, behind, or unknown. +It is current when the schema the server reports running is the artefact Canopy holds for the version it reports running, behind when the two differ, and unknown when the server reports no schema at all. -The schema a deployment is running is reported by the deployment as the version it was built for, alongside the other facts its sources report about it (see [STA](statuses.md)). -Canopy does not read it out of the deployment's database, so a deployment that reports nothing is unknown rather than assumed bare. +The schema a server is running is reported by the server as the version it was built for, alongside the other facts its sources report about it (see [STA](statuses.md)). +Canopy does not read it out of the database, so a server that reports nothing is unknown rather than assumed bare. -Currency is presented per group, so whether a deployment's reports are running against the right schema is answered in one place. +Currency is presented per group, so whether a group's reports are running against the right schema is answered in one place. ## Alerting -A deployment whose schema is behind raises a reporting-schema check on its central server (see [CHK](../monitoring/checks.md)). -A deployment that owes a build not produced within its bound degrades the same check, since owed-and-unbuilt and built-but-unapplied both leave the deployment's reports mismatched to the version it runs. +A central server whose schema is behind raises a reporting-schema check on itself (see [CHK](../monitoring/checks.md)). +A server that owes a build not produced within its bound degrades the same check, since owed-and-unbuilt and built-but-unapplied both leave its reports mismatched to the version it runs. The check is a warning rather than a failure, and does not escalate. -The deployment is serving patients and its reports are returning rows; the fault is that some of those rows are computed by a schema written for a different version, and that belongs to whoever maintains the reports rather than whoever is on call for outages. +The servers are up and their reports are returning rows; the fault is that some of those rows are computed by a schema written for a different version, and that belongs to whoever maintains the reports rather than whoever is on call for outages. A failed build raises the same check, carrying the failure description. A build failing against a fixed version and a fixed configuration fails the same way every time, so a failure settles the pair rather than leaving it to retry, and a new version or a rebuild is what clears it. @@ -124,6 +124,6 @@ A build failing against a fixed version and a fixed configuration fails the same - What a reporting schema contains, and what each view in it means. - How a build is run, where it runs, and what it costs. -- The report definitions, documentation, and translations a build may produce alongside the schema. Canopy tracks them as published artefacts of the same version and deployment without interpreting them. -- Applying a schema to a deployment's databases, and the change control around doing so. -- Deciding when a deployment upgrades: an owed schema informs that decision without making it. +- The report definitions, documentation, and translations a build may produce alongside the schema. Canopy tracks them as published artefacts of the same version and group without interpreting them. +- Applying a schema to a database, and the change control around doing so. +- Deciding when a group upgrades: an owed schema informs that decision without making it. From c49f4b4703c59047c298ba4ecaa4a92f1afb128f Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:12:53 +1200 Subject: [PATCH 006/130] settle artefact identity --- .workhorse/specs/public-server/reporting-schemas.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 6da17bde0..bde02f226 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -93,8 +93,11 @@ A reporting schema is published per version and group, alongside the other artef Canopy records where each is published rather than storing it, and corroborates a reported artefact against the published artefacts it already holds for that version. A version's artefacts are otherwise identified by their type, which cannot tell one group's reporting schema from another's. -An artefact may therefore name the group it belongs to, and one that does is offered only for that group. -Two groups on the same version have two reporting schemas, and neither stands in for the other; a version's artefacts that name no group stay available to all of them. +An artefact is therefore identified by its version, its type, and, where it has one, the group it belongs to. + +Belonging to a group is what a reporting schema adds rather than something every artefact takes on. +An artefact that belongs to no group is identified by version and type as it is now, and stays available to every group: a version's server binaries, its migrations, and its masking manifest are properties of the version and of nothing narrower. +A reporting schema always names a group, so two groups on the same version have two of them and neither stands in for the other. A group's artefact for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. Supersession is by publication rather than by content, since Canopy holds where an artefact is published and not what it contains. @@ -112,7 +115,8 @@ Currency is presented per group, so whether a group's reports are running agains ## Alerting A central server whose schema is behind raises a reporting-schema check on itself (see [CHK](../monitoring/checks.md)). -A server that owes a build not produced within its bound degrades the same check, since owed-and-unbuilt and built-but-unapplied both leave its reports mismatched to the version it runs. +A server with an owed build that has not been produced degrades the same check, since owed-and-unbuilt and built-but-unapplied both leave its reports mismatched to the version it runs. +There is no separate time bound on either: a build is owed from the moment its version becomes a candidate, and the plan that made it one already carries the date it is wanted by. The check is a warning rather than a failure, and does not escalate. The servers are up and their reports are returning rows; the fault is that some of those rows are computed by a schema written for a different version, and that belongs to whoever maintains the reports rather than whoever is on call for outages. From 9a7995b73887ce0c1c2093c5c44950e756bcdd0b Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:13:37 +1200 Subject: [PATCH 007/130] trim alerting --- .workhorse/specs/public-server/reporting-schemas.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index bde02f226..2d6d99325 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -114,15 +114,12 @@ Currency is presented per group, so whether a group's reports are running agains ## Alerting -A central server whose schema is behind raises a reporting-schema check on itself (see [CHK](../monitoring/checks.md)). -A server with an owed build that has not been produced degrades the same check, since owed-and-unbuilt and built-but-unapplied both leave its reports mismatched to the version it runs. -There is no separate time bound on either: a build is owed from the moment its version becomes a candidate, and the plan that made it one already carries the date it is wanted by. +A central server whose schema is behind, or which owes a build nothing has produced, raises a reporting-schema check on itself (see [CHK](../monitoring/checks.md)). +Both leave its reports mismatched to the version it runs, and neither carries a time bound of its own: the plan that made a version a candidate already carries the date it is wanted by. -The check is a warning rather than a failure, and does not escalate. -The servers are up and their reports are returning rows; the fault is that some of those rows are computed by a schema written for a different version, and that belongs to whoever maintains the reports rather than whoever is on call for outages. +The check is a warning rather than a failure, and does not escalate: the servers are up and their reports return rows, and a schema written for the wrong version is for whoever maintains the reports rather than whoever is on call. -A failed build raises the same check, carrying the failure description. -A build failing against a fixed version and a fixed configuration fails the same way every time, so a failure settles the pair rather than leaving it to retry, and a new version or a rebuild is what clears it. +A failed build raises the same check with its failure description, and settles the pair rather than retrying, since a build against a fixed version and configuration fails the same way every time. ## Out of scope From 54b30895eb6cdc49f3b38c714c58a268dc713bdd Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:17:08 +1200 Subject: [PATCH 008/130] spec version artefacts --- .workhorse/specs/public-server/reporting-schemas.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 2d6d99325..d7f55b9e3 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -89,18 +89,10 @@ Reports are retained indefinitely as an audit trail. ## The artefact -A reporting schema is published per version and group, alongside the other artefacts a version publishes. -Canopy records where each is published rather than storing it, and corroborates a reported artefact against the published artefacts it already holds for that version. - -A version's artefacts are otherwise identified by their type, which cannot tell one group's reporting schema from another's. -An artefact is therefore identified by its version, its type, and, where it has one, the group it belongs to. - -Belonging to a group is what a reporting schema adds rather than something every artefact takes on. -An artefact that belongs to no group is identified by version and type as it is now, and stays available to every group: a version's server binaries, its migrations, and its masking manifest are properties of the version and of nothing narrower. +A reporting schema is published as an artefact of the version it was built for, scoped to the group it was built from (see [ART](../platform/artifacts.md)). A reporting schema always names a group, so two groups on the same version have two of them and neither stands in for the other. -A group's artefact for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. -Supersession is by publication rather than by content, since Canopy holds where an artefact is published and not what it contains. +A group's schema for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. ## Currency From c8028d38b1ed0ad21d72e889cfdcd8fe366189bf Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:51:23 +1200 Subject: [PATCH 009/130] demote coverage --- .workhorse/specs/public-server/reporting-schemas.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index d7f55b9e3..f796da8b6 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -82,9 +82,6 @@ A report carries: - **how much of the configuration was covered**: the number of configured surveys the schema addresses, and the number it could not; - when the build was observed. -Coverage is a primary result rather than diagnostic detail. -A build that silently skips half a group's surveys still succeeds and still publishes, and the count is what makes that visible without reading the schema. - Reports are retained indefinitely as an audit trail. ## The artefact From 5d6a46116c6327c3f96494d4f11d197315b650fe Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:31:18 +1200 Subject: [PATCH 010/130] build rides the migrate pathway --- .../specs/public-server/reporting-schemas.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index f796da8b6..234f40176 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -39,14 +39,25 @@ It is held centrally and synced down, so a schema is built from a central server It is reference data, present in every copy of the database and carrying no patient content, so a source whose patient data has been de-identified is sufficient and is what Canopy asks for where one can be had. That holds only while the product's masking manifest leaves configuration legible: a manifest masking a survey's identifiers rather than its answers would defeat the build while appearing to succeed. -Canopy holds the requirement rather than the database, and the means to have a conforming one produced: a managed restore replica of the group, restored from a recent snapshot, migrated to the version being built for, and de-identified. +Canopy holds the requirement rather than the database, and already has the means to satisfy it: a managed restore replica, restored from a recent snapshot and migrated to the version being built for, is a database that meets both halves (see [RST](restore-replicas.md)). + +A restore that carries a build is its own intent rather than a second purpose bolted onto the one that tests migrations. +The two restore the same snapshot separately, as a verifying intent and a migrating one already do, because their outcomes are independent: a version whose migrations fail against a group's data has no schema to build, and a build that fails says nothing about whether the version is safe to take. +Restoring twice is affordable precisely because neither replica is kept: each exists for one run and is torn down. + +Such a replica is held up for the length of the build rather than discarded as soon as it is healthy, since the build reads it after the migrations land. +It is not offered to operators while it stands, being a database at a version its group is not yet running. ## What is owed Canopy derives what is owed rather than an operator naming each pair of central server and version. -A central server owes a reporting schema for the version it runs and for the version its group's open plan moves it to. +A central server owes a reporting schema for the version its group's open plan moves it to. A schema that does not exist by the time an upgrade lands is an outage of every report the group has. +The plan is also what makes the build possible, since it is what has a replica restored and migrated to that version at all, so what is owed and what can be produced arrive together. + +Nothing is owed for the version a group already runs, because that version was a candidate once and its schema was built then. +The steady state therefore needs no trigger of its own; a group whose current version predates the pipeline has none on file until its next upgrade produces one. Only a published version is buildable, for the same reason it is testable: a version's schema reaches a builder as its published artefacts, and an unpublished version has none to fetch. From 71b92cee88df619dc065e6b0baa7eb8d829cc22b Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:16:35 +1200 Subject: [PATCH 011/130] settle build contract --- .../specs/public-server/reporting-schemas.md | 68 +++++++++++-------- .../specs/public-server/restore-replicas.md | 10 ++- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 234f40176..5e75c776d 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -5,7 +5,7 @@ id: RPT # Reporting schemas A reporting schema is the set of database views a group's reports read from. -Canopy decides which groups owe a reporting schema for which version, supplies what a build needs to produce one, holds the artefact that results, and grades whether what a group runs is the schema it should have. +Canopy decides which groups owe a reporting schema for which version, supplies what a build needs to produce one, records the artefact that results, and grades whether what a group runs is the schema it should have. ## Why it exists @@ -18,16 +18,17 @@ Nothing joins them, so each group's artefact is produced against a database nobo ## Actors -A **schema builder** is first-party infrastructure that produces a reporting schema from a database and publishes the result. -It holds no list of what to build: it asks Canopy what schemas are owed, builds them, and reports back. +A **schema builder** is a restore consumer that produces a reporting schema from the replica it restores and publishes the result (see [RST](restore-replicas.md)). +It holds no list of what to build: it advertises an intent carrying `build`, is dispatched the replicas that intent is owed, and reports each outcome as that replica's restore report. It is the only actor that needs to understand what the schema contains. -An **operator** declares which groups are covered, and reads the currency of each. +An **operator** declares which groups are covered, reads the currency of each, and asks for the builds the derivation does not produce on its own. Canopy owns which group owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a group actually runs. The builder owns how the schema is produced. -Whether the schema builder is also the restore consumer that produced its source database, or a separate actor that asks for one, is an operational choice rather than a contract Canopy holds either way. +The builder is the actor that holds the database rather than one asking another for it, so a build is dispatched, credentialled, and reported over the paths every other replica already uses, and no database is handed between actors. +What runs the build inside that consumer stays the consumer's own business: the contract is the same whether it builds the schema itself or drives something else that does. ## What a build is entitled to @@ -50,48 +51,52 @@ It is not offered to operators while it stands, being a database at a version it ## What is owed -Canopy derives what is owed rather than an operator naming each pair of central server and version. +Canopy derives what is owed rather than an operator naming each pair of central server and version, and an operator's request reaches the pairs the derivation does not. A central server owes a reporting schema for the version its group's open plan moves it to. A schema that does not exist by the time an upgrade lands is an outage of every report the group has. The plan is also what makes the build possible, since it is what has a replica restored and migrated to that version at all, so what is owed and what can be produced arrive together. -Nothing is owed for the version a group already runs, because that version was a candidate once and its schema was built then. -The steady state therefore needs no trigger of its own; a group whose current version predates the pipeline has none on file until its next upgrade produces one. +Nothing is derived for the version a group already runs, because that version was a candidate once and its schema was built then. +The steady state therefore needs no trigger of its own. Only a published version is buildable, for the same reason it is testable: a version's schema reaches a builder as its published artefacts, and an unpublished version has none to fetch. -Only a group an operator has declared as covered is owed anything, so a group whose reports are maintained elsewhere accrues no findings for a schema nobody wants. -Coverage is declared per group, expands over that group's central servers, is enabled or disabled, and is audited (see [ADM](../private-server/admin-access.md)). +Only a group with an enabled build replica declared for it is owed anything, so a group whose reports are maintained elsewhere accrues no findings for a schema nobody wants. +That declaration is what covers a group rather than a coverage of its own beside it: it already names the group, expands over the group's servers, is enabled or disabled, and is audited (see [RST](restore-replicas.md)). +Only a central server draws an entry from it, since a schema is built from the configuration a central server holds. A build is owed once per pair of central server and version, and is settled by a successful build for that pair. A pair is reinstated when the version's own artefacts change, since a schema built from a superseded release of the same version is not the schema that version now describes. A change to a group's configuration does not reinstate a settled pair: the schema a group has is the one it asked for when it was built, and refreshing it is an operator's decision. -## The worklist +An operator may ask for a build of the version a central server currently runs, which owes that pair and is satisfied like any other. +It is what a group whose version predates the pipeline gets its first schema from, what rebuilds one after a configuration change, and what clears a server graded behind with nothing else owed. +A request names a version its server already runs, so the replica reaches it with no migrations to apply. -A schema builder fetches what it is currently owed in one request, scoped to the calling builder. -Canopy returns one entry per unsettled pair: +## Dispatch + +A build is dispatched as a restore replica rather than through a worklist of its own (see [RST](restore-replicas.md)). +An intent carrying `build` contributes an entry per unsettled pair among the covered groups' central servers, naming: - the **group** the schema is for, and the **central server** whose configuration it is built from; -- the **version** to build for; -- where the source database is to be found, or what the builder must ask for to obtain one; +- the **version** to build for, which the replica is migrated to before the build reads it; - whether the source is required to be de-identified. -The worklist carries no credentials. +The snapshot to restore, the repo coordinates, and the intent's parameter values are the ones every replica's entry carries, and credentials are obtained per run as they are for any other restore. Entries are the latest state rather than a queue to drain, and a builder converges on them over time. ## What a build reports -A builder reports the outcome of each build back to Canopy. -A report carries: +A build reports as its replica's restore report, which already names the group, the server, the snapshot it restored, and when it was observed. +Beyond those it carries: -- the **group**, **server**, and **version** it concerns; +- the **version** it was built for; - the **outcome** — built, or failed — and, on failure, a description of what went wrong; -- the **snapshot** the source database was restored from, where it was a restored replica, joining the schema to the data it came from; -- a reference to the **published artefact**, where the build produced one; -- **how much of the configuration was covered**: the number of configured surveys the schema addresses, and the number it could not; -- when the build was observed. +- a reference to each **artefact** the build published, of which the schema is one; +- **how much of the configuration was covered**: the number of configured surveys the schema addresses, and the number it could not. + +The restore's health and the build's outcome stay separate signals out of the one report, as a migration test's already do: a healthy replica whose build failed reports a healthy restore and a failed build. Reports are retained indefinitely as an audit trail. @@ -101,14 +106,22 @@ A reporting schema is published as an artefact of the version it was built for, A reporting schema always names a group, so two groups on the same version have two of them and neither stands in for the other. A group's schema for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. +Each carries a digest, so which of them a server has applied is a fact rather than an inference from a version string. + +It is published into the group's own object storage, under a prefix distinct from that group's backup repo, over a short-lived credential Canopy issues the builder for the run (see [BAK](backup.md)). +A schema derived from a group's configuration therefore rests in that group's storage, and the credential is what confines a build to writing its own group's artefacts. +Canopy records where it is as it does for any artefact and holds no copy of the file. + +A central server obtains its group's schema for the version it runs over its own credential, and applies it without an operator moving a file. +Whatever applies it stamps the schema with the artefact it applied, which is what the server then reports and what currency is graded on. ## Currency Canopy grades a central server's reporting schema as current, behind, or unknown. -It is current when the schema the server reports running is the artefact Canopy holds for the version it reports running, behind when the two differ, and unknown when the server reports no schema at all. +It is current when the artefact the server reports having applied is the newest Canopy holds for its group and the version the server reports running, behind when it is an earlier artefact or one built for another version, and unknown when the server reports no schema at all. -The schema a server is running is reported by the server as the version it was built for, alongside the other facts its sources report about it (see [STA](statuses.md)). -Canopy does not read it out of the database, so a server that reports nothing is unknown rather than assumed bare. +The schema a server is running is reported by the server as the artefact it applied, alongside the other facts its sources report about it (see [STA](statuses.md)). +Canopy does not read it out of the database, so a server that reports nothing is unknown rather than assumed bare, and a schema applied by hand carries no artefact to report. Currency is presented per group, so whether a group's reports are running against the right schema is answered in one place. @@ -120,11 +133,12 @@ Both leave its reports mismatched to the version it runs, and neither carries a The check is a warning rather than a failure, and does not escalate: the servers are up and their reports return rows, and a schema written for the wrong version is for whoever maintains the reports rather than whoever is on call. A failed build raises the same check with its failure description, and settles the pair rather than retrying, since a build against a fixed version and configuration fails the same way every time. +A settled pair is dispatched again by an operator asking for a build, which is the action every one of these checks exists to prompt. ## Out of scope - What a reporting schema contains, and what each view in it means. - How a build is run, where it runs, and what it costs. - The report definitions, documentation, and translations a build may produce alongside the schema. Canopy tracks them as published artefacts of the same version and group without interpreting them. -- Applying a schema to a database, and the change control around doing so. +- How a server applies the schema it obtains, and the change control around doing so. - Deciding when a group upgrades: an owed schema informs that decision without making it. diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index 34cbe1a85..48cd14b52 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -96,8 +96,11 @@ The recognised semantics are: An intent whose result depends on more than the snapshot keys `once` to that wider input as well, and may treat a failure as settled rather than retryable (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). - **url** — the intent's health report carries a link to the running replica within its attached health data, which Canopy surfaces to operators. - **migrate** — the intent applies a Tamanu version's schema migrations to the replica it restores. - Canopy names a target version on each of the intent's worklist entries and withholds an entry from a server that has no candidate version. + Canopy names a target version on each of the intent's worklist entries and withholds an entry from a server it has no version to name: the server's candidate version, or for an intent carrying `build`, the version that build is owed for. `once` for such an intent is keyed to the snapshot and the target version together (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). +- **build** — the intent produces a reporting schema from the replica it restores and publishes it (see [RPT](reporting-schemas.md)). + It carries `migrate` alongside, since a schema is built at a version the replica has to be brought to, and Canopy dispatches it for the pairs of central server and version a covered group is owed a schema for rather than for the server's candidate. + `once` for such an intent is keyed to the server and the version alone, so a newer snapshot does not rebuild a schema the group already has, and a failed build settles that pair. - **redact** — the intent can de-identify the restored data before serving it. Canopy offers redaction as an option on each of the intent's replicas, supplies the masking manifest for the product being restored, and holds a redacting replica to the outcome of its redaction (see [Redaction](#redaction)). @@ -252,15 +255,16 @@ That window is where the answer is still cheap: the fleet is not moving yet, and ### Dispatching a migration test -`migrate` is a semantic an intent opts into, and an intent carrying it carries no other purpose. +`migrate` is a semantic an intent opts into, and an intent carrying it neither verifies backups nor serves a queryable replica. It carries `check` alongside, so a single restore reports the replica's health and the migrations' outcome as two signals from one report. -An intent carrying `migrate` is withheld from a server with no candidate version. +An intent carrying `migrate` is withheld from a server it has no version to name. An intent that verifies backups therefore does not also migrate: it would go undispatched for every server without a candidate, leaving the backups of any non-Tamanu product, and of every deployment with no plan open, unverified. An intent that keeps a replica queryable does not migrate either: a migrated replica sits at a version its deployment is not running, so a declaration promoted to it would give an operator a schema that does not match production. A verifying intent and a migrating intent restore the same snapshot separately. A verifying intent restores once per snapshot, and a migrating intent's `once` is keyed to the snapshot and target version together, so it restores when a new candidate version appears rather than on every snapshot. +An intent carrying `build` restores separately again, keyed to the pair it is owed rather than to the snapshot, so a schema is built when one is owed and not otherwise (see [RPT](reporting-schemas.md)). An entry for a `migrate` intent names the target version alongside the snapshot. A consumer obtains that version's migrations from its published artefacts, the same way a server being upgraded does, so naming the version is the whole reference it needs. From 4c6ab3dcd675f30896653136e6b49af9d807a166 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:28:54 +1200 Subject: [PATCH 012/130] propose apply and publish --- .../specs/public-server/reporting-schemas.md | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 5e75c776d..69c5dc8fa 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -5,7 +5,7 @@ id: RPT # Reporting schemas A reporting schema is the set of database views a group's reports read from. -Canopy decides which groups owe a reporting schema for which version, supplies what a build needs to produce one, records the artefact that results, and grades whether what a group runs is the schema it should have. +Canopy decides which groups owe a reporting schema for which version, supplies what a build needs to produce one, records the artefacts that result, and grades whether what a group runs is the schema it should have. ## Why it exists @@ -27,20 +27,26 @@ An **operator** declares which groups are covered, reads the currency of each, a Canopy owns which group owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a group actually runs. The builder owns how the schema is produced. -The builder is the actor that holds the database rather than one asking another for it, so a build is dispatched, credentialled, and reported over the paths every other replica already uses, and no database is handed between actors. -What runs the build inside that consumer stays the consumer's own business: the contract is the same whether it builds the schema itself or drives something else that does. +The builder holds the database rather than asking another actor for one, so no database is handed between actors and a build is dispatched, credentialled, and reported over the paths every other replica already uses. +What runs the build inside that consumer is the consumer's own business: the contract is the same whether it builds the schema itself or drives something else that does. ## What a build is entitled to A build requires a database at the version being built for, carrying the group's own configuration. Neither half stands alone: the right version without the configuration produces only the part of the schema every group on that version shares, and the configuration at the wrong version produces a schema for the version the group is leaving. -The configuration a build reads is a group's configured surveys, the screens that compose them, and the data elements they collect. +The configuration a build reads is a group's configured surveys, the screens that compose them, the data elements they collect, and the price lists and insurance plans its invoicing reports pivot into columns. It is held centrally and synced down, so a schema is built from a central server, and a group holding more than one owes a schema for each, since each carries its own configuration. -It is reference data, present in every copy of the database and carrying no patient content, so a source whose patient data has been de-identified is sufficient and is what Canopy asks for where one can be had. -That holds only while the product's masking manifest leaves configuration legible: a manifest masking a survey's identifiers rather than its answers would defeat the build while appearing to succeed. +It is reference data, present in every copy of the database and carrying no patient content, so nothing a build reads is what a de-identified source exists to protect. -Canopy holds the requirement rather than the database, and already has the means to satisfy it: a managed restore replica, restored from a recent snapshot and migrated to the version being built for, is a database that meets both halves (see [RST](restore-replicas.md)). +A build's source is not de-identified, because the product's masking manifest masks columns the configuration is read through, and a masked visibility flag or reference identifier produces a schema that is wrong in ways a successful build does not show: the configuration it could not read is absent from the result rather than reported as missing. +Redaction protects a replica someone is given, and a build's replica is given to nobody: it serves one run and is torn down. + +A build has inputs the database does not hold: the group's own model definitions, and the formatting and language settings its reports are built with. +Those are the builder's to keep and Canopy neither supplies nor records them, so the database a build is entitled to is one necessary input rather than the whole of one. + +Canopy holds the requirement rather than the database, and already has the means to satisfy it: a managed restore replica, restored from a recent snapshot and migrated to the version being built for, meets both halves (see [RST](restore-replicas.md)). +The requirement is the version and the configuration rather than the group's data, so a database freshly migrated to that version meets it too where the configuration is supplied to the build on its own, and a build satisfied that way is the same build and settles the same pair. A restore that carries a build is its own intent rather than a second purpose bolted onto the one that tests migrations. The two restore the same snapshot separately, as a verifying intent and a migrating one already do, because their outcomes are independent: a version whose migrations fail against a group's data has no schema to build, and a build that fails says nothing about whether the version is safe to take. @@ -51,11 +57,11 @@ It is not offered to operators while it stands, being a database at a version it ## What is owed -Canopy derives what is owed rather than an operator naming each pair of central server and version, and an operator's request reaches the pairs the derivation does not. +Canopy derives what is owed rather than an operator naming each pair of central server and version, and an operator may request a pair the derivation does not reach. A central server owes a reporting schema for the version its group's open plan moves it to. A schema that does not exist by the time an upgrade lands is an outage of every report the group has. -The plan is also what makes the build possible, since it is what has a replica restored and migrated to that version at all, so what is owed and what can be produced arrive together. +The plan is also what has a replica restored and migrated to that version at all, so what is owed and what can be produced arrive together. Nothing is derived for the version a group already runs, because that version was a candidate once and its schema was built then. The steady state therefore needs no trigger of its own. @@ -72,7 +78,7 @@ A change to a group's configuration does not reinstate a settled pair: the schem An operator may ask for a build of the version a central server currently runs, which owes that pair and is satisfied like any other. It is what a group whose version predates the pipeline gets its first schema from, what rebuilds one after a configuration change, and what clears a server graded behind with nothing else owed. -A request names a version its server already runs, so the replica reaches it with no migrations to apply. +The version a request names is one its server already runs, so the replica reaches it with no migrations to apply. ## Dispatch @@ -80,12 +86,32 @@ A build is dispatched as a restore replica rather than through a worklist of its An intent carrying `build` contributes an entry per unsettled pair among the covered groups' central servers, naming: - the **group** the schema is for, and the **central server** whose configuration it is built from; -- the **version** to build for, which the replica is migrated to before the build reads it; -- whether the source is required to be de-identified. +- the **version** to build for, which the replica is migrated to before the build reads it. The snapshot to restore, the repo coordinates, and the intent's parameter values are the ones every replica's entry carries, and credentials are obtained per run as they are for any other restore. Entries are the latest state rather than a queue to drain, and a builder converges on them over time. +## What a build publishes + +A build publishes what it produced as artefacts of the version it built for, scoped to the group it built from (see [ART](../platform/artifacts.md)): the **reporting schema** itself, the **report definitions** that read from it, the **documentation** describing its views, and the **analytics metadata** derived beside them. +Canopy records each and interprets only the schema, which is the one a server applies and the one currency is graded on. + +Publishing is part of the build rather than a step taken afterwards: the builder publishes and registers what it produced in the run it reports, so a schema that exists is one a server can already fetch. + +An artefact is published for the exact version it was built for and never for a range of versions. +A schema follows from the migrations a version applies, so one built against a patch is not the schema another patch of the same minor describes, and a range would offer the fleet a schema for a version it does not run. + +A reporting schema names a group, so two groups on the same version have two of them and neither stands in for the other. +The version half on its own is the exception: a schema built from no group's configuration belongs to the version alone and is published unscoped, with the version's other artefacts rather than derived per group, so it exists for every version whether any group is covered or not. +It is what a group with no schema of its own is offered, and a group's own schema takes precedence over it, carrying that group's configuration as well as the version's shape. + +A group-scoped artefact is published into the group's own object storage, under a prefix distinct from that group's backup repo, over a short-lived credential Canopy issues the builder for the run (see [BAK](backup.md)). +A schema derived from a group's configuration therefore rests in that group's storage, and the credential is what confines a build to writing its own group's artefacts. +Canopy records where each artefact is and holds no copy of the file. + +A group's schema for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. +Each carries a digest, so which of them a server has applied is a fact rather than an inference from a version string. + ## What a build reports A build reports as its replica's restore report, which already names the group, the server, the snapshot it restored, and when it was observed. @@ -94,31 +120,26 @@ Beyond those it carries: - the **version** it was built for; - the **outcome** — built, or failed — and, on failure, a description of what went wrong; - a reference to each **artefact** the build published, of which the schema is one; -- **how much of the configuration was covered**: the number of configured surveys the schema addresses, and the number it could not. +- **how much of the configuration was covered**: the number of configured surveys, price lists, and insurance plans the schema addresses, and the number it could not. -The restore's health and the build's outcome stay separate signals out of the one report, as a migration test's already do: a healthy replica whose build failed reports a healthy restore and a failed build. +The restore's health and the build's outcome stay separate signals from the one report, as a migration test's already do: a healthy replica whose build failed reports a healthy restore and a failed build. Reports are retained indefinitely as an audit trail. -## The artefact +## Applying -A reporting schema is published as an artefact of the version it was built for, scoped to the group it was built from (see [ART](../platform/artifacts.md)). -A reporting schema always names a group, so two groups on the same version have two of them and neither stands in for the other. +A central server applies the newest schema Canopy offers it for the version it runs, with no operator moving a file: it obtains the artefact over its own credential, applies it, and stamps the schema with what it applied. +It applies one when its stamp and the offered artefact differ, and does nothing when they match, so an upgrade that emptied the schema is repaired by the server itself. +A schema is applied by replacing it whole, which cannot proceed under an open report, so a server applies before its reporting connections are serving rather than beneath them. -A group's schema for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. -Each carries a digest, so which of them a server has applied is a fact rather than an inference from a version string. - -It is published into the group's own object storage, under a prefix distinct from that group's backup repo, over a short-lived credential Canopy issues the builder for the run (see [BAK](backup.md)). -A schema derived from a group's configuration therefore rests in that group's storage, and the credential is what confines a build to writing its own group's artefacts. -Canopy records where it is as it does for any artefact and holds no copy of the file. +A server provisions the reporting role and the schema's privileges for itself, so a schema arriving after the server started is readable as soon as it is applied, with no grant to run after it. -A central server obtains its group's schema for the version it runs over its own credential, and applies it without an operator moving a file. -Whatever applies it stamps the schema with the artefact it applied, which is what the server then reports and what currency is graded on. +A server that cannot obtain or apply one keeps what it has and reports that, which grades it behind or unknown rather than silently current. ## Currency Canopy grades a central server's reporting schema as current, behind, or unknown. -It is current when the artefact the server reports having applied is the newest Canopy holds for its group and the version the server reports running, behind when it is an earlier artefact or one built for another version, and unknown when the server reports no schema at all. +It is current when the artefact the server reports having applied is the newest Canopy offers it for the version it reports running, behind when it is an earlier artefact or one built for another version, and unknown when the server reports no schema at all. The schema a server is running is reported by the server as the artefact it applied, alongside the other facts its sources report about it (see [STA](statuses.md)). Canopy does not read it out of the database, so a server that reports nothing is unknown rather than assumed bare, and a schema applied by hand carries no artefact to report. @@ -133,12 +154,12 @@ Both leave its reports mismatched to the version it runs, and neither carries a The check is a warning rather than a failure, and does not escalate: the servers are up and their reports return rows, and a schema written for the wrong version is for whoever maintains the reports rather than whoever is on call. A failed build raises the same check with its failure description, and settles the pair rather than retrying, since a build against a fixed version and configuration fails the same way every time. -A settled pair is dispatched again by an operator asking for a build, which is the action every one of these checks exists to prompt. +A settled pair is dispatched again by an operator asking for a build, which is what these checks exist to prompt. ## Out of scope - What a reporting schema contains, and what each view in it means. - How a build is run, where it runs, and what it costs. -- The report definitions, documentation, and translations a build may produce alongside the schema. Canopy tracks them as published artefacts of the same version and group without interpreting them. -- How a server applies the schema it obtains, and the change control around doing so. +- What the report definitions, documentation, and analytics metadata a build publishes contain, and what reads them. +- The mechanics of applying a schema to a database, and the change control around doing so. - Deciding when a group upgrades: an owed schema informs that decision without making it. From 14edef86846363c9d34c235510736b56096decc7 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:23:05 +1200 Subject: [PATCH 013/130] facilities run it too --- .workhorse/specs/public-server/reporting-schemas.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 69c5dc8fa..65bddae24 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -128,7 +128,10 @@ Reports are retained indefinitely as an audit trail. ## Applying -A central server applies the newest schema Canopy offers it for the version it runs, with no operator moving a file: it obtains the artefact over its own credential, applies it, and stamps the schema with what it applied. +A server applies the newest schema Canopy offers it for the version it runs, with no operator moving a file: it obtains the artefact over its own credential, applies it, and stamps the schema with what it applied. + +A facility server is offered its group's schema as a central server is, since a schema follows the group and the version rather than the server it was built from, and a facility runs the reports the same views serve. +Where a facility's database is the one its central server uses, it is already applied and the facility stamps nothing of its own. It applies one when its stamp and the offered artefact differ, and does nothing when they match, so an upgrade that emptied the schema is repaired by the server itself. A schema is applied by replacing it whole, which cannot proceed under an open report, so a server applies before its reporting connections are serving rather than beneath them. @@ -138,7 +141,7 @@ A server that cannot obtain or apply one keeps what it has and reports that, whi ## Currency -Canopy grades a central server's reporting schema as current, behind, or unknown. +Canopy grades the reporting schema of every server that reports one, as current, behind, or unknown. It is current when the artefact the server reports having applied is the newest Canopy offers it for the version it reports running, behind when it is an earlier artefact or one built for another version, and unknown when the server reports no schema at all. The schema a server is running is reported by the server as the artefact it applied, alongside the other facts its sources report about it (see [STA](statuses.md)). @@ -148,7 +151,7 @@ Currency is presented per group, so whether a group's reports are running agains ## Alerting -A central server whose schema is behind, or which owes a build nothing has produced, raises a reporting-schema check on itself (see [CHK](../monitoring/checks.md)). +A server whose schema is behind raises a reporting-schema check on itself, as does a central server owed a build nothing has produced (see [CHK](../monitoring/checks.md)). Both leave its reports mismatched to the version it runs, and neither carries a time bound of its own: the plan that made a version a candidate already carries the date it is wanted by. The check is a warning rather than a failure, and does not escalate: the servers are up and their reports return rows, and a schema written for the wrong version is for whoever maintains the reports rather than whoever is on call. From dfb931e22024899af2e98b425b9d6be07c8466e6 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:03:46 +1200 Subject: [PATCH 014/130] rpt review pass --- .workhorse/specs/public-server/reporting-schemas.md | 2 ++ .workhorse/specs/public-server/restore-replicas.md | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 65bddae24..006bb2581 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -146,6 +146,7 @@ It is current when the artefact the server reports having applied is the newest The schema a server is running is reported by the server as the artefact it applied, alongside the other facts its sources report about it (see [STA](statuses.md)). Canopy does not read it out of the database, so a server that reports nothing is unknown rather than assumed bare, and a schema applied by hand carries no artefact to report. +A server that reports only the version its schema was built for, as one applied by hand does, is graded behind where Canopy offers it a schema, since what it can offer is not what the server applied, and otherwise on the version alone: current when it is the version the server runs, and behind when it is not. Currency is presented per group, so whether a group's reports are running against the right schema is answered in one place. @@ -157,6 +158,7 @@ Both leave its reports mismatched to the version it runs, and neither carries a The check is a warning rather than a failure, and does not escalate: the servers are up and their reports return rows, and a schema written for the wrong version is for whoever maintains the reports rather than whoever is on call. A failed build raises the same check with its failure description, and settles the pair rather than retrying, since a build against a fixed version and configuration fails the same way every time. +A replica that failed to restore or come up is the restore's own health rather than a build failure, and is dispatched again as any unhealthy restore is. A settled pair is dispatched again by an operator asking for a build, which is what these checks exist to prompt. ## Out of scope diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index 48cd14b52..b8f18807d 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -43,7 +43,7 @@ A restore consumer authenticates as a single device holding the `backup-restore` The role is generic: any future restore consumer uses the same role with its own declared replicas. A `backup-restore` device has no implicit server and no implicit group; it is not a member of any group it reads. -The role is read-only by contract, enforced at the API: +The role is read-only against the repo by contract, enforced at the API: - A `backup-restore` caller requesting backup (write) credentials is rejected. The read-only guarantee is server-enforced, so a compromised consumer cannot pivot to writing or poisoning a repo. @@ -100,7 +100,8 @@ The recognised semantics are: `once` for such an intent is keyed to the snapshot and the target version together (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). - **build** — the intent produces a reporting schema from the replica it restores and publishes it (see [RPT](reporting-schemas.md)). It carries `migrate` alongside, since a schema is built at a version the replica has to be brought to, and Canopy dispatches it for the pairs of central server and version a covered group is owed a schema for rather than for the server's candidate. - `once` for such an intent is keyed to the server and the version alone, so a newer snapshot does not rebuild a schema the group already has, and a failed build settles that pair. + `once` for such an intent is keyed to the server and the version rather than the snapshot, so a newer snapshot does not rebuild a schema the group already has, and a failed build settles that pair. + A settled pair is reinstated when the version's artefacts change or an operator asks for the build (see [RPT](reporting-schemas.md)). - **redact** — the intent can de-identify the restored data before serving it. Canopy offers redaction as an option on each of the intent's replicas, supplies the masking manifest for the product being restored, and holds a redacting replica to the outcome of its redaction (see [Redaction](#redaction)). @@ -188,6 +189,8 @@ Canopy verifies the caller has an enabled declaration covering that `(group, typ - the repo password. The credentials permit reading the repo and nothing else; they cannot write, overwrite, or delete. +A consumer whose intent carries `build` additionally obtains, for the same `(group, type)`, a short-lived credential that writes to the group's artefact prefix and reaches nothing in the repo, which is what its builds publish over (see [RPT](reporting-schemas.md)). +The repo stays read-only to every consumer: the artefact prefix lies outside it, and a credential for one cannot touch the other. Each issuance is audited. A consumer may include an optional run correlation identifier with a credential request; Canopy records it on the issuance so the run is tied to its later health report. Absence of a covering declaration is a definitive refusal, not a transient error, and a consumer surfaces it as a clear failure for the operator to diagnose by inspecting the declaration in Canopy. From 1a82c7edd38dbd0803db53a4fbcab5dc91cfa55e Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:12:01 +1200 Subject: [PATCH 015/130] device applies the schema --- .workhorse/specs/public-server/reporting-schemas.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 006bb2581..33f55fe7a 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -128,16 +128,17 @@ Reports are retained indefinitely as an audit trail. ## Applying -A server applies the newest schema Canopy offers it for the version it runs, with no operator moving a file: it obtains the artefact over its own credential, applies it, and stamps the schema with what it applied. +A server's device applies the newest schema Canopy offers the server for the version it runs, with no operator moving a file: it obtains the artefact over its own credential, applies it, and stamps the schema with what it applied (see [DID](device-identity.md)). +The device does this beside the running product, which neither fetches a schema nor restarts for one, so a rebuild lands on a server that stays up. A facility server is offered its group's schema as a central server is, since a schema follows the group and the version rather than the server it was built from, and a facility runs the reports the same views serve. Where a facility's database is the one its central server uses, it is already applied and the facility stamps nothing of its own. -It applies one when its stamp and the offered artefact differ, and does nothing when they match, so an upgrade that emptied the schema is repaired by the server itself. -A schema is applied by replacing it whole, which cannot proceed under an open report, so a server applies before its reporting connections are serving rather than beneath them. +The device compares the stamp with the offered artefact each time it reports on its server, applies when they differ, and does nothing when they match, so an upgrade that emptied the schema is repaired by the device itself. +A schema is applied by replacing it whole in one transaction, so a report running while it lands sees the schema it started under or the new one, and never a half-built one. -A server provisions the reporting role and the schema's privileges for itself, so a schema arriving after the server started is readable as soon as it is applied, with no grant to run after it. +The device applies the schema as the product's own database role and grants the reporting role its privileges in the same transaction, so a schema arriving after the product started is readable as soon as it lands, with no grant to run after it. -A server that cannot obtain or apply one keeps what it has and reports that, which grades it behind or unknown rather than silently current. +A device that cannot obtain or apply one leaves what the server has and reports that, which grades the server behind or unknown rather than silently current. ## Currency From 7b1e3ccc71ba98dba741ba22510d5867edf48b07 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:36:09 +1200 Subject: [PATCH 016/130] name and trace artefacts --- .workhorse/specs/public-server/reporting-schemas.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 33f55fe7a..e79cf1985 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -95,6 +95,8 @@ Entries are the latest state rather than a queue to drain, and a builder converg A build publishes what it produced as artefacts of the version it built for, scoped to the group it built from (see [ART](../platform/artifacts.md)): the **reporting schema** itself, the **report definitions** that read from it, the **documentation** describing its views, and the **analytics metadata** derived beside them. Canopy records each and interprets only the schema, which is the one a server applies and the one currency is graded on. +Each is registered under the type a server or operator asks for it by, `reporting-schema`, `report-definitions`, `reporting-docs`, and `analytics-metadata`, on the platform `any`, since none of them is specific to one. +Each names the build report that produced it, which carries the snapshot it was built from, the server whose configuration it read, and when (see [What a build reports](#what-a-build-reports)). Publishing is part of the build rather than a step taken afterwards: the builder publishes and registers what it produced in the run it reports, so a schema that exists is one a server can already fetch. From 71c3a3843e55fc5e99c808b2c1ce525071cad5b3 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:57:47 +1200 Subject: [PATCH 017/130] address spec review --- .workhorse/specs/platform/artifacts.md | 91 +++++++++++++------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index e3731eb84..7efe6327f 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -2,67 +2,70 @@ id: ART --- -# Version artefacts +# Version artifacts -An artefact is a file published for a version that Canopy records the location of and does not hold: an installer, a package, a set of migrations, a manifest. -Canopy is the index rather than the store: a server or the infrastructure acting on a version's behalf learns from Canopy where a file is and fetches it from there, and where the location is not one that caller may read, Canopy passes the bytes through on request rather than keeping a copy. +An artifact is a file published for a version: an installer, a package, a set of migrations, a manifest. +Canopy is the index of them: it holds where each one is, what it is, and whom it is for, and a server or the infrastructure acting on a version's behalf learns from Canopy which files a version has and fetches them from where they rest. +An artifact may be for one group alone, and Canopy offers it only to a caller whose group it is. -## What an artefact belongs to +## What an artifact belongs to -An artefact belongs to one exact version, or to a range of versions given as a semver pattern, and never to both. -A range artefact covers every version its pattern matches, so a file that does not change between releases is published once instead of per release. +An artifact belongs to one exact version, or to a range of versions given as a semver pattern, and never to both. +A range artifact covers every version its pattern matches, so a file that does not change between releases is published once instead of per release. -An artefact carries a type and a platform, which together say what it is and what it is for. -A version's artefacts are the exact-version ones plus every range artefact whose pattern matches it. +An artifact carries a type and a platform, which together say what it is and what it is for. +A version's artifacts are the exact-version ones plus every range artifact whose pattern matches it. -## What a version offers +An artifact may belong to a group, and one that does is for that group alone. +An artifact belonging to no group is for every group. +A group scope exists because some artifacts are derived from a group's own data and are wrong for anyone else. -Canopy offers one artefact per type and platform, choosing the most specific of those that match. -An exact-version artefact is more specific than any range artefact; between two ranges, the narrower is more specific. -A pattern Canopy cannot parse matches nothing rather than everything, so a malformed range withholds a file instead of offering it to the whole fleet. +## Where an artifact rests -The full set, including the artefacts specificity passed over, is available to operators. -What resolution hides is a fact about how a version was published and an operator has to be able to see it. +An artifact belonging to no group rests at a URL, which whoever is offered the artifact fetches directly. -## Group-scoped artefacts +A group-scoped artifact rests as an object in its own group's storage, under a prefix of its own apart from the group's backup repo, and a registration placing one anywhere else is refused. +Canopy reads it on a caller's behalf by assuming the group's storage role confined to reading that prefix (see [BAK](../public-server/backup.md)), and streams the bytes to the caller, so the file rests only in the group's storage and is readable only through Canopy. +The boundary is therefore enforced on the read rather than resting on a location being hard to guess. -An artefact may belong to a group, and one that does is offered only to that group. -An artefact belonging to no group is offered to every group, which is what a version's installers, migrations, and manifests are: properties of the version and of nothing narrower. +Canopy issues a publisher short-lived credentials for writing into a group's artifact prefix the way it issues backup credentials: by assuming the group's storage role under a session policy confined to that prefix, recorded before they are returned, and only to a caller authorised to register artifacts for that group (see [Registration](#registration)). +The prefix is apart from the backup repo so that a credential which writes artifacts reaches no backup. -Belonging to a group is therefore an addition rather than a requirement, and the artefacts published today are unaffected. -Resolution keys on type, platform, and group together, so a group-scoped artefact and an unscoped one of the same type do not displace each other and each group is offered its own. +## What a version offers -A group-scoped artefact is offered only where the caller's group is known. -A caller whose credential carries a group, a server device being the case that matters, never names one and is answered for its own group alone, so a server cannot ask what another group is offered. -A caller carrying no group of its own names the group it asks about and is answered only for a group it is authorised for, which is how a build reads what it is about to replace and how an operator reads the fleet. -A read carrying no identity at all is answered with the unscoped artefacts alone, so giving an artefact a group narrows who is offered it rather than widening what an open path serves. -A group-scoped artefact's existence is disclosed only to a caller it is offered to: a caller that names or guesses one it is not offered is answered as though it did not exist, so which groups hold one is not enumerable through the artefact surface. +Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known. +Where several match, the most specific is offered. +An artifact scoped to the caller's group is more specific than one belonging to no group. +Among artifacts of the same scope, an exact-version artifact is more specific than any range artifact, and between two ranges the narrower is more specific. +A group-scoped artifact and an unscoped one of the same type and platform are therefore both held, each group is offered the one for it, and no caller is offered both. +A pattern Canopy cannot parse matches nothing rather than everything, so a malformed range withholds a file instead of offering it to the whole fleet. -A group scope exists because some artefacts are derived from a group's own data and are wrong for anyone else, a reporting schema being the case that motivates it (see [RPT](../public-server/reporting-schemas.md)). -Such an artefact is published into that group's own object storage, over a credential Canopy issues the publisher for the run, and is read back through Canopy, which passes it only to a caller the artefact is offered to. -The boundary is therefore enforced on the read rather than resting on a location being hard to guess. +The full set, including the artifacts specificity passed over, is available to operators. +What resolution hides is a fact about how a version was published and an operator has to be able to see it. -## Registration +## Who is offered a group-scoped artifact -A releaser device registers artefacts against a version over its own credential, which is what a product's release automation uses when it publishes. -Registering a group-scoped artefact requires being authorised for that group, and the group is named on the registration rather than inferred from the caller. -A restore consumer registers the artefacts its builds produce for the groups its own declarations authorise it to read, so a build needs no releaser credential to publish what it made. -A releaser credential carries no group and registers unscoped artefacts alone, which is what a version's installers, migrations, and manifests are. -An operator may also record one directly. -Canopy records which device registered an artefact and, where the registration names one, the run that produced it, so an artefact that arrived by automation is distinguishable from one entered by hand and traceable to what made it. +A caller whose credential is bound to a server has that server's group, where the server has one. +It never names a group and is answered for its own alone, so a server cannot ask what another group is offered. +A caller carrying no group of its own names the group it asks about and is answered for a group it is authorised for: an operator for any group, and a restore consumer for the groups its enabled declarations cover (see [RST](../public-server/restore-replicas.md)). +A read carrying no identity, or naming no group, is answered with the unscoped artifacts alone, so giving an artifact a group narrows who is offered it rather than widening what an open path serves. +A group-scoped artifact's existence is disclosed only to a caller it is offered to: a caller that names or guesses one it is not offered is answered as though it did not exist, so which groups hold one is not enumerable through the artifact surface. +Canopy passes a group-scoped artifact's bytes only to a caller it is offered to. -## What Canopy does not know +## Registration -Canopy holds where an artefact is published and not what it contains. -It does not go looking for the file to check it, so an artefact whose location stops resolving is not detected until something tries to read it, and two artefacts published to the same location with no digest between them are the same artefact to Canopy however their contents differ. +A registration names the version or range, the type, the platform, the location, and the group where the artifact has one. +The group is named on the registration rather than inferred from the caller. -An artefact carries a digest where whoever registers it records one, and a group-scoped artefact carries one always, because what a server has applied is graded against the artefact Canopy holds and successive builds for one version are otherwise indistinguishable (see [RPT](../public-server/reporting-schemas.md)). -Where Canopy passes an artefact through and holds a digest for it, it verifies the bytes against that digest and refuses them on a mismatch, so a file replaced at its published location fails the read rather than reaching a server as the artefact it is not. +A releaser device registers unscoped artifacts, and carries no authorisation for any group. +A restore consumer registers artifacts scoped to a group only under an enabled declaration for that group whose intent publishes artifacts (see [RST](../public-server/restore-replicas.md)), and a consumer whose declarations only restore registers nothing. +An operator registers either. +A registration naming a group the caller is not authorised for is refused. +Credentials for writing into a group's storage are issued on the same authorisation, so a caller that may not register for a group cannot publish into it either. -A version's publication is corroborated against the artefacts Canopy holds for it rather than against the files themselves, which is enough to say a version has published what it needs to and not enough to say those files are good. +Canopy records which device registered an artifact and, where the registration names one, the run that produced it, so an artifact that arrived by automation is distinguishable from one entered by hand and traceable to what made it. -## Out of scope +## Digests -- Hosting or retaining the files themselves: Canopy passes bytes through on request and keeps no copy of them. -- What any artefact contains, and whether it is fit for what fetches it. -- Which artefacts a product must publish to be considered released. +An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always, since its bytes reach a caller through Canopy and are verified on the way. +Where Canopy passes an artifact through and holds a digest for it, it verifies the bytes against that digest and refuses them on a mismatch, so a file replaced at its published location fails the read rather than reaching a server as the artifact it is not. From fc6359e04d82b2f3eee9a9a08953a7aabe479352 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:55:43 +1200 Subject: [PATCH 018/130] narrow artifact registrars --- .workhorse/specs/platform/artifacts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 7efe6327f..1c3a93d52 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -47,7 +47,7 @@ What resolution hides is a fact about how a version was published and an operato A caller whose credential is bound to a server has that server's group, where the server has one. It never names a group and is answered for its own alone, so a server cannot ask what another group is offered. -A caller carrying no group of its own names the group it asks about and is answered for a group it is authorised for: an operator for any group, and a restore consumer for the groups its enabled declarations cover (see [RST](../public-server/restore-replicas.md)). +A caller carrying no group of its own names the group it asks about and is answered for a group it is authorised for: an operator for any group, and a component that produces or applies a group's artifacts for that group, as defined with those artifacts. A read carrying no identity, or naming no group, is answered with the unscoped artifacts alone, so giving an artifact a group narrows who is offered it rather than widening what an open path serves. A group-scoped artifact's existence is disclosed only to a caller it is offered to: a caller that names or guesses one it is not offered is answered as though it did not exist, so which groups hold one is not enumerable through the artifact surface. Canopy passes a group-scoped artifact's bytes only to a caller it is offered to. @@ -58,8 +58,8 @@ A registration names the version or range, the type, the platform, the location, The group is named on the registration rather than inferred from the caller. A releaser device registers unscoped artifacts, and carries no authorisation for any group. -A restore consumer registers artifacts scoped to a group only under an enabled declaration for that group whose intent publishes artifacts (see [RST](../public-server/restore-replicas.md)), and a consumer whose declarations only restore registers nothing. An operator registers either. +A component that produces a group-scoped artifact registers it for that group under an authorisation defined with that artifact, and is authorised for no other. A registration naming a group the caller is not authorised for is refused. Credentials for writing into a group's storage are issued on the same authorisation, so a caller that may not register for a group cannot publish into it either. From f682611df1b25cf86a1fa822bd2162fb034fb715 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:57:37 +1200 Subject: [PATCH 019/130] address spec review --- .../specs/public-server/reporting-schemas.md | 180 +++++------------- .../specs/public-server/restore-replicas.md | 16 +- 2 files changed, 53 insertions(+), 143 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index e79cf1985..43558032b 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -4,170 +4,82 @@ id: RPT # Reporting schemas -A reporting schema is the set of database views a group's reports read from. -Canopy decides which groups owe a reporting schema for which version, supplies what a build needs to produce one, records the artefacts that result, and grades whether what a group runs is the schema it should have. +A reporting schema is the set of database views a Tamanu server's reports read from. +It is built for one Tamanu version against a database of one group, since its views follow from the group's configuration as well as from the version's schema, and every Tamanu server in that group applies the same one. +Canopy holds zero or one reporting schema per pair of group and Tamanu version, has one built for every pair it knows of, and offers it to the Tamanu servers of that group. ## Why it exists -A reporting schema is derived from two things no single actor holds. -Half of it follows from a Tamanu version's database schema and is the same for every group on that version; the other half follows from the group's own configuration, and can only be produced from a database carrying it. - -Canopy already holds both sides. -It tracks Tamanu's releases and which version each server runs (see [APP](../servers/products.md)), it knows which version a group is planning to move to (see [UPG](../private-server/upgrade-plans.md)), and it can have a replica of that group's data restored and migrated to that version (see [RST](restore-replicas.md)). -Nothing joins them, so each group's artefact is produced against a database nobody records, on a cadence nobody can see, and a group can run for a year on a schema built for a version it has long left. +Part of a reporting schema follows from the Tamanu version's database schema and is the same for every group on that version. +The rest follows from the group's own configuration, which only a database carrying that configuration can supply. +A schema for a pair is therefore built from a database of that group at that version, and ahead of an upgrade no such database exists, since the group's servers run the version they are leaving. +Canopy restores a group's backups into replicas and migrates them to a version (see [RST](restore-replicas.md)), so it is where such a database is produced, and it knows the version each group runs and the one it is moving to (see [APP](../servers/products.md), [UPG](../private-server/upgrade-plans.md)), so it is where the pairs are known. ## Actors -A **schema builder** is a restore consumer that produces a reporting schema from the replica it restores and publishes the result (see [RST](restore-replicas.md)). -It holds no list of what to build: it advertises an intent carrying `build`, is dispatched the replicas that intent is owed, and reports each outcome as that replica's restore report. -It is the only actor that needs to understand what the schema contains. - -An **operator** declares which groups are covered, reads the currency of each, and asks for the builds the derivation does not produce on its own. - -Canopy owns which group owes a schema for which version, the database that build is entitled to, the artefact that results, and the grading of what a group actually runs. -The builder owns how the schema is produced. - -The builder holds the database rather than asking another actor for one, so no database is handed between actors and a build is dispatched, credentialled, and reported over the paths every other replica already uses. -What runs the build inside that consumer is the consumer's own business: the contract is the same whether it builds the schema itself or drives something else that does. - -## What a build is entitled to - -A build requires a database at the version being built for, carrying the group's own configuration. -Neither half stands alone: the right version without the configuration produces only the part of the schema every group on that version shares, and the configuration at the wrong version produces a schema for the version the group is leaving. - -The configuration a build reads is a group's configured surveys, the screens that compose them, the data elements they collect, and the price lists and insurance plans its invoicing reports pivot into columns. -It is held centrally and synced down, so a schema is built from a central server, and a group holding more than one owes a schema for each, since each carries its own configuration. -It is reference data, present in every copy of the database and carrying no patient content, so nothing a build reads is what a de-identified source exists to protect. - -A build's source is not de-identified, because the product's masking manifest masks columns the configuration is read through, and a masked visibility flag or reference identifier produces a schema that is wrong in ways a successful build does not show: the configuration it could not read is absent from the result rather than reported as missing. -Redaction protects a replica someone is given, and a build's replica is given to nobody: it serves one run and is torn down. - -A build has inputs the database does not hold: the group's own model definitions, and the formatting and language settings its reports are built with. -Those are the builder's to keep and Canopy neither supplies nor records them, so the database a build is entitled to is one necessary input rather than the whole of one. - -Canopy holds the requirement rather than the database, and already has the means to satisfy it: a managed restore replica, restored from a recent snapshot and migrated to the version being built for, meets both halves (see [RST](restore-replicas.md)). -The requirement is the version and the configuration rather than the group's data, so a database freshly migrated to that version meets it too where the configuration is supplied to the build on its own, and a build satisfied that way is the same build and settles the same pair. - -A restore that carries a build is its own intent rather than a second purpose bolted onto the one that tests migrations. -The two restore the same snapshot separately, as a verifying intent and a migrating one already do, because their outcomes are independent: a version whose migrations fail against a group's data has no schema to build, and a build that fails says nothing about whether the version is safe to take. -Restoring twice is affordable precisely because neither replica is kept: each exists for one run and is torn down. - -Such a replica is held up for the length of the build rather than discarded as soon as it is healthy, since the build reads it after the migrations land. -It is not offered to operators while it stands, being a database at a version its group is not yet running. - -## What is owed - -Canopy derives what is owed rather than an operator naming each pair of central server and version, and an operator may request a pair the derivation does not reach. - -A central server owes a reporting schema for the version its group's open plan moves it to. -A schema that does not exist by the time an upgrade lands is an outage of every report the group has. -The plan is also what has a replica restored and migrated to that version at all, so what is owed and what can be produced arrive together. +A **schema builder** produces a reporting schema from a database Canopy has restored and migrated for it, and publishes the result. +It is a restore consumer (see [RST](restore-replicas.md)): a build operates on a replica, so the builder is dispatched, credentialled, and reports over the replica pathways and authorisations, and it advertises an intent carrying `reporting-schema`. +How the builder produces a schema is the builder's own. -Nothing is derived for the version a group already runs, because that version was a candidate once and its schema was built then. -The steady state therefore needs no trigger of its own. +An **operator** declares which groups have a builder, reads which schema each server runs, and asks for the builds the derivation does not produce. -Only a published version is buildable, for the same reason it is testable: a version's schema reaches a builder as its published artefacts, and an unpublished version has none to fetch. +A **Tamanu server's device** fetches the schema Canopy offers its server and applies it (see [DID](device-identity.md)). -Only a group with an enabled build replica declared for it is owed anything, so a group whose reports are maintained elsewhere accrues no findings for a schema nobody wants. -That declaration is what covers a group rather than a coverage of its own beside it: it already names the group, expands over the group's servers, is enabled or disabled, and is audited (see [RST](restore-replicas.md)). -Only a central server draws an entry from it, since a schema is built from the configuration a central server holds. +Canopy owns which pairs exist, the replica a build is given, the artifact that results, and offering it to the group's servers. -A build is owed once per pair of central server and version, and is settled by a successful build for that pair. -A pair is reinstated when the version's own artefacts change, since a schema built from a superseded release of the same version is not the schema that version now describes. -A change to a group's configuration does not reinstate a settled pair: the schema a group has is the one it asked for when it was built, and refreshing it is an operator's decision. +## Pairs -An operator may ask for a build of the version a central server currently runs, which owes that pair and is satisfied like any other. -It is what a group whose version predates the pipeline gets its first schema from, what rebuilds one after a configuration change, and what clears a server graded behind with nothing else owed. -The version a request names is one its server already runs, so the replica reaches it with no migrations to apply. +A reporting schema is unique per pair of group and Tamanu version, and Canopy holds zero or one per pair. +The pairs are, for each group covered by an enabled declaration of a `reporting-schema` intent, each version a Tamanu server of the group reports running and the version its open plan moves it to (see [UPG](../private-server/upgrade-plans.md)). +That declaration is what covers a group: it names the group, is enabled or disabled, and is audited (see [RST](restore-replicas.md)). +Only a published version is in a pair, since a version's migrations reach a builder as its published artifacts (see [ART](../platform/artifacts.md)) and an unpublished one has none. -## Dispatch +A pair with no schema is built, and a pair with one is settled. +A settled pair is built again when the version's own artifacts change, since a schema built from a superseded release of the version is not the schema that version describes, and when an operator asks for it. +An operator asking for a pair's build is how a schema is refreshed after the group's configuration changes. +A rebuilt pair's schema replaces the one it held. +A failed build settles the pair as well, since a build against a fixed version and configuration fails the same way every time, and the pair is built again on the same two events. -A build is dispatched as a restore replica rather than through a worklist of its own (see [RST](restore-replicas.md)). -An intent carrying `build` contributes an entry per unsettled pair among the covered groups' central servers, naming: +## The build contract -- the **group** the schema is for, and the **central server** whose configuration it is built from; -- the **version** to build for, which the replica is migrated to before the build reads it. +Canopy dispatches a build to the builder as a restore replica, through the worklist every replica is dispatched through (see [RST](restore-replicas.md)). +The entry names the group and the Tamanu version the schema is for, and a central server of the group whose snapshot the replica is restored from, since the configuration a schema follows from is held centrally. +It carries what any replica's entry carries: the snapshot to restore, the repo coordinates, and the intent's parameter values. +The replica is migrated to the named version before the build reads it, and is not de-identified, since masking alters the configuration a schema follows from. -The snapshot to restore, the repo coordinates, and the intent's parameter values are the ones every replica's entry carries, and credentials are obtained per run as they are for any other restore. -Entries are the latest state rather than a queue to drain, and a builder converges on them over time. +The builder obtains read credentials for the restore per run as any consumer does, and a short-lived credential that writes to the group's artifact prefix and reaches nothing in the backup repo (see [RST](restore-replicas.md), [ART](../platform/artifacts.md)). -## What a build publishes +In the run it reports, the builder registers the **reporting schema** as an artifact of the exact version being built for, scoped to the group, of type `reporting-schema` on platform `any`, resting in the group's storage and carrying a digest (see [ART](../platform/artifacts.md)). +It may register further artifacts beside the schema for the same version and group, under types of its choosing, which Canopy offers as it offers any artifact. +The builder is authorised to register artifacts for a group its enabled `reporting-schema` declaration covers and for no other, and is the one device other than a releaser that registers artifacts (see [ART](../platform/artifacts.md)). -A build publishes what it produced as artefacts of the version it built for, scoped to the group it built from (see [ART](../platform/artifacts.md)): the **reporting schema** itself, the **report definitions** that read from it, the **documentation** describing its views, and the **analytics metadata** derived beside them. -Canopy records each and interprets only the schema, which is the one a server applies and the one currency is graded on. -Each is registered under the type a server or operator asks for it by, `reporting-schema`, `report-definitions`, `reporting-docs`, and `analytics-metadata`, on the platform `any`, since none of them is specific to one. -Each names the build report that produced it, which carries the snapshot it was built from, the server whose configuration it read, and when (see [What a build reports](#what-a-build-reports)). - -Publishing is part of the build rather than a step taken afterwards: the builder publishes and registers what it produced in the run it reports, so a schema that exists is one a server can already fetch. - -An artefact is published for the exact version it was built for and never for a range of versions. -A schema follows from the migrations a version applies, so one built against a patch is not the schema another patch of the same minor describes, and a range would offer the fleet a schema for a version it does not run. - -A reporting schema names a group, so two groups on the same version have two of them and neither stands in for the other. -The version half on its own is the exception: a schema built from no group's configuration belongs to the version alone and is published unscoped, with the version's other artefacts rather than derived per group, so it exists for every version whether any group is covered or not. -It is what a group with no schema of its own is offered, and a group's own schema takes precedence over it, carrying that group's configuration as well as the version's shape. - -A group-scoped artefact is published into the group's own object storage, under a prefix distinct from that group's backup repo, over a short-lived credential Canopy issues the builder for the run (see [BAK](backup.md)). -A schema derived from a group's configuration therefore rests in that group's storage, and the credential is what confines a build to writing its own group's artefacts. -Canopy records where each artefact is and holds no copy of the file. - -A group's schema for a version supersedes any earlier one for the same pair, and the earlier ones remain addressable, since a group that has not applied the newest is running an older one and its currency has to be gradeable against something. -Each carries a digest, so which of them a server has applied is a fact rather than an inference from a version string. +A schema is published for the exact version and never for a range, since it follows from the migrations that version applies, and one built against a patch is not the schema another patch of the same minor describes. ## What a build reports -A build reports as its replica's restore report, which already names the group, the server, the snapshot it restored, and when it was observed. +A build reports as its replica's restore report (see [RST](restore-replicas.md)), which names the group, the server, the snapshot restored, and when it was observed. Beyond those it carries: - the **version** it was built for; -- the **outcome** — built, or failed — and, on failure, a description of what went wrong; -- a reference to each **artefact** the build published, of which the schema is one; -- **how much of the configuration was covered**: the number of configured surveys, price lists, and insurance plans the schema addresses, and the number it could not. - -The restore's health and the build's outcome stay separate signals from the one report, as a migration test's already do: a healthy replica whose build failed reports a healthy restore and a failed build. +- the **outcome**, built or failed, and on failure a description of what went wrong; +- a reference to each **artifact** the build registered, of which the schema is one. +The restore's health and the build's outcome stay separate signals from the one report, as a migration test's do: a healthy replica whose build failed reports a healthy restore and a failed build. Reports are retained indefinitely as an audit trail. -## Applying - -A server's device applies the newest schema Canopy offers the server for the version it runs, with no operator moving a file: it obtains the artefact over its own credential, applies it, and stamps the schema with what it applied (see [DID](device-identity.md)). -The device does this beside the running product, which neither fetches a schema nor restarts for one, so a rebuild lands on a server that stays up. - -A facility server is offered its group's schema as a central server is, since a schema follows the group and the version rather than the server it was built from, and a facility runs the reports the same views serve. -Where a facility's database is the one its central server uses, it is already applied and the facility stamps nothing of its own. -The device compares the stamp with the offered artefact each time it reports on its server, applies when they differ, and does nothing when they match, so an upgrade that emptied the schema is repaired by the device itself. -A schema is applied by replacing it whole in one transaction, so a report running while it lands sees the schema it started under or the new one, and never a half-built one. +## The offering contract -The device applies the schema as the product's own database role and grants the reporting role its privileges in the same transaction, so a schema arriving after the product started is readable as soon as it lands, with no grant to run after it. +Canopy offers a Tamanu server's device the schema for the pair of its group and the version the server reports running, resolved as any group-scoped artifact is (see [ART](../platform/artifacts.md)). +The device's credential carries its server's group, so it is offered its own group's schema and can fetch no other's. +A facility server is offered the same schema as the central servers of its group, since a schema follows the group and the version rather than the server it was built from. -A device that cannot obtain or apply one leaves what the server has and reports that, which grades the server behind or unknown rather than silently current. - -## Currency - -Canopy grades the reporting schema of every server that reports one, as current, behind, or unknown. -It is current when the artefact the server reports having applied is the newest Canopy offers it for the version it reports running, behind when it is an earlier artefact or one built for another version, and unknown when the server reports no schema at all. - -The schema a server is running is reported by the server as the artefact it applied, alongside the other facts its sources report about it (see [STA](statuses.md)). -Canopy does not read it out of the database, so a server that reports nothing is unknown rather than assumed bare, and a schema applied by hand carries no artefact to report. -A server that reports only the version its schema was built for, as one applied by hand does, is graded behind where Canopy offers it a schema, since what it can offer is not what the server applied, and otherwise on the version alone: current when it is the version the server runs, and behind when it is not. - -Currency is presented per group, so whether a group's reports are running against the right schema is answered in one place. +The device compares what its server runs with what it is offered, applies the offered schema where they differ, and reports the result as a check on its server through the status contract (see [STA](statuses.md)), which is graded, presented, and alerted as any source's check is (see [CHK](../monitoring/checks.md)). ## Alerting -A server whose schema is behind raises a reporting-schema check on itself, as does a central server owed a build nothing has produced (see [CHK](../monitoring/checks.md)). -Both leave its reports mismatched to the version it runs, and neither carries a time bound of its own: the plan that made a version a candidate already carries the date it is wanted by. - -The check is a warning rather than a failure, and does not escalate: the servers are up and their reports return rows, and a schema written for the wrong version is for whoever maintains the reports rather than whoever is on call. - -A failed build raises the same check with its failure description, and settles the pair rather than retrying, since a build against a fixed version and configuration fails the same way every time. +A failed build raises a reporting-schema check on the group's central server, carrying the failure description (see [CHK](../monitoring/checks.md)). +The check is a warning rather than a failure, and does not escalate: the server is up and its reports return rows, and a schema that cannot be built for the version its group is moving to is for whoever maintains the reports rather than whoever is on call. A replica that failed to restore or come up is the restore's own health rather than a build failure, and is dispatched again as any unhealthy restore is. -A settled pair is dispatched again by an operator asking for a build, which is what these checks exist to prompt. - -## Out of scope +The check recovers when the pair is built, and an operator asking for the build is what clears it. -- What a reporting schema contains, and what each view in it means. -- How a build is run, where it runs, and what it costs. -- What the report definitions, documentation, and analytics metadata a build publishes contain, and what reads them. -- The mechanics of applying a schema to a database, and the change control around doing so. -- Deciding when a group upgrades: an owed schema informs that decision without making it. +Pairs are presented per group, showing which have a schema, which are being built, and which failed, so whether a group's servers can be offered the schema for the version they run or are moving to is answered in one place. diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index b8f18807d..2022d6dae 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -96,11 +96,11 @@ The recognised semantics are: An intent whose result depends on more than the snapshot keys `once` to that wider input as well, and may treat a failure as settled rather than retryable (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). - **url** — the intent's health report carries a link to the running replica within its attached health data, which Canopy surfaces to operators. - **migrate** — the intent applies a Tamanu version's schema migrations to the replica it restores. - Canopy names a target version on each of the intent's worklist entries and withholds an entry from a server it has no version to name: the server's candidate version, or for an intent carrying `build`, the version that build is owed for. + Canopy names a target version on each of the intent's worklist entries and withholds an entry from a server that has no candidate version. `once` for such an intent is keyed to the snapshot and the target version together (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). -- **build** — the intent produces a reporting schema from the replica it restores and publishes it (see [RPT](reporting-schemas.md)). - It carries `migrate` alongside, since a schema is built at a version the replica has to be brought to, and Canopy dispatches it for the pairs of central server and version a covered group is owed a schema for rather than for the server's candidate. - `once` for such an intent is keyed to the server and the version rather than the snapshot, so a newer snapshot does not rebuild a schema the group already has, and a failed build settles that pair. +- **reporting-schema** — the intent builds a Tamanu reporting schema from the replica it restores and registers it as an artefact (see [RPT](reporting-schemas.md)). + It carries `migrate` alongside, and its entries name the version of the pair of group and Tamanu version being built for, on a central server of the group, rather than the server's candidate. + `once` for such an intent is keyed to the group and the version rather than the snapshot, so a newer snapshot does not rebuild a schema the pair already has, and a failed build settles the pair. A settled pair is reinstated when the version's artefacts change or an operator asks for the build (see [RPT](reporting-schemas.md)). - **redact** — the intent can de-identify the restored data before serving it. Canopy offers redaction as an option on each of the intent's replicas, supplies the masking manifest for the product being restored, and holds a redacting replica to the outcome of its redaction (see [Redaction](#redaction)). @@ -189,7 +189,7 @@ Canopy verifies the caller has an enabled declaration covering that `(group, typ - the repo password. The credentials permit reading the repo and nothing else; they cannot write, overwrite, or delete. -A consumer whose intent carries `build` additionally obtains, for the same `(group, type)`, a short-lived credential that writes to the group's artefact prefix and reaches nothing in the repo, which is what its builds publish over (see [RPT](reporting-schemas.md)). +A consumer whose intent carries `reporting-schema` additionally obtains, for the same `(group, type)`, a short-lived credential that writes to the group's artefact prefix and reaches nothing in the repo, over which it publishes what it builds (see [RPT](reporting-schemas.md)). The repo stays read-only to every consumer: the artefact prefix lies outside it, and a credential for one cannot touch the other. Each issuance is audited. A consumer may include an optional run correlation identifier with a credential request; Canopy records it on the issuance so the run is tied to its later health report. @@ -258,16 +258,14 @@ That window is where the answer is still cheap: the fleet is not moving yet, and ### Dispatching a migration test -`migrate` is a semantic an intent opts into, and an intent carrying it neither verifies backups nor serves a queryable replica. -It carries `check` alongside, so a single restore reports the replica's health and the migrations' outcome as two signals from one report. +An intent carrying `migrate` carries `check` alongside, so a single restore reports the replica's health and the migrations' outcome as two signals from one report. -An intent carrying `migrate` is withheld from a server it has no version to name. +An intent carrying `migrate` is withheld from a server with no candidate version. An intent that verifies backups therefore does not also migrate: it would go undispatched for every server without a candidate, leaving the backups of any non-Tamanu product, and of every deployment with no plan open, unverified. An intent that keeps a replica queryable does not migrate either: a migrated replica sits at a version its deployment is not running, so a declaration promoted to it would give an operator a schema that does not match production. A verifying intent and a migrating intent restore the same snapshot separately. A verifying intent restores once per snapshot, and a migrating intent's `once` is keyed to the snapshot and target version together, so it restores when a new candidate version appears rather than on every snapshot. -An intent carrying `build` restores separately again, keyed to the pair it is owed rather than to the snapshot, so a schema is built when one is owed and not otherwise (see [RPT](reporting-schemas.md)). An entry for a `migrate` intent names the target version alongside the snapshot. A consumer obtains that version's migrations from its published artefacts, the same way a server being upgraded does, so naming the version is the whole reference it needs. From e3716dbcb72b1159d884b5ac040f3b6e3941ac15 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:02:59 +1200 Subject: [PATCH 020/130] canopy holds group artifacts --- .workhorse/specs/platform/artifacts.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 1c3a93d52..7eb507444 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -5,7 +5,7 @@ id: ART # Version artifacts An artifact is a file published for a version: an installer, a package, a set of migrations, a manifest. -Canopy is the index of them: it holds where each one is, what it is, and whom it is for, and a server or the infrastructure acting on a version's behalf learns from Canopy which files a version has and fetches them from where they rest. +Canopy holds what each one is and whom it is for, and a server or the infrastructure acting on a version's behalf learns from Canopy which files a version has and fetches each from where it rests. An artifact may be for one group alone, and Canopy offers it only to a caller whose group it is. ## What an artifact belongs to @@ -22,14 +22,15 @@ A group scope exists because some artifacts are derived from a group's own data ## Where an artifact rests -An artifact belonging to no group rests at a URL, which whoever is offered the artifact fetches directly. +An artifact belonging to no group rests at a location Canopy records and does not hold, which whoever is offered the artifact reads for itself. -A group-scoped artifact rests as an object in its own group's storage, under a prefix of its own apart from the group's backup repo, and a registration placing one anywhere else is refused. -Canopy reads it on a caller's behalf by assuming the group's storage role confined to reading that prefix (see [BAK](../public-server/backup.md)), and streams the bytes to the caller, so the file rests only in the group's storage and is readable only through Canopy. -The boundary is therefore enforced on the read rather than resting on a location being hard to guess. +A group-scoped artifact is carried to Canopy by the registration that publishes it, and Canopy holds it. +A publisher sends the bytes on the connection it registers over and is issued no credential to any store, so being authorised to register for a group is the whole of what publishing into it takes. +Canopy holds such an artifact in storage of its own, apart from any group's backup repo, so an artifact carries the retention, access, and cost basis of an artifact rather than those a backup repo is kept under (see [BAK](../public-server/backup.md)). +Where Canopy puts them is its own, and no caller addresses them there. -Canopy issues a publisher short-lived credentials for writing into a group's artifact prefix the way it issues backup credentials: by assuming the group's storage role under a session policy confined to that prefix, recorded before they are returned, and only to a caller authorised to register artifacts for that group (see [Registration](#registration)). -The prefix is apart from the backup repo so that a credential which writes artifacts reaches no backup. +Canopy serves the bytes only to a caller the artifact is offered to. +The boundary is therefore enforced on the read rather than resting on a location being hard to guess. ## What a version offers @@ -54,14 +55,13 @@ Canopy passes a group-scoped artifact's bytes only to a caller it is offered to. ## Registration -A registration names the version or range, the type, the platform, the location, and the group where the artifact has one. +A registration names the version or range, the type, the platform, and the group where the artifact has one, and carries either the location of an unscoped artifact or the bytes of a group-scoped one. The group is named on the registration rather than inferred from the caller. A releaser device registers unscoped artifacts, and carries no authorisation for any group. An operator registers either. A component that produces a group-scoped artifact registers it for that group under an authorisation defined with that artifact, and is authorised for no other. -A registration naming a group the caller is not authorised for is refused. -Credentials for writing into a group's storage are issued on the same authorisation, so a caller that may not register for a group cannot publish into it either. +A registration naming a group the caller is not authorised for is refused, which is the only gate publishing into a group has to pass. Canopy records which device registered an artifact and, where the registration names one, the run that produced it, so an artifact that arrived by automation is distinguishable from one entered by hand and traceable to what made it. From b08c2871c48b4a481c51c8e0000d687f37ae76d8 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:03:35 +1200 Subject: [PATCH 021/130] publish through canopy --- .workhorse/specs/public-server/reporting-schemas.md | 4 ++-- .workhorse/specs/public-server/restore-replicas.md | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 43558032b..e52f24b6a 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -47,9 +47,9 @@ The entry names the group and the Tamanu version the schema is for, and a centra It carries what any replica's entry carries: the snapshot to restore, the repo coordinates, and the intent's parameter values. The replica is migrated to the named version before the build reads it, and is not de-identified, since masking alters the configuration a schema follows from. -The builder obtains read credentials for the restore per run as any consumer does, and a short-lived credential that writes to the group's artifact prefix and reaches nothing in the backup repo (see [RST](restore-replicas.md), [ART](../platform/artifacts.md)). +The builder obtains read credentials for the restore per run as any consumer does, and no storage credential of any kind for what it publishes (see [RST](restore-replicas.md)). -In the run it reports, the builder registers the **reporting schema** as an artifact of the exact version being built for, scoped to the group, of type `reporting-schema` on platform `any`, resting in the group's storage and carrying a digest (see [ART](../platform/artifacts.md)). +In the run it reports, the builder registers the **reporting schema** as an artifact of the exact version being built for, scoped to the group, of type `reporting-schema` on platform `any`, carrying a digest and the bytes themselves, which Canopy holds and serves (see [ART](../platform/artifacts.md)). It may register further artifacts beside the schema for the same version and group, under types of its choosing, which Canopy offers as it offers any artifact. The builder is authorised to register artifacts for a group its enabled `reporting-schema` declaration covers and for no other, and is the one device other than a releaser that registers artifacts (see [ART](../platform/artifacts.md)). diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index 2022d6dae..552b28ebc 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -43,7 +43,7 @@ A restore consumer authenticates as a single device holding the `backup-restore` The role is generic: any future restore consumer uses the same role with its own declared replicas. A `backup-restore` device has no implicit server and no implicit group; it is not a member of any group it reads. -The role is read-only against the repo by contract, enforced at the API: +The role is read-only by contract, enforced at the API: - A `backup-restore` caller requesting backup (write) credentials is rejected. The read-only guarantee is server-enforced, so a compromised consumer cannot pivot to writing or poisoning a repo. @@ -189,8 +189,7 @@ Canopy verifies the caller has an enabled declaration covering that `(group, typ - the repo password. The credentials permit reading the repo and nothing else; they cannot write, overwrite, or delete. -A consumer whose intent carries `reporting-schema` additionally obtains, for the same `(group, type)`, a short-lived credential that writes to the group's artefact prefix and reaches nothing in the repo, over which it publishes what it builds (see [RPT](reporting-schemas.md)). -The repo stays read-only to every consumer: the artefact prefix lies outside it, and a credential for one cannot touch the other. +A consumer that publishes what it produces does so through Canopy over the connection it already holds, and is issued no storage credential for it (see [RPT](reporting-schemas.md)), so the only object storage a consumer is ever given reach into is a repo it may read. Each issuance is audited. A consumer may include an optional run correlation identifier with a credential request; Canopy records it on the issuance so the run is tied to its later health report. Absence of a covering declaration is a definitive refusal, not a transient error, and a consumer surfaces it as a clear failure for the operator to diagnose by inspecting the declaration in Canopy. From a1432082798aef6f55398dc5c51bd73874f4c637 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:09:05 +1200 Subject: [PATCH 022/130] artifact lifetime and digests --- .workhorse/specs/platform/artifacts.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 7eb507444..2e6f558bc 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -28,6 +28,7 @@ A group-scoped artifact is carried to Canopy by the registration that publishes A publisher sends the bytes on the connection it registers over and is issued no credential to any store, so being authorised to register for a group is the whole of what publishing into it takes. Canopy holds such an artifact in storage of its own, apart from any group's backup repo, so an artifact carries the retention, access, and cost basis of an artifact rather than those a backup repo is kept under (see [BAK](../public-server/backup.md)). Where Canopy puts them is its own, and no caller addresses them there. +Canopy holds an artifact's bytes for as long as that artifact is registered, and keeps none of what it has stopped serving. Canopy serves the bytes only to a caller the artifact is offered to. The boundary is therefore enforced on the read rather than resting on a location being hard to guess. @@ -38,7 +39,7 @@ Canopy offers a caller one artifact per type and platform, chosen from the artif Where several match, the most specific is offered. An artifact scoped to the caller's group is more specific than one belonging to no group. Among artifacts of the same scope, an exact-version artifact is more specific than any range artifact, and between two ranges the narrower is more specific. -A group-scoped artifact and an unscoped one of the same type and platform are therefore both held, each group is offered the one for it, and no caller is offered both. +A group-scoped artifact and an unscoped one of the same type and platform are therefore both recorded, each group is offered the one for it, and no caller is offered both. A pattern Canopy cannot parse matches nothing rather than everything, so a malformed range withholds a file instead of offering it to the whole fleet. The full set, including the artifacts specificity passed over, is available to operators. @@ -62,10 +63,12 @@ A releaser device registers unscoped artifacts, and carries no authorisation for An operator registers either. A component that produces a group-scoped artifact registers it for that group under an authorisation defined with that artifact, and is authorised for no other. A registration naming a group the caller is not authorised for is refused, which is the only gate publishing into a group has to pass. +A registration replaces whatever is already registered for the same version or range, type, platform, and group, so a rebuilt artifact is published exactly as a first one is and a caller is never offered two of a kind. Canopy records which device registered an artifact and, where the registration names one, the run that produced it, so an artifact that arrived by automation is distinguishable from one entered by hand and traceable to what made it. ## Digests -An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always, since its bytes reach a caller through Canopy and are verified on the way. -Where Canopy passes an artifact through and holds a digest for it, it verifies the bytes against that digest and refuses them on a mismatch, so a file replaced at its published location fails the read rather than reaching a server as the artifact it is not. +An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always. +Canopy verifies the bytes it holds against that digest as it serves them and refuses them on a mismatch, so a corrupted artifact fails the read rather than reaching a server as the artifact it is not. +An unscoped artifact is read from its location by the caller rather than by Canopy, so its digest is what that caller checks what it fetched against, and an artifact registered without one is fetched unchecked. From 9e16ab597176078bd3d5d1725027adeecb77cd32 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:41:03 +1200 Subject: [PATCH 023/130] settle artifact spec review --- .workhorse/specs/platform/artifacts.md | 6 +++++- .workhorse/specs/public-server/reporting-schemas.md | 4 ++-- .workhorse/specs/public-server/restore-replicas.md | 12 ++++++------ crates/private-server/src/fns/statuses.rs | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 2e6f558bc..135bf7475 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -33,6 +33,9 @@ Canopy holds an artifact's bytes for as long as that artifact is registered, and Canopy serves the bytes only to a caller the artifact is offered to. The boundary is therefore enforced on the read rather than resting on a location being hard to guess. +An artifact Canopy holds and an artifact Canopy records a location for are one thing to whoever is offered it. +It is offered one artifact per type and platform, and where the bytes rest is not part of what it is offered. + ## What a version offers Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known. @@ -70,5 +73,6 @@ Canopy records which device registered an artifact and, where the registration n ## Digests An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always. -Canopy verifies the bytes it holds against that digest as it serves them and refuses them on a mismatch, so a corrupted artifact fails the read rather than reaching a server as the artifact it is not. +Canopy verifies a group-scoped artifact's bytes against its digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again. +It verifies them again as it serves them and refuses them on a mismatch, so an artifact corrupted after it was taken in fails the read rather than reaching a server as the artifact it is not. An unscoped artifact is read from its location by the caller rather than by Canopy, so its digest is what that caller checks what it fetched against, and an artifact registered without one is fetched unchecked. diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index e52f24b6a..8a6f8427f 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -13,7 +13,7 @@ Canopy holds zero or one reporting schema per pair of group and Tamanu version, Part of a reporting schema follows from the Tamanu version's database schema and is the same for every group on that version. The rest follows from the group's own configuration, which only a database carrying that configuration can supply. A schema for a pair is therefore built from a database of that group at that version, and ahead of an upgrade no such database exists, since the group's servers run the version they are leaving. -Canopy restores a group's backups into replicas and migrates them to a version (see [RST](restore-replicas.md)), so it is where such a database is produced, and it knows the version each group runs and the one it is moving to (see [APP](../servers/products.md), [UPG](../private-server/upgrade-plans.md)), so it is where the pairs are known. +Canopy restores a group's backups into replicas and migrates them to a version (see [RST](restore-replicas.md)), so it is where such a database is produced, and it knows the version each group runs and the one it is moving to (see [APP](../servers/application-types.md), [UPG](../private-server/upgrade-plans.md)), so it is where the pairs are known. ## Actors @@ -23,7 +23,7 @@ How the builder produces a schema is the builder's own. An **operator** declares which groups have a builder, reads which schema each server runs, and asks for the builds the derivation does not produce. -A **Tamanu server's device** fetches the schema Canopy offers its server and applies it (see [DID](device-identity.md)). +A **Tamanu server's device** fetches the schema Canopy offers its server and applies it (see [DID](machine-identity.md)). Canopy owns which pairs exist, the replica a build is given, the artifact that results, and offering it to the group's servers. diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index 3085dd5d9..0cbc2e2cb 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -98,10 +98,10 @@ The recognised semantics are: - **migrate** — the intent applies a Tamanu version's schema migrations to the replica it restores. Canopy names a target version on each of the intent's worklist entries and withholds an entry where no application on the machine has a candidate version. `once` for such an intent is keyed to the snapshot and the target version together (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). -- **reporting-schema** — the intent builds a Tamanu reporting schema from the replica it restores and registers it as an artefact (see [RPT](reporting-schemas.md)). +- **reporting-schema** — the intent builds a Tamanu reporting schema from the replica it restores and registers it as an artifact (see [RPT](reporting-schemas.md)). It carries `migrate` alongside, and its entries name the version of the pair of group and Tamanu version being built for, on a central server of the group, rather than the server's candidate. `once` for such an intent is keyed to the group and the version rather than the snapshot, so a newer snapshot does not rebuild a schema the pair already has, and a failed build settles the pair. - A settled pair is reinstated when the version's artefacts change or an operator asks for the build (see [RPT](reporting-schemas.md)). + A settled pair is reinstated when the version's artifacts change or an operator asks for the build (see [RPT](reporting-schemas.md)). - **redact** — the intent can de-identify the restored data before serving it. Canopy offers redaction as an option on each of the intent's replicas, supplies the masking manifest for the product being restored, and holds a redacting replica to the outcome of its redaction (see [Redaction](#redaction)). @@ -250,7 +250,7 @@ One candidate, not one per version along the path. Migrations are applied to the restored snapshot in sequence, so a run targeting the planned version applies every migration between the snapshot's version and that one, and exercises the whole chain an upgrade would. Where a chain does break, the failing migration named in the report identifies the step without a second run. -Only a published version is a candidate, because a version's migrations reach a consumer as its published artefacts, and an unpublished version has none to fetch. +Only a published version is a candidate, because a version's migrations reach a consumer as its published artifacts, and an unpublished version has none to fetch. Publication is what makes a version testable and what makes it reachable by an application, so the two arrive together. Only an application running Tamanu has candidates, because the migrations under test are Tamanu's and no other type has an upgrade path through them. @@ -272,7 +272,7 @@ A verifying intent restores once per snapshot, and a migrating intent's `once` i An entry for a `migrate` intent names the target version alongside the snapshot, and the application whose candidate it is. A report echoes that application back, so the finding lands on the workload the version belongs to rather than being re-derived; a consumer that does not send it has it resolved from the machine and the version. -A consumer obtains that version's migrations from its published artefacts, the same way an application being upgraded does, so naming the version is the whole reference it needs. +A consumer obtains that version's migrations from its published artifacts, the same way an application being upgraded does, so naming the version is the whole reference it needs. A machine none of whose applications has a candidate version contributes no entry, whatever its declaration says. There is nothing to migrate to, and an entry naming no version would ask a consumer to restore a database for no reason. @@ -347,7 +347,7 @@ The consumer resolves the version against the data it restored — the version o A redacting declaration contributes no worklist entry for a machine none of whose applications has a type with a manifest, and each such application surfaces as a gap on the declaration. A replica that cannot be redacted is not restored at all: an unredacted replica standing in for a redacted one is worse than no replica. -Canopy corroborates a product's manifest template against the published artefacts it already holds per version. +Canopy corroborates a product's manifest template against the published artifacts it already holds per version. A redacting declaration covering an application whose version has no published manifest is a gap, surfaced before a restore is attempted rather than discovered when one fails. The manifest is resolved through the applications on the machine being restored, since what to mask is a property of the product in the snapshot rather than of the box that took it. @@ -417,7 +417,7 @@ That is for whoever gave out the replica to act on, not for whoever is on call f - How a consumer provisions, runs, names, or tears down a replica, or how it applies migrations or a masking manifest to one. - A consumer's runtime placement, storage sizing, or scheduling. -- Producing reporting schemas, or any other artefact, from a migrated replica (see [RPT](reporting-schemas.md)). +- Producing reporting schemas, or any other artifact, from a migrated replica (see [RPT](reporting-schemas.md)). - The contents of a masking manifest, and what each masking it names does to a value. - Deciding or scheduling when a group upgrades: verdicts inform that decision without making it. - Scoping object-storage credentials below the granularity of a group's repo: one repo holds all of a group's machines' snapshots, so credentials are necessarily group-wide while targeting and reporting are per-machine. diff --git a/crates/private-server/src/fns/statuses.rs b/crates/private-server/src/fns/statuses.rs index 00558ef0a..292382f09 100644 --- a/crates/private-server/src/fns/statuses.rs +++ b/crates/private-server/src/fns/statuses.rs @@ -681,7 +681,7 @@ pub struct StatusSnapshotData { /// Reported system timezone. pub timezone: Option, /// Version the server's reporting schema was built for. Absent until a - /// server runs a schema that stamps one (spec: RPT#currency). + /// server runs a schema that stamps one (spec: RPT#the-offering-contract). pub reporting_schema: Option, /// Additional unstructured data reported alongside the snapshot, keyed /// by source (`{ [source]: { …fields } }`) so a multi-source snapshot's From 68aebaa8420384999faeffdd7fad0b844fa9c6e8 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:41:42 +1200 Subject: [PATCH 024/130] scope artifacts to groups --- ERRORS.md | 8 + crates/commons-errors/src/lib.rs | 14 + crates/commons-servers/src/device_auth/mod.rs | 27 ++ crates/database/src/artifacts.rs | 340 ++++++++++-------- crates/database/src/restore.rs | 9 +- crates/database/src/schema.rs | 8 +- crates/database/src/server_groups.rs | 15 + crates/private-server/src/fns/versions.rs | 118 +++++- crates/public-server/src/artifacts.rs | 175 ++++++--- crates/public-server/src/versions.rs | 93 ++++- .../down.sql | 20 ++ .../up.sql | 48 +++ 12 files changed, 648 insertions(+), 227 deletions(-) create mode 100644 migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql create mode 100644 migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql diff --git a/ERRORS.md b/ERRORS.md index 4410fa97e..b5483282f 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -37,6 +37,14 @@ Issued when a database resource (such as a version, server, or other entity) can Issued when a version range is valid, but does not match any of the available versions. +## Artifact not found + +Issued when the version has no artifact with the given id that the caller is offered. An artifact scoped to a group the caller is not in is reported the same way as one that does not exist, so which groups hold an artifact cannot be discovered through this endpoint. + +## Artifact digest mismatch + +Issued when the bytes Canopy holds for an artifact do not match the digest recorded for it. The artifact is not served: a corrupted artifact fails the read rather than reaching a server as the artifact it is not. + ## Unusable range Issued when a version range is syntactically valid, but not usable to obtain concrete versions. diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index 6d769415d..9b921460a 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -66,6 +66,12 @@ pub enum AppError { #[error("no versions match given range")] NoMatchingVersions, + #[error("no such artifact for this version")] + ArtifactNotFound, + + #[error("artifact does not match its digest")] + ArtifactDigestMismatch, + #[error("version range is not usable")] UnusableRange, @@ -249,6 +255,12 @@ impl AppError { match self { Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, Self::NoMatchingVersions => StatusCode::NOT_FOUND, + // An artifact a caller is not offered is missing in exactly the + // way one that never existed is, so which groups hold an artifact + // is not enumerable through this endpoint. + // spec: ART#who-is-offered-a-group-scoped-artifact + Self::ArtifactNotFound => StatusCode::NOT_FOUND, + Self::ArtifactDigestMismatch => StatusCode::INTERNAL_SERVER_ERROR, Self::UnusableRange => StatusCode::BAD_REQUEST, // Both arise purely from what a client sent: a version segment in // a URL path, or the `X-Version` header. Nothing on the server is @@ -322,6 +334,8 @@ impl AppError { Self::Tera(_) => "render", Self::Io(_) => "io", Self::NoMatchingVersions => "no-matching-versions", + Self::ArtifactNotFound => "artifact-not-found", + Self::ArtifactDigestMismatch => "artifact-digest-mismatch", Self::UnusableRange => "unusable-range", Self::Timesync(_) => "timesync", Self::AuthMissingHeader(_) => "auth-missing-header", diff --git a/crates/commons-servers/src/device_auth/mod.rs b/crates/commons-servers/src/device_auth/mod.rs index 6b789da59..445fa1824 100644 --- a/crates/commons-servers/src/device_auth/mod.rs +++ b/crates/commons-servers/src/device_auth/mod.rs @@ -88,6 +88,33 @@ device_role_struct!(ServerDevice, DeviceRole::Machine); device_role_struct!(ReleaserDevice, DeviceRole::Releaser); device_role_struct!(BackupRestoreDevice, DeviceRole::BackupRestore); +/// A read that is open to everyone but answers a device for its own group +/// takes `Option`: absent identity is not a refusal, it just +/// narrows what the caller is offered. +impl axum::extract::OptionalFromRequestParts for AuthDevice +where + Db: FromRef, + Option: FromRef, + mtls::ClientCertHeader: FromRef, + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result, Self::Rejection> { + match >::from_request_parts(parts, state).await { + Ok(device) => Ok(Some(device)), + // A caller that presented nothing is anonymous. One that presented + // something Canopy could not place is anonymous too: refusing here + // would turn a stale certificate into a hard failure on a path that + // serves everyone. + Err(_) => Ok(None), + } + } +} + impl axum::extract::FromRequestParts for AuthDevice where Db: FromRef, diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 56ad863c6..b3350d969 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -1,16 +1,41 @@ use commons_errors::{AppError, Result}; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::versions::Version; +/// Which artifacts a read may see. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// The unscoped artifacts alone: a read carrying no identity, or one whose + /// caller has no group of its own. + Unscoped, + /// The unscoped artifacts plus the named group's. + Group(Uuid), + /// Every artifact, whatever group it belongs to. Operator views only. + Fleet, +} + +impl Scope { + /// What a caller resolving to this group may see. + pub fn for_caller(group: Option) -> Self { + match group { + Some(group) => Self::Group(group), + None => Self::Unscoped, + } + } +} + /// A downloadable artifact belonging to a release version: an installer, /// package, or other file published for a given type and platform. -#[derive( - Debug, Clone, Serialize, Deserialize, Queryable, Selectable, Associations, utoipa::ToSchema, -)] +/// +/// The bytes of a group-scoped artifact are not loaded here; they are large, +/// and every listing would carry them. Read them with [`Artifact::content_for`]. +#[derive(Debug, Clone, Deserialize, Queryable, Selectable, Associations)] #[diesel(belongs_to(Version))] #[diesel(table_name = crate::schema::artifacts)] #[diesel(check_for_backend(diesel::pg::Pg))] @@ -25,8 +50,9 @@ pub struct Artifact { pub artifact_type: String, /// The platform the artifact targets (e.g. an OS or architecture name). pub platform: String, - /// URL the artifact can be downloaded from. - pub download_url: String, + /// URL the artifact can be downloaded from. `null` for a group-scoped + /// artifact, whose bytes Canopy holds instead. + pub download_url: Option, /// The device that registered this artifact, if it was registered by a /// releaser device rather than created by an operator. pub device_id: Option, @@ -34,6 +60,16 @@ pub struct Artifact { /// shared across a range of versions rather than pinned to one. `null` /// for exact-version artifacts. pub version_range_pattern: Option, + /// The group this artifact is for. `null` for an artifact that is for + /// every group. + pub group_id: Option, + /// Media type of the bytes Canopy holds, where the registration named one. + pub content_type: Option, + /// Algorithm-prefixed digest of the artifact's bytes, e.g. + /// `sha256:2cf24dba…`. Always set for a group-scoped artifact. + pub digest: Option, + /// The run that produced this artifact, where the registration named one. + pub run_id: Option, } #[derive(Debug, Deserialize, Insertable)] @@ -44,55 +80,38 @@ pub struct NewArtifact { pub version_id: Option, pub artifact_type: String, pub platform: String, - pub download_url: String, + pub download_url: Option, pub device_id: Option, pub version_range_pattern: Option, + pub group_id: Option, + pub content: Option>, + pub content_type: Option, + pub digest: Option, + pub run_id: Option, +} + +/// The bytes Canopy holds for a group-scoped artifact. +pub struct ArtifactContent { + pub bytes: Vec, + pub content_type: Option, + pub digest: String, +} + +/// The digest Canopy records and verifies bytes against. +pub fn digest_of(bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) } impl Artifact { + /// The artifacts of a version that `scope` may see, one per type and + /// platform, most specific first. + // spec: ART#what-a-version-offers pub async fn get_for_version( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, ) -> Result> { - use crate::schema::artifacts::*; - - // First, get the version from the database to extract semver - let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let semver = version.as_semver(); - - // Query all artifacts (both exact match and range-based) - let mut artifacts: Vec = table - .select(Self::as_select()) - .filter( - version_id - .eq(Some(target_version_id)) - .or(version_range_pattern.is_not_null()), - ) - .order_by(artifact_type.asc()) - .then_order_by(platform.asc()) - .load(db) - .await - .map_err(AppError::from)?; - - // Filter out range artifacts that don't match the version - artifacts.retain(|artifact| { - if artifact.version_id == Some(target_version_id) { - // Exact match, always keep - true - } else if let Some(pattern) = &artifact.version_range_pattern { - // Range match, check if version satisfies the pattern - match node_semver::Range::parse(pattern) { - Ok(range) => range.satisfies(&semver), - Err(_) => false, // Invalid pattern, skip this artifact - } - } else { - // Should not happen due to DB constraint, but be safe - false - } - }); - - // Sort by specificity to handle conflicts - Self::sort_by_specificity(&mut artifacts); + let mut artifacts = Self::matching(db, target_version_id, scope).await?; // Keep the first (most specific) artifact per platform+artifact_type. // Not `dedup_by_key`: that only drops *consecutive* duplicates, and the @@ -106,51 +125,67 @@ impl Artifact { Ok(artifacts) } - /// Get all artifacts for a version including duplicates (by platform+artifact_type). - /// This is for private/admin views where you want to see all matching artifacts. - /// Does not deduplicate - useful for understanding what's actually configured. + /// Every artifact of a version that `scope` may see, including the ones + /// specificity passed over. For operator views. + // spec: ART#what-a-version-offers pub async fn get_for_version_all_matches( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, + ) -> Result> { + Self::matching(db, target_version_id, scope).await + } + + /// Artifacts of a version visible to `scope`, sorted most specific first + /// and not deduplicated. + async fn matching( + db: &mut AsyncPgConnection, + target_version_id: Uuid, + scope: Scope, ) -> Result> { use crate::schema::artifacts::*; - // First, get the version from the database to extract semver let version = crate::versions::Version::get_by_id(db, target_version_id).await?; let semver = version.as_semver(); - // Query all artifacts (both exact match and range-based) - let mut artifacts: Vec = table + let mut query = table .select(Self::as_select()) .filter( version_id .eq(Some(target_version_id)) .or(version_range_pattern.is_not_null()), ) + .into_boxed(); + + query = match scope { + Scope::Unscoped => query.filter(group_id.is_null()), + Scope::Group(caller) => query.filter(group_id.is_null().or(group_id.eq(caller))), + Scope::Fleet => query, + }; + + let mut artifacts: Vec = query .order_by(artifact_type.asc()) .then_order_by(platform.asc()) .load(db) .await .map_err(AppError::from)?; - // Filter out range artifacts that don't match the version artifacts.retain(|artifact| { if artifact.version_id == Some(target_version_id) { - // Exact match, always keep true } else if let Some(pattern) = &artifact.version_range_pattern { - // Range match, check if version satisfies the pattern + // An unparseable pattern matches nothing rather than + // everything, so a malformed range withholds a file instead of + // offering it to the whole fleet. match node_semver::Range::parse(pattern) { Ok(range) => range.satisfies(&semver), - Err(_) => false, // Invalid pattern, skip this artifact + Err(_) => false, } } else { - // Should not happen due to DB constraint, but be safe false } }); - // Sort by specificity but DON'T deduplicate - we want to see all of them Self::sort_by_specificity(&mut artifacts); Ok(artifacts) @@ -158,11 +193,27 @@ impl Artifact { /// Sort artifacts by specificity, with most specific first. /// Priority: - /// 1. Exact version matches (version_id set) - /// 2. More specific ranges (range that allows_all of other matching ranges) - /// 3. When ranges are incomparable, use pattern specificity: ^ > ~ > .x > others + /// 1. Group-scoped artifacts over unscoped ones + /// 2. Exact version matches (version_id set) + /// 3. More specific ranges (range that allows_all of other matching ranges) + /// 4. When ranges are incomparable, use pattern specificity: ^ > ~ > .x > others + // spec: ART#what-a-version-offers fn sort_by_specificity(artifacts: &mut [Self]) { artifacts.sort_by(|a, b| { + // An artifact scoped to the caller's group is more specific than one + // belonging to no group. Only one group's artifacts are ever in + // play here, except under `Scope::Fleet`, which is never deduplicated. + let a_is_scoped = a.group_id.is_some(); + let b_is_scoped = b.group_id.is_some(); + + if a_is_scoped != b_is_scoped { + return if a_is_scoped { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + // Exact match always wins let a_is_exact = a.version_id.is_some(); let b_is_exact = b.version_id.is_some(); @@ -190,12 +241,6 @@ impl Artifact { if range_b.allows_all(&range_a) && !range_a.allows_all(&range_b) { return std::cmp::Ordering::Less; // a is more specific } - // Ranges are equal or incomparable - use pattern specificity as tiebreaker - if range_a.allows_all(&range_b) && range_b.allows_all(&range_a) { - // Ranges are equivalent, check pattern specificity - return Self::compare_pattern_specificity(pattern_a, pattern_b); - } - // Ranges are incomparable - use pattern specificity as tiebreaker return Self::compare_pattern_specificity(pattern_a, pattern_b); } @@ -222,12 +267,67 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } + /// The bytes Canopy holds for an artifact, where it holds any. + pub async fn content_for( + db: &mut AsyncPgConnection, + artifact_id: Uuid, + ) -> Result> { + use crate::schema::artifacts::dsl::*; + + let row: Option<(Option>, Option, Option)> = artifacts + .filter(id.eq(artifact_id)) + .select((content, content_type, digest)) + .first(db) + .await + .optional() + .map_err(AppError::from)?; + + Ok(match row { + Some((Some(bytes), media_type, Some(recorded))) => Some(ArtifactContent { + bytes, + content_type: media_type, + digest: recorded, + }), + _ => None, + }) + } + + /// Register an artifact, replacing whatever is already registered for the + /// same version or range, type, platform, and group. + // spec: ART#registration + pub async fn register(db: &mut AsyncPgConnection, input: NewArtifact) -> Result { + use crate::schema::artifacts::dsl::*; + + diesel::insert_into(artifacts) + .values(&input) + .on_conflict(( + artifact_type, + platform, + version_id, + version_range_pattern, + group_id, + )) + .do_update() + .set(( + download_url.eq(&input.download_url), + device_id.eq(input.device_id), + content.eq(&input.content), + content_type.eq(&input.content_type), + digest.eq(&input.digest), + run_id.eq(input.run_id), + )) + .returning(Self::as_select()) + .get_result(db) + .await + .map_err(AppError::from) + } + pub async fn update( db: &mut AsyncPgConnection, artifact_id: Uuid, new_type: String, new_platform: String, - new_url: String, + new_url: Option, ) -> Result<()> { use crate::schema::artifacts::dsl::*; @@ -243,32 +343,6 @@ impl Artifact { Ok(()) } - pub async fn create( - db: &mut AsyncPgConnection, - ver_id: Uuid, - art_type: String, - plat: String, - url: String, - ) -> Result { - use crate::schema::artifacts::dsl::*; - - let new_artifact = NewArtifact { - version_id: Some(ver_id), - artifact_type: art_type, - platform: plat, - download_url: url, - device_id: None, - version_range_pattern: None, - }; - - diesel::insert_into(artifacts) - .values(new_artifact) - .returning(Self::as_select()) - .get_result(db) - .await - .map_err(AppError::from) - } - pub async fn delete(db: &mut AsyncPgConnection, artifact_id: Uuid) -> Result<()> { use crate::schema::artifacts::dsl::*; @@ -285,42 +359,21 @@ impl Artifact { pub async fn get_for_version_with_metadata( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, ) -> Result> { - // First get the matching artifacts and the version details let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let matching_artifacts = Self::get_for_version(db, target_version_id).await?; + let matching_artifacts = Self::get_for_version(db, target_version_id, scope).await?; - // Get all artifacts in DB to check for overrides use crate::schema::artifacts::*; let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; let semver = version.as_semver(); - // For each matching artifact, determine if it's exact and if it has an override - // Since these are already deduplicated (from get_for_version), they're all used in public API let result = matching_artifacts .into_iter() .map(|a| { let is_exact = a.version_id == Some(target_version_id); - - // Check if there's a ranged artifact that also matches but is overridden by this exact one - let has_range_override = if is_exact { - all_artifacts.iter().any(|other| { - other.version_range_pattern.is_some() - && other.artifact_type == a.artifact_type - && other.platform == a.platform - && other.id != a.id && if let Some(pattern) = &other.version_range_pattern { - match node_semver::Range::parse(pattern) { - Ok(range) => range.satisfies(&semver), - Err(_) => false, - } - } else { - false - } - }) - } else { - false - }; + let has_range_override = Self::overridden_range(&all_artifacts, &a, &semver); (a, is_exact, has_range_override, true) // true = used in public API }) @@ -336,48 +389,26 @@ impl Artifact { pub async fn get_for_version_all_matches_with_metadata( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, ) -> Result> { - // Get all matching artifacts (no deduplication) and version details let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let matching_artifacts = Self::get_for_version_all_matches(db, target_version_id).await?; + let matching_artifacts = + Self::get_for_version_all_matches(db, target_version_id, scope).await?; - // Get the public API version (deduplicated) to know which ones are actually used - let public_api_artifacts = Self::get_for_version(db, target_version_id).await?; + let public_api_artifacts = Self::get_for_version(db, target_version_id, scope).await?; let public_api_ids: std::collections::HashSet = public_api_artifacts.iter().map(|a| a.id).collect(); - // Get all artifacts in DB to check for overrides use crate::schema::artifacts::*; let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; let semver = version.as_semver(); - // For each matching artifact, determine if it's exact, if it has an override, and if it's used in public API let result = matching_artifacts .into_iter() .map(|a| { let is_exact = a.version_id == Some(target_version_id); - - // Check if there's a ranged artifact that also matches but is overridden by this exact one - let has_range_override = if is_exact { - all_artifacts.iter().any(|other| { - other.version_range_pattern.is_some() - && other.artifact_type == a.artifact_type - && other.platform == a.platform - && other.id != a.id && if let Some(pattern) = &other.version_range_pattern { - match node_semver::Range::parse(pattern) { - Ok(range) => range.satisfies(&semver), - Err(_) => false, - } - } else { - false - } - }) - } else { - false - }; - - // Check if this artifact is actually used in the public API + let has_range_override = Self::overridden_range(&all_artifacts, &a, &semver); let is_used_in_public_api = public_api_ids.contains(&a.id); (a, is_exact, has_range_override, is_used_in_public_api) @@ -386,4 +417,23 @@ impl Artifact { Ok(result) } + + /// Whether an exact artifact displaces a range artifact that also matches. + fn overridden_range(all: &[Self], artifact: &Self, semver: &node_semver::Version) -> bool { + if artifact.version_id.is_none() { + return false; + } + + all.iter().any(|other| { + other.artifact_type == artifact.artifact_type + && other.platform == artifact.platform + && other.group_id == artifact.group_id + && other.id != artifact.id + && other + .version_range_pattern + .as_deref() + .and_then(|pattern| node_semver::Range::parse(pattern).ok()) + .is_some_and(|range| range.satisfies(semver)) + }) + } } diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 39b815415..81061d42b 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -526,10 +526,11 @@ pub async fn redaction_gap_for( ))); }; - let published = crate::artifacts::Artifact::get_for_version(db, version.id) - .await? - .into_iter() - .any(|a| a.artifact_type == manifest.artifact_type); + let published = + crate::artifacts::Artifact::get_for_version(db, version.id, crate::artifacts::Scope::Fleet) + .await? + .into_iter() + .any(|a| a.artifact_type == manifest.artifact_type); Ok((!published).then_some((RedactionGapReason::VersionHasNoManifest, Some(shown)))) } diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index 1c31b129f..1a69948c3 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -95,9 +95,14 @@ diesel::table! { version_id -> Nullable, artifact_type -> Text, platform -> Text, - download_url -> Text, + download_url -> Nullable, device_id -> Nullable, version_range_pattern -> Nullable, + group_id -> Nullable, + content -> Nullable, + content_type -> Nullable, + digest -> Nullable, + run_id -> Nullable, } } @@ -832,6 +837,7 @@ diesel::joinable!(application_names -> applications (application_id)); diesel::joinable!(application_reported_detail -> applications (application_id)); diesel::joinable!(applications -> machines (machine_id)); diesel::joinable!(artifacts -> devices (device_id)); +diesel::joinable!(artifacts -> server_groups (group_id)); diesel::joinable!(artifacts -> versions (version_id)); diesel::joinable!(backup_credential_issuances -> devices (device_id)); diesel::joinable!(backup_credential_issuances -> server_groups (group_id)); diff --git a/crates/database/src/server_groups.rs b/crates/database/src/server_groups.rs index 3284d62fe..8bc4bde33 100644 --- a/crates/database/src/server_groups.rs +++ b/crates/database/src/server_groups.rs @@ -169,6 +169,21 @@ impl ServerGroup { .map_err(AppError::from) } + /// Every group's name by id, including archived ones, so a reference to a + /// group can be shown by name whatever state the group is in. + pub async fn names_by_id( + db: &mut AsyncPgConnection, + ) -> Result> { + use crate::schema::server_groups::dsl; + Ok(dsl::server_groups + .select((dsl::id, dsl::name)) + .load::<(Uuid, String)>(db) + .await + .map_err(AppError::from)? + .into_iter() + .collect()) + } + pub async fn list_all(db: &mut AsyncPgConnection) -> Result> { use crate::schema::server_groups::dsl; dsl::server_groups diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index ac1a81749..4cbfd9b50 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -3,11 +3,17 @@ use std::str::FromStr; use axum::Json; use axum::extract::State; +use base64::{Engine as _, prelude::BASE64_STANDARD}; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::version::{VersionStatus, VersionStr}; -use database::{artifacts::Artifact, version_known_issues::VersionKnownIssue, versions::Version}; +use database::{ + artifacts::{Artifact, NewArtifact, Scope, digest_of}, + server_groups::ServerGroup, + version_known_issues::VersionKnownIssue, + versions::Version, +}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -15,6 +21,11 @@ use uuid::Uuid; use crate::state::AppState; +/// Cap on the bytes Canopy will hold for one artifact. A reporting schema is a +/// SQL file; anything approaching this is not one, and the rows live in +/// Postgres alongside everything else. +const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; + /// A single released (or draft) software version. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct VersionData { @@ -177,8 +188,17 @@ pub struct ArtifactData { pub artifact_type: String, /// Target platform this artifact is built for. pub platform: String, - /// URL clients use to download this artifact. - pub download_url: String, + /// URL clients use to download this artifact. `null` when Canopy holds + /// the bytes itself. + pub download_url: Option, + /// The group this artifact is for, when it is for one alone. + pub group_id: Option, + /// Name of that group, for display. + pub group_name: Option, + /// Algorithm-prefixed digest recorded for the artifact, where there is one. + pub digest: Option, + /// `true` when Canopy holds this artifact's bytes rather than a location. + pub canopy_holds_bytes: bool, /// `true` when this artifact is tied to the exact version being /// queried; `false` when it was matched via a version range pattern /// instead. @@ -428,8 +448,17 @@ pub async fn get_version_artifacts( let mut conn = state.db_read.get().await?; let version = VersionStr::from_str(&args.version)?; let version_record = Version::get_by_version(&mut conn, version).await?; - let artifacts_with_metadata = - Artifact::get_for_version_with_metadata(&mut conn, version_record.id).await?; + // The full set, including what specificity passed over and every group's, + // because what resolution hides is a fact about how a version was + // published and an operator has to be able to see it. + // spec: ART#what-a-version-offers + let artifacts_with_metadata = Artifact::get_for_version_all_matches_with_metadata( + &mut conn, + version_record.id, + Scope::Fleet, + ) + .await?; + let group_names = ServerGroup::names_by_id(&mut conn).await?; Ok(Json( artifacts_with_metadata .into_iter() @@ -438,7 +467,11 @@ pub async fn get_version_artifacts( id: a.id, artifact_type: a.artifact_type, platform: a.platform, + canopy_holds_bytes: a.download_url.is_none(), download_url: a.download_url, + group_name: a.group_id.and_then(|g| group_names.get(&g).cloned()), + group_id: a.group_id, + digest: a.digest, is_exact, version_range_pattern: a.version_range_pattern, has_range_override, @@ -545,8 +578,8 @@ pub struct UpdateArtifactArgs { pub artifact_type: String, /// New target platform. pub platform: String, - /// New download URL. - pub download_url: String, + /// New download URL. Leave unset for an artifact whose bytes Canopy holds. + pub download_url: Option, } /// Update an existing artifact's type, platform, and download URL. @@ -589,8 +622,14 @@ pub struct CreateArtifactArgs { pub artifact_type: String, /// Target platform. pub platform: String, - /// Download URL for the artifact. - pub download_url: String, + /// Download URL, for an artifact Canopy records a location for. + pub download_url: Option, + /// The group this artifact is for. Naming one makes Canopy hold the bytes. + pub group_id: Option, + /// The artifact's bytes, base64-encoded. Required when a group is named. + pub content_base64: Option, + /// Media type of those bytes. + pub content_type: Option, } /// Create a new artifact tied to an exact version. @@ -613,19 +652,70 @@ pub async fn create_artifact( Json(args): Json, ) -> Result> { let mut conn = state.db.get().await?; - let artifact = Artifact::create( + + // An artifact is either for a group, in which case Canopy holds its bytes, + // or for every group, in which case Canopy records where it rests. + // spec: ART#where-an-artifact-rests + let (content, digest) = match (&args.group_id, &args.content_base64) { + (Some(_), Some(encoded)) => { + let bytes = BASE64_STANDARD + .decode(encoded) + .map_err(|_| AppError::custom("content_base64 is not valid base64"))?; + if bytes.len() > MAX_HELD_ARTIFACT_BYTES { + return Err(AppError::custom(format!( + "artifact is larger than the {MAX_HELD_ARTIFACT_BYTES} byte limit" + ))); + } + let digest = digest_of(&bytes); + (Some(bytes), Some(digest)) + } + (Some(_), None) => { + return Err(AppError::custom( + "a group-scoped artifact must carry its bytes", + )); + } + (None, Some(_)) => { + return Err(AppError::custom( + "only a group-scoped artifact carries bytes", + )); + } + (None, None) => (None, None), + }; + + if args.group_id.is_none() && args.download_url.is_none() { + return Err(AppError::custom( + "an artifact needs a download URL or a group", + )); + } + + let artifact = Artifact::register( &mut conn, - args.version_id, - args.artifact_type, - args.platform, - args.download_url, + NewArtifact { + version_id: Some(args.version_id), + artifact_type: args.artifact_type, + platform: args.platform, + download_url: args.download_url, + device_id: None, + version_range_pattern: None, + group_id: args.group_id, + content, + content_type: args.content_type, + digest, + run_id: None, + }, ) .await?; + + let group_names = ServerGroup::names_by_id(&mut conn).await?; Ok(Json(ArtifactData { id: artifact.id, artifact_type: artifact.artifact_type, platform: artifact.platform, + canopy_holds_bytes: artifact.download_url.is_none(), download_url: artifact.download_url, + group_name: artifact.group_id.and_then(|g| group_names.get(&g).cloned()), + group_id: artifact.group_id, + digest: artifact.digest, is_exact: true, version_range_pattern: None, has_range_override: false, diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index aee31bab2..5f0150087 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -1,21 +1,100 @@ use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; -use commons_errors::{ProblemDetailsSchema, Result}; -use commons_servers::device_auth::ReleaserDevice; +use commons_errors::{AppError, ProblemDetailsSchema, Result}; +use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ Db, - artifacts::{Artifact, NewArtifact}, + artifacts::{Artifact as ArtifactRow, NewArtifact, Scope}, + machines::Machine, versions::{NewVersion, Version}, }; use diesel::SelectableHelper as _; use diesel_async::RunQueryDsl as _; +use serde::Serialize; +use uuid::Uuid; use crate::state::AppState; +/// An artifact as it is offered to a caller. +#[derive(Debug, Clone, Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct Artifact { + /// Unique identifier of the artifact. + pub id: Uuid, + /// The exact version this artifact belongs to. `null` for range + /// artifacts, which apply to every version matching + /// `version_range_pattern` instead. + pub version_id: Option, + /// What kind of artifact this is (e.g. an installer or package name). + pub artifact_type: String, + /// The platform the artifact targets (e.g. an OS or architecture name). + pub platform: String, + /// URL the artifact can be downloaded from. For an artifact whose bytes + /// Canopy holds, this is Canopy's own download endpoint for it. + pub download_url: String, + /// The device that registered this artifact, if it was registered by a + /// releaser device rather than created by an operator. + pub device_id: Option, + /// Semver range this artifact applies to (e.g. `^2.10.0`), for artifacts + /// shared across a range of versions rather than pinned to one. `null` + /// for exact-version artifacts. + pub version_range_pattern: Option, + /// The group this artifact is for. `null` for an artifact that is for + /// every group. + pub group_id: Option, + /// Algorithm-prefixed digest of the artifact's bytes, e.g. + /// `sha256:2cf24dba…`, where one was recorded. + pub digest: Option, +} + +impl Artifact { + /// Present a stored row to a caller it is offered to. + /// + /// An artifact Canopy holds has no location of its own, so it is offered + /// Canopy's download endpoint: whoever is offered an artifact is given one + /// URL to fetch it from, whichever of the two it turned out to be. + // spec: ART#where-an-artifact-rests + pub(crate) fn offered(row: ArtifactRow, base: &str, version: &str) -> Self { + let download_url = row + .download_url + .clone() + .unwrap_or_else(|| format!("{base}/versions/{version}/artifacts/{}/download", row.id)); + + Self { + id: row.id, + version_id: row.version_id, + artifact_type: row.artifact_type, + platform: row.platform, + download_url, + device_id: row.device_id, + version_range_pattern: row.version_range_pattern, + group_id: row.group_id, + digest: row.digest, + } + } +} + +/// What the authenticated caller may see. +/// +/// A caller's group is derived from its identity and never taken from the +/// request, and a caller with no identity, no machine, or no group is offered +/// the unscoped artifacts alone rather than refused. +// spec: ART#who-is-offered-a-group-scoped-artifact +pub(crate) async fn caller_scope( + conn: &mut database::diesel_async::AsyncPgConnection, + device: Option, +) -> Result { + let Some(device) = device else { + return Ok(Scope::Unscoped); + }; + + let machine = Machine::get_by_device_id(conn, device.0.id).await?; + Ok(Scope::for_caller(machine.and_then(|m| m.group_id))) +} + pub fn routes() -> OpenApiRouter { OpenApiRouter::new().routes(routes!(create)) } @@ -48,6 +127,7 @@ pub fn routes() -> OpenApiRouter { ("version" = String, Path, description = "Exact semver (e.g. `2.10.5`) or range pattern (e.g. `2.10.x`, `^2.10.0`)."), ("artifact_type" = String, Path), ("platform" = String, Path), + ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused."), ), request_body(content = String, description = "Download URL for the artifact, as a plain-text body."), responses( @@ -62,23 +142,34 @@ async fn create( device: ReleaserDevice, State(db): State, Path((version, artifact_type, platform)): Path<(String, String, String)>, + Query(scope): Query, + headers: axum::http::HeaderMap, url: String, ) -> Result> { use node_semver::{Range, Version as SemverVersion}; + // A releaser registers unscoped artifacts and carries no authorisation for + // any group, so the group-scoped path is not reachable from this endpoint + // at all rather than being refused per group. + // spec: ART#registration + if scope.group.is_some() { + return Err(AppError::AuthInsufficientPermissions { + required: "authorisation for the named group".into(), + }); + } + let mut db = db.get().await?; let device_id = device.0.0.id; - // Try to parse as a specific version first - if let Ok(semver) = SemverVersion::parse(&version) { - // It's a specific version (e.g., "1.0.5") + let (version_id, version_range_pattern) = if let Ok(semver) = SemverVersion::parse(&version) { let version_str = VersionStr(semver); - // Try to get the version, or create it as a draft if it doesn't exist + // The version an artifact names may not exist yet: it is created as a + // draft so the artifact has something to attach to, and publishing it + // stays a separate step. let version_id = match Version::get_by_version(&mut db, version_str.clone()).await { Ok(version) => version.id, Err(_) => { - // Version doesn't exist, create it as a draft let new_version = NewVersion { major: version_str.0.major as _, minor: version_str.0.minor as _, @@ -88,52 +179,46 @@ async fn create( device_id: Some(device_id), }; - let version = diesel::insert_into(database::schema::versions::table) + diesel::insert_into(database::schema::versions::table) .values(new_version) .returning(Version::as_select()) - .get_result(&mut db) - .await?; - - version.id + .get_result::(&mut db) + .await? + .id } }; - let input = NewArtifact { - version_id: Some(version_id), - platform, - artifact_type, - download_url: url, - device_id: Some(device_id), - version_range_pattern: None, - }; - - let artifact = diesel::insert_into(database::schema::artifacts::table) - .values(input) - .returning(Artifact::as_select()) - .get_result(&mut db) - .await?; - - Ok(Json(artifact)) + (Some(version_id), None) } else { - // Try to parse as a range (e.g., "1.0.x", "^1.0.0") - Range::parse(&version) - .map_err(|_| commons_errors::AppError::custom("Invalid version or version range"))?; + Range::parse(&version).map_err(|_| AppError::custom("Invalid version or version range"))?; + + (None, Some(version.clone())) + }; - let input = NewArtifact { - version_id: None, + let row = ArtifactRow::register( + &mut db, + NewArtifact { + version_id, platform, artifact_type, - download_url: url, + download_url: Some(url), device_id: Some(device_id), - version_range_pattern: Some(version), - }; + version_range_pattern, + group_id: None, + content: None, + content_type: None, + digest: None, + run_id: None, + }, + ) + .await?; - let artifact = diesel::insert_into(database::schema::artifacts::table) - .values(input) - .returning(Artifact::as_select()) - .get_result(&mut db) - .await?; + let base = crate::versions::public_base_url(&headers); + Ok(Json(Artifact::offered(row, &base, &version))) +} - Ok(Json(artifact)) - } +/// The group a registration names, where it names one. +#[derive(Debug, serde::Deserialize)] +struct RegisterScope { + group: Option, } diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 3dbc9961c..3301f550c 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -8,17 +8,17 @@ use axum::{ Json, body::{Body, Bytes}, extract::{Path, State}, - http::header, + http::{StatusCode, header}, response::IntoResponse, routing::{Router, get}, }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; -use commons_servers::device_auth::{AdminDevice, ReleaserDevice}; +use commons_servers::device_auth::{AdminDevice, AuthDevice, ReleaserDevice}; use commons_types::version::{VersionRange, VersionStr}; use database::{ Db, - artifacts::Artifact, + artifacts::{Artifact as ArtifactRow, Scope}, version_known_issues::VersionKnownIssue, versions::{NewVersion, Version, ViewVersion}, }; @@ -36,7 +36,10 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "ui")] use tera::{Context, Tera}; -use crate::state::AppState; +use crate::{ + artifacts::{Artifact, caller_scope}, + state::AppState, +}; /// Drop versions that any known issue's range still covers. The public /// site never serves these — the admin UI shows them, but clients only @@ -170,12 +173,11 @@ async fn list(State(db): State) -> Result>> { Ok(Json(versions)) } -/// Base URL for absolute links in the feed. Prefers the configured -/// `PUBLIC_URL`; otherwise reconstructs the origin from the request's -/// forwarded scheme and `Host` header so local and test runs still emit -/// well-formed links. -#[cfg(feature = "ui")] -fn feed_base_url(headers: &axum::http::HeaderMap) -> String { +/// Base URL for absolute links Canopy emits about itself. Prefers the +/// configured `PUBLIC_URL`; otherwise reconstructs the origin from the +/// request's forwarded scheme and `Host` header so local and test runs still +/// emit well-formed links. +pub(crate) fn public_base_url(headers: &axum::http::HeaderMap) -> String { if let Ok(url) = std::env::var("PUBLIC_URL") { let trimmed = url.trim_end_matches('/'); if !trimmed.is_empty() { @@ -211,7 +213,7 @@ async fn releases_rss( let versions = Version::get_all(&mut db).await?; let versions = filter_ready(&mut db, versions).await?; - let base = feed_base_url(&headers); + let base = public_base_url(&headers); let items: Vec = versions .into_iter() @@ -379,6 +381,7 @@ async fn view_artifacts( Path(version): Path, State(db): State, State(tera): State>, + headers: axum::http::HeaderMap, ) -> Result> { use commons_types::version::VersionStatus; use diesel::QueryDsl; @@ -405,7 +408,7 @@ async fn view_artifacts( let version = VersionRange::from_str(&version)?; let mut version = latest_matching_ready(&mut db, version.0).await?; version.changelog = parse_markdown(&version.changelog); - let artifacts = Artifact::get_for_version(&mut db, version.id).await?; + let artifacts = offered_artifacts(&mut db, &version, Scope::Unscoped, &headers).await?; // The latest *ready* version in this minor. The page's own version came // through `latest_matching_ready`, so the banner has to use the same set: @@ -504,15 +507,36 @@ async fn view_artifacts( ), )] async fn list_artifacts( + device: Option, Path(version): Path, State(db): State, + headers: axum::http::HeaderMap, ) -> Result>> { let mut db = db.get().await?; let version = VersionRange::from_str(&version)?; let version = latest_matching_ready(&mut db, version.0).await?; - let artifacts = Artifact::get_for_version(&mut db, version.id).await?; + let scope = caller_scope(&mut db, device).await?; - Ok(Json(artifacts)) + Ok(Json( + offered_artifacts(&mut db, &version, scope, &headers).await?, + )) +} + +/// The artifacts of a version as `scope` is offered them. +async fn offered_artifacts( + db: &mut AsyncPgConnection, + version: &Version, + scope: Scope, + headers: &axum::http::HeaderMap, +) -> Result> { + let base = public_base_url(headers); + let shown = version.as_semver().to_string(); + + Ok(ArtifactRow::get_for_version(db, version.id, scope) + .await? + .into_iter() + .map(|row| Artifact::offered(row, &base, &shown)) + .collect()) } #[cfg(feature = "ui")] @@ -520,11 +544,12 @@ async fn view_mobile_install( Path(version): Path, State(db): State, State(tera): State>, + headers: axum::http::HeaderMap, ) -> Result> { let mut db = db.get().await?; let version = VersionRange::from_str(&version)?; let version = latest_matching_ready(&mut db, version.0).await?; - let artifacts = Artifact::get_for_version(&mut db, version.id) + let artifacts = offered_artifacts(&mut db, &version, Scope::Unscoped, &headers) .await? .into_iter() .filter(|a| a.artifact_type == "mobile") @@ -614,6 +639,7 @@ async fn update_for( } async fn download_artifact( + device: Option, State(db): State, Path((version, artifact_id)): Path<(String, String)>, ) -> Result { @@ -622,21 +648,52 @@ async fn download_artifact( let mut db = db.get().await?; let version = VersionRange::from_str(&version)?; let version = latest_matching_ready(&mut db, version.0).await?; + let scope = caller_scope(&mut db, device).await?; let artifact_uuid = Uuid::parse_str(&artifact_id).map_err(|_| AppError::custom("Invalid artifact ID"))?; - let artifacts = Artifact::get_for_version(&mut db, version.id).await?; + // Resolution is what enforces the boundary: an artifact scoped to a group + // this caller is not offered is simply not in the set, so it is missing in + // exactly the way an artifact that never existed is. + // spec: ART#who-is-offered-a-group-scoped-artifact + let artifacts = ArtifactRow::get_for_version(&mut db, version.id, scope).await?; let artifact = artifacts .into_iter() .find(|a| a.id == artifact_uuid) - .ok_or_else(|| AppError::custom("Artifact not found for this version"))?; + .ok_or(AppError::ArtifactNotFound)?; + + if let Some(held) = ArtifactRow::content_for(&mut db, artifact.id).await? { + let recomputed = database::artifacts::digest_of(&held.bytes); + if recomputed != held.digest { + tracing::error!( + artifact = %artifact.id, + "held artifact does not match its digest; refusing to serve" + ); + return Err(AppError::ArtifactDigestMismatch); + } + + let content_type = held + .content_type + .unwrap_or_else(|| "application/octet-stream".to_owned()); + + return Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + Body::from(held.bytes), + ) + .into_response()); + } + + let Some(download_url) = artifact.download_url else { + return Err(AppError::ArtifactNotFound); + }; let client = reqwest::Client::builder() .build() .map_err(|err| AppError::custom(format!("failed to build HTTP client: {err}")))?; let response = client - .get(&artifact.download_url) + .get(&download_url) .send() .await .map_err(|err| AppError::custom(format!("Failed to download artifact: {err}")))?; diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql new file mode 100644 index 000000000..7a36d2619 --- /dev/null +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql @@ -0,0 +1,20 @@ +DROP INDEX artifacts_identity; + +DELETE FROM artifacts WHERE group_id IS NOT NULL; + +ALTER TABLE artifacts ADD CONSTRAINT artifacts_type_platform_version_id UNIQUE ( + artifact_type, platform, version_id +); + +DROP INDEX artifacts_group_id; + +ALTER TABLE artifacts DROP CONSTRAINT artifact_rests_by_scope; + +ALTER TABLE artifacts + DROP COLUMN group_id, + DROP COLUMN content, + DROP COLUMN content_type, + DROP COLUMN digest, + DROP COLUMN run_id; + +ALTER TABLE artifacts ALTER COLUMN download_url SET NOT NULL; diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql new file mode 100644 index 000000000..1faff4bdb --- /dev/null +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql @@ -0,0 +1,48 @@ +-- ── An artifact may belong to a group ─────────────────────────────────────── +-- +-- An artifact belonging to no group is for every group. One that belongs to a +-- group is for that group alone, and Canopy holds its bytes rather than a +-- location, so the boundary is enforced on the read instead of resting on a +-- location being hard to guess. + +ALTER TABLE artifacts + ADD COLUMN group_id UUID REFERENCES server_groups(id) ON DELETE CASCADE, + ADD COLUMN content BYTEA, + ADD COLUMN content_type TEXT, + ADD COLUMN digest TEXT, + ADD COLUMN run_id UUID; + +ALTER TABLE artifacts ALTER COLUMN download_url DROP NOT NULL; + +-- An unscoped artifact rests at a location Canopy records and does not hold; a +-- group-scoped one rests in Canopy and always carries a digest, which the read +-- verifies the bytes against. +ALTER TABLE artifacts ADD CONSTRAINT artifact_rests_by_scope CHECK ( + (group_id IS NULL + AND download_url IS NOT NULL + AND content IS NULL + AND content_type IS NULL) + OR + (group_id IS NOT NULL + AND download_url IS NULL + AND content IS NOT NULL + AND digest IS NOT NULL) +); + +CREATE INDEX artifacts_group_id ON artifacts (group_id); + +-- ── Identity ──────────────────────────────────────────────────────────────── +-- +-- A registration replaces whatever is already registered for the same version +-- or range, type, platform, and group, so that tuple has to be a key to upsert +-- on. The old constraint keyed on version_id alone, which left range artifacts +-- with no uniqueness at all: version_id is NULL for every one of them, and the +-- default treatment of NULL makes those rows all distinct from each other. +-- NULLS NOT DISTINCT is what lets one index cover the exact and range shapes +-- and the grouped and ungrouped ones together. + +ALTER TABLE artifacts DROP CONSTRAINT artifacts_type_platform_version_id; + +CREATE UNIQUE INDEX artifacts_identity + ON artifacts (artifact_type, platform, version_id, version_range_pattern, group_id) + NULLS NOT DISTINCT; From 87435a33022728073eb2198aca4a78460559c9bd Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:41:42 +1200 Subject: [PATCH 025/130] test artifact scoping --- crates/database/tests/it/artifact_scopes.rs | 251 ++++++++++++++++++++ crates/database/tests/it/main.rs | 1 + 2 files changed, 252 insertions(+) create mode 100644 crates/database/tests/it/artifact_scopes.rs diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs new file mode 100644 index 000000000..0b72182db --- /dev/null +++ b/crates/database/tests/it/artifact_scopes.rs @@ -0,0 +1,251 @@ +//! DB-layer tests for group-scoped artifact resolution (`database::artifacts`). +//! +//! spec: ART + +use commons_tests::db::TestDb; +use database::{ + artifacts::{Artifact, NewArtifact, Scope, digest_of}, + diesel_async::AsyncPgConnection, +}; +use diesel_async::SimpleAsyncConnection; +use uuid::Uuid; + +async fn seed_version(conn: &mut AsyncPgConnection, major: i32, minor: i32, patch: i32) -> Uuid { + conn.batch_execute(&format!( + "INSERT INTO versions (major, minor, patch, changelog, status) \ + VALUES ({major}, {minor}, {patch}, '', 'published')" + )) + .await + .expect("seed version"); + + let version = database::versions::Version::get_by_version( + conn, + commons_types::version::VersionStr(node_semver::Version { + major: major as u64, + minor: minor as u64, + patch: patch as u64, + build: vec![], + pre_release: vec![], + }), + ) + .await + .expect("read back version"); + version.id +} + +async fn seed_group(conn: &mut AsyncPgConnection, name: &str) -> Uuid { + conn.batch_execute(&format!( + "INSERT INTO server_groups (name) VALUES ('{name}')" + )) + .await + .expect("seed group"); + + let groups = database::server_groups::ServerGroup::list_all(conn) + .await + .expect("list groups"); + groups + .into_iter() + .find(|g| g.name == name) + .expect("group is there") + .id +} + +fn unscoped(version_id: Uuid, artifact_type: &str, url: &str) -> NewArtifact { + NewArtifact { + version_id: Some(version_id), + artifact_type: artifact_type.to_owned(), + platform: "any".to_owned(), + download_url: Some(url.to_owned()), + device_id: None, + version_range_pattern: None, + group_id: None, + content: None, + content_type: None, + digest: None, + run_id: None, + } +} + +fn held(version_id: Uuid, artifact_type: &str, group: Uuid, bytes: &[u8]) -> NewArtifact { + NewArtifact { + version_id: Some(version_id), + artifact_type: artifact_type.to_owned(), + platform: "any".to_owned(), + download_url: None, + device_id: None, + version_range_pattern: None, + group_id: Some(group), + content: Some(bytes.to_vec()), + content_type: Some("application/sql".to_owned()), + digest: Some(digest_of(bytes)), + run_id: None, + } +} + +/// A group-scoped artifact and an unscoped one of the same type and platform +/// are both recorded, each group is offered the one for it, and no caller is +/// offered both. +#[tokio::test(flavor = "multi_thread")] +async fn each_group_is_offered_its_own_and_never_both() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + let other = seed_group(&mut conn, "drifting").await; + + Artifact::register(&mut conn, unscoped(version, "reporting-schema", "https://x/y")) + .await + .expect("register unscoped"); + Artifact::register(&mut conn, held(version, "reporting-schema", theirs, b"theirs")) + .await + .expect("register held"); + + let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(theirs)) + .await + .expect("resolve for the owning group"); + assert_eq!(offered.len(), 1, "never offered both"); + assert_eq!(offered[0].group_id, Some(theirs)); + + // Another group's read reaches the unscoped one, not the first group's. + let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(other)) + .await + .expect("resolve for another group"); + assert_eq!(offered.len(), 1); + assert_eq!(offered[0].group_id, None); + + // A read carrying no identity is answered with the unscoped set alone. + let offered = Artifact::get_for_version(&mut conn, version, Scope::Unscoped) + .await + .expect("resolve anonymously"); + assert_eq!(offered.len(), 1); + assert_eq!(offered[0].group_id, None); + + // An operator sees what resolution passed over. + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 2); + }) + .await; +} + +/// A group-scoped artifact is more specific than an unscoped one, so it wins +/// even where the unscoped one is exact and it is only a range match. +#[tokio::test(flavor = "multi_thread")] +async fn group_scope_outranks_an_exact_unscoped_artifact() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + Artifact::register(&mut conn, unscoped(version, "installer", "https://x/exact")) + .await + .expect("register exact unscoped"); + + let mut ranged = held(version, "installer", theirs, b"ranged"); + ranged.version_id = None; + ranged.version_range_pattern = Some("2.60.x".to_owned()); + Artifact::register(&mut conn, ranged) + .await + .expect("register ranged held"); + + let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(theirs)) + .await + .expect("resolve"); + assert_eq!(offered.len(), 1); + assert_eq!( + offered[0].group_id, + Some(theirs), + "the group's range artifact beats an exact artifact for everyone" + ); + }) + .await; +} + +/// A registration replaces whatever is already registered for the same version, +/// type, platform and group, and the bytes it replaces do not survive. +#[tokio::test(flavor = "multi_thread")] +async fn registering_again_replaces_the_bytes_it_held() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + let first = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"first build"), + ) + .await + .expect("first registration"); + + let second = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"second build"), + ) + .await + .expect("second registration"); + + assert_eq!(first.id, second.id, "replaced in place, not duplicated"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1, "a caller is never offered two of a kind"); + + let content = Artifact::content_for(&mut conn, second.id) + .await + .expect("read content") + .expect("bytes are held"); + assert_eq!(content.bytes, b"second build"); + assert_eq!(content.digest, digest_of(b"second build")); + }) + .await; +} + +/// The same type and platform can be registered for one group and for another +/// without colliding, which the old version-only unique constraint could not +/// express. +#[tokio::test(flavor = "multi_thread")] +async fn two_groups_hold_their_own_of_the_same_kind() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let one = seed_group(&mut conn, "kamaka").await; + let two = seed_group(&mut conn, "drifting").await; + + Artifact::register(&mut conn, held(version, "reporting-schema", one, b"one")) + .await + .expect("first group"); + Artifact::register(&mut conn, held(version, "reporting-schema", two, b"two")) + .await + .expect("second group"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 2); + }) + .await; +} + +/// A range artifact registered twice replaces itself. Before the identity +/// index this could not hold: `version_id` is NULL for every range artifact, +/// and the default treatment of NULL made each row distinct from the last. +#[tokio::test(flavor = "multi_thread")] +async fn a_range_artifact_replaces_itself() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + for url in ["https://x/first", "https://x/second"] { + let mut ranged = unscoped(version, "installer", url); + ranged.version_id = None; + ranged.version_range_pattern = Some("2.60.x".to_owned()); + Artifact::register(&mut conn, ranged) + .await + .expect("register range artifact"); + } + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1, "the second registration replaced the first"); + assert_eq!(all[0].download_url.as_deref(), Some("https://x/second")); + }) + .await; +} diff --git a/crates/database/tests/it/main.rs b/crates/database/tests/it/main.rs index bbe539dae..bde25560a 100644 --- a/crates/database/tests/it/main.rs +++ b/crates/database/tests/it/main.rs @@ -7,6 +7,7 @@ mod admins; mod application_certificates; mod application_reported_key; mod application_types; +mod artifact_scopes; mod backfill_registered_at_migration; mod backup_detection; mod backups; From 96f0e79426e303b1903c0019dce36e9822db2068 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:43:39 +1200 Subject: [PATCH 026/130] regenerate api surface --- crates/canopy-api/src/generated.rs | 35 +++++++-- crates/database/tests/it/artifact_scopes.rs | 18 +++-- crates/public-server/openapi.json | 29 +++++++- crates/public-server/tests/it/versions.rs | 7 +- private-web/openapi.json | 79 +++++++++++++++++---- private-web/src/api-types.ts | 37 ++++++++-- 6 files changed, 170 insertions(+), 35 deletions(-) diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index ac814d081..ee34dc59d 100644 --- a/crates/canopy-api/src/generated.rs +++ b/crates/canopy-api/src/generated.rs @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.0"; /// BLAKE3 digest of that document, so a document that changed without the /// version moving with it can be told from one that did not. -pub const OPENAPI_BLAKE3: &str = "3bd62c232a727651fbe22f9a0be57d3f80eb1b48694f68d3f56db903df9276b4"; +pub const OPENAPI_BLAKE3: &str = "90101b6a8f49ebb4d7038e3153325fca10a048af43d65d3a5d45beea6a46dd28"; /// Error types. pub mod error { @@ -232,14 +232,13 @@ impl ::std::fmt::Display for ApplicationType { self.0.fmt(f) } } -/**A downloadable artifact belonging to a release version: an installer, -package, or other file published for a given type and platform.*/ +///An artifact as it is offered to a caller. /// ///
JSON schema /// /// ```json ///{ -/// "description": "A downloadable artifact belonging to a release version: an installer,\npackage, or other file published for a given type and platform.", +/// "description": "An artifact as it is offered to a caller.", /// "type": "object", /// "required": [ /// "artifact_type", @@ -260,10 +259,25 @@ package, or other file published for a given type and platform.*/ /// ], /// "format": "uuid" /// }, +/// "digest": { +/// "description": "Algorithm-prefixed digest of the artifact's bytes, e.g.\n`sha256:2cf24dba…`, where one was recorded.", +/// "type": [ +/// "string", +/// "null" +/// ] +/// }, /// "download_url": { -/// "description": "URL the artifact can be downloaded from.", +/// "description": "URL the artifact can be downloaded from. For an artifact whose bytes\nCanopy holds, this is Canopy's own download endpoint for it.", /// "type": "string" /// }, +/// "group_id": { +/// "description": "The group this artifact is for. `null` for an artifact that is for\nevery group.", +/// "type": [ +/// "string", +/// "null" +/// ], +/// "format": "uuid" +/// }, /// "id": { /// "description": "Unique identifier of the artifact.", /// "type": "string", @@ -302,8 +316,17 @@ pub struct Artifact { releaser device rather than created by an operator.*/ #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] pub device_id: ::std::option::Option<::uuid::Uuid>, - ///URL the artifact can be downloaded from. + /**Algorithm-prefixed digest of the artifact's bytes, e.g. +`sha256:2cf24dba…`, where one was recorded.*/ + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub digest: ::std::option::Option<::std::string::String>, + /**URL the artifact can be downloaded from. For an artifact whose bytes +Canopy holds, this is Canopy's own download endpoint for it.*/ pub download_url: ::std::string::String, + /**The group this artifact is for. `null` for an artifact that is for +every group.*/ + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub group_id: ::std::option::Option<::uuid::Uuid>, ///Unique identifier of the artifact. pub id: ::uuid::Uuid, ///The platform the artifact targets (e.g. an OS or architecture name). diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 0b72182db..27573ba84 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -92,12 +92,18 @@ async fn each_group_is_offered_its_own_and_never_both() { let theirs = seed_group(&mut conn, "kamaka").await; let other = seed_group(&mut conn, "drifting").await; - Artifact::register(&mut conn, unscoped(version, "reporting-schema", "https://x/y")) - .await - .expect("register unscoped"); - Artifact::register(&mut conn, held(version, "reporting-schema", theirs, b"theirs")) - .await - .expect("register held"); + Artifact::register( + &mut conn, + unscoped(version, "reporting-schema", "https://x/y"), + ) + .await + .expect("register unscoped"); + Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"theirs"), + ) + .await + .expect("register held"); let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(theirs)) .await diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index ec782067d..b72d1a1fd 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -138,6 +138,16 @@ "schema": { "type": "string" } + }, + { + "name": "group", + "in": "query", + "description": "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused.", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } } ], "requestBody": { @@ -1667,7 +1677,7 @@ }, "Artifact": { "type": "object", - "description": "A downloadable artifact belonging to a release version: an installer,\npackage, or other file published for a given type and platform.", + "description": "An artifact as it is offered to a caller.", "required": [ "id", "artifact_type", @@ -1687,9 +1697,24 @@ "format": "uuid", "description": "The device that registered this artifact, if it was registered by a\nreleaser device rather than created by an operator." }, + "digest": { + "type": [ + "string", + "null" + ], + "description": "Algorithm-prefixed digest of the artifact's bytes, e.g.\n`sha256:2cf24dba…`, where one was recorded." + }, "download_url": { "type": "string", - "description": "URL the artifact can be downloaded from." + "description": "URL the artifact can be downloaded from. For an artifact whose bytes\nCanopy holds, this is Canopy's own download endpoint for it." + }, + "group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The group this artifact is for. `null` for an artifact that is for\nevery group." }, "id": { "type": "string", diff --git a/crates/public-server/tests/it/versions.rs b/crates/public-server/tests/it/versions.rs index 14d999c6f..ffe6966c9 100644 --- a/crates/public-server/tests/it/versions.rs +++ b/crates/public-server/tests/it/versions.rs @@ -515,11 +515,14 @@ async fn artifact_download_proxy() { .await; response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); - // Nonexistent artifact + // Nonexistent artifact. 404 rather than an error, because an artifact + // scoped to a group the caller is not offered has to be answered the + // same way, and that answer must not be distinguishable. + // spec: ART#who-is-offered-a-group-scoped-artifact let response = public .get("/versions/1.2.3/artifacts/44444444-4444-4444-4444-444444444444/download") .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + response.assert_status(StatusCode::NOT_FOUND); // Nonexistent version let response = public diff --git a/private-web/openapi.json b/private-web/openapi.json index 6f98c7697..a1df7a53d 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -8641,7 +8641,7 @@ "id", "artifact_type", "platform", - "download_url", + "canopy_holds_bytes", "is_exact", "has_range_override", "is_used_in_public_api" @@ -8651,9 +8651,38 @@ "type": "string", "description": "Kind of artifact (for example, an installer or update package)." }, + "canopy_holds_bytes": { + "type": "boolean", + "description": "`true` when Canopy holds this artifact's bytes rather than a location." + }, + "digest": { + "type": [ + "string", + "null" + ], + "description": "Algorithm-prefixed digest recorded for the artifact, where there is one." + }, "download_url": { - "type": "string", - "description": "URL clients use to download this artifact." + "type": [ + "string", + "null" + ], + "description": "URL clients use to download this artifact. `null` when Canopy holds\nthe bytes itself." + }, + "group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The group this artifact is for, when it is for one alone." + }, + "group_name": { + "type": [ + "string", + "null" + ], + "description": "Name of that group, for display." }, "has_range_override": { "type": "boolean", @@ -9953,17 +9982,41 @@ "required": [ "version_id", "artifact_type", - "platform", - "download_url" + "platform" ], "properties": { "artifact_type": { "type": "string", "description": "Artifact type." }, + "content_base64": { + "type": [ + "string", + "null" + ], + "description": "The artifact's bytes, base64-encoded. Required when a group is named." + }, + "content_type": { + "type": [ + "string", + "null" + ], + "description": "Media type of those bytes." + }, "download_url": { - "type": "string", - "description": "Download URL for the artifact." + "type": [ + "string", + "null" + ], + "description": "Download URL, for an artifact Canopy records a location for." + }, + "group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The group this artifact is for. Naming one makes Canopy hold the bytes." }, "platform": { "type": "string", @@ -17270,7 +17323,7 @@ "string", "null" ], - "description": "Version the server's reporting schema was built for. Absent until a\nserver runs a schema that stamps one (spec: RPT#currency)." + "description": "Version the server's reporting schema was built for. Absent until a\nserver runs a schema that stamps one (spec: RPT#the-offering-contract)." }, "server_id": { "type": "string", @@ -17575,8 +17628,7 @@ "required": [ "artifact_id", "artifact_type", - "platform", - "download_url" + "platform" ], "properties": { "artifact_id": { @@ -17589,8 +17641,11 @@ "description": "New artifact type." }, "download_url": { - "type": "string", - "description": "New download URL." + "type": [ + "string", + "null" + ], + "description": "New download URL. Leave unset for an artifact whose bytes Canopy holds." }, "platform": { "type": "string", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 3d5ec21a0..1f99b3a37 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -4465,8 +4465,22 @@ export interface components { ArtifactData: { /** @description Kind of artifact (for example, an installer or update package). */ artifact_type: string; - /** @description URL clients use to download this artifact. */ - download_url: string; + /** @description `true` when Canopy holds this artifact's bytes rather than a location. */ + canopy_holds_bytes: boolean; + /** @description Algorithm-prefixed digest recorded for the artifact, where there is one. */ + digest?: string | null; + /** + * @description URL clients use to download this artifact. `null` when Canopy holds + * the bytes itself. + */ + download_url?: string | null; + /** + * Format: uuid + * @description The group this artifact is for, when it is for one alone. + */ + group_id?: string | null; + /** @description Name of that group, for display. */ + group_name?: string | null; /** * @description Only meaningful when `is_exact` is `true`: `true` when a * range-matched artifact of the same type and platform also matches @@ -5358,8 +5372,17 @@ export interface components { CreateArtifactArgs: { /** @description Artifact type. */ artifact_type: string; - /** @description Download URL for the artifact. */ - download_url: string; + /** @description The artifact's bytes, base64-encoded. Required when a group is named. */ + content_base64?: string | null; + /** @description Media type of those bytes. */ + content_type?: string | null; + /** @description Download URL, for an artifact Canopy records a location for. */ + download_url?: string | null; + /** + * Format: uuid + * @description The group this artifact is for. Naming one makes Canopy hold the bytes. + */ + group_id?: string | null; /** @description Target platform. */ platform: string; /** @@ -10058,7 +10081,7 @@ export interface components { postgres?: string | null; /** * @description Version the server's reporting schema was built for. Absent until a - * server runs a schema that stamps one (spec: RPT#currency). + * server runs a schema that stamps one (spec: RPT#the-offering-contract). */ reporting_schema?: string | null; /** @@ -10236,8 +10259,8 @@ export interface components { artifact_id: string; /** @description New artifact type. */ artifact_type: string; - /** @description New download URL. */ - download_url: string; + /** @description New download URL. Leave unset for an artifact whose bytes Canopy holds. */ + download_url?: string | null; /** @description New target platform. */ platform: string; }; From 5c432196b8538dba90ce2ca550fcb3e0031360da Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:47:59 +1200 Subject: [PATCH 027/130] show held artifacts to operators --- crates/private-server/tests/it/artifacts.rs | 28 +++++++++++++-------- private-web/src/routes/VersionDetail.tsx | 17 ++++++++++++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index d1baabfbe..1567b40d9 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -32,9 +32,10 @@ async fn artifact_multiple_ranges_pattern_specificity_private_endpoint() { .await .unwrap(); - // The private detail page calls the same deduplicated view that the - // public API serves: among multiple ranges that match a version, only - // the most specific one wins. + // The operator view shows every artifact that matches, including the + // ones specificity passed over, and marks which one is actually served. + // What resolution hides is a fact about how a version was published. + // spec: ART#what-a-version-offers let response = private .post("/api/versions/get_version_artifacts") .json(&serde_json::json!({"version": "2.44.5"})) @@ -43,19 +44,24 @@ async fn artifact_multiple_ranges_pattern_specificity_private_endpoint() { response.assert_status_ok(); let artifacts: Vec = response.json(); - assert_eq!( - artifacts.len(), - 1, - "deduplicated view should keep only the more specific range" - ); - let chosen = &artifacts[0]; + assert_eq!(artifacts.len(), 2, "both matching ranges are shown"); + + let chosen = artifacts + .iter() + .find(|a| a.is_used_in_public_api) + .expect("one of them is the one served"); assert_eq!(chosen.id.to_string(), narrower_range_id.to_lowercase()); assert_eq!( chosen.version_range_pattern, Some("^2.44.2".to_string()), - "the more specific range should be the one returned" + "the more specific range is the one served" ); - assert!(chosen.is_used_in_public_api); + + let passed_over = artifacts + .iter() + .find(|a| !a.is_used_in_public_api) + .expect("the broader range is shown but not served"); + assert_eq!(passed_over.id.to_string(), broader_range_id.to_lowercase()); }) .await } diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 5e7aed60e..6c8ee4e29 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -476,7 +476,22 @@ function ArtifactRow({ {artifact.platform} - {artifact.download_url.startsWith("https://") ? ( + {artifact.canopy_holds_bytes ? ( + + + Held by Canopy for {artifact.group_name ?? "a group"} + + {artifact.digest && ( + + {artifact.digest} + + )} + + ) : artifact.download_url?.startsWith("https://") ? ( Date: Mon, 7 Sep 2026 09:49:11 +1200 Subject: [PATCH 028/130] register artifacts for a group --- private-web/src/routes/VersionDetail.tsx | 67 +++++++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 6c8ee4e29..e5bd2c4bf 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -630,6 +630,15 @@ function EditArtifactRow({ ); } +/// Canopy holds a group-scoped artifact's bytes, and the API takes them in the +/// JSON body, so the file is read here rather than posted as a multipart form. +async function encodeFile(file: File): Promise { + const buffer = new Uint8Array(await file.arrayBuffer()); + let binary = ""; + for (const byte of buffer) binary += String.fromCharCode(byte); + return btoa(binary); +} + function CreateArtifactForm({ versionId, onCreated, @@ -640,7 +649,12 @@ function CreateArtifactForm({ const [type, setType] = useState(""); const [platform, setPlatform] = useState(""); const [url, setUrl] = useState(""); + const [groupId, setGroupId] = useState(""); + const [file, setFile] = useState(null); const action = useApiAction("versions", "create_artifact"); + const groups = useApi("fleet/groups", "list", {}, []); + + const scoped = groupId !== ""; const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -649,11 +663,16 @@ function CreateArtifactForm({ version_id: versionId, artifact_type: type, platform, - download_url: url, + download_url: scoped ? null : url, + group_id: scoped ? groupId : null, + content_base64: file ? await encodeFile(file) : null, + content_type: file ? file.type || null : null, }); setType(""); setPlatform(""); setUrl(""); + setGroupId(""); + setFile(null); onCreated(); } catch { /* surfaced via action.error */ @@ -686,17 +705,49 @@ function CreateArtifactForm({ /> setUrl(e.target.value)} + select + label="Group" + value={groupId} + onChange={(e) => setGroupId(e.target.value)} disabled={action.pending} - fullWidth - required - /> + sx={{ minWidth: 160 }} + > + Every group + {(groups.status === "ok" ? groups.data : []).map((g) => ( + + {g.name} + + ))} + + {scoped ? ( + + ) : ( + setUrl(e.target.value)} + disabled={action.pending} + fullWidth + required + /> + )} From e400b92be407586c1b0bd87e08ff76c8c860cd15 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:51:43 +1200 Subject: [PATCH 029/130] test group scoped artifacts --- .../public-server/tests/it/artifact_scopes.rs | 220 ++++++++++++++++++ crates/public-server/tests/it/main.rs | 1 + private-web/e2e/artifact-scopes.spec.ts | 102 ++++++++ private-web/e2e/seed.ts | 44 +++- 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 crates/public-server/tests/it/artifact_scopes.rs create mode 100644 private-web/e2e/artifact-scopes.spec.ts diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs new file mode 100644 index 000000000..ede60b110 --- /dev/null +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -0,0 +1,220 @@ +//! A group-scoped artifact reaches its own group's machines and nobody else. +//! +//! spec: ART + +use axum::http::StatusCode; +use database::artifacts::digest_of; +use diesel_async::SimpleAsyncConnection; + +const VERSION: &str = "11111111-1111-1111-1111-111111111111"; +const UNSCOPED: &str = "22222222-2222-2222-2222-222222222222"; +const THEIRS: &str = "33333333-3333-3333-3333-333333333333"; +const GROUP_A: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const GROUP_B: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + +/// One published version, two groups, and a `reporting-schema` artifact for +/// each of the unscoped and group-A cases. +async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { + let digest = digest_of(b"group a schema"); + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{VERSION}', 2, 60, 0, '', 'published'); + + INSERT INTO server_groups (id, name) VALUES + ('{GROUP_A}', 'kamaka'), ('{GROUP_B}', 'drifting'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{UNSCOPED}', '{VERSION}', 'any', 'reporting-schema', 'https://example.com/all.sql'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) + VALUES ('{THEIRS}', '{VERSION}', 'any', 'reporting-schema', '{GROUP_A}', + 'group a schema'::bytea, 'application/sql', '{digest}')", + )) + .await + .expect("seed"); +} + +/// Put the authenticated device on a machine in the given group. +async fn enrol( + conn: &mut database::diesel_async::AsyncPgConnection, + device_id: uuid::Uuid, + group: &str, +) { + conn.batch_execute(&format!( + "INSERT INTO machines (name, group_id, device_id) + VALUES ('box', '{group}', '{device_id}')" + )) + .await + .expect("enrol machine"); +} + +/// A read carrying no identity is answered with the unscoped artifacts alone, +/// so giving an artifact a group narrows who is offered it rather than +/// widening what an open path serves. +#[tokio::test(flavor = "multi_thread")] +async fn an_anonymous_read_sees_only_unscoped_artifacts() { + commons_tests::server::run(async |mut conn, public, _| { + seed(&mut conn).await; + + let response = public.get("/versions/2.60.0/artifacts").await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + assert!(artifacts[0]["group_id"].is_null()); + }) + .await +} + +/// A caller whose credential is bound to a machine has that machine's group, +/// and the artifact scoped to it displaces the unscoped one of the same type +/// and platform. +#[tokio::test(flavor = "multi_thread")] +async fn a_machine_is_offered_its_own_group_s_artifact() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + assert_eq!(artifacts.len(), 1, "never offered both"); + assert_eq!(artifacts[0]["id"], THEIRS); + assert_eq!(artifacts[0]["group_id"], GROUP_A); + }, + ) + .await +} + +/// A machine in another group reaches the unscoped artifact, and the one it is +/// not offered is answered as though it did not exist. The refusal is the same +/// one an artifact id that was never registered gets, so which groups hold an +/// artifact is not enumerable here. +#[tokio::test(flavor = "multi_thread")] +async fn another_group_cannot_tell_the_artifact_apart_from_a_missing_one() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_B).await; + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED, "not group A's"); + + // The one it is not offered, by its real id. + let refused = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + // An id that was never registered at all. + let absent = public + .get("/versions/2.60.0/artifacts/99999999-9999-9999-9999-999999999999/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!( + refused.status_code(), + absent.status_code(), + "an artifact held for another group must answer exactly as a missing one does" + ); + assert_eq!(refused.status_code(), StatusCode::NOT_FOUND); + assert_eq!(refused.text(), absent.text(), "and say the same thing"); + }, + ) + .await +} + +/// Canopy serves the bytes it holds to a caller the artifact is offered to. +#[tokio::test(flavor = "multi_thread")] +async fn the_owning_group_is_served_the_held_bytes() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + response.assert_status_ok(); + assert_eq!(response.text(), "group a schema"); + }, + ) + .await +} + +/// Canopy verifies the bytes it holds against the recorded digest as it serves +/// them, so a corrupted artifact fails the read rather than reaching a server +/// as the artifact it is not. +#[tokio::test(flavor = "multi_thread")] +async fn corrupted_bytes_fail_the_read() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute(&format!( + "UPDATE artifacts SET content = 'tampered'::bytea WHERE id = '{THEIRS}'" + )) + .await + .expect("corrupt the stored bytes"); + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!response.text().contains("tampered")); + }, + ) + .await +} + +/// A releaser credential carries no authorisation for any group, so a +/// registration naming one is refused. +#[tokio::test(flavor = "multi_thread")] +async fn a_releaser_cannot_register_for_a_group() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let response = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP_A}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.sql") + .await; + + assert_eq!(response.status_code(), StatusCode::FORBIDDEN); + + // The same registration without a group is accepted. + let response = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + response.assert_status_ok(); + }, + ) + .await +} diff --git a/crates/public-server/tests/it/main.rs b/crates/public-server/tests/it/main.rs index 91cfbc814..d3d925e2e 100644 --- a/crates/public-server/tests/it/main.rs +++ b/crates/public-server/tests/it/main.rs @@ -4,6 +4,7 @@ // Nextest still runs every #[tokio::test] in parallel as usual. mod application_aliases; +mod artifact_scopes; mod auth_requirements; mod backup; mod backup_secrets; diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts new file mode 100644 index 000000000..e3bb72ce0 --- /dev/null +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -0,0 +1,102 @@ +import { + resetSeededTables, + seedArtifact, + seedServerGroup, + seedVersion, +} from "./seed"; +import { expect, test } from "./test-fixtures"; + +/// How a version's artifacts are presented to an operator: every artifact that +/// matches, whose group each is for, and which one is actually served. +/// +/// spec: ART +test.describe("group-scoped artifacts", () => { + test.beforeEach(async ({ sql }) => { + await resetSeededTables(sql); + }); + + /// The full set, including the artifacts specificity passed over, is + /// available to operators: what resolution hides is a fact about how a + /// version was published and an operator has to be able to see it. + /// + /// spec: ART#what-a-version-offers + test("an operator sees a group's artifact alongside the one it displaces", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + downloadUrl: "https://example.com/all.sql", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + + // Both are listed, so the operator can see what the group's own + // artifact displaced. + const rows = page.locator("table tbody tr"); + await expect(rows).toHaveCount(2); + + // The group's artifact says whose it is and shows its digest rather + // than a location, because Canopy holds the bytes. + await expect(page.getByText("Held by Canopy for kamaka")).toBeVisible(); + await expect(page.getByText(/^sha256:/)).toBeVisible(); + + // The unscoped one still shows where it rests. + await expect( + page.getByRole("link", { name: "https://example.com/all.sql" }), + ).toBeVisible(); + }); + + /// A range artifact a more specific one displaces is shown, and marked as + /// not being the one served. + /// + /// spec: ART#what-a-version-offers + test("an artifact resolution passed over is marked rather than hidden", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + + await seedArtifact(sql, { + versionId: null, + rangePattern: "2.60.x", + artifactType: "installer", + platform: "windows", + downloadUrl: "https://example.com/broad.exe", + }); + await seedArtifact(sql, { + versionId: null, + rangePattern: "^2.60.0", + artifactType: "installer", + platform: "windows", + downloadUrl: "https://example.com/narrow.exe", + }); + + await page.goto(`/versions/2.60.0`); + + await expect(page.locator("table tbody tr")).toHaveCount(2); + await expect(page.getByText("[Hidden]")).toHaveCount(1); + }); +}); diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index f8ec1c304..a54f79d77 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -11,7 +11,7 @@ // often don't want in setup (e.g. submitting a status fires events // and opens incidents). -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { Client } from "pg"; export interface Sql { @@ -1036,6 +1036,48 @@ export async function seedVersion( return { id, major, minor, patch }; } +/** Seed an artifact for a version. Naming a group makes Canopy hold the bytes + * rather than record a location. */ +export async function seedArtifact( + sql: Sql, + opts: { + versionId?: string | null; + artifactType?: string; + platform?: string; + downloadUrl?: string; + rangePattern?: string | null; + groupId?: string | null; + content?: string; + }, +): Promise { + const id = randomUUID(); + const scoped = opts.groupId != null; + const content = opts.content ?? "held bytes"; + const digest = scoped + ? `sha256:${createHash("sha256").update(content).digest("hex")}` + : null; + + await sql.query( + `INSERT INTO artifacts + (id, version_id, artifact_type, platform, download_url, version_range_pattern, + group_id, content, content_type, digest) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + id, + opts.versionId ?? null, + opts.artifactType ?? "installer", + opts.platform ?? "windows", + scoped ? null : (opts.downloadUrl ?? "https://example.com/installer.exe"), + opts.rangePattern ?? null, + opts.groupId ?? null, + scoped ? Buffer.from(content) : null, + scoped ? "application/sql" : null, + digest, + ], + ); + return id; +} + // ── Backup-credentials seeding ────────────────────────────────────────────── export type BackupConfigStatus = "provisioning" | "ready"; From 9ecea71e1704f0188f3f404e7a5091b23a36c8f4 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:54:58 +1200 Subject: [PATCH 030/130] tighten auth comment --- crates/commons-servers/src/device_auth/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/commons-servers/src/device_auth/mod.rs b/crates/commons-servers/src/device_auth/mod.rs index 445fa1824..7c476d5c9 100644 --- a/crates/commons-servers/src/device_auth/mod.rs +++ b/crates/commons-servers/src/device_auth/mod.rs @@ -106,10 +106,8 @@ where ) -> Result, Self::Rejection> { match >::from_request_parts(parts, state).await { Ok(device) => Ok(Some(device)), - // A caller that presented nothing is anonymous. One that presented - // something Canopy could not place is anonymous too: refusing here - // would turn a stale certificate into a hard failure on a path that - // serves everyone. + // A credential Canopy cannot place is anonymous, not refused: a + // stale certificate must not fail a path that serves everyone. Err(_) => Ok(None), } } From 49c8cc52d9fcfcd9b2d7ea9d83d65079a09f3429 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:14:04 +1200 Subject: [PATCH 031/130] refresh openapi snapshot --- private-web/openapi.json | 2 +- private-web/src/api-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/private-web/openapi.json b/private-web/openapi.json index 6f98c7697..a3d97d0fe 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -17270,7 +17270,7 @@ "string", "null" ], - "description": "Version the server's reporting schema was built for. Absent until a\nserver runs a schema that stamps one (spec: RPT#currency)." + "description": "Version the server's reporting schema was built for. Absent until a\nserver runs a schema that stamps one (spec: RPT#the-offering-contract)." }, "server_id": { "type": "string", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 3d5ec21a0..28f26ac3b 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -10058,7 +10058,7 @@ export interface components { postgres?: string | null; /** * @description Version the server's reporting schema was built for. Absent until a - * server runs a schema that stamps one (spec: RPT#currency). + * server runs a schema that stamps one (spec: RPT#the-offering-contract). */ reporting_schema?: string | null; /** From 22be6dc12589cbeba968460bea02b4914fa7d523 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:19:27 +1200 Subject: [PATCH 032/130] held artifacts take no url --- crates/database/src/artifacts.rs | 15 ++++++++ crates/database/tests/it/artifact_scopes.rs | 39 +++++++++++++++++++++ private-web/src/routes/VersionDetail.tsx | 25 ++++++++----- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index b3350d969..89e02c494 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -331,6 +331,21 @@ impl Artifact { ) -> Result<()> { use crate::schema::artifacts::dsl::*; + // An artifact Canopy holds has no location to change. Replacing its + // bytes is a registration, which is what carries the digest. + // spec: ART#where-an-artifact-rests + let scoped: Option = artifacts + .filter(id.eq(artifact_id)) + .select(group_id) + .first(db) + .await + .map_err(AppError::from)?; + if scoped.is_some() && new_url.is_some() { + return Err(AppError::Conflict( + "an artifact Canopy holds has no download URL".into(), + )); + } + diesel::update(artifacts.filter(id.eq(artifact_id))) .set(( artifact_type.eq(new_type), diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 27573ba84..8d400384a 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -255,3 +255,42 @@ async fn a_range_artifact_replaces_itself() { }) .await; } + +/// An artifact Canopy holds has no location, so an attempt to give it one is +/// refused rather than reaching the shape constraint as a database error. +#[tokio::test(flavor = "multi_thread")] +async fn a_held_artifact_cannot_be_given_a_url() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + let artifact = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"schema"), + ) + .await + .expect("register"); + + let refused = Artifact::update( + &mut conn, + artifact.id, + "reporting-schema".to_owned(), + "any".to_owned(), + Some("https://example.com/elsewhere.sql".to_owned()), + ) + .await; + assert!(refused.is_err(), "a held artifact takes no location"); + + // Renaming it without offering a location is still fine. + Artifact::update( + &mut conn, + artifact.id, + "reporting-assets".to_owned(), + "any".to_owned(), + None, + ) + .await + .expect("rename is allowed"); + }) + .await; +} diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index e5bd2c4bf..766f69f99 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -568,7 +568,7 @@ function EditArtifactRow({ artifact_id: artifact.id, artifact_type: type, platform, - download_url: url, + download_url: artifact.canopy_holds_bytes ? null : url, }); onClose(true); } catch { @@ -597,14 +597,21 @@ function EditArtifactRow({ /> - setUrl(e.target.value)} - disabled={action.pending} - required - /> + {artifact.canopy_holds_bytes ? ( + + Held by Canopy for {artifact.group_name ?? "a group"}. Register + it again to replace the bytes. + + ) : ( + setUrl(e.target.value)} + disabled={action.pending} + required + /> + )} From 6e383dcdac2ce70f53cc16eb18602cb01e179c38 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:20:22 +1200 Subject: [PATCH 033/130] drop unused resolver --- crates/database/src/artifacts.rs | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 89e02c494..d7e5a1279 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -368,35 +368,6 @@ impl Artifact { Ok(()) } - /// Get artifacts enriched with metadata about whether they're exact or ranged, - /// and whether an exact artifact overrides a ranged one. - /// Note: this returns only deduplicated artifacts (as shown in public API) - pub async fn get_for_version_with_metadata( - db: &mut AsyncPgConnection, - target_version_id: Uuid, - scope: Scope, - ) -> Result> { - let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let matching_artifacts = Self::get_for_version(db, target_version_id, scope).await?; - - use crate::schema::artifacts::*; - let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; - - let semver = version.as_semver(); - - let result = matching_artifacts - .into_iter() - .map(|a| { - let is_exact = a.version_id == Some(target_version_id); - let has_range_override = Self::overridden_range(&all_artifacts, &a, &semver); - - (a, is_exact, has_range_override, true) // true = used in public API - }) - .collect(); - - Ok(result) - } - /// Get artifacts with metadata for a version, including all matches (not deduplicated). /// Also indicates which artifact is actually used in the public API. /// This is for private/admin views where you want to see all configured artifacts From d2c6888b3264caec1b5806f2ec1bc0af23a0ef07 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:27:54 +1200 Subject: [PATCH 034/130] name machines and applications in rpt --- .../specs/public-server/reporting-schemas.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 8a6f8427f..bc6acada8 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -4,15 +4,15 @@ id: RPT # Reporting schemas -A reporting schema is the set of database views a Tamanu server's reports read from. -It is built for one Tamanu version against a database of one group, since its views follow from the group's configuration as well as from the version's schema, and every Tamanu server in that group applies the same one. -Canopy holds zero or one reporting schema per pair of group and Tamanu version, has one built for every pair it knows of, and offers it to the Tamanu servers of that group. +A reporting schema is the set of database views a Tamanu application's reports read from. +It is built for one Tamanu version against a database of one group, since its views follow from the group's configuration as well as from the version's schema, and every Tamanu application in that group applies the same one. +Canopy holds zero or one reporting schema per pair of group and Tamanu version, has one built for every pair it knows of, and offers it to the Tamanu applications of that group. ## Why it exists Part of a reporting schema follows from the Tamanu version's database schema and is the same for every group on that version. The rest follows from the group's own configuration, which only a database carrying that configuration can supply. -A schema for a pair is therefore built from a database of that group at that version, and ahead of an upgrade no such database exists, since the group's servers run the version they are leaving. +A schema for a pair is therefore built from a database of that group at that version, and ahead of an upgrade no such database exists, since the group's applications run the version they are leaving. Canopy restores a group's backups into replicas and migrates them to a version (see [RST](restore-replicas.md)), so it is where such a database is produced, and it knows the version each group runs and the one it is moving to (see [APP](../servers/application-types.md), [UPG](../private-server/upgrade-plans.md)), so it is where the pairs are known. ## Actors @@ -21,16 +21,16 @@ A **schema builder** produces a reporting schema from a database Canopy has rest It is a restore consumer (see [RST](restore-replicas.md)): a build operates on a replica, so the builder is dispatched, credentialled, and reports over the replica pathways and authorisations, and it advertises an intent carrying `reporting-schema`. How the builder produces a schema is the builder's own. -An **operator** declares which groups have a builder, reads which schema each server runs, and asks for the builds the derivation does not produce. +An **operator** declares which groups have a builder, reads which schema each application runs, and asks for the builds the derivation does not produce. -A **Tamanu server's device** fetches the schema Canopy offers its server and applies it (see [DID](machine-identity.md)). +The **device of a machine a Tamanu application runs on** fetches the schema Canopy offers that application and applies it (see [DID](machine-identity.md)). -Canopy owns which pairs exist, the replica a build is given, the artifact that results, and offering it to the group's servers. +Canopy owns which pairs exist, the replica a build is given, the artifact that results, and offering it to the group's applications. ## Pairs A reporting schema is unique per pair of group and Tamanu version, and Canopy holds zero or one per pair. -The pairs are, for each group covered by an enabled declaration of a `reporting-schema` intent, each version a Tamanu server of the group reports running and the version its open plan moves it to (see [UPG](../private-server/upgrade-plans.md)). +The pairs are, for each group covered by an enabled declaration of a `reporting-schema` intent, each version a Tamanu application of the group reports running and the version its open plan moves it to (see [UPG](../private-server/upgrade-plans.md)). That declaration is what covers a group: it names the group, is enabled or disabled, and is audited (see [RST](restore-replicas.md)). Only a published version is in a pair, since a version's migrations reach a builder as its published artifacts (see [ART](../platform/artifacts.md)) and an unpublished one has none. @@ -43,7 +43,7 @@ A failed build settles the pair as well, since a build against a fixed version a ## The build contract Canopy dispatches a build to the builder as a restore replica, through the worklist every replica is dispatched through (see [RST](restore-replicas.md)). -The entry names the group and the Tamanu version the schema is for, and a central server of the group whose snapshot the replica is restored from, since the configuration a schema follows from is held centrally. +The entry names the group and the Tamanu version the schema is for, and a machine of the group running a central Tamanu application, whose snapshot the replica is restored from, since the configuration a schema follows from is held centrally. It carries what any replica's entry carries: the snapshot to restore, the repo coordinates, and the intent's parameter values. The replica is migrated to the named version before the build reads it, and is not de-identified, since masking alters the configuration a schema follows from. @@ -57,7 +57,7 @@ A schema is published for the exact version and never for a range, since it foll ## What a build reports -A build reports as its replica's restore report (see [RST](restore-replicas.md)), which names the group, the server, the snapshot restored, and when it was observed. +A build reports as its replica's restore report (see [RST](restore-replicas.md)), which names the group, the machine, the snapshot restored, and when it was observed. Beyond those it carries: - the **version** it was built for; @@ -69,17 +69,17 @@ Reports are retained indefinitely as an audit trail. ## The offering contract -Canopy offers a Tamanu server's device the schema for the pair of its group and the version the server reports running, resolved as any group-scoped artifact is (see [ART](../platform/artifacts.md)). -The device's credential carries its server's group, so it is offered its own group's schema and can fetch no other's. -A facility server is offered the same schema as the central servers of its group, since a schema follows the group and the version rather than the server it was built from. +Canopy offers a Tamanu application's device the schema for the pair of its group and the version that application reports running, resolved as any group-scoped artifact is (see [ART](../platform/artifacts.md)). +The device's credential resolves to the machine it is enrolled as and so to that machine's group, so it is offered its own group's schema and can fetch no other's. +A facility application is offered the same schema as the central applications of its group, since a schema follows the group and the version rather than the application it was built from. -The device compares what its server runs with what it is offered, applies the offered schema where they differ, and reports the result as a check on its server through the status contract (see [STA](statuses.md)), which is graded, presented, and alerted as any source's check is (see [CHK](../monitoring/checks.md)). +The device compares what its application runs with what it is offered, applies the offered schema where they differ, and reports the result as a check on that application through the status contract (see [STA](statuses.md)), which is graded, presented, and alerted as any source's check is (see [CHK](../monitoring/checks.md)). ## Alerting -A failed build raises a reporting-schema check on the group's central server, carrying the failure description (see [CHK](../monitoring/checks.md)). -The check is a warning rather than a failure, and does not escalate: the server is up and its reports return rows, and a schema that cannot be built for the version its group is moving to is for whoever maintains the reports rather than whoever is on call. +A failed build raises a reporting-schema check on the group's central Tamanu application, carrying the failure description (see [CHK](../monitoring/checks.md)). +The check is a warning rather than a failure, and does not escalate: the application is up and its reports return rows, and a schema that cannot be built for the version its group is moving to is for whoever maintains the reports rather than whoever is on call. A replica that failed to restore or come up is the restore's own health rather than a build failure, and is dispatched again as any unhealthy restore is. The check recovers when the pair is built, and an operator asking for the build is what clears it. -Pairs are presented per group, showing which have a schema, which are being built, and which failed, so whether a group's servers can be offered the schema for the version they run or are moving to is answered in one place. +Pairs are presented per group, showing which have a schema, which are being built, and which failed, so whether a group's applications can be offered the schema for the version they run or are moving to is answered in one place. From 387eee13193c14d2704d642c02134e4b56dad8e1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:33:28 +1200 Subject: [PATCH 035/130] record reporting schema builds --- crates/commons-types/src/backup.rs | 15 +- crates/database/src/lib.rs | 1 + crates/database/src/reporting_schemas.rs | 292 ++++++++++++++++++ crates/database/src/schema.rs | 28 ++ crates/database/src/server_groups.rs | 22 +- .../down.sql | 2 + .../up.sql | 30 ++ 7 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 crates/database/src/reporting_schemas.rs create mode 100644 migrations/2026-09-06-223010-0000_reporting_schema_builds/down.sql create mode 100644 migrations/2026-09-06-223010-0000_reporting_schema_builds/up.sql diff --git a/crates/commons-types/src/backup.rs b/crates/commons-types/src/backup.rs index 3f2535291..e14ca925e 100644 --- a/crates/commons-types/src/backup.rs +++ b/crates/commons-types/src/backup.rs @@ -418,6 +418,13 @@ pub mod semantics { /// worklist entry, withholds an entry from a server whose product has no /// manifest, and holds the replica to the redaction outcome reported back. pub const REDACT: &str = "redact"; + /// The intent builds a Tamanu reporting schema from the replica it restores + /// and registers it as a group-scoped artifact: Canopy names the pair's + /// version on the worklist entry, restores a machine of the group running a + /// central Tamanu application, and keys `once` to the group and the version + /// rather than the snapshot. + // spec: RPT + pub const REPORTING_SCHEMA: &str = "reporting-schema"; } /// The parameters Canopy owns on behalf of the `redact` semantic. @@ -496,11 +503,9 @@ pub struct IntentDescriptor { /// Human-readable description of the intent, if provided. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - /// Behaviours this intent opts into. Recognised values are `check` (a - /// health report is expected for each replica), `once` (a given snapshot - /// is only ever dispatched to a replica once, rather than repeatedly - /// until overdue), and `url` (a replica's health report includes a link - /// to it). Unrecognised values are stored but have no effect. + /// Behaviours this intent opts into; see [`semantics`] for what each one + /// grants. Unrecognised values are stored but have no effect, so a consumer + /// may advertise ahead of Canopy support. #[serde(default)] pub semantics: Vec, /// Configurable parameters this intent accepts per replica, keyed by diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs index ec7f5a8db..9d7495444 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -29,6 +29,7 @@ pub mod partitions; pub mod pg_duration; pub mod recovery_vault; pub mod reported_detail; +pub mod reporting_schemas; pub mod restore; pub mod schema; pub mod self_alerts; diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs new file mode 100644 index 000000000..ed6d5c716 --- /dev/null +++ b/crates/database/src/reporting_schemas.rs @@ -0,0 +1,292 @@ +//! Reporting-schema builds: which pairs of group and Tamanu version have a +//! schema, which have been tried, and which an operator has asked for again. +//! +//! spec: RPT + +use commons_errors::{AppError, Result}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + restore::{BackupRestoreCheck, NewBackupRestoreCheck}, + versions::Version, +}; +use commons_types::backup::RunOutcome; + +/// A build of one pair, hanging off the restore report that carries the +/// replica's own health. +#[derive(Debug, Clone, Serialize, Queryable, Selectable, utoipa::ToSchema)] +#[diesel(table_name = crate::schema::reporting_schema_builds)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ReportingSchemaBuild { + /// The restore report this build was reported with. + pub check_id: i64, + /// The group the schema was built for. + pub group_id: Uuid, + /// The Tamanu version the schema was built for. + pub version_id: Uuid, + /// The central application whose snapshot the replica was restored from. + pub application_id: Option, + /// Whether a schema came out of it. + pub built: bool, + /// What went wrong, where it did not. + pub error: Option, +} + +#[derive(Debug, Clone)] +pub struct NewReportingSchemaBuild { + pub group_id: Uuid, + pub version_id: Uuid, + pub application_id: Option, + pub built: bool, + pub error: Option, +} + +impl ReportingSchemaBuild { + /// Record a build: the replica's restore report first, then the build that + /// rode on it. + // spec: RPT#what-a-build-reports + pub async fn record( + db: &mut AsyncPgConnection, + report: NewBackupRestoreCheck, + build: NewReportingSchemaBuild, + ) -> Result { + let restore_failed = report.outcome != RunOutcome::Success; + + let check_id = BackupRestoreCheck::record_report(db, report).await?; + + // A replica that failed to restore says nothing about whether the pair + // can be built: the build never ran. Restore-health already raises on + // that, and recording no build leaves the pair unsettled so it is + // dispatched again, which is what an unhealthy restore should do. + if restore_failed { + return Ok(check_id); + } + + diesel::insert_into(crate::schema::reporting_schema_builds::table) + .values(( + crate::schema::reporting_schema_builds::check_id.eq(check_id), + crate::schema::reporting_schema_builds::group_id.eq(build.group_id), + crate::schema::reporting_schema_builds::version_id.eq(build.version_id), + crate::schema::reporting_schema_builds::application_id.eq(build.application_id), + crate::schema::reporting_schema_builds::built.eq(build.built), + crate::schema::reporting_schema_builds::error.eq(build.error), + )) + .execute(db) + .await?; + + // An operator's ask is answered once the build it asked for lands, + // whichever way it went. + ReportingSchemaRequest::clear(db, build.group_id, build.version_id).await?; + + Ok(check_id) + } + + /// The most recent build of a pair, if it has been tried. + pub async fn latest_for_pair( + db: &mut AsyncPgConnection, + group: Uuid, + version: Uuid, + ) -> Result> { + use crate::schema::{backup_restore_checks, reporting_schema_builds}; + + reporting_schema_builds::table + .inner_join( + backup_restore_checks::table + .on(backup_restore_checks::id.eq(reporting_schema_builds::check_id)), + ) + .filter(reporting_schema_builds::group_id.eq(group)) + .filter(reporting_schema_builds::version_id.eq(version)) + .order_by(backup_restore_checks::reported_at.desc()) + .select(Self::as_select()) + .first(db) + .await + .optional() + .map_err(AppError::from) + } + + /// Whether a pair is settled: it has been built or has failed, and either + /// way is not dispatched again until the version's artifacts change or an + /// operator asks. + // spec: RPT#pairs + pub async fn is_settled( + db: &mut AsyncPgConnection, + group: Uuid, + version: Uuid, + ) -> Result { + if ReportingSchemaRequest::pending(db, group, version).await? { + return Ok(false); + } + + Ok(Self::latest_for_pair(db, group, version).await?.is_some()) + } +} + +/// An operator asking for a pair's build. +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Selectable, utoipa::ToSchema)] +#[diesel(table_name = crate::schema::reporting_schema_requests)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ReportingSchemaRequest { + pub group_id: Uuid, + pub version_id: Uuid, + #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] + pub requested_at: Timestamp, + pub requested_by: Option, +} + +impl ReportingSchemaRequest { + /// Enqueue, or refresh, an ask for a pair. + // spec: RPT#pairs + pub async fn enqueue( + db: &mut AsyncPgConnection, + group: Uuid, + version: Uuid, + requested_by: Option<&str>, + ) -> Result<()> { + use crate::schema::reporting_schema_requests::dsl; + + diesel::insert_into(dsl::reporting_schema_requests) + .values(( + dsl::group_id.eq(group), + dsl::version_id.eq(version), + dsl::requested_by.eq(requested_by), + )) + .on_conflict((dsl::group_id, dsl::version_id)) + .do_update() + .set(( + dsl::requested_at.eq(diesel::dsl::now), + dsl::requested_by.eq(requested_by), + )) + .execute(db) + .await + .map_err(AppError::from)?; + + Ok(()) + } + + pub async fn pending(db: &mut AsyncPgConnection, group: Uuid, version: Uuid) -> Result { + use crate::schema::reporting_schema_requests::dsl; + + Ok(dsl::reporting_schema_requests + .filter(dsl::group_id.eq(group)) + .filter(dsl::version_id.eq(version)) + .select(dsl::group_id) + .first::(db) + .await + .optional() + .map_err(AppError::from)? + .is_some()) + } + + async fn clear(db: &mut AsyncPgConnection, group: Uuid, version: Uuid) -> Result<()> { + use crate::schema::reporting_schema_requests::dsl; + + diesel::delete( + dsl::reporting_schema_requests + .filter(dsl::group_id.eq(group)) + .filter(dsl::version_id.eq(version)), + ) + .execute(db) + .await + .map_err(AppError::from)?; + + Ok(()) + } +} + +/// Where a pair stands, for the operator view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum PairState { + /// No build has been recorded, so the pair is on the worklist. + Awaiting, + /// A build produced a schema. + Built, + /// A build ran and produced none. + Failed, +} + +/// One pair of group and Tamanu version, and where it stands. +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct Pair { + pub group_id: Uuid, + pub version_id: Uuid, + pub version: String, + pub state: PairState, + /// What went wrong, where a build failed. + pub error: Option, + /// Whether an operator has asked for this pair to be built again. + pub requested: bool, +} + +/// The pairs of a group: every published version its Tamanu applications report +/// running, plus the version its open plan moves it to. +// spec: RPT#pairs +pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { + let mut versions = versions_for_group(db, group).await?; + versions.sort_by_key(|v| (v.major, v.minor, v.patch)); + versions.dedup_by_key(|v| v.id); + + let mut pairs = Vec::with_capacity(versions.len()); + for version in versions { + let latest = ReportingSchemaBuild::latest_for_pair(db, group, version.id).await?; + let requested = ReportingSchemaRequest::pending(db, group, version.id).await?; + + let (state, error) = match &latest { + None => (PairState::Awaiting, None), + Some(build) if build.built => (PairState::Built, None), + Some(build) => (PairState::Failed, build.error.clone()), + }; + + pairs.push(Pair { + group_id: group, + version_id: version.id, + version: version.as_semver().to_string(), + state, + error, + requested, + }); + } + + Ok(pairs) +} + +/// Every published version a group's Tamanu applications report running, plus +/// the version its open plan moves it to. +/// +/// A reported version Canopy holds no release row for is not a pair: a build +/// needs that version's migrations, which reach a builder as its published +/// artifacts. +// spec: RPT#pairs +pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { + use commons_types::version::VersionStatus; + + let applications = crate::applications::Application::list_live_in_group(db, group).await?; + let tamanu: Vec = applications + .iter() + .filter(|a| a.r#type.software() == "tamanu") + .map(|a| a.id) + .collect(); + + let mut versions = Vec::new(); + + let reported = crate::reported_detail::ReportedDetail::last_versions(db, &tamanu).await?; + for shown in reported.into_values() { + // A version Canopy holds no release row for is not a pair: a build needs + // that version's migrations, which reach a builder as published artifacts. + if let Ok(version) = Version::get_by_version(db, shown).await + && version.status == VersionStatus::Published + { + versions.push(version); + } + } + + if let Some(target) = crate::upgrade_plans::planned_target(db, group).await? { + versions.push(target); + } + + Ok(versions) +} diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index 1a69948c3..a4ae26249 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -605,6 +605,26 @@ diesel::table! { } } +diesel::table! { + reporting_schema_builds (check_id) { + check_id -> Int8, + group_id -> Uuid, + version_id -> Uuid, + application_id -> Nullable, + built -> Bool, + error -> Nullable, + } +} + +diesel::table! { + reporting_schema_requests (group_id, version_id) { + group_id -> Uuid, + version_id -> Uuid, + requested_at -> Timestamptz, + requested_by -> Nullable, + } +} + diesel::table! { restore_consumer_capabilities (consumer_device_id, intent) { consumer_device_id -> Uuid, @@ -883,6 +903,12 @@ diesel::joinable!(migration_tests -> applications (application_id)); diesel::joinable!(migration_tests -> backup_restore_checks (check_id)); diesel::joinable!(migration_tests -> versions (target_version_id)); diesel::joinable!(migration_timings -> migration_tests (check_id)); +diesel::joinable!(reporting_schema_builds -> applications (application_id)); +diesel::joinable!(reporting_schema_builds -> backup_restore_checks (check_id)); +diesel::joinable!(reporting_schema_builds -> server_groups (group_id)); +diesel::joinable!(reporting_schema_builds -> versions (version_id)); +diesel::joinable!(reporting_schema_requests -> server_groups (group_id)); +diesel::joinable!(reporting_schema_requests -> versions (version_id)); diesel::joinable!(restore_consumer_capabilities -> devices (consumer_device_id)); diesel::joinable!(restore_replicas -> devices (consumer_device_id)); diesel::joinable!(restore_replicas -> machines (machine_id)); @@ -947,6 +973,8 @@ diesel::allow_tables_to_appear_in_same_query!( migration_tests, migration_timings, recovery_vault_writes, + reporting_schema_builds, + reporting_schema_requests, restore_consumer_capabilities, restore_replicas, scoped_check_policies, diff --git a/crates/database/src/server_groups.rs b/crates/database/src/server_groups.rs index 8bc4bde33..2b37989d3 100644 --- a/crates/database/src/server_groups.rs +++ b/crates/database/src/server_groups.rs @@ -184,6 +184,22 @@ impl ServerGroup { .collect()) } + /// The group's canonical central application: the highest-ranked one, and + /// the lowest id among equals so the choice is stable. + /// + /// There is no fallback. A group with no central has none, because a + /// group's version, and the database a reporting schema is built from, are + /// things its central has and nothing else stands in for. + // spec: APP#capabilities + pub fn canonical_central( + members: &[crate::applications::Application], + ) -> Option<&crate::applications::Application> { + members + .iter() + .filter(|s| s.r#type == ApplicationType::TamanuCentral) + .min_by_key(|s| (rank_priority(s.rank), s.id)) + } + pub async fn list_all(db: &mut AsyncPgConnection) -> Result> { use crate::schema::server_groups::dsl; dsl::server_groups @@ -511,11 +527,7 @@ impl ServerGroup { // version, because a group's version is a thing its central has // and nothing else stands in for it. // spec: APP#capabilities - let canonical = members - .iter() - .filter(|s| s.r#type == ApplicationType::TamanuCentral) - .min_by_key(|s| (rank_priority(s.rank), s.id)) - .map(|s| s.id); + let canonical = Self::canonical_central(&members).map(|s| s.id); let (version_application_id, effective_version) = match canonical { None => (None, None), diff --git a/migrations/2026-09-06-223010-0000_reporting_schema_builds/down.sql b/migrations/2026-09-06-223010-0000_reporting_schema_builds/down.sql new file mode 100644 index 000000000..50a79d7c4 --- /dev/null +++ b/migrations/2026-09-06-223010-0000_reporting_schema_builds/down.sql @@ -0,0 +1,2 @@ +DROP TABLE reporting_schema_requests; +DROP TABLE reporting_schema_builds; diff --git a/migrations/2026-09-06-223010-0000_reporting_schema_builds/up.sql b/migrations/2026-09-06-223010-0000_reporting_schema_builds/up.sql new file mode 100644 index 000000000..10df934c3 --- /dev/null +++ b/migrations/2026-09-06-223010-0000_reporting_schema_builds/up.sql @@ -0,0 +1,30 @@ +-- A reporting-schema build's result, hanging off the restore-health report that +-- carries its common fields, the way a migration test's does. Its own table +-- rather than nullable columns on the report, because the pair a build is for +-- is the whole point of it and a plain restore report has nothing to put there. +-- +-- A row here is what settles a pair: a build that failed settles it as firmly +-- as one that produced a schema, since a build against a fixed version and +-- configuration fails the same way every time. +CREATE TABLE reporting_schema_builds ( + check_id BIGINT PRIMARY KEY REFERENCES backup_restore_checks (id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES server_groups (id) ON DELETE CASCADE, + version_id UUID NOT NULL REFERENCES versions (id) ON DELETE CASCADE, + application_id UUID REFERENCES applications (id) ON DELETE SET NULL, + built BOOLEAN NOT NULL, + error TEXT +); + +-- Whether a pair is settled is the question the worklist asks on every pass. +CREATE INDEX reporting_schema_builds_pair ON reporting_schema_builds (group_id, version_id); + +-- An operator asking for a pair's build, which is how a schema is refreshed +-- after the group's configuration changes and how a settled pair is reinstated. +-- Keyed on the pair rather than the machine, because the pair is what is built. +CREATE TABLE reporting_schema_requests ( + group_id UUID NOT NULL REFERENCES server_groups (id) ON DELETE CASCADE, + version_id UUID NOT NULL REFERENCES versions (id) ON DELETE CASCADE, + requested_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + requested_by TEXT, + PRIMARY KEY (group_id, version_id) +); From 29ad8a90868abef4e06806c093237d8ef1bb4bdb Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:35:49 +1200 Subject: [PATCH 036/130] dispatch and report schema builds --- crates/public-server/src/restore.rs | 142 +++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 4b1da674f..4c8eb3674 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -28,6 +28,7 @@ use database::{ backups::{BackupRun, NewBackupCredentialIssuance, ServerGroupBackupConfig}, migration_tests::{self, MigrationTest, NewMigrationTest}, pg_duration::PgDuration, + reporting_schemas::{NewReportingSchemaBuild, ReportingSchemaBuild}, restore::{ BackupRestoreCheck, NewBackupRestoreCheck, RestoreConsumerCapability, RestoreReplica, }, @@ -290,11 +291,76 @@ async fn worklist( let once = descriptor.has_semantic(semantics::ONCE); let migrates = descriptor.has_semantic(semantics::MIGRATE); let owns_masking = descriptor.has_semantic(semantics::REDACT); + let builds_schema = descriptor.has_semantic(semantics::REPORTING_SCHEMA); let replica_values: ParamValues = serde_json::from_value(d.params.clone()).unwrap_or_default(); let params = resolve_params(&descriptor.params, &replica_values); let region = cfg.region.clone().unwrap_or_else(instance_default_region); + + // A build is dispatched per pair rather than per machine. The + // configuration a schema follows from is held centrally, so every pair + // of a group restores the same central's snapshot and differs only in + // the version it is migrated to. + // spec: RPT#the-build-contract + if builds_schema { + // Masking alters the configuration a schema follows from, so a + // redacting declaration builds nothing rather than building from a + // database that is no longer the group's. + if d.redacts { + continue; + } + + let members = + database::applications::Application::list_live_in_group(&mut conn, d.group_id) + .await?; + let Some(central) = database::server_groups::ServerGroup::canonical_central(&members) + else { + continue; + }; + let central_type = central.r#type.clone(); + let machine = + database::machines::Machine::get_by_id(&mut conn, central.machine_id).await?; + let latest = snapshots.get(&(machine.id, d.r#type.clone())); + + for version in + database::reporting_schemas::versions_for_group(&mut conn, d.group_id).await? + { + if once + && database::reporting_schemas::ReportingSchemaBuild::is_settled( + &mut conn, d.group_id, version.id, + ) + .await? + { + continue; + } + + #[expect(deprecated, reason = "emitted for consumers on the earlier shape")] + out.push(WorklistEntry { + replica_id: d.id, + group_id: d.group_id, + machine_id: machine.id, + server_id: machine.id, + application_type: Some(central_type.clone()), + r#type: d.r#type.clone(), + intent: d.intent.clone(), + name: d.name.clone(), + overdue_after_seconds: d.overdue_after.map(|f| f.0.as_secs()), + params: params.clone(), + snapshot_id: latest.and_then(|r| r.snapshot_id.clone()), + snapshot_at: latest.map(|r| r.reported_at.to_string()), + storage: "s3".into(), + bucket: cfg.bucket.clone(), + prefix: cfg.prefix.clone(), + region: region.clone(), + target_version: Some(version.as_semver().to_string()), + target_version_id: Some(version.id), + }); + } + + continue; + } + for machine in machines { let key = (machine.id, d.name.clone()); if !seen.insert(key) { @@ -659,6 +725,10 @@ pub struct VerificationArgs { /// What the migrations did, for a report under a `migrate` intent. Omit for /// every other intent. pub migration: Option, + /// What a reporting-schema build produced, where the replica was restored + /// for one. Absent on any other report. + // spec: RPT#what-a-build-reports + pub reporting_schema: Option, /// What the masking manifest did, for a replica that redacts. Omit for a /// replica that doesn't. pub redaction: Option, @@ -747,6 +817,26 @@ pub struct MigrationArgs { pub timings: Vec, } +/// What a reporting-schema build reports beyond its replica's restore health. +// spec: RPT#what-a-build-reports +#[derive(Debug, Deserialize, ToSchema)] +pub struct ReportingSchemaArgs { + /// The version the schema was built for, as semver, echoed from the + /// worklist entry's `target_version`. + pub target_version: Option, + /// The same version as the identifier, echoed from `target_version_id`. + /// Accepted for a consumer that reports the identifier; omit it when + /// `target_version` is sent. + pub target_version_id: Option, + /// Whether a schema came out of the build. + pub built: bool, + /// What went wrong, where the build failed. + pub error: Option, + /// The artifacts the build registered, of which the schema is one. + #[serde(default)] + pub artifacts: Vec, +} + /// How long one migration took. #[derive(Debug, Deserialize, ToSchema)] pub struct MigrationTimingArgs { @@ -864,8 +954,34 @@ async fn verification( redaction_error: args.redaction.as_ref().and_then(|r| r.error.clone()), }; - match args.migration { - Some(migration) => { + match (args.migration, args.reporting_schema) { + // A build rides the migrate pathway, so a report may carry both; the + // build is the one that settles the pair. + (_, Some(build)) => { + let version_id = resolve_build_target(&mut conn, &build).await?; + // The build is held against the group's central application, which is + // the one whose database the schema followed from and the one the + // entry named. + // spec: RPT#alerting + let members = + database::applications::Application::list_live_in_group(&mut conn, args.group) + .await?; + let application_id = + database::server_groups::ServerGroup::canonical_central(&members).map(|a| a.id); + ReportingSchemaBuild::record( + &mut conn, + report, + NewReportingSchemaBuild { + group_id: args.group, + version_id, + application_id, + built: build.built, + error: build.error, + }, + ) + .await?; + } + (Some(migration), None) => { let target_version_id = resolve_migration_target(&mut conn, &migration).await?; let application_id = resolve_migration_application(&mut conn, &migration, machine_id, target_version_id) @@ -877,7 +993,7 @@ async fn verification( ) .await?; } - None => { + (None, None) => { BackupRestoreCheck::record_report(&mut conn, report).await?; } } @@ -908,6 +1024,26 @@ async fn resolve_migration_target( .ok_or_else(|| AppError::BadRequest("migration report names no target version".into())) } +/// Resolve the version a reporting-schema build is about. +/// +/// The semver is preferred, matching a migration report: it is what the entry +/// carried and what the builder actually built for. +async fn resolve_build_target( + conn: &mut AsyncPgConnection, + build: &ReportingSchemaArgs, +) -> Result { + if let Some(semver) = &build.target_version { + return Ok( + database::versions::Version::get_by_version(conn, semver.parse()?) + .await? + .id, + ); + } + build + .target_version_id + .ok_or_else(|| AppError::BadRequest("build report names no version".into())) +} + /// Resolve the application a migration report is about. /// /// The version under test is an application's candidate while the data is the From e448ee6710a562c76969ad4095952da56730ecd9 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:39:04 +1200 Subject: [PATCH 037/130] builders register their group's artifacts --- crates/canopy-api/src/generated.rs | 104 +++++++++++++++++-- crates/database/src/restore.rs | 39 ++++++++ crates/public-server/openapi.json | 77 +++++++++++++- crates/public-server/src/artifacts.rs | 138 +++++++++++++++++++------- private-web/openapi.json | 2 +- private-web/src/api-types.ts | 8 +- 6 files changed, 310 insertions(+), 58 deletions(-) diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index ee34dc59d..3cc9d2838 100644 --- a/crates/canopy-api/src/generated.rs +++ b/crates/canopy-api/src/generated.rs @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.0"; /// BLAKE3 digest of that document, so a document that changed without the /// version moving with it can be told from one that did not. -pub const OPENAPI_BLAKE3: &str = "90101b6a8f49ebb4d7038e3153325fca10a048af43d65d3a5d45beea6a46dd28"; +pub const OPENAPI_BLAKE3: &str = "ae0477349fb99de7543edb3eb9689a326b0219c49383112b18362e748b0e2b0f"; /// Error types. pub mod error { @@ -1452,7 +1452,7 @@ opts into and the settings it accepts per replica.*/ /// "$ref": "#/components/schemas/BTreeMap" /// }, /// "semantics": { -/// "description": "Behaviours this intent opts into. Recognised values are `check` (a\nhealth report is expected for each replica), `once` (a given snapshot\nis only ever dispatched to a replica once, rather than repeatedly\nuntil overdue), and `url` (a replica's health report includes a link\nto it). Unrecognised values are stored but have no effect.", +/// "description": "Behaviours this intent opts into; see [`semantics`] for what each one\ngrants. Unrecognised values are stored but have no effect, so a consumer\nmay advertise ahead of Canopy support.", /// "type": "array", /// "items": { /// "type": "string" @@ -1476,11 +1476,9 @@ pub struct IntentDescriptor { parameter name.*/ #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] pub params: ::std::option::Option, - /**Behaviours this intent opts into. Recognised values are `check` (a -health report is expected for each replica), `once` (a given snapshot -is only ever dispatched to a replica once, rather than repeatedly -until overdue), and `url` (a replica's health report includes a link -to it). Unrecognised values are stored but have no effect.*/ + /**Behaviours this intent opts into; see [`semantics`] for what each one +grants. Unrecognised values are stored but have no effect, so a consumer +may advertise ahead of Canopy support.*/ #[serde(default, skip_serializing_if = "::std::vec::Vec::is_empty")] pub semantics: ::std::vec::Vec<::std::string::String>, } @@ -2435,6 +2433,78 @@ run: if progress reports already carried it, that value stands.*/ #[serde(rename = "type")] pub type_: ::std::string::String, } +///What a reporting-schema build reports beyond its replica's restore health. +/// +///
JSON schema +/// +/// ```json +///{ +/// "description": "What a reporting-schema build reports beyond its replica's restore health.", +/// "type": "object", +/// "required": [ +/// "built" +/// ], +/// "properties": { +/// "artifacts": { +/// "description": "The artifacts the build registered, of which the schema is one.", +/// "type": "array", +/// "items": { +/// "type": "string", +/// "format": "uuid" +/// } +/// }, +/// "built": { +/// "description": "Whether a schema came out of the build.", +/// "type": "boolean" +/// }, +/// "error": { +/// "description": "What went wrong, where the build failed.", +/// "type": [ +/// "string", +/// "null" +/// ] +/// }, +/// "target_version": { +/// "description": "The version the schema was built for, as semver, echoed from the\nworklist entry's `target_version`.", +/// "type": [ +/// "string", +/// "null" +/// ] +/// }, +/// "target_version_id": { +/// "description": "The same version as the identifier, echoed from `target_version_id`.\nAccepted for a consumer that reports the identifier; omit it when\n`target_version` is sent.", +/// "type": [ +/// "string", +/// "null" +/// ], +/// "format": "uuid" +/// } +/// } +///} +/// ``` +///
+#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[derive(::bon::Builder)] +#[non_exhaustive] +pub struct ReportingSchemaArgs { + ///The artifacts the build registered, of which the schema is one. + #[serde(default, skip_serializing_if = "::std::vec::Vec::is_empty")] + pub artifacts: ::std::vec::Vec<::uuid::Uuid>, + ///Whether a schema came out of the build. + pub built: bool, + ///What went wrong, where the build failed. + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub error: ::std::option::Option<::std::string::String>, + /**The version the schema was built for, as semver, echoed from the +worklist entry's `target_version`.*/ + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub target_version: ::std::option::Option<::std::string::String>, + /**The same version as the identifier, echoed from `target_version_id`. +Accepted for a consumer that reports the identifier; omit it when +`target_version` is sent.*/ + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub target_version_id: ::std::option::Option<::uuid::Uuid>, +} ///A request to certify a key for a name. /// ///
JSON schema @@ -3373,6 +3443,17 @@ impl ::std::fmt::Display for UrlField { /// "type": "string", /// "format": "uuid" /// }, +/// "reporting_schema": { +/// "oneOf": [ +/// { +/// "type": "null" +/// }, +/// { +/// "description": "What a reporting-schema build produced, where the replica was restored\nfor one. Absent on any other report.", +/// "$ref": "#/components/schemas/ReportingSchemaArgs" +/// } +/// ] +/// }, /// "run_id": { /// "description": "This must be the run-uuid the client minted for this run.\nThe field is optional only so older clients don't break; it WILL be made\nmandatory in future.", /// "type": [ @@ -3481,6 +3562,8 @@ checks. A replica only counts as verified when the outcome is type, and intent, so a report that named no declaration could not be attributed to one of them.*/ pub replica_id: ::uuid::Uuid, + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub reporting_schema: ::std::option::Option, /**This must be the run-uuid the client minted for this run. The field is optional only so older clients don't break; it WILL be made mandatory in future.*/ @@ -3982,9 +4065,12 @@ impl crate::CanopyClient { pub async fn applications_self(&self) -> crate::Result { self.call_json(::http::Method::GET, "/applications/self", None::<&()>).await } - /// Register a downloadable artifact for a version or version range. + /// Register an artifact for a version or version range. /// - /// Requires a device certificate with the releaser role (or admin). The + /// A releaser registers an artifact that rests elsewhere, naming its location. + /// A component that produces a group's artifacts registers one for that group, + /// sending the bytes on this connection; Canopy holds them and is issued no + /// credential to any store. The /// path identifies the version the artifact belongs to — either an exact /// version (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`, /// `^2.10.0`) — followed by the artifact's type and target platform. The diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 81061d42b..0732bd719 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -314,6 +314,45 @@ impl RestoreReplica { /// Whether an enabled declaration covers `(consumer, group, type)` — the /// authorization check for issuing restore credentials. A server-scoped or /// a group-wide declaration both satisfy it. + /// Whether a consumer may register group-scoped artifacts for this group: + /// it has an enabled declaration covering the group whose intent it + /// advertises as building reporting schemas, and no other group. + /// + /// The authorisation is defined with the artifact rather than granted to + /// restore consumers at large, so a consumer that restores for a group but + /// builds nothing publishes nothing. + // spec: ART#registration, RPT#the-build-contract + pub async fn authorizes_schema_artifacts( + db: &mut AsyncPgConnection, + consumer_device_id: Uuid, + group_id: Uuid, + ) -> Result { + let building: Vec = + RestoreConsumerCapability::list_for_consumer(db, consumer_device_id) + .await? + .into_iter() + .filter(|d| d.has_semantic(semantics::REPORTING_SCHEMA)) + .map(|d| d.intent) + .collect(); + + if building.is_empty() { + return Ok(false); + } + + use crate::schema::restore_replicas::dsl; + let n: i64 = dsl::restore_replicas + .filter(dsl::consumer_device_id.eq(consumer_device_id)) + .filter(dsl::group_id.eq(group_id)) + .filter(dsl::intent.eq_any(building.iter().map(|i| i.0.clone()).collect::>())) + .filter(dsl::enabled.eq(true)) + .count() + .get_result(db) + .await + .map_err(AppError::from)?; + + Ok(n > 0) + } + pub async fn authorizes( db: &mut AsyncPgConnection, consumer_device_id: Uuid, diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index b72d1a1fd..9ef8c1bc1 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -110,8 +110,8 @@ "tags": [ "artifacts" ], - "summary": "Register a downloadable artifact for a version or version range.", - "description": "Requires a device certificate with the releaser role (or admin). The\npath identifies the version the artifact belongs to — either an exact\nversion (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`,\n`^2.10.0`) — followed by the artifact's type and target platform. The\nrequest body is the plain-text URL clients should download the\nartifact from.\n\nWhen an exact version is given and it doesn't exist yet, it is created\nautomatically as an unpublished draft so the artifact has a version to\nattach to; publishing that version later (via the version-creation\nendpoint) is a separate step. When a range pattern is given instead,\nthe artifact isn't tied to one version — it matches whichever\npublished version currently satisfies the range at lookup time.\n\nReturns the created artifact record. Returns 400 if the version or\nrange syntax can't be parsed.", + "summary": "Register an artifact for a version or version range.", + "description": "A releaser registers an artifact that rests elsewhere, naming its location.\nA component that produces a group's artifacts registers one for that group,\nsending the bytes on this connection; Canopy holds them and is issued no\ncredential to any store. The\npath identifies the version the artifact belongs to — either an exact\nversion (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`,\n`^2.10.0`) — followed by the artifact's type and target platform. The\nrequest body is the plain-text URL clients should download the\nartifact from.\n\nWhen an exact version is given and it doesn't exist yet, it is created\nautomatically as an unpublished draft so the artifact has a version to\nattach to; publishing that version later (via the version-creation\nendpoint) is a separate step. When a range pattern is given instead,\nthe artifact isn't tied to one version — it matches whichever\npublished version currently satisfies the range at lookup time.\n\nReturns the created artifact record. Returns 400 if the version or\nrange syntax can't be parsed.", "operationId": "register_artifact", "parameters": [ { @@ -142,7 +142,17 @@ { "name": "group", "in": "query", - "description": "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused.", + "description": "Group the artifact is for. A releaser credential carries no authorisation for any group; a component that produces a group's artifacts is authorised for that group alone.", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "run", + "in": "query", + "description": "The run that produced the artifact, where one produced it.", "required": false, "schema": { "type": "string", @@ -151,7 +161,7 @@ } ], "requestBody": { - "description": "Download URL for the artifact, as a plain-text body.", + "description": "For an unscoped artifact, its download URL as a plain-text body. For a group-scoped one, the artifact's bytes, which Canopy holds and verifies against the digest it takes of them.", "content": { "text/plain": { "schema": { @@ -206,6 +216,9 @@ "security": [ { "releaser-device": [] + }, + { + "backup-restore-device": [] } ] } @@ -2225,7 +2238,7 @@ "items": { "type": "string" }, - "description": "Behaviours this intent opts into. Recognised values are `check` (a\nhealth report is expected for each replica), `once` (a given snapshot\nis only ever dispatched to a replica once, rather than repeatedly\nuntil overdue), and `url` (a replica's health report includes a link\nto it). Unrecognised values are stored but have no effect." + "description": "Behaviours this intent opts into; see [`semantics`] for what each one\ngrants. Unrecognised values are stored but have no effect, so a consumer\nmay advertise ahead of Canopy support." } } }, @@ -2756,6 +2769,49 @@ } } }, + "ReportingSchemaArgs": { + "type": "object", + "description": "What a reporting-schema build reports beyond its replica's restore health.", + "required": [ + "built" + ], + "properties": { + "artifacts": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "The artifacts the build registered, of which the schema is one." + }, + "built": { + "type": "boolean", + "description": "Whether a schema came out of the build." + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "What went wrong, where the build failed." + }, + "target_version": { + "type": [ + "string", + "null" + ], + "description": "The version the schema was built for, as semver, echoed from the\nworklist entry's `target_version`." + }, + "target_version_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The same version as the identifier, echoed from `target_version_id`.\nAccepted for a consumer that reports the identifier; omit it when\n`target_version` is sent." + } + } + }, "RequestCertificateArgs": { "type": "object", "description": "A request to certify a key for a name.", @@ -3162,6 +3218,17 @@ "format": "uuid", "description": "The declaration this report concerns, taken from the worklist entry's\n`replica_id`. Required: several replicas can share one group, machine,\ntype, and intent, so a report that named no declaration could not be\nattributed to one of them." }, + "reporting_schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReportingSchemaArgs", + "description": "What a reporting-schema build produced, where the replica was restored\nfor one. Absent on any other report." + } + ] + }, "run_id": { "type": [ "string", diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 5f0150087..4e15947aa 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -4,12 +4,16 @@ use axum::{ }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; -use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; -use commons_types::version::{VersionStatus, VersionStr}; +use commons_servers::device_auth::AuthDevice; +use commons_types::{ + device::DeviceRole, + version::{VersionStatus, VersionStr}, +}; use database::{ Db, - artifacts::{Artifact as ArtifactRow, NewArtifact, Scope}, + artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, digest_of}, machines::Machine, + restore::RestoreReplica, versions::{NewVersion, Version}, }; use diesel::SelectableHelper as _; @@ -99,9 +103,12 @@ pub fn routes() -> OpenApiRouter { OpenApiRouter::new().routes(routes!(create)) } -/// Register a downloadable artifact for a version or version range. +/// Register an artifact for a version or version range. /// -/// Requires a device certificate with the releaser role (or admin). The +/// A releaser registers an artifact that rests elsewhere, naming its location. +/// A component that produces a group's artifacts registers one for that group, +/// sending the bytes on this connection; Canopy holds them and is issued no +/// credential to any store. The /// path identifies the version the artifact belongs to — either an exact /// version (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`, /// `^2.10.0`) — followed by the artifact's type and target platform. The @@ -122,14 +129,18 @@ pub fn routes() -> OpenApiRouter { path = "/{version}/{artifact_type}/{platform}", operation_id = "register_artifact", tag = "artifacts", - security(("releaser-device" = [])), + security( + ("releaser-device" = []), + ("backup-restore-device" = []), + ), params( ("version" = String, Path, description = "Exact semver (e.g. `2.10.5`) or range pattern (e.g. `2.10.x`, `^2.10.0`)."), ("artifact_type" = String, Path), ("platform" = String, Path), - ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused."), + ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group; a component that produces a group's artifacts is authorised for that group alone."), + ("run" = Option, Query, description = "The run that produced the artifact, where one produced it."), ), - request_body(content = String, description = "Download URL for the artifact, as a plain-text body."), + request_body(content = String, description = "For an unscoped artifact, its download URL as a plain-text body. For a group-scoped one, the artifact's bytes, which Canopy holds and verifies against the digest it takes of them."), responses( (status = 200, body = Artifact), (status = 400, body = ProblemDetailsSchema), @@ -139,27 +150,58 @@ pub fn routes() -> OpenApiRouter { )] #[axum::debug_handler] async fn create( - device: ReleaserDevice, + device: AuthDevice, State(db): State, Path((version, artifact_type, platform)): Path<(String, String, String)>, Query(scope): Query, headers: axum::http::HeaderMap, - url: String, + body: axum::body::Bytes, ) -> Result> { use node_semver::{Range, Version as SemverVersion}; - // A releaser registers unscoped artifacts and carries no authorisation for - // any group, so the group-scoped path is not reachable from this endpoint - // at all rather than being refused per group. + let mut db = db.get().await?; + let device_id = device.0.id; + let role = device.0.role; + + // Who may register what. A releaser registers unscoped artifacts and + // carries no authorisation for any group. A component that produces a + // group's artifacts registers for that group under an authorisation + // defined with those artifacts, and for no other. // spec: ART#registration - if scope.group.is_some() { - return Err(AppError::AuthInsufficientPermissions { - required: "authorisation for the named group".into(), - }); - } + let held = match scope.group { + None => { + if !matches!(role, DeviceRole::Releaser | DeviceRole::Admin) { + return Err(AppError::AuthInsufficientPermissions { + required: "releaser or admin".into(), + }); + } + None + } + Some(group) => { + let authorised = role == DeviceRole::Admin + || RestoreReplica::authorizes_schema_artifacts(&mut db, device_id, group).await?; + if !authorised { + // Refused the same way whether the group exists or not, so the + // endpoint is not a directory of which groups have a builder. + return Err(AppError::AuthInsufficientPermissions { + required: "an enabled declaration building this group's artifacts".into(), + }); + } - let mut db = db.get().await?; - let device_id = device.0.0.id; + if body.len() > MAX_HELD_ARTIFACT_BYTES { + return Err(AppError::BadRequest(format!( + "artifact is larger than the {MAX_HELD_ARTIFACT_BYTES} byte limit" + ))); + } + if body.is_empty() { + return Err(AppError::BadRequest( + "a group-scoped artifact carries its bytes".into(), + )); + } + + Some(group) + } + }; let (version_id, version_range_pattern) = if let Ok(semver) = SemverVersion::parse(&version) { let version_str = VersionStr(semver); @@ -195,30 +237,50 @@ async fn create( (None, Some(version.clone())) }; - let row = ArtifactRow::register( - &mut db, - NewArtifact { - version_id, - platform, - artifact_type, - download_url: Some(url), - device_id: Some(device_id), - version_range_pattern, - group_id: None, - content: None, - content_type: None, - digest: None, - run_id: None, - }, - ) - .await?; + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + + let row = + ArtifactRow::register( + &mut db, + NewArtifact { + version_id, + platform, + artifact_type, + download_url: match held { + None => Some(String::from_utf8(body.to_vec()).map_err(|_| { + AppError::BadRequest("download URL is not valid UTF-8".into()) + })?), + Some(_) => None, + }, + device_id: Some(device_id), + version_range_pattern, + group_id: held, + // Canopy verifies the bytes against the digest as they arrive, so it + // records the digest of what it actually took in. + // spec: ART#digests + digest: held.map(|_| digest_of(&body)), + content: held.map(|_| body.to_vec()), + content_type: held.and(content_type), + run_id: scope.run, + }, + ) + .await?; let base = crate::versions::public_base_url(&headers); Ok(Json(Artifact::offered(row, &base, &version))) } -/// The group a registration names, where it names one. +/// What a registration names beyond the path: the group an artifact is for, +/// and the run that produced it. #[derive(Debug, serde::Deserialize)] struct RegisterScope { group: Option, + run: Option, } + +/// Cap on the bytes Canopy will hold for one artifact, matching the operator +/// path. A reporting schema is a SQL file; anything approaching this is not one. +const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; diff --git a/private-web/openapi.json b/private-web/openapi.json index a1df7a53d..59e7187e6 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -11754,7 +11754,7 @@ "items": { "type": "string" }, - "description": "Behaviours this intent opts into. Recognised values are `check` (a\nhealth report is expected for each replica), `once` (a given snapshot\nis only ever dispatched to a replica once, rather than repeatedly\nuntil overdue), and `url` (a replica's health report includes a link\nto it). Unrecognised values are stored but have no effect." + "description": "Behaviours this intent opts into; see [`semantics`] for what each one\ngrants. Unrecognised values are stored but have no effect, so a consumer\nmay advertise ahead of Canopy support." } } }, diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 1f99b3a37..6e782daf7 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -6505,11 +6505,9 @@ export interface components { */ params?: components["schemas"]["BTreeMap"]; /** - * @description Behaviours this intent opts into. Recognised values are `check` (a - * health report is expected for each replica), `once` (a given snapshot - * is only ever dispatched to a replica once, rather than repeatedly - * until overdue), and `url` (a replica's health report includes a link - * to it). Unrecognised values are stored but have no effect. + * @description Behaviours this intent opts into; see [`semantics`] for what each one + * grants. Unrecognised values are stored but have no effect, so a consumer + * may advertise ahead of Canopy support. */ semantics?: string[]; }; From 8f9084bd5400e0df8215e1d9e886c02f9be49a00 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:50:20 +1200 Subject: [PATCH 038/130] grade unbuilt reporting schemas --- crates/database/src/backup.rs | 3 + crates/database/src/backup/refs.rs | 19 +++ crates/database/src/reporting_schemas.rs | 143 +++++++++++++++++++++++ 3 files changed, 165 insertions(+) diff --git a/crates/database/src/backup.rs b/crates/database/src/backup.rs index ef2b242c0..920e2be7d 100644 --- a/crates/database/src/backup.rs +++ b/crates/database/src/backup.rs @@ -29,6 +29,9 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result { let mut filed = staleness::sweep(db, &rows).await?; filed += reconcile::sweep(db, &rows).await?; filed += crate::restore::sweep_restore_checks(db).await?; + // Not counted in `filed`: this one files per group rather than per event, + // and its own instances are pairs. + crate::reporting_schemas::sweep(db).await?; // Not an event, but the same cadence: a plan closes once its group reports // the target, and this sweep is what notices. crate::upgrade_plans::close_met_plans(db).await?; diff --git a/crates/database/src/backup/refs.rs b/crates/database/src/backup/refs.rs index b9c325957..7c01c3811 100644 --- a/crates/database/src/backup/refs.rs +++ b/crates/database/src/backup/refs.rs @@ -122,6 +122,13 @@ pub const MIGRATION_TEST: &str = "migration-test"; /// redacting replicas as instances. pub const REDACTION: &str = "redaction"; +/// A reporting schema could not be built for a pair of this group and a Tamanu +/// version it runs or is moving to. Application-scoped, `Warning`, does not +/// escalate. One check on the group's central application with its unbuilt +/// pairs as instances. +// spec: RPT#alerting +pub const REPORTING_SCHEMA: &str = "reporting-schema"; + // --- shipped documentation (seeded into the catalog on first filing) --- pub const STALENESS_DOC: &str = "## Description @@ -302,6 +309,18 @@ One of this server's managed restore replicas reported a failed restorability ch Read the detail for the replicas named: restore errors point at the snapshot or credentials, staleness at the consumer itself. To handle one replica differently from the rest, write a rule or silence against its `check.replica_key` rather than the check as a whole."; +pub const REPORTING_SCHEMA_DOC: &str = "## Description + +A reporting schema is built for each pair of a group and a Tamanu version it runs or is moving to, from a replica of the group's own data migrated to that version. This check says a build for one of those pairs failed. Reports on the servers themselves keep working against whatever schema they already have; what is missing is the schema for a version, so reports written against it have nothing to read from. The version is in the detail rather than the check name, so a release doesn't spawn a catalog entry of its own. + +## Results + +- **warn**: a build for one of this group's pairs failed. The server is up and its reports return rows, so this is for whoever maintains the reports rather than whoever is on call. + +## Solve + +Read the failure in the report detail. A build failing against a fixed version and configuration fails the same way every time, so the pair stays settled until the version's artifacts change or an operator asks for the build again."; + pub const MIGRATION_TEST_DOC: &str = "## Description A candidate version's schema migrations were applied to a restore replica of this server's data and one of them failed, or the candidate has gone untried past the replica's overdue bound. The server itself is unaffected: it is still running the version it was, and the finding is about a version it has not taken. The version under test is in the detail rather than the check name, so a release doesn't spawn a catalog entry of its own. diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index ed6d5c716..39f3cf2c0 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -290,3 +290,146 @@ pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Resu Ok(versions) } + +/// File the reporting-schema check for every group that has a builder. +/// +/// One check per group, on its central application, with each of the group's +/// pairs as an instance. The version is in the instance detail rather than the +/// check name, so a release does not spawn a catalog entry of its own. +// spec: RPT#alerting +pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { + use crate::{ + applications::Application, + backup::refs, + issues::{ + CheckInstance, GradedInstance, InstancedCheckFiling, Scope, file_check_instances, + }, + server_groups::ServerGroup, + }; + use commons_types::status::CheckResult; + + for group in ServerGroup::list_all(db).await? { + if !group_builds_schemas(db, group.id).await? { + continue; + } + + let members = Application::list_live_in_group(db, group.id).await?; + let Some(central) = ServerGroup::canonical_central(&members).map(|a| a.id) else { + continue; + }; + + let pairs = pairs_for_group(db, group.id).await?; + let instances: Vec = pairs + .iter() + .filter(|p| p.state != PairState::Awaiting) + .map(|pair| CheckInstance { + label: pair.version.clone(), + observed: match pair.state { + PairState::Built => CheckResult::Passed, + _ => CheckResult::Warning, + }, + detail: Some(serde_json::json!({ + "version": pair.version, + "why": pair.error.clone().unwrap_or_else(|| { + format!("no schema could be built for {}", pair.version) + }), + })), + }) + .collect(); + + // An empty set is not nothing to do: a check already open has to be + // closed, or it stays open forever once its last pair goes away. + if instances.is_empty() { + let open = crate::backup::staleness::open_server_issue_active( + db, + central, + refs::REPORTING_SCHEMA, + ) + .await?; + if open { + crate::issues::file_check( + db, + crate::issues::CheckFiling { + source: crate::statuses::CANOPY_SOURCE, + scope: Scope::Application(central), + device_id: None, + check: refs::REPORTING_SCHEMA, + observed: CheckResult::Passed, + detail: None, + message: &format!("No reporting schema is owed for {}", group.name), + title: Some("reporting schema not built"), + default_ceiling: CheckResult::Warning, + default_escalates: false, + documentation: Some(refs::REPORTING_SCHEMA_DOC), + }, + ) + .await?; + } + continue; + } + + let name = group.name.clone(); + let total = instances.len(); + file_check_instances( + db, + InstancedCheckFiling { + source: crate::statuses::CANOPY_SOURCE, + scope: Scope::Application(central), + device_id: None, + check: refs::REPORTING_SCHEMA, + title: Some("reporting schema not built"), + instances, + default_ceiling: CheckResult::Warning, + default_escalates: false, + documentation: Some(refs::REPORTING_SCHEMA_DOC), + }, + &move |degraded: &[GradedInstance]| match degraded { + [] => format!("Reporting schemas are built for every version {name} runs"), + [one] => format!( + "No reporting schema for {name} on {}: {}", + one.label, + one.detail + .as_ref() + .and_then(|d| d.get("why")) + .and_then(|v| v.as_str()) + .unwrap_or("the build failed") + ), + many => format!( + "No reporting schema for {} of {total} versions {name} runs: {}", + many.len(), + many.iter() + .map(|i| i.label.as_str()) + .collect::>() + .join(", ") + ), + }, + ) + .await?; + } + + Ok(()) +} + +/// Whether a group has an enabled declaration whose intent builds schemas. +async fn group_builds_schemas(db: &mut AsyncPgConnection, group: Uuid) -> Result { + use crate::restore::{RestoreConsumerCapability, RestoreReplica}; + use commons_types::backup::semantics; + + for declaration in RestoreReplica::list_for_group(db, group).await? { + if !declaration.enabled { + continue; + } + let advertises = + RestoreConsumerCapability::list_for_consumer(db, declaration.consumer_device_id) + .await? + .into_iter() + .any(|d| { + d.intent == declaration.intent && d.has_semantic(semantics::REPORTING_SCHEMA) + }); + if advertises { + return Ok(true); + } + } + + Ok(false) +} From a14a5e070bf71745bf0d604ff61135cb56a03107 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:57:12 +1200 Subject: [PATCH 039/130] test reporting schema pairs --- crates/database/tests/it/main.rs | 1 + crates/database/tests/it/reporting_schemas.rs | 254 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 crates/database/tests/it/reporting_schemas.rs diff --git a/crates/database/tests/it/main.rs b/crates/database/tests/it/main.rs index bde25560a..951960422 100644 --- a/crates/database/tests/it/main.rs +++ b/crates/database/tests/it/main.rs @@ -49,6 +49,7 @@ mod reachability_silence_migration; mod reachability_sweep; mod recovery_vault; mod reported_detail; +mod reporting_schemas; mod restore; mod rotation_interlock; mod scope; diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs new file mode 100644 index 000000000..f99d71b0c --- /dev/null +++ b/crates/database/tests/it/reporting_schemas.rs @@ -0,0 +1,254 @@ +//! Pair derivation and settling for reporting schemas. +//! +//! spec: RPT + +use commons_tests::db::TestDb; +use database::{ + diesel_async::AsyncPgConnection, + reporting_schemas::{ + NewReportingSchemaBuild, PairState, ReportingSchemaBuild, ReportingSchemaRequest, + pairs_for_group, versions_for_group, + }, + restore::NewBackupRestoreCheck, +}; +use diesel_async::SimpleAsyncConnection; +use uuid::Uuid; + +const GROUP: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const MACHINE: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; +const CENTRAL: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; +const FACILITY: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd"; +const CONSUMER: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + +/// A group with a central on 2.60.0 and a facility on 2.59.0, both published. +async fn seed(conn: &mut AsyncPgConnection) -> (Uuid, Uuid) { + conn.batch_execute(&format!( + "INSERT INTO devices (id, role) VALUES ('{CONSUMER}', 'backup-restore'); + + INSERT INTO versions (id, major, minor, patch, changelog, status) VALUES + ('11111111-1111-1111-1111-111111111111', 2, 59, 0, '', 'published'), + ('22222222-2222-2222-2222-222222222222', 2, 60, 0, '', 'published'); + + INSERT INTO server_groups (id, name) VALUES ('{GROUP}', 'kamaka'); + + INSERT INTO machines (id, name, group_id) VALUES ('{MACHINE}', 'box', '{GROUP}'); + + INSERT INTO applications (id, type, name, host, machine_id, group_id) VALUES + ('{CENTRAL}', 'tamanu-central', 'central', 'https://c', '{MACHINE}', '{GROUP}'), + ('{FACILITY}', 'tamanu-facility', 'facility', 'https://f', '{MACHINE}', '{GROUP}'); + + INSERT INTO application_reported_detail (application_id, source, reported_at, version) VALUES + ('{CENTRAL}', 'tamanu', NOW(), '2.60.0'), + ('{FACILITY}', 'tamanu', NOW(), '2.59.0')", + )) + .await + .expect("seed"); + + ( + "11111111-1111-1111-1111-111111111111".parse().unwrap(), + "22222222-2222-2222-2222-222222222222".parse().unwrap(), + ) +} + +fn group() -> Uuid { + GROUP.parse().unwrap() +} + +/// Record a build against a throwaway restore report for the pair. +async fn record_build(conn: &mut AsyncPgConnection, version: Uuid, built: bool) { + let report = NewBackupRestoreCheck { + replica_id: None, + replica_name: None, + consumer_device_id: CONSUMER.parse().unwrap(), + group_id: group(), + machine_id: Some(MACHINE.parse().unwrap()), + r#type: "tamanu-postgres".parse().unwrap(), + intent: "reporting-schema".parse().unwrap(), + snapshot_id: Some("snap-1".to_owned()), + outcome: commons_types::backup::RunOutcome::Success, + error: None, + replica_healthy: true, + postgres_version: None, + observed_at: jiff::Timestamp::now(), + s3_sent_raw_bytes: None, + s3_sent_payload_bytes: None, + s3_received_raw_bytes: None, + s3_received_payload_bytes: None, + health_details: None, + run_id: None, + redaction_outcome: None, + redaction_manifest_version: None, + redaction_columns_masked: None, + redaction_columns_skipped: None, + redaction_error: None, + }; + + ReportingSchemaBuild::record( + conn, + report, + NewReportingSchemaBuild { + group_id: group(), + version_id: version, + application_id: Some(CENTRAL.parse().unwrap()), + built, + error: (!built).then(|| "views did not compile".to_owned()), + }, + ) + .await + .expect("record build"); +} + +/// The pairs are every version the group's Tamanu applications report running. +/// A facility mid-rollout is on a different version from its central, and both +/// are pairs, because a schema follows the version rather than the application. +#[tokio::test(flavor = "multi_thread")] +async fn a_facility_on_its_own_version_is_a_pair_of_its_own() { + TestDb::run(|mut conn, _url| async move { + let (older, newer) = seed(&mut conn).await; + + let versions = versions_for_group(&mut conn, group()) + .await + .expect("derive versions"); + let ids: Vec = versions.iter().map(|v| v.id).collect(); + + assert!(ids.contains(&older), "the facility's version is a pair"); + assert!(ids.contains(&newer), "the central's version is a pair"); + }) + .await; +} + +/// A pair with no build is awaiting one; a built pair is settled; a failed +/// build settles it as firmly, since a build against a fixed version and +/// configuration fails the same way every time. +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_build_settles_the_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "an untried pair is on the worklist" + ); + + record_build(&mut conn, newer, false).await; + + assert!( + ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "a failure settles it as firmly as a pass" + ); + + let pairs = pairs_for_group(&mut conn, group()).await.expect("pairs"); + let failed = pairs.iter().find(|p| p.version_id == newer).unwrap(); + assert_eq!(failed.state, PairState::Failed); + assert_eq!(failed.error.as_deref(), Some("views did not compile")); + }) + .await; +} + +/// An operator asking for a build reinstates a settled pair, and the ask is +/// answered once the build lands. +#[tokio::test(flavor = "multi_thread")] +async fn an_operator_ask_reinstates_a_settled_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + record_build(&mut conn, newer, true).await; + assert!( + ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap() + ); + + ReportingSchemaRequest::enqueue(&mut conn, group(), newer, Some("someone@bes.au")) + .await + .expect("enqueue"); + + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "an ask puts the pair back on the worklist" + ); + + let pairs = pairs_for_group(&mut conn, group()).await.expect("pairs"); + assert!( + pairs + .iter() + .find(|p| p.version_id == newer) + .unwrap() + .requested + ); + + // The build that answers the ask clears it. + record_build(&mut conn, newer, true).await; + assert!( + ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "the ask is answered once the build it asked for lands" + ); + }) + .await; +} + +/// A replica that failed to restore says nothing about whether the pair can be +/// built, so it records no build and the pair stays on the worklist. +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_restore_leaves_the_pair_unsettled() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + let report = NewBackupRestoreCheck { + replica_id: None, + replica_name: None, + consumer_device_id: CONSUMER.parse().unwrap(), + group_id: group(), + machine_id: Some(MACHINE.parse().unwrap()), + r#type: "tamanu-postgres".parse().unwrap(), + intent: "reporting-schema".parse().unwrap(), + snapshot_id: Some("snap-1".to_owned()), + outcome: commons_types::backup::RunOutcome::Failure, + error: Some("replica never came up".to_owned()), + replica_healthy: false, + postgres_version: None, + observed_at: jiff::Timestamp::now(), + s3_sent_raw_bytes: None, + s3_sent_payload_bytes: None, + s3_received_raw_bytes: None, + s3_received_payload_bytes: None, + health_details: None, + run_id: None, + redaction_outcome: None, + redaction_manifest_version: None, + redaction_columns_masked: None, + redaction_columns_skipped: None, + redaction_error: None, + }; + + ReportingSchemaBuild::record( + &mut conn, + report, + NewReportingSchemaBuild { + group_id: group(), + version_id: newer, + application_id: Some(CENTRAL.parse().unwrap()), + built: false, + error: None, + }, + ) + .await + .expect("record"); + + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "an unhealthy restore is dispatched again rather than settling the pair" + ); + }) + .await; +} From a2a9f6b5632d4c8a77955faf6cdae9e03210d991 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:05:21 +1200 Subject: [PATCH 040/130] present pairs to operators --- crates/private-server/src/fns.rs | 2 + .../src/fns/reporting_schemas.rs | 93 +++++++++ private-web/e2e/reporting-schemas.spec.ts | 90 +++++++++ private-web/e2e/seed.ts | 2 +- private-web/openapi.json | 189 ++++++++++++++++++ private-web/src/api-types.ts | 156 +++++++++++++++ .../components/ReportingSchemasSection.tsx | 154 ++++++++++++++ private-web/src/routes/GroupDetail.tsx | 2 + 8 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 crates/private-server/src/fns/reporting_schemas.rs create mode 100644 private-web/e2e/reporting-schemas.spec.ts create mode 100644 private-web/src/components/ReportingSchemasSection.tsx diff --git a/crates/private-server/src/fns.rs b/crates/private-server/src/fns.rs index bfdb17b61..0942b831b 100644 --- a/crates/private-server/src/fns.rs +++ b/crates/private-server/src/fns.rs @@ -16,6 +16,7 @@ pub mod machines; pub mod maintenance; pub mod mcp_tokens; pub mod migration_tests; +pub mod reporting_schemas; pub mod restore_replicas; pub mod self_alerts; pub mod server_groups; @@ -139,6 +140,7 @@ pub fn routes() -> OpenApiRouter { .nest("/issues", issues::routes()) .nest("/mcp_tokens", mcp_tokens::routes()) .nest("/migration_tests", migration_tests::routes()) + .nest("/reporting_schemas", reporting_schemas::routes()) .nest("/restore_replicas", restore_replicas::routes()) .nest("/self_alerts", self_alerts::routes()) .nest("/maintenance", maintenance::routes()) diff --git a/crates/private-server/src/fns/reporting_schemas.rs b/crates/private-server/src/fns/reporting_schemas.rs new file mode 100644 index 000000000..41f53f3cf --- /dev/null +++ b/crates/private-server/src/fns/reporting_schemas.rs @@ -0,0 +1,93 @@ +use axum::Json; +use axum::extract::State; +use canopy_utoipa_axum::{router::OpenApiRouter, routes}; +use commons_errors::{ProblemDetailsSchema, Result}; +use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; +use database::reporting_schemas::{Pair, ReportingSchemaRequest}; +use serde::Deserialize; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::state::AppState; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(for_group)) + .routes(routes!(build)) +} + +/// Request body for reading a group's pairs. +#[derive(Deserialize, ToSchema)] +pub struct PairsForGroupArgs { + /// The group to report on. + pub group_id: Uuid, +} + +/// Where each of a group's pairs of group and Tamanu version stands. +/// +/// One entry per published version the group's Tamanu applications report +/// running, plus the version its open plan moves it to, so whether a group's +/// applications can be offered the schema for the version they run or are +/// moving to is answered in one place. +// spec: RPT#alerting +#[utoipa::path( + post, + path = "/for_group", + operation_id = "reporting_schemas_for_group", + tag = "reporting_schemas", + security(("tailscale-admin" = [])), + request_body = PairsForGroupArgs, + responses( + (status = 200, description = "Pairs, one per version the group runs or is moving to.", body = Vec), + (status = 401, body = ProblemDetailsSchema), + (status = 403, body = ProblemDetailsSchema), + ), +)] +pub async fn for_group( + State(state): State, + _admin: TailscaleAdmin, + Json(args): Json, +) -> Result>> { + let mut conn = state.db_read.get().await?; + let pairs = database::reporting_schemas::pairs_for_group(&mut conn, args.group_id).await?; + Ok(Json(pairs)) +} + +/// Which pair to build. +#[derive(Deserialize, ToSchema)] +pub struct BuildPairArgs { + pub group_id: Uuid, + pub version_id: Uuid, +} + +/// Ask for a pair's schema to be built. +/// +/// This is how a schema is refreshed after the group's configuration changes, +/// and how a settled pair is put back on the worklist: a build against a fixed +/// version and configuration fails the same way every time, so a failed pair +/// waits for this rather than retrying on its own. +// spec: RPT#pairs +#[utoipa::path( + post, + path = "/build", + operation_id = "reporting_schemas_build", + tag = "reporting_schemas", + security(("tailscale-admin" = [])), + request_body = BuildPairArgs, + responses( + (status = 200), + (status = 401, body = ProblemDetailsSchema), + (status = 403, body = ProblemDetailsSchema), + ), +)] +pub async fn build( + State(state): State, + admin: TailscaleAdmin, + Json(args): Json, +) -> Result> { + let mut conn = state.db.get().await?; + let TailscaleAdmin(TailscaleUser { login, .. }) = admin; + ReportingSchemaRequest::enqueue(&mut conn, args.group_id, args.version_id, Some(&login)) + .await?; + Ok(Json(())) +} diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts new file mode 100644 index 000000000..9891ea027 --- /dev/null +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -0,0 +1,90 @@ +import { + resetSeededTables, + seedApplicationReport, + seedServer, + seedServerGroup, + seedVersion, +} from "./seed"; +import { expect, test } from "./test-fixtures"; + +/// How a group's reporting-schema pairs are presented, and how an operator asks +/// for one to be built. +/// +/// spec: RPT +test.describe("reporting schemas", () => { + test.beforeEach(async ({ sql }) => { + await resetSeededTables(sql); + }); + + /// One pair per version the group's Tamanu applications report running. A + /// facility mid-rollout is on a different version from its central, so both + /// are pairs: a schema follows the version rather than the application. + /// + /// spec: RPT#pairs + test("a group shows a pair per version its applications run", async ({ + page, + sql, + }) => { + await seedVersion(sql, { major: 2, minor: 59, patch: 0, status: "published" }); + await seedVersion(sql, { major: 2, minor: 60, patch: 0, status: "published" }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + const central = await seedServer(sql, { + name: "central", + groupId: group.id, + type: "tamanu-central", + }); + const facility = await seedServer(sql, { + name: "facility", + groupId: group.id, + type: "tamanu-facility", + }); + await seedApplicationReport(sql, { + applicationId: central.id, + version: "2.60.0", + }); + await seedApplicationReport(sql, { + applicationId: facility.id, + version: "2.59.0", + }); + + await page.goto(`/groups/${group.id}`); + + const section = page.getByTestId("reporting-schemas"); + await expect(section).toBeVisible(); + await expect(section.getByTestId("reporting-schema-row")).toHaveCount(2); + await expect(section.getByText("2.59.0")).toBeVisible(); + await expect(section.getByText("2.60.0")).toBeVisible(); + + // Nothing has been built yet, so both are awaiting one. + await expect(section.getByText("Awaiting build")).toHaveCount(2); + }); + + /// An operator asking for a build is what reinstates a pair, so the ask has + /// to be visible once made. + /// + /// spec: RPT#pairs + test("asking for a build records the ask", async ({ page, sql }) => { + await seedVersion(sql, { major: 2, minor: 60, patch: 0, status: "published" }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + const central = await seedServer(sql, { + name: "central", + groupId: group.id, + type: "tamanu-central", + }); + await seedApplicationReport(sql, { + applicationId: central.id, + version: "2.60.0", + }); + + await page.goto(`/groups/${group.id}`); + + const section = page.getByTestId("reporting-schemas"); + await section.getByRole("button", { name: "Build sooner" }).click(); + + await expect(section.getByText("Build asked for")).toBeVisible(); + + const rows = await sql.query("SELECT requested_by FROM reporting_schema_requests"); + expect(rows.rows).toHaveLength(1); + }); +}); diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index a54f79d77..2dcc2657c 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -167,7 +167,7 @@ async function applicationTypeOf(sql: Sql, applicationId: string): Promise { await sql.query( - "TRUNCATE statuses, application_reported_detail, machine_reported_detail, issues, device_keys, applications, machines, server_groups, server_group_domains, devices, versions, tailscale_users, check_policies, scoped_check_policies, source_policies, server_group_backup_config, server_group_backup_schedule, machine_backup_capabilities, backup_requests, backup_runs, backup_run_progress, backup_repo_stats, backup_maintenance_runs, backup_credential_issuances, restore_replicas, restore_consumer_capabilities, backup_restore_checks, migration_tests, migration_timings, upgrade_plans, maintenance_windows, version_known_issues, recovery_vault_writes, application_names, application_certificates, compromised_keys RESTART IDENTITY CASCADE", + "TRUNCATE statuses, application_reported_detail, machine_reported_detail, issues, device_keys, applications, machines, server_groups, server_group_domains, devices, versions, tailscale_users, check_policies, scoped_check_policies, source_policies, server_group_backup_config, server_group_backup_schedule, machine_backup_capabilities, backup_requests, backup_runs, backup_run_progress, backup_repo_stats, backup_maintenance_runs, backup_credential_issuances, restore_replicas, restore_consumer_capabilities, backup_restore_checks, migration_tests, migration_timings, reporting_schema_builds, reporting_schema_requests, upgrade_plans, maintenance_windows, version_known_issues, recovery_vault_writes, application_names, application_certificates, compromised_keys RESTART IDENTITY CASCADE", ); // The truncate takes the migration-seeded nil "Canopy" application with // it; self-alerts attach to that row, so put it back. diff --git a/private-web/openapi.json b/private-web/openapi.json index 59e7187e6..0813a9527 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -6188,6 +6188,116 @@ ] } }, + "/api/reporting_schemas/build": { + "post": { + "tags": [ + "reporting_schemas" + ], + "summary": "Ask for a pair's schema to be built.", + "description": "This is how a schema is refreshed after the group's configuration changes,\nand how a settled pair is put back on the worklist: a build against a fixed\nversion and configuration fails the same way every time, so a failed pair\nwaits for this rather than retrying on its own.", + "operationId": "reporting_schemas_build", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildPairArgs" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + } + }, + "security": [ + { + "tailscale-admin": [] + } + ] + } + }, + "/api/reporting_schemas/for_group": { + "post": { + "tags": [ + "reporting_schemas" + ], + "summary": "Where each of a group's pairs of group and Tamanu version stands.", + "description": "One entry per published version the group's Tamanu applications report\nrunning, plus the version its open plan moves it to, so whether a group's\napplications can be offered the schema for the version they run or are\nmoving to is answered in one place.", + "operationId": "reporting_schemas_for_group", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairsForGroupArgs" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Pairs, one per version the group runs or is moving to.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pair" + } + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + } + }, + "security": [ + { + "tailscale-admin": [] + } + ] + } + }, "/api/restore_replicas/checks": { "post": { "tags": [ @@ -9287,6 +9397,24 @@ } } }, + "BuildPairArgs": { + "type": "object", + "description": "Which pair to build.", + "required": [ + "group_id", + "version_id" + ], + "properties": { + "group_id": { + "type": "string", + "format": "uuid" + }, + "version_id": { + "type": "string", + "format": "uuid" + } + } + }, "Caps": { "type": "object", "description": "What Canopy does for an application of a given type.\n\nReachability, health checks and backups are deliberately absent: checks are\ngraded by the source that reports them, and backup types are advertised per\nmachine by the agent, so both already work for any type.", @@ -13975,6 +14103,67 @@ } } }, + "Pair": { + "type": "object", + "description": "One pair of group and Tamanu version, and where it stands.", + "required": [ + "group_id", + "version_id", + "version", + "state", + "requested" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ], + "description": "What went wrong, where a build failed." + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "requested": { + "type": "boolean", + "description": "Whether an operator has asked for this pair to be built again." + }, + "state": { + "$ref": "#/components/schemas/PairState" + }, + "version": { + "type": "string" + }, + "version_id": { + "type": "string", + "format": "uuid" + } + } + }, + "PairState": { + "type": "string", + "description": "Where a pair stands, for the operator view.", + "enum": [ + "awaiting", + "built", + "failed" + ] + }, + "PairsForGroupArgs": { + "type": "object", + "description": "Request body for reading a group's pairs.", + "required": [ + "group_id" + ], + "properties": { + "group_id": { + "type": "string", + "format": "uuid", + "description": "The group to report on." + } + } + }, "ParamType": { "type": "string", "description": "The data type of a restore-replica configuration parameter, which\ndetermines how its value is validated. `duration` and `bytes` values must\nbe non-negative integers (a count of seconds and of bytes, respectively);\n`integer` accepts any whole number, positive or negative; `boolean` is a\nJSON boolean; `text` is a JSON string.", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 6e782daf7..2095bde9a 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -3091,6 +3091,52 @@ export interface paths { patch?: never; trace?: never; }; + "/api/reporting_schemas/build": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Ask for a pair's schema to be built. + * @description This is how a schema is refreshed after the group's configuration changes, + * and how a settled pair is put back on the worklist: a build against a fixed + * version and configuration fails the same way every time, so a failed pair + * waits for this rather than retrying on its own. + */ + post: operations["reporting_schemas_build"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/reporting_schemas/for_group": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Where each of a group's pairs of group and Tamanu version stands. + * @description One entry per published version the group's Tamanu applications report + * running, plus the version its open plan moves it to, so whether a group's + * applications can be offered the schema for the version they run or are + * moving to is answered in one place. + */ + post: operations["reporting_schemas_for_group"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/restore_replicas/checks": { parameters: { query?: never; @@ -4902,6 +4948,13 @@ export interface components { /** @description Label value. */ value: string; }; + /** @description Which pair to build. */ + BuildPairArgs: { + /** Format: uuid */ + group_id: string; + /** Format: uuid */ + version_id: string; + }; /** * @description What Canopy does for an application of a given type. * @@ -7897,6 +7950,32 @@ export interface components { */ offset: number; }; + /** @description One pair of group and Tamanu version, and where it stands. */ + Pair: { + /** @description What went wrong, where a build failed. */ + error?: string | null; + /** Format: uuid */ + group_id: string; + /** @description Whether an operator has asked for this pair to be built again. */ + requested: boolean; + state: components["schemas"]["PairState"]; + version: string; + /** Format: uuid */ + version_id: string; + }; + /** + * @description Where a pair stands, for the operator view. + * @enum {string} + */ + PairState: "awaiting" | "built" | "failed"; + /** @description Request body for reading a group's pairs. */ + PairsForGroupArgs: { + /** + * Format: uuid + * @description The group to report on. + */ + group_id: string; + }; /** * @description The data type of a restore-replica configuration parameter, which * determines how its value is validated. `duration` and `bytes` values must @@ -14789,6 +14868,83 @@ export interface operations { }; }; }; + reporting_schemas_build: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BuildPairArgs"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + }; + }; + reporting_schemas_for_group: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PairsForGroupArgs"]; + }; + }; + responses: { + /** @description Pairs, one per version the group runs or is moving to. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Pair"][]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + }; + }; restore_replicas_checks: { parameters: { query?: never; diff --git a/private-web/src/components/ReportingSchemasSection.tsx b/private-web/src/components/ReportingSchemasSection.tsx new file mode 100644 index 000000000..1e70cfb33 --- /dev/null +++ b/private-web/src/components/ReportingSchemasSection.tsx @@ -0,0 +1,154 @@ +import { + Alert, + Box, + Button, + Chip, + LinearProgress, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import { useApi, useApiAction } from "../api"; + +type PairState = "awaiting" | "built" | "failed"; + +/// Which of the group's versions have a reporting schema, which failed, and +/// which are still to be built, so whether the group's applications can be +/// offered the schema for the version they run or are moving to is answered in +/// one place. +// spec: RPT#alerting +export default function ReportingSchemasSection({ + groupId, +}: { + groupId: string; +}) { + const pairs = useApi( + "reporting_schemas", + "for_group", + { group_id: groupId }, + [groupId], + ); + const build = useApiAction("reporting_schemas", "build"); + + if (pairs.status === "loading" || pairs.status === "idle") { + return ( + + + + + ); + } + if (pairs.status === "error") { + return ( + + + {pairs.error.message} + + ); + } + + if (pairs.data.length === 0) { + return ( + + + + No Tamanu application in this group reports a published version, so + there is nothing to build a schema against. + + + ); + } + + const ask = async (versionId: string) => { + try { + await build.call({ group_id: groupId, version_id: versionId }); + pairs.reload(); + } catch { + /* surfaced via build.error */ + } + }; + + return ( + + + {build.error && ( + + {build.error.message} + + )} + + + + Version + Schema + + + + + {pairs.data.map((pair) => ( + + + {pair.version} + + + + + + {pair.requested ? ( + + Build asked for + + ) : ( + + )} + + + ))} + +
+
+ ); +} + +function StateChip({ + state, + error, +}: { + state: PairState; + error?: string | null; +}) { + if (state === "built") { + return ; + } + if (state === "awaiting") { + return ; + } + return ( + + + + ); +} + +function SectionHeading() { + return ( + + Reporting schemas + + One per version this group runs or is moving to, built from a replica + of the group's own data. + + + ); +} diff --git a/private-web/src/routes/GroupDetail.tsx b/private-web/src/routes/GroupDetail.tsx index d60f1df6e..43b5749fc 100644 --- a/private-web/src/routes/GroupDetail.tsx +++ b/private-web/src/routes/GroupDetail.tsx @@ -17,6 +17,7 @@ import RestoreIcon from "@mui/icons-material/RestoreFromTrash"; import { Link as RouterLink, useNavigate, useParams } from "react-router-dom"; import GroupDomainsSection from "../components/GroupDomainsSection"; import MigrationTestsSection from "../components/MigrationTestsSection"; +import ReportingSchemasSection from "../components/ReportingSchemasSection"; import { OperatorAvatar, connectedFor } from "../components/OperatorAvatars"; import ActiveIncidentCard from "../components/ActiveIncidentCard"; import GroupTree from "../components/GroupTree"; @@ -241,6 +242,7 @@ export default function GroupDetail() { + From 30dce514e7bf53a1ea3fca536e8c3ab4811da648 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:14:56 +1200 Subject: [PATCH 041/130] test schema dispatch and publishing --- crates/public-server/tests/it/main.rs | 1 + .../tests/it/reporting_schemas.rs | 189 ++++++++++++++++++ private-web/e2e/reporting-schemas.spec.ts | 4 +- 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 crates/public-server/tests/it/reporting_schemas.rs diff --git a/crates/public-server/tests/it/main.rs b/crates/public-server/tests/it/main.rs index d3d925e2e..959851dc5 100644 --- a/crates/public-server/tests/it/main.rs +++ b/crates/public-server/tests/it/main.rs @@ -19,6 +19,7 @@ mod mcp; mod names; mod openapi_spec; mod password; +mod reporting_schemas; mod restore; mod server_self; mod server_versions; diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs new file mode 100644 index 000000000..54ddd76b2 --- /dev/null +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -0,0 +1,189 @@ +//! Dispatching reporting-schema builds, and who may publish what one produces. +//! +//! spec: RPT + +use axum::http::StatusCode; +use diesel_async::SimpleAsyncConnection; + +const GROUP: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const OTHER_GROUP: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; +const MACHINE: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; +const CENTRAL: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; +const VERSION: &str = "22222222-2222-2222-2222-222222222222"; + +/// A group whose central runs 2.60.0, with a ready backup repo and a snapshot, +/// and a consumer device declared against it building reporting schemas. +async fn seed(conn: &mut database::diesel_async::AsyncPgConnection, consumer: uuid::Uuid) { + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{VERSION}', 2, 60, 0, '', 'published'); + + INSERT INTO server_groups (id, name) VALUES + ('{GROUP}', 'kamaka'), ('{OTHER_GROUP}', 'drifting'); + + INSERT INTO machines (id, name, group_id) VALUES ('{MACHINE}', 'box', '{GROUP}'); + + INSERT INTO applications (id, type, name, host, machine_id, group_id) + VALUES ('{CENTRAL}', 'tamanu-central', 'central', 'https://c', '{MACHINE}', '{GROUP}'); + + INSERT INTO application_reported_detail (application_id, source, reported_at, version) + VALUES ('{CENTRAL}', 'tamanu', NOW(), '2.60.0'); + + INSERT INTO server_group_backup_config + (group_id, bucket, prefix, target_role_arn, maintenance_role_arn, repo_password_ref, status) + VALUES ('{GROUP}', 'b', 'p/', 'arn:t', 'arn:m', 'ref', 'ready'); + + INSERT INTO backup_runs + (id, device_id, machine_id, group_id, type, purpose, outcome, snapshot_id, reported_at) + VALUES (gen_random_uuid(), '{consumer}', '{MACHINE}', '{GROUP}', 'tamanu-postgres', 'backup', 'success', 'snap-1', NOW()); + + INSERT INTO restore_consumer_capabilities + (consumer_device_id, intent, description, semantics, params) + VALUES ('{consumer}', 'schema-build', 'builds schemas', + '[\"check\", \"once\", \"migrate\", \"reporting-schema\"]'::jsonb, '{{}}'::jsonb); + + INSERT INTO restore_replicas + (consumer_device_id, group_id, type, intent, name, enabled) + VALUES ('{consumer}', '{GROUP}', 'tamanu-postgres', 'schema-build', 'schemas', true)", + )) + .await + .expect("seed"); +} + +/// A build is dispatched per pair on the group's central machine, naming the +/// pair's version rather than the machine's own upgrade candidate. +#[tokio::test(flavor = "multi_thread")] +async fn a_build_is_dispatched_per_pair_on_the_central() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + let ours: Vec<&serde_json::Value> = entries + .iter() + .filter(|e| e["intent"] == "schema-build") + .collect(); + + assert_eq!(ours.len(), 1, "one entry for the group's one pair"); + assert_eq!(ours[0]["machine_id"], MACHINE, "restores the central's box"); + assert_eq!( + ours[0]["target_version"], "2.60.0", + "names the pair's version" + ); + assert_eq!(ours[0]["application_type"], "tamanu-central"); + }, + ) + .await +} + +/// `once` is keyed to the pair rather than the snapshot, so a pair that has been +/// built drops off the worklist and stays off while the snapshot moves on. +#[tokio::test(flavor = "multi_thread")] +async fn a_built_pair_drops_off_the_worklist() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + // Record a build for the pair, riding a restore report as one does. + conn.batch_execute(&format!( + "INSERT INTO backup_restore_checks + (consumer_device_id, group_id, machine_id, type, intent, snapshot_id, + outcome, replica_healthy, observed_at, reported_at) + VALUES ('{device_id}', '{GROUP}', '{MACHINE}', 'tamanu-postgres', + 'schema-build', 'snap-1', 'success', true, NOW(), NOW()); + + INSERT INTO reporting_schema_builds (check_id, group_id, version_id, built) + SELECT id, '{GROUP}', '{VERSION}', true FROM backup_restore_checks + ORDER BY id DESC LIMIT 1", + )) + .await + .expect("record a build"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + assert!( + !entries.iter().any(|e| e["intent"] == "schema-build"), + "a built pair is settled and not dispatched again" + ); + + // A newer snapshot does not bring it back: the key is the pair. + conn.batch_execute(&format!( + "INSERT INTO backup_runs + (id, device_id, machine_id, group_id, type, purpose, outcome, snapshot_id, reported_at) + VALUES (gen_random_uuid(), '{device_id}', '{MACHINE}', '{GROUP}', 'tamanu-postgres', 'backup', 'success', 'snap-2', NOW())", + )) + .await + .expect("newer snapshot"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let entries: Vec = response.json(); + assert!( + !entries.iter().any(|e| e["intent"] == "schema-build"), + "a newer snapshot does not rebuild a schema the pair already has" + ); + }, + ) + .await +} + +/// A builder registers artifacts for the group its declaration covers, and is +/// refused another's the same way it would be refused a group that does not +/// exist. +#[tokio::test(flavor = "multi_thread")] +async fn a_builder_publishes_only_for_its_own_group() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let ours = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + ours.assert_status_ok(); + + let theirs = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={OTHER_GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("CREATE VIEW ...") + .await; + assert_eq!(theirs.status_code(), StatusCode::FORBIDDEN); + + let nowhere = public + .post( + "/artifacts/2.60.0/reporting-schema/any?group=99999999-9999-9999-9999-999999999999", + ) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("CREATE VIEW ...") + .await; + assert_eq!( + nowhere.status_code(), + theirs.status_code(), + "a group it is not authorised for and one that does not exist answer alike" + ); + }, + ) + .await +} diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index 9891ea027..49ce85a1e 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -84,7 +84,7 @@ test.describe("reporting schemas", () => { await expect(section.getByText("Build asked for")).toBeVisible(); - const rows = await sql.query("SELECT requested_by FROM reporting_schema_requests"); - expect(rows.rows).toHaveLength(1); + const asks = await sql.query("SELECT requested_by FROM reporting_schema_requests"); + expect(asks).toHaveLength(1); }); }); From 40d2739254e1790b1b47d7bb009d982a4dcb944a Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:21:26 +1200 Subject: [PATCH 042/130] describe the schema api --- crates/database/src/reporting_schemas.rs | 4 ++++ .../src/fns/reporting_schemas.rs | 2 ++ crates/private-server/src/openapi.rs | 1 + private-web/openapi.json | 22 ++++++++++++++----- private-web/src/api-types.ts | 22 +++++++++++++++---- 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 39f3cf2c0..543949d49 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -212,9 +212,13 @@ pub enum PairState { /// One pair of group and Tamanu version, and where it stands. #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct Pair { + /// The group this pair is for. pub group_id: Uuid, + /// The Tamanu version this pair is for. pub version_id: Uuid, + /// That version as semver, for display. pub version: String, + /// Whether the pair has a schema, failed to build one, or is awaiting one. pub state: PairState, /// What went wrong, where a build failed. pub error: Option, diff --git a/crates/private-server/src/fns/reporting_schemas.rs b/crates/private-server/src/fns/reporting_schemas.rs index 41f53f3cf..5ef0c82b6 100644 --- a/crates/private-server/src/fns/reporting_schemas.rs +++ b/crates/private-server/src/fns/reporting_schemas.rs @@ -56,7 +56,9 @@ pub async fn for_group( /// Which pair to build. #[derive(Deserialize, ToSchema)] pub struct BuildPairArgs { + /// The group whose schema to build. pub group_id: Uuid, + /// The Tamanu version to build it for. pub version_id: Uuid, } diff --git a/crates/private-server/src/openapi.rs b/crates/private-server/src/openapi.rs index 8fe8690e3..be113d0ea 100644 --- a/crates/private-server/src/openapi.rs +++ b/crates/private-server/src/openapi.rs @@ -31,6 +31,7 @@ use utoipa::{ (name = "mcp_tokens", description = "Bearer tokens for the public MCP mount."), (name = "upgrade_plans", description = "Where each group is going: the version it intends to move to, and when."), (name = "migration_tests", description = "Where each server stands against the version it would take next."), + (name = "reporting_schemas", description = "Which of a group's versions have a reporting schema built for them."), (name = "restore_replicas", description = "Managed restore replicas: capabilities, worklist, and health."), (name = "self_alerts", description = "Canopy's alerts about its own operation."), (name = "server_groups", description = "Application group management and group-level configuration."), diff --git a/private-web/openapi.json b/private-web/openapi.json index 0813a9527..c1c1fa57a 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -9407,11 +9407,13 @@ "properties": { "group_id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "The group whose schema to build." }, "version_id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "The Tamanu version to build it for." } } }, @@ -14123,21 +14125,25 @@ }, "group_id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "The group this pair is for." }, "requested": { "type": "boolean", "description": "Whether an operator has asked for this pair to be built again." }, "state": { - "$ref": "#/components/schemas/PairState" + "$ref": "#/components/schemas/PairState", + "description": "Whether the pair has a schema, failed to build one, or is awaiting one." }, "version": { - "type": "string" + "type": "string", + "description": "That version as semver, for display." }, "version_id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "The Tamanu version this pair is for." } } }, @@ -18416,6 +18422,10 @@ "name": "migration_tests", "description": "Where each server stands against the version it would take next." }, + { + "name": "reporting_schemas", + "description": "Which of a group's versions have a reporting schema built for them." + }, { "name": "restore_replicas", "description": "Managed restore replicas: capabilities, worklist, and health." diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 2095bde9a..7c96731e0 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -4950,9 +4950,15 @@ export interface components { }; /** @description Which pair to build. */ BuildPairArgs: { - /** Format: uuid */ + /** + * Format: uuid + * @description The group whose schema to build. + */ group_id: string; - /** Format: uuid */ + /** + * Format: uuid + * @description The Tamanu version to build it for. + */ version_id: string; }; /** @@ -7954,13 +7960,21 @@ export interface components { Pair: { /** @description What went wrong, where a build failed. */ error?: string | null; - /** Format: uuid */ + /** + * Format: uuid + * @description The group this pair is for. + */ group_id: string; /** @description Whether an operator has asked for this pair to be built again. */ requested: boolean; + /** @description Whether the pair has a schema, failed to build one, or is awaiting one. */ state: components["schemas"]["PairState"]; + /** @description That version as semver, for display. */ version: string; - /** Format: uuid */ + /** + * Format: uuid + * @description The Tamanu version this pair is for. + */ version_id: string; }; /** From 3b78fb796126ebc59e07d04fc8c74db1755cb07d Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:40:55 +1200 Subject: [PATCH 043/130] rebuild when a version's artifacts move --- crates/database/src/artifacts.rs | 24 ++++ crates/database/src/reporting_schemas.rs | 25 +++- crates/database/src/schema.rs | 2 + crates/database/tests/it/reporting_schemas.rs | 110 ++++++++++++++++++ crates/public-server/src/restore.rs | 1 + .../down.sql | 1 + .../up.sql | 9 ++ 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/down.sql create mode 100644 migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/up.sql diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index d7e5a1279..f8d6d7a08 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -70,6 +70,9 @@ pub struct Artifact { pub digest: Option, /// The run that produced this artifact, where the registration named one. pub run_id: Option, + /// When this artifact was last registered. + #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] + pub updated_at: jiff::Timestamp, } #[derive(Debug, Deserialize, Insertable)] @@ -267,6 +270,27 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } + /// When any artifact of this version was last registered. + /// + /// A schema built from a superseded release of a version is not the schema + /// that version describes, so this is what a build is held against. + // spec: RPT#pairs + pub async fn newest_change_for_version( + db: &mut AsyncPgConnection, + version: Uuid, + ) -> Result> { + use crate::schema::artifacts::dsl; + + let newest: Option = dsl::artifacts + .filter(dsl::version_id.eq(version)) + .select(diesel::dsl::max(dsl::updated_at)) + .first(db) + .await + .map_err(AppError::from)?; + + Ok(newest.map(Into::into)) + } + /// The bytes Canopy holds for an artifact, where it holds any. pub async fn content_for( db: &mut AsyncPgConnection, diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 543949d49..1bc5c7737 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -34,6 +34,12 @@ pub struct ReportingSchemaBuild { pub built: bool, /// What went wrong, where it did not. pub error: Option, + /// The artifacts this build registered, of which the schema is one. + pub artifact_ids: Vec>, + /// When the build was recorded, which is what a later artifact change is + /// compared against. + #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] + pub built_at: Timestamp, } #[derive(Debug, Clone)] @@ -43,6 +49,7 @@ pub struct NewReportingSchemaBuild { pub application_id: Option, pub built: bool, pub error: Option, + pub artifact_ids: Vec, } impl ReportingSchemaBuild { @@ -74,6 +81,11 @@ impl ReportingSchemaBuild { crate::schema::reporting_schema_builds::application_id.eq(build.application_id), crate::schema::reporting_schema_builds::built.eq(build.built), crate::schema::reporting_schema_builds::error.eq(build.error), + crate::schema::reporting_schema_builds::artifact_ids.eq(build + .artifact_ids + .into_iter() + .map(Some) + .collect::>()), )) .execute(db) .await?; @@ -121,7 +133,18 @@ impl ReportingSchemaBuild { return Ok(false); } - Ok(Self::latest_for_pair(db, group, version).await?.is_some()) + let Some(build) = Self::latest_for_pair(db, group, version).await? else { + return Ok(false); + }; + + // A schema built from a superseded release of the version is not the + // schema that version describes, so an artifact registered since the + // build puts the pair back on the worklist. + let changed = crate::artifacts::Artifact::newest_change_for_version(db, version).await?; + Ok(match changed { + Some(at) => at <= build.built_at, + None => true, + }) } } diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index a4ae26249..fac8576a5 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -613,6 +613,8 @@ diesel::table! { application_id -> Nullable, built -> Bool, error -> Nullable, + artifact_ids -> Array>, + built_at -> Timestamptz, } } diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index f99d71b0c..c0ce7ef33 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -54,6 +54,40 @@ fn group() -> Uuid { GROUP.parse().unwrap() } +/// A restore report for the pair, as a consumer would send one. +fn report_for( + outcome: commons_types::backup::RunOutcome, + healthy: bool, + error: Option, +) -> NewBackupRestoreCheck { + NewBackupRestoreCheck { + replica_id: None, + replica_name: None, + consumer_device_id: CONSUMER.parse().unwrap(), + group_id: group(), + machine_id: Some(MACHINE.parse().unwrap()), + r#type: "tamanu-postgres".parse().unwrap(), + intent: "reporting-schema".parse().unwrap(), + snapshot_id: Some("snap-1".to_owned()), + outcome, + error, + replica_healthy: healthy, + postgres_version: None, + observed_at: jiff::Timestamp::now(), + s3_sent_raw_bytes: None, + s3_sent_payload_bytes: None, + s3_received_raw_bytes: None, + s3_received_payload_bytes: None, + health_details: None, + run_id: None, + redaction_outcome: None, + redaction_manifest_version: None, + redaction_columns_masked: None, + redaction_columns_skipped: None, + redaction_error: None, + } +} + /// Record a build against a throwaway restore report for the pair. async fn record_build(conn: &mut AsyncPgConnection, version: Uuid, built: bool) { let report = NewBackupRestoreCheck { @@ -92,6 +126,7 @@ async fn record_build(conn: &mut AsyncPgConnection, version: Uuid, built: bool) application_id: Some(CENTRAL.parse().unwrap()), built, error: (!built).then(|| "views did not compile".to_owned()), + artifact_ids: vec![], }, ) .await @@ -238,6 +273,7 @@ async fn a_failed_restore_leaves_the_pair_unsettled() { application_id: Some(CENTRAL.parse().unwrap()), built: false, error: None, + artifact_ids: vec![], }, ) .await @@ -252,3 +288,77 @@ async fn a_failed_restore_leaves_the_pair_unsettled() { }) .await; } + +/// A schema built from a superseded release of a version is not the schema that +/// version describes, so registering an artifact against the version puts the +/// pair back on the worklist without an operator asking. +#[tokio::test(flavor = "multi_thread")] +async fn a_new_artifact_for_the_version_reinstates_the_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + record_build(&mut conn, newer, true).await; + assert!( + ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "a built pair settles" + ); + + conn.batch_execute(&format!( + "INSERT INTO artifacts (version_id, artifact_type, platform, download_url) + VALUES ('{}', 'migrations', 'any', 'https://example.com/m.tar')", + newer + )) + .await + .expect("register an artifact for the version"); + + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "the version's artifacts changed, so the pair is built again" + ); + }) + .await; +} + +/// A build records the artifacts it registered, so an operator can see what came +/// out of it rather than only that something did. +#[tokio::test(flavor = "multi_thread")] +async fn a_build_records_what_it_registered() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + let artifact: Uuid = "77777777-7777-7777-7777-777777777777".parse().unwrap(); + conn.batch_execute(&format!( + "INSERT INTO artifacts (id, version_id, artifact_type, platform, download_url) + VALUES ('{artifact}', '{newer}', 'reporting-schema', 'any', 'https://example.com/s.sql')" + )) + .await + .expect("seed artifact"); + + let report = report_for(commons_types::backup::RunOutcome::Success, true, None); + ReportingSchemaBuild::record( + &mut conn, + report, + NewReportingSchemaBuild { + group_id: group(), + version_id: newer, + application_id: Some(CENTRAL.parse().unwrap()), + built: true, + error: None, + artifact_ids: vec![artifact], + }, + ) + .await + .expect("record"); + + let build = ReportingSchemaBuild::latest_for_pair(&mut conn, group(), newer) + .await + .unwrap() + .expect("a build"); + assert_eq!(build.artifact_ids, vec![Some(artifact)]); + }) + .await; +} diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 4c8eb3674..9fcb28256 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -977,6 +977,7 @@ async fn verification( application_id, built: build.built, error: build.error, + artifact_ids: build.artifacts, }, ) .await?; diff --git a/migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/down.sql b/migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/down.sql new file mode 100644 index 000000000..0cc7b00d3 --- /dev/null +++ b/migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/down.sql @@ -0,0 +1 @@ +ALTER TABLE reporting_schema_builds DROP COLUMN artifact_ids, DROP COLUMN built_at; diff --git a/migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/up.sql b/migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/up.sql new file mode 100644 index 000000000..ae8a4f939 --- /dev/null +++ b/migrations/2026-09-06-233313-0000_reporting_schema_build_artifacts/up.sql @@ -0,0 +1,9 @@ +-- The artifacts a build registered, of which the schema is one. An array +-- rather than a side table: the list is short, only ever read whole, and has +-- no fields of its own to carry. +ALTER TABLE reporting_schema_builds ADD COLUMN artifact_ids UUID[] NOT NULL DEFAULT '{}'; + +-- A pair is settled against the artifacts the version had when it was built, +-- so a build carries when it happened without a join back to its report. +ALTER TABLE reporting_schema_builds + ADD COLUMN built_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(); From 0636c502e325e53b6c0a64754be955917b3ac79b Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:51:45 +1200 Subject: [PATCH 044/130] offer flag per scope --- crates/database/src/artifacts.rs | 47 ++++++++++++++++---- crates/database/tests/it/artifact_scopes.rs | 48 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index d7e5a1279..3705368d7 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -201,8 +201,8 @@ impl Artifact { fn sort_by_specificity(artifacts: &mut [Self]) { artifacts.sort_by(|a, b| { // An artifact scoped to the caller's group is more specific than one - // belonging to no group. Only one group's artifacts are ever in - // play here, except under `Scope::Fleet`, which is never deduplicated. + // belonging to no group. A deduplicating read resolves one scope, so + // only one group's artifacts are ever in play here. let a_is_scoped = a.group_id.is_some(); let b_is_scoped = b.group_id.is_some(); @@ -340,10 +340,18 @@ impl Artifact { .first(db) .await .map_err(AppError::from)?; - if scoped.is_some() && new_url.is_some() { - return Err(AppError::Conflict( - "an artifact Canopy holds has no download URL".into(), - )); + match (scoped.is_some(), new_url.is_some()) { + (true, true) => { + return Err(AppError::Conflict( + "an artifact Canopy holds has no download URL".into(), + )); + } + (false, false) => { + return Err(AppError::Conflict( + "an artifact Canopy does not hold needs a download URL".into(), + )); + } + _ => {} } diesel::update(artifacts.filter(id.eq(artifact_id))) @@ -381,9 +389,30 @@ impl Artifact { let matching_artifacts = Self::get_for_version_all_matches(db, target_version_id, scope).await?; - let public_api_artifacts = Self::get_for_version(db, target_version_id, scope).await?; - let public_api_ids: std::collections::HashSet = - public_api_artifacts.iter().map(|a| a.id).collect(); + // An artifact is offered where it wins inside a scope that is actually + // resolved, so the fleet-wide answer is the union over the unscoped read + // and each group present rather than one deduplication across them all: + // two groups' artifacts of one type and platform are both served. + // spec: ART#what-a-version-offers + let scopes: Vec = match scope { + Scope::Fleet => { + let mut groups: Vec = + matching_artifacts.iter().filter_map(|a| a.group_id).collect(); + groups.sort_unstable(); + groups.dedup(); + std::iter::once(Scope::Unscoped) + .chain(groups.into_iter().map(Scope::Group)) + .collect() + } + resolved => vec![resolved], + }; + + let mut public_api_ids: std::collections::HashSet = std::collections::HashSet::new(); + for scope in scopes { + for offered in Self::get_for_version(db, target_version_id, scope).await? { + public_api_ids.insert(offered.id); + } + } use crate::schema::artifacts::*; let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 8d400384a..64ea88de3 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -230,6 +230,54 @@ async fn two_groups_hold_their_own_of_the_same_kind() { .await; } +/// Each group is served its own, so the operator view marks both as offered. +/// Deduplicating once across the fleet picks one and hides the other, which is +/// the opposite of what an operator has to be able to see. +#[tokio::test(flavor = "multi_thread")] +async fn the_operator_view_marks_every_group_s_own_as_offered() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let one = seed_group(&mut conn, "kamaka").await; + let two = seed_group(&mut conn, "drifting").await; + + Artifact::register(&mut conn, unscoped(version, "installer", "https://x/i.exe")) + .await + .expect("unscoped"); + Artifact::register(&mut conn, held(version, "reporting-schema", one, b"one")) + .await + .expect("first group"); + Artifact::register(&mut conn, held(version, "reporting-schema", two, b"two")) + .await + .expect("second group"); + + let all = + Artifact::get_for_version_all_matches_with_metadata(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + + assert_eq!(all.len(), 3); + for (artifact, _, _, offered) in all { + assert!( + offered, + "{} for {:?} is served to someone", + artifact.artifact_type, artifact.group_id + ); + } + }) + .await; +} + +/// The digest is a prefixed sha256 of the bytes. Pinned against a known answer +/// rather than against `digest_of` of the same input, which would hold just as +/// well if the function returned a constant. +#[test] +fn the_digest_is_a_prefixed_sha256() { + assert_eq!( + digest_of(b""), + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); +} + /// A range artifact registered twice replaces itself. Before the identity /// index this could not hold: `version_id` is NULL for every range artifact, /// and the default treatment of NULL made each row distinct from the last. From 42f055f2b67f86a2bec44e713a99d7fb7a464f98 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:51:45 +1200 Subject: [PATCH 045/130] scope redaction gap --- crates/database/src/restore.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 81061d42b..1574db1ed 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -526,10 +526,13 @@ pub async fn redaction_gap_for( ))); }; - let published = - crate::artifacts::Artifact::get_for_version(db, version.id, crate::artifacts::Scope::Fleet) - .await? - .into_iter() + let published = crate::artifacts::Artifact::get_for_version( + db, + version.id, + crate::artifacts::Scope::for_caller(server.group_id), + ) + .await? + .into_iter() .any(|a| a.artifact_type == manifest.artifact_type); Ok((!published).then_some((RedactionGapReason::VersionHasNoManifest, Some(shown)))) From dba3c1f8f3b402489ab5b231a350e5480afceb52 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:51:45 +1200 Subject: [PATCH 046/130] bad registrations are 400 --- crates/private-server/src/fns/versions.rs | 26 ++++++++++++++++------- crates/public-server/src/versions.rs | 4 ++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 4cbfd9b50..0fd40bf33 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -660,9 +660,9 @@ pub async fn create_artifact( (Some(_), Some(encoded)) => { let bytes = BASE64_STANDARD .decode(encoded) - .map_err(|_| AppError::custom("content_base64 is not valid base64"))?; + .map_err(|_| AppError::BadRequest("content_base64 is not valid base64".into()))?; if bytes.len() > MAX_HELD_ARTIFACT_BYTES { - return Err(AppError::custom(format!( + return Err(AppError::BadRequest(format!( "artifact is larger than the {MAX_HELD_ARTIFACT_BYTES} byte limit" ))); } @@ -670,21 +670,31 @@ pub async fn create_artifact( (Some(bytes), Some(digest)) } (Some(_), None) => { - return Err(AppError::custom( - "a group-scoped artifact must carry its bytes", + return Err(AppError::BadRequest( + "a group-scoped artifact must carry its bytes".into(), )); } (None, Some(_)) => { - return Err(AppError::custom( - "only a group-scoped artifact carries bytes", + return Err(AppError::BadRequest( + "only a group-scoped artifact carries bytes".into(), )); } (None, None) => (None, None), }; + // An artifact rests in one place or the other, so a registration naming a + // group and a location together is refused rather than written and caught + // by the constraint. + // spec: ART#where-an-artifact-rests + if args.group_id.is_some() && args.download_url.is_some() { + return Err(AppError::BadRequest( + "an artifact Canopy holds has no download URL".into(), + )); + } + if args.group_id.is_none() && args.download_url.is_none() { - return Err(AppError::custom( - "an artifact needs a download URL or a group", + return Err(AppError::BadRequest( + "an artifact needs a download URL or a group".into(), )); } diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 3301f550c..920aff351 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -650,8 +650,8 @@ async fn download_artifact( let version = latest_matching_ready(&mut db, version.0).await?; let scope = caller_scope(&mut db, device).await?; - let artifact_uuid = - Uuid::parse_str(&artifact_id).map_err(|_| AppError::custom("Invalid artifact ID"))?; + let artifact_uuid = Uuid::parse_str(&artifact_id) + .map_err(|_| AppError::BadRequest("Invalid artifact ID".into()))?; // Resolution is what enforces the boundary: an artifact scoped to a group // this caller is not offered is simply not in the set, so it is missing in From a5c780fce3594ac355f2282531be5b5a695bc0c1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:59:23 +1200 Subject: [PATCH 047/130] cover schema alerting --- crates/database/tests/it/reporting_schemas.rs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index c0ce7ef33..38d42fdcd 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -362,3 +362,211 @@ async fn a_build_records_what_it_registered() { }) .await; } + +/// A declaration whose consumer advertises a schema-building intent, which is +/// what brings a group into the sweep at all. +async fn declare_builder(conn: &mut AsyncPgConnection, enabled: bool) { + conn.batch_execute(&format!( + "INSERT INTO restore_consumer_capabilities + (consumer_device_id, intent, description, semantics, params) + VALUES ('{CONSUMER}', 'reporting-schema', '', + '[\"check\",\"once\",\"migrate\",\"reporting-schema\"]'::jsonb, '[]'::jsonb); + + INSERT INTO restore_replicas + (consumer_device_id, group_id, type, intent, name, enabled, params) + VALUES ('{CONSUMER}', '{GROUP}', 'tamanu-postgres', 'reporting-schema', + 'kamaka-schemas', {enabled}, '{{}}'::jsonb)", + )) + .await + .expect("declare builder"); +} + +/// The reporting-schema issues standing against the group's central. +async fn schema_issues(conn: &mut AsyncPgConnection) -> Vec { + database::issues::Issue::list_by_source_ref( + conn, + database::statuses::CANOPY_SOURCE, + database::backup::refs::REPORTING_SCHEMA, + &[CENTRAL.parse().unwrap()], + ) + .await + .expect("list issues") +} + +/// A failed build files against the group's central application, carrying the +/// builder's own description, and grades a warning rather than a failure. +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_build_warns_on_the_group_central() { + TestDb::run(|mut conn, _url| async move { + let (older, _newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + record_build(&mut conn, older, false).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + + let issues = schema_issues(&mut conn).await; + assert_eq!(issues.len(), 1, "one check per group, on its central"); + let issue = &issues[0]; + assert_eq!( + issue.effective_result, + Some(commons_types::status::CheckResult::Warning), + "a failed build is a warning, not a failure" + ); + assert!(issue.active); + assert!( + issue.message.contains("2.59.0") && issue.message.contains("views did not compile"), + "the builder's own description reaches the operator: {}", + issue.message + ); + }) + .await; +} + +/// The check does not escalate. A warning ceiling is what holds that: an +/// escalating flag is normalised away for anything below a failure, so pinning +/// the ceiling is what stops a schema nobody can build waking whoever is on +/// call for an application that is up and answering. +#[tokio::test(flavor = "multi_thread")] +async fn the_reporting_schema_check_cannot_escalate() { + TestDb::run(|mut conn, _url| async move { + let (older, _newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + record_build(&mut conn, older, false).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + + let policies = database::check_policies::CheckPolicy::get_across_namespaces( + &mut conn, + database::statuses::CANOPY_SOURCE, + database::backup::refs::REPORTING_SCHEMA, + ) + .await + .expect("read the policy"); + + assert!(!policies.is_empty(), "the filing seeds a policy"); + for policy in &policies { + assert_eq!( + policy.ceiling, + commons_types::status::CheckResult::Warning, + "a failed build tops out at a warning" + ); + assert!(!policy.escalates, "and so cannot escalate"); + } + + assert!( + !schema_issues(&mut conn).await[0].escalates, + "which the issue carries through" + ); + }) + .await; +} + +/// The check recovers when the pair is built. +#[tokio::test(flavor = "multi_thread")] +async fn a_built_pair_grades_the_check_passed() { + TestDb::run(|mut conn, _url| async move { + let (older, _newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + record_build(&mut conn, older, true).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + + let issues = schema_issues(&mut conn).await; + assert_eq!(issues.len(), 1); + assert_eq!( + issues[0].effective_result, + Some(commons_types::status::CheckResult::Passed), + "a built pair is not a finding" + ); + }) + .await; +} + +/// A pair still awaiting its first build is not a failure: nothing has gone +/// wrong yet, and the worklist is what moves it along. +#[tokio::test(flavor = "multi_thread")] +async fn a_pair_awaiting_its_first_build_files_nothing() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + declare_builder(&mut conn, true).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + + assert!( + schema_issues(&mut conn).await.is_empty(), + "an unbuilt pair is not yet a finding" + ); + }) + .await; +} + +/// A group nothing builds schemas for owes none, so a disabled declaration +/// files nothing even where a build once failed. +#[tokio::test(flavor = "multi_thread")] +async fn a_disabled_declaration_takes_the_group_out_of_the_sweep() { + TestDb::run(|mut conn, _url| async move { + let (older, _newer) = seed(&mut conn).await; + declare_builder(&mut conn, false).await; + record_build(&mut conn, older, false).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + + assert!( + schema_issues(&mut conn).await.is_empty(), + "a group with no enabled builder is not owed a schema" + ); + }) + .await; +} + +/// An open check has to be closed when the group's last non-awaiting pair goes +/// away, or it stands forever against a group that owes nothing. +#[tokio::test(flavor = "multi_thread")] +async fn the_check_closes_once_the_group_owes_no_schema() { + TestDb::run(|mut conn, _url| async move { + let (older, _newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + record_build(&mut conn, older, false).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + assert_eq!( + schema_issues(&mut conn).await[0].effective_result, + Some(commons_types::status::CheckResult::Warning), + "the warning stands while the pair is failed" + ); + + conn.batch_execute("DELETE FROM reporting_schema_builds") + .await + .expect("drop the build"); + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep again"); + + let issues = schema_issues(&mut conn).await; + assert_eq!(issues.len(), 1, "the same check, regraded"); + assert_eq!( + issues[0].effective_result, + Some(commons_types::status::CheckResult::Passed), + "a group owed no schema is not a finding" + ); + assert!( + issues[0].message.contains("No reporting schema is owed"), + "the closing message says why: {}", + issues[0].message + ); + }) + .await; +} From 4d77c40e11db368aa7bf8877cf791f25dbc8865c Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:01:07 +1200 Subject: [PATCH 048/130] test the pairs section --- .../ReportingSchemasSection.test.tsx | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 private-web/src/components/ReportingSchemasSection.test.tsx diff --git a/private-web/src/components/ReportingSchemasSection.test.tsx b/private-web/src/components/ReportingSchemasSection.test.tsx new file mode 100644 index 000000000..d876dfa71 --- /dev/null +++ b/private-web/src/components/ReportingSchemasSection.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ReportingSchemasSection from "./ReportingSchemasSection"; + +type Pair = { + group_id: string; + version_id: string; + version: string; + state: "awaiting" | "built" | "failed"; + error?: string | null; + requested: boolean; +}; + +const GROUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +function pair(over: Partial = {}): Pair { + return { + group_id: GROUP, + version_id: "11111111-1111-1111-1111-111111111111", + version: "2.60.0", + state: "awaiting", + error: null, + requested: false, + ...over, + }; +} + +/// Answer `for_group` with `pairs`, and `build` with either a 200 or a +/// ProblemDetails the component is expected to surface. +function stubApi(pairs: Pair[], build: { status: number; body?: unknown } = { status: 200 }) { + const calls: { url: string; body: unknown }[] = []; + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + + if (url.includes("reporting_schemas/build")) { + return new Response(JSON.stringify(build.body ?? {}), { + status: build.status, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(pairs), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + vi.stubGlobal("fetch", fetch); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("a pair's state reads off the chip", () => { + it("shows a built pair as built", async () => { + stubApi([pair({ state: "built" })]); + render(); + + expect(await screen.findByText("Built")).toBeTruthy(); + }); + + it("shows an unbuilt pair as awaiting, which is not a failure", async () => { + stubApi([pair()]); + render(); + + expect(await screen.findByText("Awaiting build")).toBeTruthy(); + expect(screen.queryByText("Failed")).toBeNull(); + }); + + it("carries the builder's own error on the failed chip", async () => { + stubApi([pair({ state: "failed", error: "views did not compile" })]); + render(); + + fireEvent.mouseOver(await screen.findByText("Failed")); + expect(await screen.findByText("views did not compile")).toBeTruthy(); + }); + + it("falls back where the build reported no description", async () => { + stubApi([pair({ state: "failed", error: null })]); + render(); + + fireEvent.mouseOver(await screen.findByText("Failed")); + expect(await screen.findByText("the build failed")).toBeTruthy(); + }); +}); + +describe("asking for a build", () => { + it("offers a first build on an unbuilt pair and a rebuild on a settled one", async () => { + stubApi([ + pair({ version_id: "1", version: "2.59.0", state: "awaiting" }), + pair({ version_id: "2", version: "2.60.0", state: "built" }), + pair({ version_id: "3", version: "2.61.0", state: "failed" }), + ]); + render(); + + expect(await screen.findByText("Build sooner")).toBeTruthy(); + expect(screen.getAllByText("Build again")).toHaveLength(2); + }); + + it("names the pair rather than the group's latest version", async () => { + const calls = stubApi([pair({ version_id: "abc", version: "2.59.0" })]); + render(); + + fireEvent.click(await screen.findByText("Build sooner")); + + await waitFor(() => { + const ask = calls.find((c) => c.url.includes("reporting_schemas/build")); + expect(ask?.body).toEqual({ group_id: GROUP, version_id: "abc" }); + }); + }); + + it("replaces the control once an ask is recorded, so it is not asked twice", async () => { + stubApi([pair({ requested: true })]); + render(); + + expect(await screen.findByText("Build asked for")).toBeTruthy(); + expect(screen.queryByText("Build sooner")).toBeNull(); + }); + + it("surfaces a refused ask rather than looking like it worked", async () => { + stubApi([pair()], { + status: 403, + body: { title: "insufficient permissions: admin role required" }, + }); + render(); + + fireEvent.click(await screen.findByText("Build sooner")); + + expect(await screen.findByText(/insufficient permissions/)).toBeTruthy(); + }); +}); + +describe("a group with nothing to build against", () => { + it("says why rather than showing an empty table", async () => { + stubApi([]); + render(); + + expect( + await screen.findByText(/no Tamanu application in this group reports a published version/i), + ).toBeTruthy(); + expect(screen.queryByText("Version")).toBeNull(); + }); +}); From 4b5ec0ba30c1908cc63297e325acd3aa533953ed Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:03:44 +1200 Subject: [PATCH 049/130] cover builder authorisation --- .../tests/it/reporting_schemas.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index 54ddd76b2..e2a7b298e 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -187,3 +187,68 @@ async fn a_builder_publishes_only_for_its_own_group() { ) .await } + +/// A declaration an operator has turned off does not authorise anything. It is +/// the enabled declaration that covers a group, so a builder whose declaration +/// is disabled is refused its own group's artifacts. +#[tokio::test(flavor = "multi_thread")] +async fn a_disabled_declaration_authorises_nothing() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE restore_replicas SET enabled = false WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("disable the declaration"); + + let refused = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + + assert_eq!(refused.status_code(), StatusCode::FORBIDDEN); + }, + ) + .await +} + +/// Restoring for a group is not the same authority as building its schema. A +/// consumer whose declaration covers the group but whose intent advertises no +/// `reporting-schema` semantic is refused, so a verify or migrate consumer +/// cannot publish a schema for the group it already restores. +#[tokio::test(flavor = "multi_thread")] +async fn restoring_for_a_group_does_not_authorise_publishing_its_schema() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE restore_consumer_capabilities + SET semantics = '[\"check\", \"once\", \"migrate\"]'::jsonb + WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("withdraw the semantic"); + + let refused = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + + assert_eq!(refused.status_code(), StatusCode::FORBIDDEN); + }, + ) + .await +} From 27405ebcfe3fa7baab625706173e66e8971f1ba0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:12:47 +1200 Subject: [PATCH 050/130] pin artifact resolution rules --- crates/database/tests/it/artifact_scopes.rs | 151 ++++++++++++++++++ crates/private-server/tests/it/artifacts.rs | 108 +++++++++++++ .../public-server/tests/it/artifact_scopes.rs | 147 +++++++++++++++++ crates/public-server/tests/it/versions.rs | 6 +- 4 files changed, 410 insertions(+), 2 deletions(-) diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 64ea88de3..d0a7b97ce 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -342,3 +342,154 @@ async fn a_held_artifact_cannot_be_given_a_url() { }) .await; } + +/// A range artifact, for the specificity rules that need one. +fn ranged(artifact_type: &str, pattern: &str, url: &str) -> NewArtifact { + NewArtifact { + version_id: None, + artifact_type: artifact_type.to_owned(), + platform: "any".to_owned(), + download_url: Some(url.to_owned()), + device_id: None, + version_range_pattern: Some(pattern.to_owned()), + group_id: None, + content: None, + content_type: None, + digest: None, + run_id: None, + } +} + +/// Register a pair for one type both ways round and return which id won each +/// time. Resolution has to reorder rather than take what the query happened to +/// return first, so a rule only counts as pinned when it holds either way. +async fn winner_either_way( + conn: &mut AsyncPgConnection, + version: Uuid, + winner: impl Fn(&str) -> NewArtifact, + loser: impl Fn(&str) -> NewArtifact, +) -> (Uuid, Uuid, Uuid, Uuid) { + let first_winner = Artifact::register(conn, winner("winner-first")) + .await + .expect("winner registered first"); + Artifact::register(conn, loser("winner-first")) + .await + .expect("loser registered second"); + + Artifact::register(conn, loser("loser-first")) + .await + .expect("loser registered first"); + let second_winner = Artifact::register(conn, winner("loser-first")) + .await + .expect("winner registered second"); + + let offered = Artifact::get_for_version(conn, version, Scope::Unscoped) + .await + .expect("offered"); + + let pick = |artifact_type: &str| { + offered + .iter() + .find(|a| a.artifact_type == artifact_type) + .unwrap_or_else(|| panic!("something offered for {artifact_type}")) + .id + }; + + assert_eq!(offered.len(), 2, "one per type, whatever the order"); + ( + pick("winner-first"), + first_winner.id, + pick("loser-first"), + second_winner.id, + ) +} + +/// Within one scope an exact-version artifact is more specific than any range. +/// The group rule short-circuits ahead of this one, so a test that crosses +/// scopes never reaches it. +#[tokio::test(flavor = "multi_thread")] +async fn an_exact_artifact_displaces_a_range_of_the_same_scope() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let (a, expected_a, b, expected_b) = winner_either_way( + &mut conn, + version, + |t| unscoped(version, t, "https://x/exact"), + |t| ranged(t, "2.60.x", "https://x/range"), + ) + .await; + + assert_eq!(a, expected_a, "exact wins when it is registered first"); + assert_eq!(b, expected_b, "and when the range is"); + }) + .await; +} + +/// Between two ranges that both match, the narrower is more specific. +#[tokio::test(flavor = "multi_thread")] +async fn the_narrower_of_two_ranges_wins() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let (a, expected_a, b, expected_b) = winner_either_way( + &mut conn, + version, + |t| ranged(t, "~2.60.0", "https://x/narrow"), + |t| ranged(t, "^2.0.0", "https://x/wide"), + ) + .await; + + assert_eq!(a, expected_a, "narrow wins when it is registered first"); + assert_eq!(b, expected_b, "and when the wide one is"); + }) + .await; +} + +/// Where two ranges cover the same versions neither is narrower, so the +/// tiebreak is how explicitly each was written: `^` over `~` over `.x`. +#[tokio::test(flavor = "multi_thread")] +async fn equally_wide_ranges_fall_back_to_pattern_rank() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let (a, expected_a, b, expected_b) = winner_either_way( + &mut conn, + version, + |t| ranged(t, "~2.60.0", "https://x/tilde"), + |t| ranged(t, "2.60.x", "https://x/wildcard"), + ) + .await; + + assert_eq!(a, expected_a, "~ outranks .x when registered first"); + assert_eq!(b, expected_b, "and when .x is"); + }) + .await; +} + +/// A pattern Canopy cannot parse matches nothing rather than everything, so a +/// malformed range withholds a file instead of offering it to the whole fleet. +#[tokio::test(flavor = "multi_thread")] +async fn a_malformed_range_matches_nothing() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + Artifact::register( + &mut conn, + ranged("installer", "definitely not a range", "https://x/nope"), + ) + .await + .expect("register"); + + let offered = Artifact::get_for_version(&mut conn, version, Scope::Unscoped) + .await + .expect("offered"); + assert!(offered.is_empty(), "offered to nobody"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert!(all.is_empty(), "and matches the version for no one"); + }) + .await; +} diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 1567b40d9..54591bf2f 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -65,3 +65,111 @@ async fn artifact_multiple_ranges_pattern_specificity_private_endpoint() { }) .await } + +/// A registration that names neither a location nor a group, names both a group +/// and a location, or carries bytes without a group, is a client mistake and is +/// refused as one. Writing the row and letting the check constraint catch it +/// answers 500 for input the operator controls. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_that_rests_nowhere_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "77777777-7777-7777-7777-777777777777"; + let group = "88888888-8888-8888-8888-888888888888"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let refusals = [ + // Neither a location nor a group. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + }), + // A group and a location together: it rests in one place or the other. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "group_id": group, "content_base64": "aGVsbG8=", + "download_url": "https://example.com/x.exe", + }), + // A group with no bytes to hold. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "group_id": group, + }), + // Bytes with no group to hold them for. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "content_base64": "aGVsbG8=", "download_url": "https://example.com/x.exe", + }), + // Bytes that are not base64. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "group_id": group, "content_base64": "not base64 at all!!", + }), + ]; + + for args in refusals { + let response = private + .post("/api/versions/create_artifact") + .json(&args) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::BAD_REQUEST, + "refused as a client mistake: {args}" + ); + } + }) + .await +} + +/// Editing only an artifact's type or platform must not take its location away. +/// The field is optional on the wire, so an omitted URL used to null the column +/// and fail the constraint, losing the artifact and answering 500. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_artifact_cannot_lose_its_location() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "99999999-9999-9999-9999-999999999999"; + let artifact = "aaaaaaaa-0000-0000-0000-aaaaaaaaaaaa"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{artifact}', '{version}', 'any', 'installer', 'https://example.com/x.exe')", + )) + .await + .unwrap(); + + let response = private + .post("/api/versions/update_artifact") + .json(&serde_json::json!({ + "artifact_id": artifact, + "artifact_type": "installer", + "platform": "windows", + })) + .await; + // Refused as a conflict, not left to the check constraint, which would + // answer 500 for something the operator asked for. + assert_eq!(response.status_code(), axum::http::StatusCode::CONFLICT); + + let listed = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + listed.assert_status_ok(); + let artifacts: Vec = listed.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!( + artifacts[0]["download_url"], "https://example.com/x.exe", + "the location it had is still the location it has" + ); + }) + .await +} diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index ede60b110..f7d9a0b99 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -218,3 +218,150 @@ async fn a_releaser_cannot_register_for_a_group() { ) .await } + +/// The same read over the client-certificate header the live ingress sets. +/// Every other test here runs the Envoy path the harness selects by default, +/// which is not what is deployed. +#[tokio::test(flavor = "multi_thread")] +async fn the_owning_group_is_served_over_the_nginx_header() { + commons_tests::server::run_with_device_auth_on( + commons_servers::device_auth::mtls::ClientCertHeader::Mtls, + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("mtls-certificate", &cert) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], THEIRS); + }, + ) + .await +} + +/// The listing hands out a URL that fetches the bytes, including where the +/// caller named a range rather than the resolved version. Building the path by +/// hand instead leaves the synthesis untested. +#[tokio::test(flavor = "multi_thread")] +async fn the_offered_download_url_fetches_the_bytes() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get("/versions/2.60.x/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + let url = artifacts[0]["download_url"] + .as_str() + .expect("a download url") + .to_owned(); + + assert!( + url.ends_with(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")), + "names the resolved version, not the range asked for: {url}" + ); + + let path = url.split_once("://").map_or(url.as_str(), |(_, rest)| { + rest.split_once('/').map_or("", |(_, path)| path) + }); + let fetched = public + .get(&format!("/{path}")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + fetched.assert_status_ok(); + assert_eq!(fetched.text(), "group a schema"); + }, + ) + .await +} + +/// An artifact of another version is missing in the same way one held for +/// another group is, so the three refusals a caller can provoke are not +/// distinguishable from each other. +#[tokio::test(flavor = "multi_thread")] +async fn an_artifact_of_another_version_is_refused_identically() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('44444444-4444-4444-4444-444444444444', 2, 59, 0, '', 'published'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('55555555-5555-5555-5555-555555555555', + '44444444-4444-4444-4444-444444444444', 'any', 'installer', + 'https://example.com/old.exe')", + ) + .await + .expect("seed another version"); + + let elsewhere = public + .get("/versions/2.60.0/artifacts/55555555-5555-5555-5555-555555555555/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let absent = public + .get("/versions/2.60.0/artifacts/99999999-9999-9999-9999-999999999999/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(elsewhere.status_code(), StatusCode::NOT_FOUND); + assert_eq!(elsewhere.status_code(), absent.status_code()); + assert_eq!(elsewhere.text(), absent.text()); + }, + ) + .await +} + +/// A device Canopy can place but which sits on no machine, and one on a machine +/// with no group, are both answered as an anonymous caller is rather than +/// refused. +#[tokio::test(flavor = "multi_thread")] +async fn a_device_with_no_group_is_answered_anonymously() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + + // No machine at all. + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + + // On a machine, but the machine belongs to no group. + conn.batch_execute(&format!( + "INSERT INTO machines (name, device_id) VALUES ('ungrouped', '{device_id}')" + )) + .await + .expect("enrol without a group"); + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + }, + ) + .await +} diff --git a/crates/public-server/tests/it/versions.rs b/crates/public-server/tests/it/versions.rs index ffe6966c9..fcb7b9004 100644 --- a/crates/public-server/tests/it/versions.rs +++ b/crates/public-server/tests/it/versions.rs @@ -509,11 +509,13 @@ async fn artifact_download_proxy() { .await .unwrap(); - // Invalid artifact ID format + // An unreadable artifact id is a client mistake, the same as an + // unreadable version in the path, so it answers 400 rather than + // polluting 5xx monitoring with input a caller controls. let response = public .get("/versions/1.2.3/artifacts/not-a-uuid/download") .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + response.assert_status(StatusCode::BAD_REQUEST); // Nonexistent artifact. 404 rather than an error, because an artifact // scoped to a group the caller is not offered has to be answered the From 9e8023f155703c1dd66847e68542bb9efa924c52 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:24:54 +1200 Subject: [PATCH 051/130] assert which artifact is hidden --- crates/database/src/artifacts.rs | 6 ++- crates/database/src/restore.rs | 2 +- private-web/e2e/artifact-scopes.spec.ts | 57 ++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 3705368d7..fa0505bec 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -396,8 +396,10 @@ impl Artifact { // spec: ART#what-a-version-offers let scopes: Vec = match scope { Scope::Fleet => { - let mut groups: Vec = - matching_artifacts.iter().filter_map(|a| a.group_id).collect(); + let mut groups: Vec = matching_artifacts + .iter() + .filter_map(|a| a.group_id) + .collect(); groups.sort_unstable(); groups.dedup(); std::iter::once(Scope::Unscoped) diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 1574db1ed..c67d32d73 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -533,7 +533,7 @@ pub async fn redaction_gap_for( ) .await? .into_iter() - .any(|a| a.artifact_type == manifest.artifact_type); + .any(|a| a.artifact_type == manifest.artifact_type); Ok((!published).then_some((RedactionGapReason::VersionHasNoManifest, Some(shown)))) } diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts index e3bb72ce0..d0094bbad 100644 --- a/private-web/e2e/artifact-scopes.spec.ts +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -79,24 +79,77 @@ test.describe("group-scoped artifacts", () => { status: "published", }); + // `2.60.x` is >=2.60.0 <2.61.0 and `^2.60.0` is >=2.60.0 <3.0.0, so the + // wildcard is the narrower of the two and is the one served. await seedArtifact(sql, { versionId: null, rangePattern: "2.60.x", artifactType: "installer", platform: "windows", - downloadUrl: "https://example.com/broad.exe", + downloadUrl: "https://example.com/narrow.exe", }); await seedArtifact(sql, { versionId: null, rangePattern: "^2.60.0", artifactType: "installer", platform: "windows", - downloadUrl: "https://example.com/narrow.exe", + downloadUrl: "https://example.com/wide.exe", }); await page.goto(`/versions/2.60.0`); await expect(page.locator("table tbody tr")).toHaveCount(2); await expect(page.getByText("[Hidden]")).toHaveCount(1); + + // Counting the markers alone would pass just as well with the marker on + // the artifact that is actually served. + const wide = page + .locator("table tbody tr") + .filter({ hasText: "https://example.com/wide.exe" }); + await expect(wide.getByText("[Hidden]")).toBeVisible(); + + const narrow = page + .locator("table tbody tr") + .filter({ hasText: "https://example.com/narrow.exe" }); + await expect(narrow.getByText("[Hidden]")).toHaveCount(0); + }); + + /// A group's artifact displaces the unscoped one for that group alone, so + /// the unscoped one is still what every other caller is served and is not + /// marked as passed over. Resolving once across the fleet marks it hidden, + /// which tells the operator the opposite of the truth. + /// + /// spec: ART#what-a-version-offers + test("an artifact a group's own displaces is not marked hidden", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: null, + rangePattern: "2.60.x", + artifactType: "reporting-schema", + platform: "any", + downloadUrl: "https://example.com/fleet.sql", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + + await expect(page.locator("table tbody tr")).toHaveCount(2); + await expect(page.getByText("[Hidden]")).toHaveCount(0); }); }); From 980f6adab4d82a1a979c6645905136d524caddb1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:26:31 +1200 Subject: [PATCH 052/130] fix pair derivation --- crates/database/src/reporting_schemas.rs | 19 +++++- crates/database/tests/it/reporting_schemas.rs | 60 +++++++++++++++++++ private-web/e2e/reporting-schemas.spec.ts | 60 +++++++++++++++++++ .../ReportingSchemasSection.test.tsx | 13 ++-- .../components/ReportingSchemasSection.tsx | 5 +- 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 1bc5c7737..74bfc0b2e 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -251,11 +251,17 @@ pub struct Pair { /// The pairs of a group: every published version its Tamanu applications report /// running, plus the version its open plan moves it to. +/// +/// A group no enabled declaration covers has no pairs. Canopy owes it no +/// schema, so listing versions against it would offer an operator a build +/// nothing will pick up. // spec: RPT#pairs pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { - let mut versions = versions_for_group(db, group).await?; - versions.sort_by_key(|v| (v.major, v.minor, v.patch)); - versions.dedup_by_key(|v| v.id); + if !group_builds_schemas(db, group).await? { + return Ok(Vec::new()); + } + + let versions = versions_for_group(db, group).await?; let mut pairs = Vec::with_capacity(versions.len()); for version in versions { @@ -315,6 +321,13 @@ pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Resu versions.push(target); } + // A pair is unique per group and version, so two applications on one + // version are one pair, and a plan moving a group to a version something + // already runs adds none. Dispatch counts a restore and a migrate per + // entry, so a duplicate here is paid for rather than merely untidy. + versions.sort_by_key(|v| (v.major, v.minor, v.patch)); + versions.dedup_by_key(|v| v.id); + Ok(versions) } diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index 38d42fdcd..985389857 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -159,6 +159,7 @@ async fn a_facility_on_its_own_version_is_a_pair_of_its_own() { async fn a_failed_build_settles_the_pair() { TestDb::run(|mut conn, _url| async move { let (_older, newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; assert!( !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) @@ -190,6 +191,7 @@ async fn a_failed_build_settles_the_pair() { async fn an_operator_ask_reinstates_a_settled_pair() { TestDb::run(|mut conn, _url| async move { let (_older, newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; record_build(&mut conn, newer, true).await; assert!( @@ -570,3 +572,61 @@ async fn the_check_closes_once_the_group_owes_no_schema() { }) .await; } + +/// A group nothing builds schemas for is owed none, so it presents no pairs +/// even where its applications report published versions. Listing them would +/// offer an operator a build nothing will pick up. +#[tokio::test(flavor = "multi_thread")] +async fn a_group_with_no_builder_has_no_pairs() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + + assert!( + pairs_for_group(&mut conn, group()) + .await + .expect("pairs") + .is_empty(), + "no declaration covers the group, so it is owed no schema" + ); + + declare_builder(&mut conn, true).await; + + assert_eq!( + pairs_for_group(&mut conn, group()) + .await + .expect("pairs") + .len(), + 2, + "declaring a builder is what brings the pairs into being" + ); + }) + .await; +} + +/// A pair is unique per group and version. Two applications reporting one +/// version are one pair, so the worklist dispatches one restore rather than +/// one per application. +#[tokio::test(flavor = "multi_thread")] +async fn two_applications_on_one_version_are_one_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + conn.batch_execute(&format!( + "UPDATE application_reported_detail SET version = '2.60.0' + WHERE application_id = '{FACILITY}'" + )) + .await + .expect("put the facility on the central's version"); + + let versions = versions_for_group(&mut conn, group()) + .await + .expect("derive versions"); + + assert_eq!( + versions.iter().filter(|v| v.id == newer).count(), + 1, + "one pair, not one per reporting application: {versions:?}" + ); + }) + .await; +} diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index 49ce85a1e..e8575cf81 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -1,12 +1,40 @@ +import type { Sql } from "./seed"; import { resetSeededTables, seedApplicationReport, + seedDevice, + seedRestoreConsumerCapability, + seedRestoreReplica, seedServer, seedServerGroup, seedVersion, } from "./seed"; import { expect, test } from "./test-fixtures"; +/// A consumer that advertises a schema-building intent, declared against the +/// group. That declaration is what brings the group's pairs into being: canopy +/// owes a schema only where something is there to build one. +/// +/// spec: RPT#pairs +async function declareBuilder(sql: Sql, groupId: string): Promise { + const consumer = await seedDevice(sql, { role: "backup-restore" }); + await seedRestoreConsumerCapability(sql, { + deviceId: consumer.id, + intents: [ + { + intent: "reporting-schema", + semantics: ["check", "once", "migrate", "reporting-schema"], + }, + ], + }); + await seedRestoreReplica(sql, { + consumerDeviceId: consumer.id, + groupId, + intent: "reporting-schema", + name: "kamaka-schemas", + }); +} + /// How a group's reporting-schema pairs are presented, and how an operator asks /// for one to be built. /// @@ -28,6 +56,7 @@ test.describe("reporting schemas", () => { await seedVersion(sql, { major: 2, minor: 59, patch: 0, status: "published" }); await seedVersion(sql, { major: 2, minor: 60, patch: 0, status: "published" }); const group = await seedServerGroup(sql, { name: "kamaka" }); + await declareBuilder(sql, group.id); const central = await seedServer(sql, { name: "central", @@ -67,6 +96,7 @@ test.describe("reporting schemas", () => { test("asking for a build records the ask", async ({ page, sql }) => { await seedVersion(sql, { major: 2, minor: 60, patch: 0, status: "published" }); const group = await seedServerGroup(sql, { name: "kamaka" }); + await declareBuilder(sql, group.id); const central = await seedServer(sql, { name: "central", groupId: group.id, @@ -87,4 +117,34 @@ test.describe("reporting schemas", () => { const asks = await sql.query("SELECT requested_by FROM reporting_schema_requests"); expect(asks).toHaveLength(1); }); + + /// A group nothing builds schemas for is owed none, so it shows no pairs + /// even where its applications report published versions. Listing them + /// would offer an operator a build nothing will pick up, and a row stuck on + /// "Awaiting build" reads as a backlog rather than as an absent builder. + /// + /// spec: RPT#pairs + test("a group with no builder declared shows no pairs", async ({ + page, + sql, + }) => { + await seedVersion(sql, { major: 2, minor: 60, patch: 0, status: "published" }); + const group = await seedServerGroup(sql, { name: "drifting" }); + const central = await seedServer(sql, { + name: "central", + groupId: group.id, + type: "tamanu-central", + }); + await seedApplicationReport(sql, { + applicationId: central.id, + version: "2.60.0", + }); + + await page.goto(`/groups/${group.id}`); + + const section = page.getByTestId("reporting-schemas"); + await expect(section).toBeVisible(); + await expect(section.getByTestId("reporting-schema-row")).toHaveCount(0); + await expect(section.getByText(/no builder is declared/i)).toBeVisible(); + }); }); diff --git a/private-web/src/components/ReportingSchemasSection.test.tsx b/private-web/src/components/ReportingSchemasSection.test.tsx index d876dfa71..df91640ec 100644 --- a/private-web/src/components/ReportingSchemasSection.test.tsx +++ b/private-web/src/components/ReportingSchemasSection.test.tsx @@ -132,14 +132,17 @@ describe("asking for a build", () => { }); }); -describe("a group with nothing to build against", () => { - it("says why rather than showing an empty table", async () => { +describe("a group with nothing to build", () => { + // Two different reasons reach the same empty answer: no builder is declared + // for the group, or nothing in it reports a published version. Naming both + // is what stops an operator reading an absent builder as a backlog. + it("names both reasons rather than showing an empty table", async () => { stubApi([]); render(); - expect( - await screen.findByText(/no Tamanu application in this group reports a published version/i), - ).toBeTruthy(); + const empty = await screen.findByText(/nothing to build for this group/i); + expect(empty.textContent).toMatch(/no builder is declared/i); + expect(empty.textContent).toMatch(/reports a published version/i); expect(screen.queryByText("Version")).toBeNull(); }); }); diff --git a/private-web/src/components/ReportingSchemasSection.tsx b/private-web/src/components/ReportingSchemasSection.tsx index 1e70cfb33..689082225 100644 --- a/private-web/src/components/ReportingSchemasSection.tsx +++ b/private-web/src/components/ReportingSchemasSection.tsx @@ -57,8 +57,9 @@ export default function ReportingSchemasSection({ - No Tamanu application in this group reports a published version, so - there is nothing to build a schema against. + Nothing to build for this group: either no builder is declared for it + under Backups, or no Tamanu application in it reports a published + version. ); From 3279655de1e621106934420903f22eaa205e0a89 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:48:28 +1200 Subject: [PATCH 053/130] cover artifact retention and scope --- crates/database/tests/it/artifact_scopes.rs | 119 ++++++++++++++++++ .../public-server/tests/it/artifact_scopes.rs | 113 +++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index d0a7b97ce..94db5762b 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -493,3 +493,122 @@ async fn a_malformed_range_matches_nothing() { }) .await; } + +/// Canopy records which device registered an artifact and the run that produced +/// it, so one that arrived by automation is distinguishable from one entered by +/// hand. A re-registration carries the new provenance rather than keeping the +/// old, since the row now describes a different build. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn provenance_is_recorded_and_replaced() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + let run = Uuid::new_v4(); + + let mut first = held(version, "reporting-schema", theirs, b"first"); + first.run_id = Some(run); + let registered = Artifact::register(&mut conn, first) + .await + .expect("register with a run"); + assert_eq!(registered.run_id, Some(run)); + + // Entered by hand this time: the run that is no longer named is cleared + // rather than left standing over bytes it did not produce. + let second = held(version, "reporting-schema", theirs, b"second"); + let replaced = Artifact::register(&mut conn, second) + .await + .expect("register without a run"); + assert_eq!(replaced.id, registered.id); + assert_eq!(replaced.run_id, None); + }) + .await; +} + +/// Canopy keeps none of what it has stopped serving, so deleting an artifact +/// takes the bytes with it rather than leaving them addressable. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn deleting_an_artifact_takes_its_bytes() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + let artifact = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"schema"), + ) + .await + .expect("register"); + + Artifact::delete(&mut conn, artifact.id) + .await + .expect("delete"); + + assert!( + Artifact::content_for(&mut conn, artifact.id) + .await + .expect("read content") + .is_none() + ); + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert!(all.is_empty()); + }) + .await; +} + +/// An artifact Canopy does not hold has no bytes to read, which is what makes +/// the download fall through to the location it recorded instead. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_artifact_holds_no_bytes() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let artifact = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/i")) + .await + .expect("register"); + + assert!( + Artifact::content_for(&mut conn, artifact.id) + .await + .expect("read content") + .is_none() + ); + }) + .await; +} + +/// A group's artifacts go with the group. Bytes Canopy holds for a group that +/// no longer exists are bytes it has stopped serving. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn deleting_a_group_takes_its_artifacts() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + Artifact::register(&mut conn, unscoped(version, "installer", "https://x/i")) + .await + .expect("unscoped"); + Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"schema"), + ) + .await + .expect("held"); + + conn.batch_execute(&format!("DELETE FROM server_groups WHERE id = '{theirs}'")) + .await + .expect("delete the group"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1, "the group's went with it"); + assert_eq!(all[0].group_id, None); + }) + .await; +} diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index f7d9a0b99..cd57ec12b 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -365,3 +365,116 @@ async fn a_device_with_no_group_is_answered_anonymously() { ) .await } + +/// The public pages and the release feed are read by anyone, so they resolve +/// unscoped whoever asks. A caller presenting the owning group's credential +/// still sees no trace of the artifact Canopy holds for it. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn the_public_pages_never_carry_a_group_s_artifact() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + for path in [ + "/versions/2.60.0", + "/versions/2.60.0/mobile", + "/versions/rss", + ] { + let response = public + .get(path) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + // A page that did not render carries no artifact either, which + // would pass the assertions below for the wrong reason. + response.assert_status_ok(); + let body = response.text(); + assert!( + !body.contains(THEIRS), + "{path} names the artifact held for group A" + ); + assert!( + !body.contains("group a schema"), + "{path} carries the bytes held for group A" + ); + } + }, + ) + .await +} + +/// A corrupted artifact fails the read as itself, so an operator reading the +/// problem type is told the bytes no longer match rather than being left with +/// an unclassified fault. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn a_digest_mismatch_says_what_it_is() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute(&format!( + "UPDATE artifacts SET content = 'tampered'::bytea WHERE id = '{THEIRS}'" + )) + .await + .expect("corrupt the stored bytes"); + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + let problem: serde_json::Value = response.json(); + assert_eq!(problem["type"], "/errors/artifact-digest-mismatch"); + }, + ) + .await +} + +/// A registration through the endpoint replaces what stood for the same version, +/// type and platform, so a caller is never offered two of a kind. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn registering_again_over_the_wire_replaces() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let first = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/first.exe") + .await; + first.assert_status_ok(); + let first: serde_json::Value = first.json(); + + let second = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/second.exe") + .await; + second.assert_status_ok(); + let second: serde_json::Value = second.json(); + + assert_eq!(first["id"], second["id"], "replaced in place"); + assert_eq!(second["download_url"], "https://example.com/second.exe"); + + let listed = public.get("/versions/2.60.0/artifacts").await; + let artifacts: Vec = listed.json(); + assert_eq!( + artifacts + .iter() + .filter(|a| a["artifact_type"] == "installer") + .count(), + 1, + ); + }, + ) + .await +} From e286119fc6d390d1bd1ec82a3710490367ef9659 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:54:27 +1200 Subject: [PATCH 054/130] cover the build report --- .../tests/it/reporting_schemas.rs | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index e2a7b298e..c5687cf56 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -252,3 +252,237 @@ async fn restoring_for_a_group_does_not_authorise_publishing_its_schema() { ) .await } + +/// The declaration `seed` made, which a report has to name. +async fn declaration_id(conn: &mut database::diesel_async::AsyncPgConnection) -> uuid::Uuid { + use diesel::{QueryableByName, sql_query, sql_types}; + use diesel_async::RunQueryDsl; + + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = sql_types::Uuid)] + id: uuid::Uuid, + } + + sql_query("SELECT id FROM restore_replicas LIMIT 1") + .get_result::(conn) + .await + .expect("the seeded declaration") + .id +} + +/// A builder's report of one run, with `build` as its reporting-schema block. +fn build_report(replica: uuid::Uuid, build: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "replica_id": replica, + "group": GROUP, + "machine_id": MACHINE, + "type": "tamanu-postgres", + "intent": "schema-build", + "snapshot_id": "snap-1", + "outcome": "success", + "replica_healthy": true, + "observed_at": "2026-09-07T00:00:00Z", + "reporting_schema": build, + }) +} + +/// The build a report carries settles the pair it names, and is held against +/// the group's central application, whose database the schema followed from. +#[tokio::test(flavor = "multi_thread")] +async fn a_build_report_settles_the_pair_it_names() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + let replica = declaration_id(&mut conn).await; + + let resp = public + .post("/restore-verification") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&build_report( + replica, + serde_json::json!({ "target_version": "2.60.0", "built": true }), + )) + .await; + resp.assert_status(StatusCode::NO_CONTENT); + + let build = database::reporting_schemas::ReportingSchemaBuild::latest_for_pair( + &mut conn, + GROUP.parse().unwrap(), + VERSION.parse().unwrap(), + ) + .await + .expect("read the build") + .expect("a build landed"); + + assert!(build.built); + assert_eq!( + build.application_id, + Some(CENTRAL.parse().unwrap()), + "held against the central, not the reporting device's own machine" + ); + }, + ) + .await +} + +/// A consumer may name the version by id rather than by semver, which is what +/// the worklist entry hands it. +#[tokio::test(flavor = "multi_thread")] +async fn a_build_report_may_name_its_version_by_id() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + let replica = declaration_id(&mut conn).await; + + let resp = public + .post("/restore-verification") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&build_report( + replica, + serde_json::json!({ "target_version_id": VERSION, "built": true }), + )) + .await; + resp.assert_status(StatusCode::NO_CONTENT); + + assert!( + database::reporting_schemas::ReportingSchemaBuild::is_settled( + &mut conn, + GROUP.parse().unwrap(), + VERSION.parse().unwrap(), + ) + .await + .expect("settled"), + ); + }, + ) + .await +} + +/// A build is for a pair, so a report that names no version cannot be +/// attributed to one and is refused rather than recorded against a guess. +#[tokio::test(flavor = "multi_thread")] +async fn a_build_report_naming_no_version_is_refused() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + let replica = declaration_id(&mut conn).await; + + let resp = public + .post("/restore-verification") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&build_report(replica, serde_json::json!({ "built": true }))) + .await; + + assert_eq!(resp.status_code(), StatusCode::BAD_REQUEST); + }, + ) + .await +} + +/// A build that produced nothing settles the pair too, carrying the builder's +/// own description of what went wrong. +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_build_report_carries_its_description() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + let replica = declaration_id(&mut conn).await; + + let resp = public + .post("/restore-verification") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&build_report( + replica, + serde_json::json!({ + "target_version": "2.60.0", + "built": false, + "error": "views did not compile", + }), + )) + .await; + resp.assert_status(StatusCode::NO_CONTENT); + + let pairs = + database::reporting_schemas::pairs_for_group(&mut conn, GROUP.parse().unwrap()) + .await + .expect("pairs"); + let pair = pairs + .iter() + .find(|p| p.version == "2.60.0") + .expect("the pair"); + + assert_eq!(pair.state, database::reporting_schemas::PairState::Failed); + assert_eq!(pair.error.as_deref(), Some("views did not compile")); + }, + ) + .await +} + +/// A build rides the migrate pathway, so one run's report can carry both +/// blocks. The build is the one that settles the pair, and the migration +/// payload beside it is deliberately not recorded as a migration test. +#[tokio::test(flavor = "multi_thread")] +async fn a_report_carrying_both_records_only_the_build() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + use diesel::{QueryableByName, sql_query, sql_types}; + use diesel_async::RunQueryDsl; + + #[derive(QueryableByName)] + struct Count { + #[diesel(sql_type = sql_types::BigInt)] + count: i64, + } + + seed(&mut conn, device_id).await; + let replica = declaration_id(&mut conn).await; + + let mut body = build_report( + replica, + serde_json::json!({ "target_version": "2.60.0", "built": true }), + ); + body["migration"] = serde_json::json!({ + "target_version": "2.60.0", + "total_elapsed_seconds": 12, + "data_bytes_before": 1_000, + "data_bytes_after": 1_200, + "timings": [], + }); + + let resp = public + .post("/restore-verification") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&body) + .await; + resp.assert_status(StatusCode::NO_CONTENT); + + assert!( + database::reporting_schemas::ReportingSchemaBuild::is_settled( + &mut conn, + GROUP.parse().unwrap(), + VERSION.parse().unwrap(), + ) + .await + .expect("settled"), + "the build is what settles the pair" + ); + + let migrations = sql_query("SELECT COUNT(*) AS count FROM migration_tests") + .get_result::(&mut conn) + .await + .expect("count") + .count; + assert_eq!( + migrations, 0, + "the migration payload beside a build is not a migration test" + ); + }, + ) + .await +} From d12c0300de78a1e9c0bf95afea301bf13507ac94 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:07:50 +1200 Subject: [PATCH 055/130] cover the schema round trip --- .../tests/it/reporting_schemas.rs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index c5687cf56..617cf0843 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -486,3 +486,130 @@ async fn a_report_carrying_both_records_only_the_build() { ) .await } + +/// A schema the builder registers is what the group's machines are later +/// offered, byte for byte, under the version it was built for. +/// +/// The one device stands in for both the builder and a machine of the group: +/// which credential may do which is settled by the refusals above and in +/// `artifact_scopes`, and what this asserts is that the bytes survive the trip +/// and that the listing's own `download_url` is the one that fetches them. +#[tokio::test(flavor = "multi_thread")] +async fn a_registered_schema_is_offered_back_byte_for_byte() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + conn.batch_execute(&format!( + "UPDATE machines SET device_id = '{device_id}' WHERE id = '{MACHINE}'" + )) + .await + .expect("enrol the machine"); + + let sql = "CREATE VIEW reporting.encounters AS SELECT 1;"; + + public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text(sql) + .await + .assert_status_ok(); + + let listing = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + listing.assert_status_ok(); + + let artifacts: Vec = listing.json(); + let schema = artifacts + .iter() + .find(|a| a["artifact_type"] == "reporting-schema") + .expect("the group's schema is offered"); + + assert_eq!(schema["platform"], "any"); + assert_eq!(schema["group_id"], GROUP); + assert_eq!( + schema["version_id"], VERSION, + "published against the exact version, not a range" + ); + assert!( + schema["version_range_pattern"].is_null(), + "a schema follows the migrations one version applies: {schema}" + ); + assert_eq!( + schema["digest"].as_str().expect("a digest"), + database::artifacts::digest_of(sql.as_bytes()), + "the digest describes the bytes canopy took in" + ); + + // Follow the URL the listing handed out rather than rebuilding it, + // so the offer a device actually receives is what gets fetched. + let offered_url = schema["download_url"].as_str().expect("a download url"); + let path = offered_url + .split_once("/versions/") + .map(|(_, rest)| format!("/versions/{rest}")) + .expect("the offer names a versions path"); + + let download = public + .get(&path) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + download.assert_status_ok(); + assert_eq!(download.text(), sql); + }, + ) + .await +} + +/// A facility is offered the same schema as its group's centrals: a schema +/// follows the group and the version rather than the application it was built +/// from, and the build only ever runs against a central's snapshot. +#[tokio::test(flavor = "multi_thread")] +async fn a_facility_is_offered_the_same_schema_as_its_centrals() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + // A builder of its own, since the authenticated device here is the + // facility's machine rather than the consumer that built the schema. + let consumer = uuid::Uuid::new_v4(); + conn.batch_execute(&format!( + "INSERT INTO devices (id, role) VALUES ('{consumer}', 'backup-restore')" + )) + .await + .expect("the builder device"); + seed(&mut conn, consumer).await; + + let digest = database::artifacts::digest_of(b"the group's schema"); + conn.batch_execute(&format!( + "INSERT INTO artifacts + (version_id, platform, artifact_type, group_id, content, content_type, digest) + VALUES ('{VERSION}', 'any', 'reporting-schema', '{GROUP}', + 'the group''s schema'::bytea, 'application/sql', '{digest}'); + + INSERT INTO machines (id, name, group_id, device_id) + VALUES (gen_random_uuid(), 'facility-box', '{GROUP}', '{device_id}')" + )) + .await + .expect("seed the schema and a facility box"); + + let listing = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + listing.assert_status_ok(); + + let artifacts: Vec = listing.json(); + let schema = artifacts + .iter() + .find(|a| a["artifact_type"] == "reporting-schema") + .expect("a facility's device is offered its group's schema"); + + assert_eq!(schema["group_id"], GROUP); + }, + ) + .await +} From f2aa605f74be938cbe65af56d2c355302c4ff512 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:08:48 +1200 Subject: [PATCH 056/130] cover credential and media type --- crates/database/tests/it/artifact_scopes.rs | 48 ++++++++ crates/private-server/tests/it/artifacts.rs | 45 +++++++ .../public-server/tests/it/artifact_scopes.rs | 114 ++++++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 94db5762b..b304608bc 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -612,3 +612,51 @@ async fn deleting_a_group_takes_its_artifacts() { }) .await; } + +/// An unscoped exact artifact replaces itself too. Its key carries a NULL range +/// pattern and a NULL group, which the default treatment of NULL would have +/// made distinct from the row already there. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_exact_artifact_replaces_itself() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let first = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/one")) + .await + .expect("first"); + let second = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/two")) + .await + .expect("second"); + + assert_eq!(first.id, second.id); + assert_eq!(second.download_url.as_deref(), Some("https://x/two")); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1); + }) + .await; +} + +/// A group's name is shown whatever state the group is in, so a reference to an +/// archived group still reads as that group rather than as nothing. +#[tokio::test(flavor = "multi_thread")] +async fn an_archived_group_is_still_named() { + TestDb::run(|mut conn, _url| async move { + let theirs = seed_group(&mut conn, "kamaka").await; + + conn.batch_execute(&format!( + "UPDATE server_groups SET deleted_at = now() WHERE id = '{theirs}'" + )) + .await + .expect("archive the group"); + + let names = database::server_groups::ServerGroup::names_by_id(&mut conn) + .await + .expect("names"); + assert_eq!(names.get(&theirs).map(String::as_str), Some("kamaka")); + }) + .await; +} diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 54591bf2f..0e68ce207 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -173,3 +173,48 @@ async fn an_unscoped_artifact_cannot_lose_its_location() { }) .await } + +/// An operator registers a group-scoped artifact by carrying its bytes. Canopy +/// holds them, takes the digest of what it received, and offers the group's +/// name back so the operator can see whose it is. +// spec: ART#where-an-artifact-rests, ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn an_operator_registers_a_group_scoped_artifact() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "bbbbbbbb-0000-0000-0000-bbbbbbbbbbbb"; + let group = "cccccccc-0000-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + // "kamaka schema" — the digest asserted below is of exactly these bytes. + let response = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "reporting-schema", + "platform": "any", + "group_id": group, + "content_base64": "a2FtYWthIHNjaGVtYQ==", + "content_type": "application/sql", + })) + .await; + response.assert_status_ok(); + + let artifact: serde_json::Value = response.json(); + assert_eq!(artifact["canopy_holds_bytes"], true); + assert!(artifact["download_url"].is_null(), "it rests in Canopy"); + assert_eq!(artifact["group_id"], group); + assert_eq!(artifact["group_name"], "kamaka"); + assert_eq!( + artifact["digest"], + database::artifacts::digest_of(b"kamaka schema") + ); + }) + .await +} diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index cd57ec12b..b99505bab 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -478,3 +478,117 @@ async fn registering_again_over_the_wire_replaces() { ) .await } + +/// A credential Canopy cannot place is anonymous rather than refused, so a +/// deactivated key still reads the unscoped artifacts instead of failing a path +/// that serves everyone. The same credential registering is still a refusal: +/// the downgrade widens nothing. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn a_deactivated_key_reads_as_anonymous_and_still_cannot_register() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute(&format!( + "UPDATE device_keys SET is_active = false WHERE device_id = '{device_id}'" + )) + .await + .expect("deactivate the key"); + + // The read still answers, with the unscoped set rather than group A's. + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + + // Registering with the same credential is refused: a path that + // needs an identity does not accept one Canopy cannot place. + let refused = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + assert_eq!(refused.status_code(), StatusCode::UNAUTHORIZED); + }, + ) + .await +} + +/// The media type a registration recorded is what the bytes are served as, and +/// an artifact registered without one is served as opaque bytes rather than +/// guessed at. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn held_bytes_are_served_as_the_type_they_were_registered_with() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let typed = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + typed.header("content-type").to_str().unwrap(), + "application/sql" + ); + + conn.batch_execute(&format!( + "UPDATE artifacts SET content_type = NULL WHERE id = '{THEIRS}'" + )) + .await + .expect("drop the media type"); + + let untyped = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + untyped.header("content-type").to_str().unwrap(), + "application/octet-stream" + ); + }, + ) + .await +} + +/// An operator device registers either kind, but the group-scoped path is not +/// reachable from this endpoint at all, so an admin naming a group is refused +/// exactly as a releaser is. Publishing into a group is the private server's. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn an_admin_device_cannot_register_for_a_group_here_either() { + commons_tests::server::run_with_device_auth( + "admin", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let refused = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP_A}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.sql") + .await; + assert_eq!(refused.status_code(), StatusCode::FORBIDDEN); + + // Unscoped, the same admin credential registers fine. + let accepted = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + accepted.assert_status_ok(); + }, + ) + .await +} From 7971f28c2d915f0d103403bd41309379ebc8f5b2 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:11:08 +1200 Subject: [PATCH 057/130] seed reporting schema builds --- private-web/e2e/reporting-schemas.spec.ts | 85 ++++++++++++++++++++++- private-web/e2e/seed.ts | 48 +++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index e8575cf81..de6e3aaf7 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -3,6 +3,7 @@ import { resetSeededTables, seedApplicationReport, seedDevice, + seedReportingSchemaBuild, seedRestoreConsumerCapability, seedRestoreReplica, seedServer, @@ -16,7 +17,7 @@ import { expect, test } from "./test-fixtures"; /// owes a schema only where something is there to build one. /// /// spec: RPT#pairs -async function declareBuilder(sql: Sql, groupId: string): Promise { +async function declareBuilder(sql: Sql, groupId: string): Promise { const consumer = await seedDevice(sql, { role: "backup-restore" }); await seedRestoreConsumerCapability(sql, { deviceId: consumer.id, @@ -33,6 +34,7 @@ async function declareBuilder(sql: Sql, groupId: string): Promise { intent: "reporting-schema", name: "kamaka-schemas", }); + return consumer.id; } /// How a group's reporting-schema pairs are presented, and how an operator asks @@ -147,4 +149,85 @@ test.describe("reporting schemas", () => { await expect(section.getByTestId("reporting-schema-row")).toHaveCount(0); await expect(section.getByText(/no builder is declared/i)).toBeVisible(); }); + + /// A built pair and a failed one read differently on the screen, and the + /// failed one carries the builder's own description, which is the only + /// place an operator can read why it failed. + /// + /// spec: RPT#presentation + test("a built pair and a failed one read differently", async ({ + page, + sql, + }) => { + const built = await seedVersion(sql, { + major: 2, + minor: 59, + patch: 0, + status: "published", + }); + const failed = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + const consumer = await declareBuilder(sql, group.id); + + const central = await seedServer(sql, { + name: "central", + groupId: group.id, + type: "tamanu-central", + }); + const facility = await seedServer(sql, { + name: "facility", + groupId: group.id, + type: "tamanu-facility", + }); + await seedApplicationReport(sql, { + applicationId: central.id, + version: "2.60.0", + }); + await seedApplicationReport(sql, { + applicationId: facility.id, + version: "2.59.0", + }); + + await seedReportingSchemaBuild(sql, { + consumerDeviceId: consumer, + groupId: group.id, + machineId: central.machineId, + applicationId: central.id, + versionId: built.id, + built: true, + }); + await seedReportingSchemaBuild(sql, { + consumerDeviceId: consumer, + groupId: group.id, + machineId: central.machineId, + applicationId: central.id, + versionId: failed.id, + built: false, + error: "views did not compile", + }); + + await page.goto(`/groups/${group.id}`); + + const section = page.getByTestId("reporting-schemas"); + await expect(section.getByText("Built", { exact: true })).toBeVisible(); + await expect(section.getByText("Failed", { exact: true })).toBeVisible(); + await expect( + section.getByText("Awaiting build", { exact: true }), + ).toHaveCount(0); + + // The description is only reachable by hovering the chip, which is the + // whole of an operator's access to why the build failed. + await section.getByText("Failed", { exact: true }).hover(); + await expect(page.getByText("views did not compile")).toBeVisible(); + + // A settled pair offers a rebuild rather than a first build. + await expect( + section.getByRole("button", { name: "Build again" }), + ).toHaveCount(2); + }); }); diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index 2dcc2657c..b25dd0508 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -1638,6 +1638,54 @@ export async function seedMigrationTest( } } +/** Seed a reporting-schema build: the restore-health report that carries the + * common fields, plus the build outcome hung off it. `built: false` with an + * `error` is what makes the pair read as failed; a build settles the pair + * either way. */ +export async function seedReportingSchemaBuild( + sql: Sql, + opts: { + consumerDeviceId: string; + groupId: string; + /** The machine whose snapshot the schema was built from. */ + machineId: string; + /** The group's central, which the build is held against. */ + applicationId?: string | null; + versionId: string; + snapshotId?: string; + built?: boolean; + error?: string | null; + /** Artifact ids the build registered, of which the schema is one. */ + artifactIds?: string[]; + }, +): Promise { + const built = opts.built ?? true; + const rows = await sql.query<{ id: string }>( + `INSERT INTO backup_restore_checks + (consumer_device_id, group_id, machine_id, type, intent, snapshot_id, outcome, + replica_healthy, observed_at) + VALUES ($1, $2, $3, 'tamanu-postgres', 'reporting-schema', $4, 'success', true, NOW()) + RETURNING id`, + [opts.consumerDeviceId, opts.groupId, opts.machineId, opts.snapshotId ?? "snap-1"], + ); + const checkId = rows[0]!.id; + + await sql.query( + `INSERT INTO reporting_schema_builds + (check_id, group_id, version_id, application_id, built, error, artifact_ids) + VALUES ($1, $2, $3, $4, $5, $6, $7::uuid[])`, + [ + checkId, + opts.groupId, + opts.versionId, + opts.applicationId ?? null, + built, + built ? null : (opts.error ?? "the build failed"), + opts.artifactIds ?? [], + ], + ); +} + /** Record where a group is going. `plannedFor` is `YYYY-MM-DD`; omit for a plan * with no date. */ export interface SeededMaintenanceWindow { From 642b43596981bc7bcaa87e878ce2a188d88e4497 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:29:05 +1200 Subject: [PATCH 058/130] cover the artifact form --- private-web/e2e/artifact-scopes.spec.ts | 141 +++++++++++++++++++++++ private-web/src/routes/VersionDetail.tsx | 11 +- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts index d0094bbad..56b70b5ab 100644 --- a/private-web/e2e/artifact-scopes.spec.ts +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -152,4 +152,145 @@ test.describe("group-scoped artifacts", () => { await expect(page.locator("table tbody tr")).toHaveCount(2); await expect(page.getByText("[Hidden]")).toHaveCount(0); }); + + + /// An operator publishes into a group by carrying the bytes: there is no + /// store to be credentialled for, so being able to register for the group is + /// the whole of what publishing into it takes. + /// + /// spec: ART#where-an-artifact-rests + test("an operator registers a group's artifact by uploading it", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedServerGroup(sql, { name: "kamaka" }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("textbox", { name: "Type" }).fill("reporting-schema"); + await page.getByRole("textbox", { name: "Platform" }).fill("any"); + await page.getByRole("combobox", { name: "Group" }).click(); + await page.getByRole("option", { name: "kamaka" }).click(); + await page.getByLabel("Choose file…").setInputFiles({ + name: "kamaka.sql", + mimeType: "application/sql", + buffer: Buffer.from("kamaka schema"), + }); + // The submit shares its label with the button that revealed the form, + // so it has to be picked out of the form itself. + await page + .locator("form") + .getByRole("button", { name: "Create" }) + .click(); + + await expect(page.getByText("Held by Canopy for kamaka")).toBeVisible(); + + // Canopy holds the bytes and records the digest of what it took in. + const rows = await sql.query<{ + download_url: string | null; + digest: string | null; + content: string | null; + }>( + `SELECT download_url, digest, encode(content, 'escape') AS content + FROM artifacts WHERE version_id = $1`, + [version.id], + ); + expect(rows).toHaveLength(1); + expect(rows[0].download_url).toBeNull(); + expect(rows[0].content).toBe("kamaka schema"); + expect(rows[0].digest).toBe( + "sha256:214b3ad41c660e2837e03418fe87c70b1e82cc7c3531d78efeff9a3409ea91d9", + ); + }); + + /// An artifact Canopy holds has no location to edit. Replacing its bytes is + /// a registration, which is what carries the digest. + /// + /// spec: ART#where-an-artifact-rests + test("a held artifact offers no location to edit", async ({ page, sql }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page + .getByRole("button", { name: "edit reporting-schema any for kamaka" }) + .click(); + + await expect( + page.getByText("Register it again to replace the bytes"), + ).toBeVisible(); + }); + + /// Canopy keeps none of what it has stopped serving, so removing an artifact + /// takes the bytes it held with it. + /// + /// spec: ART#where-an-artifact-rests + test("deleting a group's artifact takes its bytes", async ({ page, sql }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + downloadUrl: "https://example.com/all.sql", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + + // The two rows share a type and platform, so the label has to name the + // group to pick one out. + await page + .getByRole("button", { name: "delete reporting-schema any for kamaka" }) + .click(); + await page.getByRole("button", { name: "Really delete" }).click(); + + await expect(page.getByText("Held by Canopy for kamaka")).toHaveCount(0); + + const [held] = await sql.query<{ n: string }>( + "SELECT count(*) AS n FROM artifacts WHERE content IS NOT NULL", + ); + expect(Number(held.n)).toBe(0); + + // The unscoped one is untouched. + const [left] = await sql.query<{ n: string }>( + "SELECT count(*) AS n FROM artifacts", + ); + expect(Number(left.n)).toBe(1); + }); }); diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 766f69f99..de8d77bdc 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -410,6 +410,13 @@ function ArtifactsSection({ ); } +// A version can hold several artifacts of one type and platform, one per group, +// so the type alone does not name a row. +function artifactLabel(artifact: ArtifactData): string { + const scope = artifact.group_name ?? (artifact.group_id ? "a group" : "every group"); + return `${artifact.artifact_type} ${artifact.platform} for ${scope}`; +} + function ArtifactRow({ artifact, unlocked, @@ -528,14 +535,14 @@ function ArtifactRow({ ) : ( setEditing(true)} > setConfirmDelete(true)} From 486ced08dc9686d3f841884da633e089ded033c2 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:37:05 +1200 Subject: [PATCH 059/130] refuse a blank location --- crates/database/src/artifacts.rs | 4 ++ crates/private-server/src/fns/versions.rs | 9 ++- crates/private-server/tests/it/artifacts.rs | 52 +++++++++++++++++ private-web/e2e/artifact-scopes.spec.ts | 62 +++++++++++++++++++++ private-web/src/routes/VersionDetail.tsx | 16 +++++- 5 files changed, 138 insertions(+), 5 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index fa0505bec..aa1ea0e33 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -340,6 +340,10 @@ impl Artifact { .first(db) .await .map_err(AppError::from)?; + // A blank URL is no location at all. The constraint only tests for NULL, + // so an empty string would pass it and leave an artifact nothing can be + // fetched from. + let new_url = new_url.filter(|url| !url.trim().is_empty()); match (scoped.is_some(), new_url.is_some()) { (true, true) => { return Err(AppError::Conflict( diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 0fd40bf33..a6c881032 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -682,17 +682,20 @@ pub async fn create_artifact( (None, None) => (None, None), }; + // A blank URL is no location at all, and the constraint only tests for NULL. + let download_url = args.download_url.filter(|url| !url.trim().is_empty()); + // An artifact rests in one place or the other, so a registration naming a // group and a location together is refused rather than written and caught // by the constraint. // spec: ART#where-an-artifact-rests - if args.group_id.is_some() && args.download_url.is_some() { + if args.group_id.is_some() && download_url.is_some() { return Err(AppError::BadRequest( "an artifact Canopy holds has no download URL".into(), )); } - if args.group_id.is_none() && args.download_url.is_none() { + if args.group_id.is_none() && download_url.is_none() { return Err(AppError::BadRequest( "an artifact needs a download URL or a group".into(), )); @@ -704,7 +707,7 @@ pub async fn create_artifact( version_id: Some(args.version_id), artifact_type: args.artifact_type, platform: args.platform, - download_url: args.download_url, + download_url, device_id: None, version_range_pattern: None, group_id: args.group_id, diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 0e68ce207..470d7564f 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -218,3 +218,55 @@ async fn an_operator_registers_a_group_scoped_artifact() { }) .await } + +/// A blank location is no location. The check constraint only tests for NULL, +/// so an empty string would pass it and leave an artifact nothing can be +/// fetched from. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_blank_download_url_is_not_a_location() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "dddddddd-0000-0000-0000-dddddddddddd"; + let artifact = "eeeeeeee-0000-0000-0000-eeeeeeeeeeee"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{artifact}', '{version}', 'any', 'installer', 'https://example.com/x.exe')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "linux", + "download_url": " ", + })) + .await; + assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + + let updated = private + .post("/api/versions/update_artifact") + .json(&serde_json::json!({ + "artifact_id": artifact, + "artifact_type": "installer", + "platform": "any", + "download_url": "", + })) + .await; + assert_eq!(updated.status_code(), axum::http::StatusCode::CONFLICT); + + let listed = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + let artifacts: Vec = listed.json(); + assert_eq!(artifacts.len(), 1, "nothing blank was written"); + assert_eq!(artifacts[0]["download_url"], "https://example.com/x.exe"); + }) + .await +} diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts index 56b70b5ab..9d2ed480c 100644 --- a/private-web/e2e/artifact-scopes.spec.ts +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -293,4 +293,66 @@ test.describe("group-scoped artifacts", () => { ); expect(Number(left.n)).toBe(1); }); + + /// An unscoped artifact rests at a location, so editing its type or platform + /// must not be a way to take that location away. The field's `required` never + /// fires: the row is not a form and Save is not a submit. + /// + /// spec: ART#where-an-artifact-rests + test("an unscoped artifact cannot be saved without a location", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "installer", + platform: "windows", + downloadUrl: "https://example.com/i.exe", + }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page + .getByRole("button", { name: "edit installer windows for every group" }) + .click(); + + await page.getByRole("textbox").last().fill(""); + await expect(page.getByRole("button", { name: "Save" })).toBeDisabled(); + + // And the location it had is still the location it has. + const [row] = await sql.query<{ download_url: string | null }>( + "SELECT download_url FROM artifacts", + ); + expect(row.download_url).toBe("https://example.com/i.exe"); + }); + + /// A group list that failed to load offers only "Every group", which reads as + /// a fleet with no groups. Publishing into a group becomes impossible, so the + /// operator has to be told rather than left to infer it. + /// + /// spec: ART#registration + test("a group list that fails to load says so", async ({ page, sql }) => { + await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedServerGroup(sql, { name: "kamaka" }); + + await page.route("**/api/fleet/groups/list", (route) => route.abort()); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByText("Could not load groups")).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Group" })).toBeDisabled(); + }); }); diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index de8d77bdc..68d58bd99 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -626,7 +626,12 @@ function EditArtifactRow({ size="small" variant="contained" onClick={save} - disabled={action.pending} + // `required` on the field never fires: the row is not a + // form and Save is not a submit, so nothing validates it. + disabled={ + action.pending || + (!artifact.canopy_holds_bytes && !url?.trim()) + } > {action.pending ? "Saving…" : "Save"} @@ -723,7 +728,14 @@ function CreateArtifactForm({ label="Group" value={groupId} onChange={(e) => setGroupId(e.target.value)} - disabled={action.pending} + disabled={action.pending || groups.status === "error"} + // Falling back to an empty list silently offers only + // "Every group", which reads as a fleet with no groups + // rather than as a list that failed to load. + error={groups.status === "error"} + helperText={ + groups.status === "error" ? "Could not load groups" : undefined + } sx={{ minWidth: 160 }} > Every group From 20a75d6a54a30868ef4b79ce7ed52d556f75a7c0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:06:40 +1200 Subject: [PATCH 060/130] size the artifact body limit --- crates/private-server/src/fns/versions.rs | 16 ++++-- crates/private-server/tests/it/artifacts.rs | 58 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index a6c881032..658701902 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::str::FromStr; use axum::Json; -use axum::extract::State; +use axum::extract::{DefaultBodyLimit, State}; use base64::{Engine as _, prelude::BASE64_STANDARD}; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; @@ -26,6 +26,11 @@ use crate::state::AppState; /// Postgres alongside everything else. const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; +/// Body budget for `create_artifact`. Base64 inflates the bytes by a third, and +/// sizing above that keeps an over-limit upload the handler's structured +/// refusal rather than axum's plain-text 413. +const MAX_CREATE_ARTIFACT_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES / 3 * 4 + 64 * 1024; + /// A single released (or draft) software version. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct VersionData { @@ -224,7 +229,11 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(update_version_status)) .routes(routes!(update_version_changelog)) .routes(routes!(update_artifact)) - .routes(routes!(create_artifact)) + .merge( + OpenApiRouter::new() + .routes(routes!(create_artifact)) + .layer(DefaultBodyLimit::max(MAX_CREATE_ARTIFACT_BODY_BYTES)), + ) .routes(routes!(delete_artifact)) .routes(routes!(list_known_issues)) .routes(routes!(add_known_issue)) @@ -663,7 +672,8 @@ pub async fn create_artifact( .map_err(|_| AppError::BadRequest("content_base64 is not valid base64".into()))?; if bytes.len() > MAX_HELD_ARTIFACT_BYTES { return Err(AppError::BadRequest(format!( - "artifact is larger than the {MAX_HELD_ARTIFACT_BYTES} byte limit" + "artifact is larger than the {} MiB limit", + MAX_HELD_ARTIFACT_BYTES / (1024 * 1024) ))); } let digest = digest_of(&bytes); diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 470d7564f..75ec2806f 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -270,3 +270,61 @@ async fn a_blank_download_url_is_not_a_location() { }) .await } + +/// The create route carries a body limit sized from the held-bytes cap, so an +/// upload well past axum's 2 MB default is accepted, and one past the cap is +/// refused by the handler naming the limit rather than by axum with a +/// plain-text 413 the SPA has nothing structured to render. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_upload_over_the_limit_is_told_what_it_is() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "ffffffff-0000-0000-0000-ffffffffffff"; + let group = "ffffffff-1111-1111-1111-ffffffffffff"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + // "AAAA" decodes to three zero bytes, so the repeat count sets the size. + let four_mib = "A".repeat(4 * (4 * 1024 * 1024 / 3)); + let accepted = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "reporting-schema", + "platform": "any", + "group_id": group, + "content_base64": four_mib, + })) + .await; + accepted.assert_status_ok(); + + let over_limit = "A".repeat(4 * (32 * 1024 * 1024 / 3 + 1)); + let refused = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "reporting-schema", + "platform": "linux", + "group_id": group, + "content_base64": over_limit, + })) + .await; + + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + let problem: serde_json::Value = refused.json(); + assert!( + problem["title"] + .as_str() + .expect("a problem-details title") + .contains("32 MiB"), + "the refusal names the limit, but got: {problem}" + ); + }) + .await +} From ee30a20b79c8854901f09c1436ca9066e49133fb Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:09:57 +1200 Subject: [PATCH 061/130] refuse oversized uploads in the form --- private-web/e2e/artifact-scopes.spec.ts | 42 ++++++++++++++++++++++++ private-web/src/routes/VersionDetail.tsx | 29 ++++++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts index 9d2ed480c..4699bd370 100644 --- a/private-web/e2e/artifact-scopes.spec.ts +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -355,4 +355,46 @@ test.describe("group-scoped artifacts", () => { await expect(page.getByText("Could not load groups")).toBeVisible(); await expect(page.getByRole("combobox", { name: "Group" })).toBeDisabled(); }); + + /// A file past what Canopy will hold is refused with the size named, before + /// it is read and encoded: base64 of an oversized file locks the tab up for + /// a request the server was always going to refuse. + /// + /// spec: ART#where-an-artifact-rests + test("a file over the limit is refused with the limit named", async ({ + page, + sql, + }) => { + await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedServerGroup(sql, { name: "kamaka" }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("textbox", { name: "Type" }).fill("reporting-schema"); + await page.getByRole("textbox", { name: "Platform" }).fill("any"); + await page.getByRole("combobox", { name: "Group" }).click(); + await page.getByRole("option", { name: "kamaka" }).click(); + await page.getByLabel("Choose file…").setInputFiles({ + name: "kamaka.sql", + mimeType: "application/sql", + buffer: Buffer.alloc(32 * 1024 * 1024 + 1), + }); + + await expect( + page.getByText("Artifact is larger than the 32 MiB limit"), + ).toBeVisible(); + await expect( + page.locator("form").getByRole("button", { name: "Create" }), + ).toBeDisabled(); + + const rows = await sql.query("SELECT id FROM artifacts"); + expect(rows).toHaveLength(0); + }); }); diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 68d58bd99..ac5bcf5c5 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -653,11 +653,16 @@ function EditArtifactRow({ /// JSON body, so the file is read here rather than posted as a multipart form. async function encodeFile(file: File): Promise { const buffer = new Uint8Array(await file.arrayBuffer()); - let binary = ""; - for (const byte of buffer) binary += String.fromCharCode(byte); - return btoa(binary); + const chunks: string[] = []; + for (let i = 0; i < buffer.length; i += 0x8000) { + chunks.push(String.fromCharCode(...buffer.subarray(i, i + 0x8000))); + } + return btoa(chunks.join("")); } +const MAX_HELD_ARTIFACT_BYTES = 32 * 1024 * 1024; +const OVER_LIMIT_MESSAGE = `Artifact is larger than the ${MAX_HELD_ARTIFACT_BYTES / (1024 * 1024)} MiB limit`; + function CreateArtifactForm({ versionId, onCreated, @@ -670,6 +675,7 @@ function CreateArtifactForm({ const [url, setUrl] = useState(""); const [groupId, setGroupId] = useState(""); const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); const action = useApiAction("versions", "create_artifact"); const groups = useApi("fleet/groups", "list", {}, []); @@ -692,6 +698,7 @@ function CreateArtifactForm({ setUrl(""); setGroupId(""); setFile(null); + setFileError(null); onCreated(); } catch { /* surfaced via action.error */ @@ -756,7 +763,15 @@ function CreateArtifactForm({ setFile(e.target.files?.[0] ?? null)} + onChange={(e) => { + const chosen = e.target.files?.[0] ?? null; + setFile(chosen); + setFileError( + chosen && chosen.size > MAX_HELD_ARTIFACT_BYTES + ? OVER_LIMIT_MESSAGE + : null, + ); + }} /> ) : ( @@ -773,14 +788,14 @@ function CreateArtifactForm({ - {action.error && ( + {(fileError ?? action.error?.message) && ( - {action.error.message} + {fileError ?? action.error?.message} )} From 868707ab961bf0c40778a4855e284710fadef0e1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:18:04 +1200 Subject: [PATCH 062/130] refuse a rename onto a taken identity --- crates/database/src/artifacts.rs | 19 ++- crates/database/tests/it/artifact_scopes.rs | 131 +++++++++++++++++++- 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index aa1ea0e33..7f4e82f83 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -358,16 +358,27 @@ impl Artifact { _ => {} } - diesel::update(artifacts.filter(id.eq(artifact_id))) + match diesel::update(artifacts.filter(id.eq(artifact_id))) .set(( artifact_type.eq(new_type), platform.eq(new_platform), download_url.eq(new_url), )) .execute(db) - .await?; - - Ok(()) + .await + { + Ok(_) => Ok(()), + // A rename onto an identity another artifact already holds is the + // operator's own input, so it is refused as a conflict rather than + // left to surface as a database fault. + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => Err(AppError::Conflict( + "an artifact of that type and platform is already registered".into(), + )), + Err(e) => Err(AppError::from(e)), + } } pub async fn delete(db: &mut AsyncPgConnection, artifact_id: Uuid) -> Result<()> { diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index b304608bc..26190a56a 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -7,7 +7,7 @@ use database::{ artifacts::{Artifact, NewArtifact, Scope, digest_of}, diesel_async::AsyncPgConnection, }; -use diesel_async::SimpleAsyncConnection; +use diesel_async::{RunQueryDsl, SimpleAsyncConnection}; use uuid::Uuid; async fn seed_version(conn: &mut AsyncPgConnection, major: i32, minor: i32, patch: i32) -> Uuid { @@ -660,3 +660,132 @@ async fn an_archived_group_is_still_named() { }) .await; } + +/// Two artifacts of one version differing only by platform. Renaming one onto +/// the other's identity is the operator's own input, so it is refused as a +/// conflict rather than reaching the unique index as a database fault. +#[tokio::test(flavor = "multi_thread")] +async fn a_rename_onto_an_existing_identity_is_refused() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + Artifact::register( + &mut conn, + unscoped(version, "installer", "https://example.com/win.exe"), + ) + .await + .expect("register"); + + let mut linux = unscoped(version, "installer", "https://example.com/lin.deb"); + linux.platform = "linux".to_owned(); + let linux = Artifact::register(&mut conn, linux) + .await + .expect("register"); + + let refused = Artifact::update( + &mut conn, + linux.id, + "installer".to_owned(), + "any".to_owned(), + Some("https://example.com/lin.deb".to_owned()), + ) + .await; + assert!( + matches!(refused, Err(commons_errors::AppError::Conflict(_))), + "a taken identity is a conflict, got {refused:?}" + ); + + // The rename is refused whole: the artifact keeps the platform it had. + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("list"); + let mut platforms: Vec<_> = all.iter().map(|a| a.platform.as_str()).collect(); + platforms.sort_unstable(); + assert_eq!(platforms, vec!["any", "linux"]); + }) + .await; +} + +const UP: &str = + include_str!("../../../../migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql"); +const DOWN: &str = + include_str!("../../../../migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql"); + +#[derive(diesel::QueryableByName)] +struct TextRow { + #[diesel(sql_type = diesel::sql_types::Text)] + value: String, +} + +/// Reverting drops the artifacts Canopy holds, since the bytes have nowhere to +/// go once the column does, and leaves the unscoped ones as they were. Nothing +/// else runs these migrations backwards, so a `down.sql` that cannot reverse +/// would only be found on the box it was needed on. +#[tokio::test(flavor = "multi_thread")] +async fn the_group_scope_migration_reverses() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let group = seed_group(&mut conn, "kamaka").await; + + Artifact::register( + &mut conn, + unscoped(version, "installer", "https://example.com/x.exe"), + ) + .await + .expect("register"); + Artifact::register( + &mut conn, + held(version, "reporting-schema", group, b"kamaka schema"), + ) + .await + .expect("register"); + + conn.batch_execute(DOWN).await.expect("revert"); + + let remaining: Vec = + diesel::sql_query("SELECT download_url AS value FROM artifacts") + .load(&mut conn) + .await + .expect("read back"); + assert_eq!( + remaining + .iter() + .map(|r| r.value.as_str()) + .collect::>(), + vec!["https://example.com/x.exe"], + "the held artifact goes with the column that held it" + ); + + let columns: Vec = diesel::sql_query( + "SELECT column_name AS value FROM information_schema.columns \ + WHERE table_name = 'artifacts' \ + AND column_name IN ('group_id', 'content', 'content_type', 'digest', 'run_id')", + ) + .load(&mut conn) + .await + .expect("read columns"); + assert!(columns.is_empty(), "every added column is gone"); + + let nullable: Vec = diesel::sql_query( + "SELECT is_nullable AS value FROM information_schema.columns \ + WHERE table_name = 'artifacts' AND column_name = 'download_url'", + ) + .load(&mut conn) + .await + .expect("read nullability"); + assert_eq!( + nullable[0].value, "NO", + "an artifact rests at a location again" + ); + + conn.batch_execute(UP).await.expect("re-apply"); + + Artifact::register( + &mut conn, + held(version, "reporting-schema", group, b"kamaka schema"), + ) + .await + .expect("a group-scoped artifact registers again"); + }) + .await; +} From 91559907063aab3bde717c873dd6fd7fa3f14e28 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:21:24 +1200 Subject: [PATCH 063/130] refuse a blank registration --- crates/public-server/src/artifacts.rs | 10 ++++ .../public-server/tests/it/artifact_scopes.rs | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 5f0150087..dbd3448ef 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -158,6 +158,16 @@ async fn create( }); } + // A blank body is no location at all. The constraint only tests for NULL, + // so an empty string would pass it and leave an artifact nothing can be + // fetched from. + // spec: ART#where-an-artifact-rests + if url.trim().is_empty() { + return Err(AppError::BadRequest( + "an artifact needs a download URL".into(), + )); + } + let mut db = db.get().await?; let device_id = device.0.0.id; diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index b99505bab..588ef3d19 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -592,3 +592,51 @@ async fn an_admin_device_cannot_register_for_a_group_here_either() { ) .await } + +/// A registration carrying no location, and one naming a group that is not a +/// group at all, are both the registrant's own mistake and are refused as one. +/// A blank body would otherwise pass the constraint, which only tests for NULL, +/// and leave an artifact nothing can be fetched from. +// spec: ART#registration, ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_with_nothing_in_it_is_refused() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + for body in ["", " "] { + let response = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text(body) + .await; + assert_eq!( + response.status_code(), + StatusCode::BAD_REQUEST, + "a body of {body:?} is no location" + ); + } + + let malformed = public + .post("/artifacts/2.60.0/installer/windows?group=not-a-uuid") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + assert_eq!( + malformed.status_code(), + StatusCode::BAD_REQUEST, + "a group that is not a uuid is a client mistake, not a 500" + ); + + // Nothing was written by any of them. + let listed = public.get("/versions/2.60.0/artifacts").await; + let artifacts: Vec = listed.json(); + assert!( + !artifacts.iter().any(|a| a["artifact_type"] == "installer"), + "nothing blank or malformed was registered" + ); + }, + ) + .await +} From 033dc915158be4842e000ec1943bd5c0e6acaa89 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:22:52 +1200 Subject: [PATCH 064/130] cover the offered flag over the wire --- crates/private-server/tests/it/artifacts.rs | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 75ec2806f..c960d16a6 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -328,3 +328,70 @@ async fn an_upload_over_the_limit_is_told_what_it_is() { }) .await } + +/// The listing's offered flag, over the wire. An artifact is offered where it +/// wins inside a scope that is actually resolved, so an unscoped artifact and +/// the group's own that displaces it are both served, to different callers, and +/// both say so. A range the exact displaces inside one scope is served to +/// nobody. +// spec: ART#what-a-version-offers +#[tokio::test(flavor = "multi_thread")] +async fn the_listing_says_which_artifacts_are_offered() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-2222-0000-0000-111111111111"; + let group = "11111111-3333-0000-0000-111111111111"; + let unscoped_schema = "11111111-4444-0000-0000-111111111111"; + let group_schema = "11111111-5555-0000-0000-111111111111"; + let exact_installer = "11111111-6666-0000-0000-111111111111"; + let range_installer = "11111111-7777-0000-0000-111111111111"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{unscoped_schema}', '{version}', 'any', 'reporting-schema', 'https://example.com/all.sql'), + ('{exact_installer}', '{version}', 'windows', 'installer', 'https://example.com/exact.exe'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, version_range_pattern, download_url) + VALUES ('{range_installer}', NULL, 'windows', 'installer', '2.60.x', 'https://example.com/range.exe'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) + VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'kamaka schema', 'application/sql', 'sha256:x')", + )) + .await + .unwrap(); + + let response = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + let offered = |id: &str| -> bool { + artifacts + .iter() + .find(|a| a["id"] == id) + .unwrap_or_else(|| panic!("{id} is listed"))["is_used_in_public_api"] + .as_bool() + .expect("a flag") + }; + + assert!( + offered(group_schema), + "the group is offered the one held for it" + ); + assert!( + offered(unscoped_schema), + "every other group is still offered the unscoped one" + ); + assert!(offered(exact_installer), "the exact wins its own scope"); + assert!( + !offered(range_installer), + "the range it displaces is served to nobody" + ); + }) + .await +} From a9190fb8564486cda09cb4093d0ef9f13e8f8246 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:25:41 +1200 Subject: [PATCH 065/130] pin error slugs to their headings --- ERRORS.md | 10 +++++ crates/commons-errors/src/lib.rs | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/ERRORS.md b/ERRORS.md index b5483282f..31e5d62a3 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -1,5 +1,10 @@ # API Errors +## Not implemented + +Issued when an endpoint exists but the behaviour behind it does not yet. +**501 Not Implemented**. + ## Environment Issued with an environment variable is not present or in the wrong format. @@ -53,6 +58,11 @@ Issued when a version range is syntactically valid, but not usable to obtain con Issued for the /timesync endpoint. +## Auth: missing header + +Issued when a header carrying the caller's identity is required but not +present. **401 Unauthorized**. + ## Auth: missing certificate Issued when a client certificate is required but not provided. diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index 9b921460a..4c30ae22e 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -390,3 +390,75 @@ impl<'de> Deserialize<'de> for AppError { Ok(AppError::Problem(Box::new(value))) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// GitHub's own heading anchors: lowercased, punctuation dropped, spaces + /// hyphenated. + fn anchor(heading: &str) -> String { + heading + .trim() + .to_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == ' ' || *c == '-') + .map(|c| if c == ' ' { '-' } else { c }) + .collect() + } + + /// `/errors/{slug}` redirects into ERRORS.md by anchor, so a slug with no + /// heading drops the reader at the top of the file with no way to tell + /// which error was theirs. + #[test] + fn every_slug_has_a_heading_to_land_on() { + let arms = include_str!("lib.rs") + .split_once("slug = match self {") + .expect("the slug match") + .1 + .split_once("unreachable!()") + .expect("the end of it") + .0; + // Every string literal between those two points is a slug. + let slugs: Vec<&str> = arms.split('"').skip(1).step_by(2).collect(); + assert!(slugs.len() > 30, "the match was not read: {slugs:?}"); + + let anchors: Vec = include_str!("../../../ERRORS.md") + .lines() + .filter_map(|line| line.strip_prefix("## ")) + .map(anchor) + .collect(); + + for slug in slugs { + assert!( + anchors.iter().any(|a| a == slug), + "/errors/{slug} lands on an anchor ERRORS.md does not have" + ); + } + } + + /// Input an operator or a client controls answers as their mistake, not as + /// a fault, and carries the slug its documentation is written under. + #[test] + fn a_client_mistake_is_not_a_fault() { + for (error, status, slug) in [ + ( + AppError::BadRequest("no".into()), + StatusCode::BAD_REQUEST, + "/errors/bad-request", + ), + ( + AppError::Conflict("taken".into()), + StatusCode::CONFLICT, + "/errors/conflict", + ), + ] { + let problem = error.to_problem_details(); + assert_eq!(problem.status, Some(status)); + assert_eq!( + problem.r#type.as_ref().map(ToString::to_string).as_deref(), + Some(slug) + ); + } + } +} From 6afbb5ddb6d4df9f8cbeec5f056c3c06a79980dd Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:09:27 +1200 Subject: [PATCH 066/130] refuse a schema range --- crates/public-server/src/artifacts.rs | 12 ++++++ .../tests/it/reporting_schemas.rs | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 4e15947aa..e51cf343d 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -232,6 +232,15 @@ async fn create( (Some(version_id), None) } else { + // A schema follows the migrations one exact version applies, and Canopy + // resolves a range artifact for every version it covers. + // spec: RPT#the-build-contract + if artifact_type == REPORTING_SCHEMA_TYPE { + return Err(AppError::BadRequest( + "a reporting schema is registered against an exact version, not a range".into(), + )); + } + Range::parse(&version).map_err(|_| AppError::custom("Invalid version or version range"))?; (None, Some(version.clone())) @@ -284,3 +293,6 @@ struct RegisterScope { /// Cap on the bytes Canopy will hold for one artifact, matching the operator /// path. A reporting schema is a SQL file; anything approaching this is not one. const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; + +/// The artifact type a reporting-schema build publishes. +const REPORTING_SCHEMA_TYPE: &str = "reporting-schema"; diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index 617cf0843..6c3dc689f 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -188,6 +188,44 @@ async fn a_builder_publishes_only_for_its_own_group() { .await } +/// A schema is registered against one exact version. Canopy resolves a range +/// artifact for every version it covers, so a range registration would hand a +/// server a schema built for a version it does not run. +#[tokio::test(flavor = "multi_thread")] +async fn a_schema_registered_against_a_range_is_refused() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let ranged = public + .post(&format!( + "/artifacts/2.60.x/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + assert_eq!(ranged.status_code(), StatusCode::BAD_REQUEST); + + let other_type = public + .post(&format!( + "/artifacts/2.60.x/installer/windows?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("installer bytes") + .await; + other_type.assert_status_ok(); + let registered: serde_json::Value = other_type.json(); + assert_eq!( + registered["version_range_pattern"], "2.60.x", + "a range is still how any other artifact type covers a minor" + ); + }, + ) + .await +} + /// A declaration an operator has turned off does not authorise anything. It is /// the enabled declaration that covers a group, so a builder whose declaration /// is disabled is refused its own group's artifacts. From 6811260f581b4063f9320e33d5de27a9399bbd6a Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:23:44 +1200 Subject: [PATCH 067/130] cover the planned pair --- private-web/e2e/reporting-schemas.spec.ts | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index de6e3aaf7..0c4ef3677 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -8,6 +8,7 @@ import { seedRestoreReplica, seedServer, seedServerGroup, + seedUpgradePlan, seedVersion, } from "./seed"; import { expect, test } from "./test-fixtures"; @@ -150,6 +151,46 @@ test.describe("reporting schemas", () => { await expect(section.getByText(/no builder is declared/i)).toBeVisible(); }); + /// A group is owed a schema for where it is going as well as where it is: + /// the version its open plan moves it to is a pair before anything runs it, + /// so the schema is there when the upgrade lands rather than being built + /// after it. + /// + /// spec: RPT#pairs + test("an open upgrade plan contributes a pair", async ({ page, sql }) => { + await seedVersion(sql, { major: 2, minor: 59, patch: 0, status: "published" }); + const target = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + await declareBuilder(sql, group.id); + + const central = await seedServer(sql, { + name: "central", + groupId: group.id, + type: "tamanu-central", + }); + await seedApplicationReport(sql, { + applicationId: central.id, + version: "2.59.0", + }); + await seedUpgradePlan(sql, { + groupId: group.id, + targetVersionId: target.id, + plannedFor: "2026-12-01", + }); + + await page.goto(`/groups/${group.id}`); + + const section = page.getByTestId("reporting-schemas"); + await expect(section.getByTestId("reporting-schema-row")).toHaveCount(2); + await expect(section.getByText("2.59.0")).toBeVisible(); + await expect(section.getByText("2.60.0")).toBeVisible(); + }); + /// A built pair and a failed one read differently on the screen, and the /// failed one carries the builder's own description, which is the only /// place an operator can read why it failed. From 821c8b85d58d1fc83e0ff989a7752cefacdf6da4 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:33:06 +1200 Subject: [PATCH 068/130] heading level and doc placement --- crates/database/src/restore.rs | 6 +++--- private-web/e2e/reporting-schemas.spec.ts | 3 +++ private-web/src/components/ReportingSchemasSection.tsx | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index e1402bdd1..e7e95b433 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -311,9 +311,6 @@ impl RestoreReplica { .await } - /// Whether an enabled declaration covers `(consumer, group, type)` — the - /// authorization check for issuing restore credentials. A server-scoped or - /// a group-wide declaration both satisfy it. /// Whether a consumer may register group-scoped artifacts for this group: /// it has an enabled declaration covering the group whose intent it /// advertises as building reporting schemas, and no other group. @@ -353,6 +350,9 @@ impl RestoreReplica { Ok(n > 0) } + /// Whether an enabled declaration covers `(consumer, group, type)` — the + /// authorization check for issuing restore credentials. A server-scoped or + /// a group-wide declaration both satisfy it. pub async fn authorizes( db: &mut AsyncPgConnection, consumer_device_id: Uuid, diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index 0c4ef3677..9b828a2c3 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -84,6 +84,9 @@ test.describe("reporting schemas", () => { const section = page.getByTestId("reporting-schemas"); await expect(section).toBeVisible(); + await expect( + section.getByRole("heading", { level: 2, name: "Reporting schemas" }), + ).toBeVisible(); await expect(section.getByTestId("reporting-schema-row")).toHaveCount(2); await expect(section.getByText("2.59.0")).toBeVisible(); await expect(section.getByText("2.60.0")).toBeVisible(); diff --git a/private-web/src/components/ReportingSchemasSection.tsx b/private-web/src/components/ReportingSchemasSection.tsx index 689082225..04702c2b1 100644 --- a/private-web/src/components/ReportingSchemasSection.tsx +++ b/private-web/src/components/ReportingSchemasSection.tsx @@ -145,7 +145,9 @@ function StateChip({ function SectionHeading() { return ( - Reporting schemas + + Reporting schemas + One per version this group runs or is moving to, built from a replica of the group's own data. From 790c580802c28652821045001fccd034addc1ec3 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:45:54 +1200 Subject: [PATCH 069/130] never redact a schema build --- crates/public-server/src/restore.rs | 10 ++++ .../tests/it/reporting_schemas.rs | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 9fcb28256..ae030d5a7 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -311,6 +311,16 @@ async fn worklist( continue; } + // Sending the masking parameters unset is what tells a consumer not + // to redact, so an intent advertising both has to be told here as + // well rather than inheriting the defaults declared with it. + // spec: RST#the-masking-manifest + let params = if owns_masking { + masked_params(¶ms, None) + } else { + params.clone() + }; + let members = database::applications::Application::list_live_in_group(&mut conn, d.group_id) .await?; diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index 6c3dc689f..200a985d6 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -142,6 +142,52 @@ async fn a_built_pair_drops_off_the_worklist() { .await } +/// An intent may advertise `redact` alongside building schemas, and a schema is +/// built against the group's own data rather than a masked copy. Canopy owns +/// the masking parameters, and sending them unset is what tells a consumer not +/// to redact, so an entry carrying the defaults declared with the intent would +/// have the builder mask the very configuration it is reading. +/// +/// spec: RST#the-masking-manifest +#[tokio::test(flavor = "multi_thread")] +async fn a_schema_build_is_never_told_to_redact() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE restore_consumer_capabilities + SET semantics = '[\"check\", \"once\", \"migrate\", \"reporting-schema\", \"redact\"]'::jsonb, + params = '{{\"redaction_manifest_url\": {{\"type\": \"text\", + \"default\": \"https://masks.example/{{version}}.yaml\"}}}}'::jsonb + WHERE consumer_device_id = '{device_id}'", + )) + .await + .expect("advertise redaction too"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + let ours: Vec<&serde_json::Value> = entries + .iter() + .filter(|e| e["intent"] == "schema-build") + .collect(); + assert_eq!(ours.len(), 1, "the pair is still dispatched"); + assert_eq!( + ours[0]["params"]["redaction_manifest_url"], + serde_json::Value::Null, + "the parameter is advertised, and sent unset" + ); + }, + ) + .await +} + /// A builder registers artifacts for the group its declaration covers, and is /// refused another's the same way it would be refused a group that does not /// exist. From 7de1f956e2c3c48e326da032f3fbe396a011a08c Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:59:11 +1200 Subject: [PATCH 070/130] require an operator identity for artifacts --- crates/private-server/src/fns/versions.rs | 6 ++ .../public-server/tests/it/artifact_scopes.rs | 56 +++++++++++++++++++ private-web/openapi.json | 7 ++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 658701902..9059344a4 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -444,6 +444,7 @@ pub async fn get_version_detail( post, path = "/get_version_artifacts", tag = "versions", + security(("tailscale-user" = [])), request_body = VersionStringArgs, responses( (status = 200, body = Vec), @@ -452,6 +453,11 @@ pub async fn get_version_detail( )] pub async fn get_version_artifacts( State(state): State, + // Every group's artifacts, digests and group names, which ART discloses to + // an operator and to nobody else. The tagged-device layer above only turns + // away a caller that both carries no identity and comes from the tailnet. + // spec: ART#who-is-offered-a-group-scoped-artifact, ADM + _user: TailscaleUser, Json(args): Json, ) -> Result>> { let mut conn = state.db_read.get().await?; diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 588ef3d19..4017a068f 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -640,3 +640,59 @@ async fn a_registration_with_nothing_in_it_is_refused() { ) .await } + +/// Archiving a machine takes its group with it. The device is unbound and its +/// keys deactivated in one transaction, so the credential that was offered the +/// group's artifact reads as no identity at all afterwards rather than keeping +/// the group the box used to be in. +// spec: ART#who-is-offered-a-group-scoped-artifact, FLT#archival +#[tokio::test(flavor = "multi_thread")] +async fn an_archived_machine_s_credential_keeps_no_group() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + const MACHINE: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + seed(&mut conn).await; + conn.batch_execute(&format!( + "INSERT INTO machines (id, name, group_id, device_id) + VALUES ('{MACHINE}', 'box', '{GROUP_A}', '{device_id}')" + )) + .await + .expect("enrol machine"); + + let before = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let artifacts: Vec = before.json(); + assert_eq!(artifacts[0]["id"], THEIRS, "its own group's, to start with"); + + database::machines::Machine::archive(&mut conn, MACHINE.parse().unwrap()) + .await + .expect("archive"); + + let after = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + after.assert_status_ok(); + let artifacts: Vec = after.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!( + artifacts[0]["id"], UNSCOPED, + "answered as a read carrying no identity is" + ); + + let refused = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + refused.status_code(), + StatusCode::NOT_FOUND, + "and the bytes it used to be served are out of reach" + ); + }, + ) + .await +} diff --git a/private-web/openapi.json b/private-web/openapi.json index a1df7a53d..52f73a2a9 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -7982,7 +7982,12 @@ } } } - } + }, + "security": [ + { + "tailscale-user": [] + } + ] } }, "/api/versions/get_version_detail": { From 25c4f5b83631e6108296f5052dd0f4174c4fd9cd Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:03:23 +1200 Subject: [PATCH 071/130] pin what is not a pair --- crates/database/tests/it/reporting_schemas.rs | 80 +++++++++++++++++++ .../tests/it/reporting_schemas.rs | 70 ++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index 985389857..62e236407 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -630,3 +630,83 @@ async fn two_applications_on_one_version_are_one_pair() { }) .await; } + +/// A build needs the version's migrations, which reach a builder as that +/// version's published artifacts. A version Canopy holds no published release +/// row for has none, so a server reporting one is not a pair however loudly it +/// reports it, and Canopy is not owed a schema it cannot build. +/// +/// spec: RPT#pairs +#[tokio::test(flavor = "multi_thread")] +async fn only_a_published_version_is_a_pair() { + TestDb::run(|mut conn, _url| async move { + let (older, newer) = seed(&mut conn).await; + + for status in ["draft", "yanked"] { + conn.batch_execute(&format!( + "UPDATE versions SET status = '{status}' WHERE id = '{older}'" + )) + .await + .expect("change the version's status"); + + let ids: Vec = versions_for_group(&mut conn, group()) + .await + .expect("derive versions") + .iter() + .map(|v| v.id) + .collect(); + + assert!(!ids.contains(&older), "a {status} version is not a pair"); + assert!(ids.contains(&newer), "the published one still is"); + } + }) + .await; +} + +/// A group is owed a schema for where it is going as well as where it is, so +/// an open plan's target is a pair before anything reports running it. A plan +/// that is no longer open is history and adds nothing: the group either got +/// there, in which case an application reports it, or it is not going. +/// +/// spec: RPT#pairs +#[tokio::test(flavor = "multi_thread")] +async fn an_open_plan_s_target_is_a_pair_and_a_closed_one_is_not() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + // Nothing reports the target: the group is on 2.59.0 throughout, and + // 2.60.0 is only where it is heading. + conn.batch_execute(&format!( + "UPDATE application_reported_detail SET version = '2.59.0'; + + INSERT INTO upgrade_plans (group_id, target_version_id, created_by) + VALUES ('{GROUP}', '{newer}', 'seed@bes.au')" + )) + .await + .expect("plan the upgrade"); + + let ids: Vec = versions_for_group(&mut conn, group()) + .await + .expect("derive versions") + .iter() + .map(|v| v.id) + .collect(); + assert!(ids.contains(&newer), "the plan's target is a pair: {ids:?}"); + + conn.batch_execute("UPDATE upgrade_plans SET met_at = NOW()") + .await + .expect("meet the plan"); + + let ids: Vec = versions_for_group(&mut conn, group()) + .await + .expect("derive versions") + .iter() + .map(|v| v.id) + .collect(); + assert!( + !ids.contains(&newer), + "a met plan is history, and nothing reports its target: {ids:?}" + ); + }) + .await; +} diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index 200a985d6..dd6b7e9e7 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -142,6 +142,76 @@ async fn a_built_pair_drops_off_the_worklist() { .await } +/// Masking alters the configuration a schema follows from, so a declaration set +/// to redact builds nothing rather than building from a database that is no +/// longer the group's. +/// +/// spec: RPT#the-build-contract +#[tokio::test(flavor = "multi_thread")] +async fn a_redacting_declaration_builds_no_schema() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE restore_replicas SET redacts = true + WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("set the declaration to redact"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + assert!( + !entries.iter().any(|e| e["intent"] == "schema-build"), + "a redacting declaration dispatches no build: {entries:?}" + ); + }, + ) + .await +} + +/// The configuration a schema follows from is held centrally, so every pair of +/// a group restores a central's snapshot. A group with no central has no +/// snapshot to build from, and dispatching against a facility would build a +/// schema from the wrong half of the deployment. +/// +/// spec: RPT#the-build-contract +#[tokio::test(flavor = "multi_thread")] +async fn a_group_with_no_central_dispatches_nothing() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE applications SET type = 'tamanu-facility' WHERE id = '{CENTRAL}'" + )) + .await + .expect("leave the group with no central"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + assert!( + !entries.iter().any(|e| e["intent"] == "schema-build"), + "nothing to restore a central's snapshot from: {entries:?}" + ); + }, + ) + .await +} + /// An intent may advertise `redact` alongside building schemas, and a schema is /// built against the group's own data rather than a masked copy. Canopy owns /// the masking parameters, and sending them unset is what tells a consumer not From 7add5f3102b00dd77a2bcdc589ec3a99f9d93182 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:11:37 +1200 Subject: [PATCH 072/130] settling ignores the snapshot --- crates/database/tests/it/reporting_schemas.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index 62e236407..473d5ac4f 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -710,3 +710,49 @@ async fn an_open_plan_s_target_is_a_pair_and_a_closed_one_is_not() { }) .await; } + +/// A settled pair stays settled when a newer snapshot arrives. Every other +/// `once` intent keys its settling to the snapshot and re-dispatches on a fresh +/// one; a schema follows the version and the group's configuration, so backing +/// the group up again is no reason to build it a second time. Keying this one +/// to the snapshot would rebuild every pair of every group on every backup. +/// +/// spec: RPT#pairs +#[tokio::test(flavor = "multi_thread")] +async fn a_newer_snapshot_does_not_unsettle_a_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + record_build(&mut conn, newer, true).await; + + conn.batch_execute(&format!( + "INSERT INTO backup_runs + (id, device_id, machine_id, group_id, type, purpose, outcome, snapshot_id, reported_at) + VALUES (gen_random_uuid(), '{CONSUMER}', '{MACHINE}', '{GROUP}', 'tamanu-postgres', + 'backup', 'success', 'snap-later', NOW())" + )) + .await + .expect("a newer snapshot"); + + assert!( + ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "the version and the configuration are unchanged, so nothing is owed" + ); + + // The pair does still come back for the one event that means the schema + // is stale, so the answer above is the rule rather than a function that + // has stopped moving. + ReportingSchemaRequest::enqueue(&mut conn, group(), newer, Some("ops@bes.au")) + .await + .expect("ask for a build"); + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "an operator asking still reinstates it" + ); + }) + .await; +} From 62107d6e16df05c44aeb01836b8892d6ed0e34f9 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:31:00 +1200 Subject: [PATCH 073/130] propagate auth faults --- crates/commons-errors/src/lib.rs | 2 +- crates/commons-servers/src/device_auth/mod.rs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index 4c30ae22e..4eeec61b1 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -251,7 +251,7 @@ impl AppError { /// /// Note the arms are ordered, so a variant listed twice silently takes /// the first match. Keep each one in exactly one arm. - fn to_http_status(&self) -> StatusCode { + pub fn to_http_status(&self) -> StatusCode { match self { Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, Self::NoMatchingVersions => StatusCode::NOT_FOUND, diff --git a/crates/commons-servers/src/device_auth/mod.rs b/crates/commons-servers/src/device_auth/mod.rs index 7c476d5c9..36896715b 100644 --- a/crates/commons-servers/src/device_auth/mod.rs +++ b/crates/commons-servers/src/device_auth/mod.rs @@ -108,7 +108,11 @@ where Ok(device) => Ok(Some(device)), // A credential Canopy cannot place is anonymous, not refused: a // stale certificate must not fail a path that serves everyone. - Err(_) => Ok(None), + // A fault on the way to that answer places nothing either way, and + // answering it anonymously serves the unscoped set to a machine + // that has a group and calls it the truth. + Err(err) if err.to_http_status().is_client_error() => Ok(None), + Err(err) => Err(err), } } } From 3231f526622d66409f8f1fffb350d3edd5690359 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:31:00 +1200 Subject: [PATCH 074/130] override ranges across scopes --- crates/database/src/artifacts.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 7f4e82f83..34193785e 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -459,7 +459,12 @@ impl Artifact { all.iter().any(|other| { other.artifact_type == artifact.artifact_type && other.platform == artifact.platform - && other.group_id == artifact.group_id + // A range this artifact's own scope cannot see is not one it + // displaces, and a group's range outranks an unscoped exact, so + // what an exact artifact overrides is a range of its own group + // or an unscoped one. + // spec: ART#what-a-version-offers + && (other.group_id.is_none() || other.group_id == artifact.group_id) && other.id != artifact.id && other .version_range_pattern From e2d163090990a2f6313d30264d14f4c929302ed0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:31:00 +1200 Subject: [PATCH 075/130] check digests on arrival --- crates/private-server/src/fns/versions.rs | 110 +++++++++++--------- crates/private-server/tests/it/artifacts.rs | 86 ++++++++++++++- private-web/openapi.json | 7 ++ private-web/src/api-types.ts | 7 ++ private-web/src/routes/VersionDetail.tsx | 24 +++-- 5 files changed, 180 insertions(+), 54 deletions(-) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 9059344a4..d7893ef95 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -463,38 +463,39 @@ pub async fn get_version_artifacts( let mut conn = state.db_read.get().await?; let version = VersionStr::from_str(&args.version)?; let version_record = Version::get_by_version(&mut conn, version).await?; - // The full set, including what specificity passed over and every group's, - // because what resolution hides is a fact about how a version was - // published and an operator has to be able to see it. - // spec: ART#what-a-version-offers - let artifacts_with_metadata = Artifact::get_for_version_all_matches_with_metadata( - &mut conn, - version_record.id, - Scope::Fleet, - ) - .await?; - let group_names = ServerGroup::names_by_id(&mut conn).await?; - Ok(Json( - artifacts_with_metadata - .into_iter() - .map( - |(a, is_exact, has_range_override, is_used_in_public_api)| ArtifactData { - id: a.id, - artifact_type: a.artifact_type, - platform: a.platform, - canopy_holds_bytes: a.download_url.is_none(), - download_url: a.download_url, - group_name: a.group_id.and_then(|g| group_names.get(&g).cloned()), - group_id: a.group_id, - digest: a.digest, - is_exact, - version_range_pattern: a.version_range_pattern, - has_range_override, - is_used_in_public_api, - }, - ) - .collect(), - )) + Ok(Json(artifacts_of(&mut conn, version_record.id).await?)) +} + +/// Every artifact of a version as an operator sees it: the full set, including +/// what specificity passed over and every group's, because what resolution +/// hides is a fact about how a version was published. +// spec: ART#what-a-version-offers +async fn artifacts_of( + conn: &mut database::diesel_async::AsyncPgConnection, + version_id: Uuid, +) -> Result> { + let artifacts_with_metadata = + Artifact::get_for_version_all_matches_with_metadata(conn, version_id, Scope::Fleet).await?; + let group_names = ServerGroup::names_by_id(conn).await?; + Ok(artifacts_with_metadata + .into_iter() + .map( + |(a, is_exact, has_range_override, is_used_in_public_api)| ArtifactData { + id: a.id, + artifact_type: a.artifact_type, + platform: a.platform, + canopy_holds_bytes: a.download_url.is_none(), + download_url: a.download_url, + group_name: a.group_id.and_then(|g| group_names.get(&g).cloned()), + group_id: a.group_id, + digest: a.digest, + is_exact, + version_range_pattern: a.version_range_pattern, + has_range_override, + is_used_in_public_api, + }, + ) + .collect()) } /// Identifies a version and the publication status to set on it. @@ -645,6 +646,11 @@ pub struct CreateArtifactArgs { pub content_base64: Option, /// Media type of those bytes. pub content_type: Option, + /// Algorithm-prefixed digest of those bytes, e.g. `sha256:2cf24dba…`. + /// Required when a group is named: Canopy checks the bytes against it as + /// they arrive and refuses the registration on a mismatch, so a corrupted + /// upload is refused while whoever sent it is still there to send it again. + pub digest: Option, } /// Create a new artifact tied to an exact version. @@ -682,7 +688,23 @@ pub async fn create_artifact( MAX_HELD_ARTIFACT_BYTES / (1024 * 1024) ))); } + let Some(claimed) = args + .digest + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + else { + return Err(AppError::BadRequest( + "a group-scoped artifact must carry the digest of its bytes".into(), + )); + }; + // spec: ART#digests let digest = digest_of(&bytes); + if claimed != digest { + return Err(AppError::BadRequest(format!( + "the bytes are {digest}, not the {claimed} the registration names" + ))); + } (Some(bytes), Some(digest)) } (Some(_), None) => { @@ -735,21 +757,15 @@ pub async fn create_artifact( ) .await?; - let group_names = ServerGroup::names_by_id(&mut conn).await?; - Ok(Json(ArtifactData { - id: artifact.id, - artifact_type: artifact.artifact_type, - platform: artifact.platform, - canopy_holds_bytes: artifact.download_url.is_none(), - download_url: artifact.download_url, - group_name: artifact.group_id.and_then(|g| group_names.get(&g).cloned()), - group_id: artifact.group_id, - digest: artifact.digest, - is_exact: true, - version_range_pattern: None, - has_range_override: false, - is_used_in_public_api: true, - })) + // Read back through the listing rather than describing the row a second + // time here: whether it overrides a range and whether it is the one served + // follow from the version's other artifacts, not from this registration. + artifacts_of(&mut conn, args.version_id) + .await? + .into_iter() + .find(|a| a.id == artifact.id) + .map(Json) + .ok_or_else(|| AppError::custom("the artifact just registered is not listed")) } /// Identifies a single artifact by id. diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index c960d16a6..e773d9721 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -94,6 +94,7 @@ async fn a_registration_that_rests_nowhere_is_refused() { serde_json::json!({ "version_id": version, "artifact_type": "installer", "platform": "any", "group_id": group, "content_base64": "aGVsbG8=", + "digest": database::artifacts::digest_of(b"hello"), "download_url": "https://example.com/x.exe", }), // A group with no bytes to hold. @@ -110,6 +111,18 @@ async fn a_registration_that_rests_nowhere_is_refused() { serde_json::json!({ "version_id": version, "artifact_type": "installer", "platform": "any", "group_id": group, "content_base64": "not base64 at all!!", + "digest": database::artifacts::digest_of(b"hello"), + }), + // Bytes that are not the digest the registration names. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "group_id": group, "content_base64": "aGVsbG8=", + "digest": database::artifacts::digest_of(b"something else"), + }), + // Bytes with no digest to check them against. + serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "group_id": group, "content_base64": "aGVsbG8=", }), ]; @@ -202,6 +215,7 @@ async fn an_operator_registers_a_group_scoped_artifact() { "group_id": group, "content_base64": "a2FtYWthIHNjaGVtYQ==", "content_type": "application/sql", + "digest": database::artifacts::digest_of(b"kamaka schema"), })) .await; response.assert_status_ok(); @@ -291,7 +305,8 @@ async fn an_upload_over_the_limit_is_told_what_it_is() { .unwrap(); // "AAAA" decodes to three zero bytes, so the repeat count sets the size. - let four_mib = "A".repeat(4 * (4 * 1024 * 1024 / 3)); + let four_mib_bytes = 3 * (4 * 1024 * 1024 / 3); + let four_mib = "A".repeat(4 * (four_mib_bytes / 3)); let accepted = private .post("/api/versions/create_artifact") .json(&serde_json::json!({ @@ -300,6 +315,7 @@ async fn an_upload_over_the_limit_is_told_what_it_is() { "platform": "any", "group_id": group, "content_base64": four_mib, + "digest": database::artifacts::digest_of(&vec![0u8; four_mib_bytes]), })) .await; accepted.assert_status_ok(); @@ -395,3 +411,71 @@ async fn the_listing_says_which_artifacts_are_offered() { }) .await } + +/// What an exact artifact overrides follows the resolution rules rather than a +/// group match: it displaces a range its own scope can see, so a group's exact +/// artifact overrides an unscoped range, and an unscoped exact overrides +/// nothing in a group whose own range outranks it. The registration answers +/// with what the listing would say rather than describing the row a second +/// time. +// spec: ART#what-a-version-offers +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_answers_what_it_overrides() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "22222222-1111-0000-0000-222222222222"; + let ours = "22222222-2222-0000-0000-222222222222"; + let theirs = "22222222-3333-0000-0000-222222222222"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES + ('{ours}', 'kamaka'), ('{theirs}', 'drifting'); + + INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, download_url) + VALUES (NULL, 'any', 'reporting-schema', '2.60.x', 'https://example.com/range.sql'); + + INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, group_id, content, content_type, digest) + VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'theirs', 'application/octet-stream', 'sha256:x')", + )) + .await + .unwrap(); + + let held = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "reporting-schema", + "platform": "any", + "group_id": ours, + "content_base64": "a2FtYWthIHNjaGVtYQ==", + "digest": database::artifacts::digest_of(b"kamaka schema"), + })) + .await; + held.assert_status_ok(); + let held: serde_json::Value = held.json(); + assert_eq!(held["is_exact"], true); + assert_eq!( + held["has_range_override"], true, + "the group's own displaces the unscoped range for that group" + ); + assert_eq!(held["is_used_in_public_api"], true); + + let unscoped = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "windows", + "download_url": "https://example.com/x.exe", + })) + .await; + unscoped.assert_status_ok(); + let unscoped: serde_json::Value = unscoped.json(); + assert_eq!( + unscoped["has_range_override"], false, + "a group's range outranks an unscoped exact, so nothing is displaced" + ); + }) + .await +} diff --git a/private-web/openapi.json b/private-web/openapi.json index 52f73a2a9..a16849c89 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -10008,6 +10008,13 @@ ], "description": "Media type of those bytes." }, + "digest": { + "type": [ + "string", + "null" + ], + "description": "Algorithm-prefixed digest of those bytes, e.g. `sha256:2cf24dba…`.\nRequired when a group is named: Canopy checks the bytes against it as\nthey arrive and refuses the registration on a mismatch, so a corrupted\nupload is refused while whoever sent it is still there to send it again." + }, "download_url": { "type": [ "string", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 1f99b3a37..64205f47c 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -5376,6 +5376,13 @@ export interface components { content_base64?: string | null; /** @description Media type of those bytes. */ content_type?: string | null; + /** + * @description Algorithm-prefixed digest of those bytes, e.g. `sha256:2cf24dba…`. + * Required when a group is named: Canopy checks the bytes against it as + * they arrive and refuses the registration on a mismatch, so a corrupted + * upload is refused while whoever sent it is still there to send it again. + */ + digest?: string | null; /** @description Download URL, for an artifact Canopy records a location for. */ download_url?: string | null; /** diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index ac5bcf5c5..86104a084 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -651,13 +651,23 @@ function EditArtifactRow({ /// Canopy holds a group-scoped artifact's bytes, and the API takes them in the /// JSON body, so the file is read here rather than posted as a multipart form. -async function encodeFile(file: File): Promise { - const buffer = new Uint8Array(await file.arrayBuffer()); +/// The digest goes with them: Canopy checks the bytes it received against it and +/// refuses the registration on a mismatch. +async function readFile(file: File): Promise<{ base64: string; digest: string }> { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; - for (let i = 0; i < buffer.length; i += 0x8000) { - chunks.push(String.fromCharCode(...buffer.subarray(i, i + 0x8000))); + for (let i = 0; i < bytes.length; i += 0x8000) { + chunks.push(String.fromCharCode(...bytes.subarray(i, i + 0x8000))); } - return btoa(chunks.join("")); + + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", buffer)); + const hex = Array.from(hash) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return { base64: btoa(chunks.join("")), digest: `sha256:${hex}` }; } const MAX_HELD_ARTIFACT_BYTES = 32 * 1024 * 1024; @@ -684,14 +694,16 @@ function CreateArtifactForm({ const submit = async (e: React.FormEvent) => { e.preventDefault(); try { + const contents = file ? await readFile(file) : null; await action.call({ version_id: versionId, artifact_type: type, platform, download_url: scoped ? null : url, group_id: scoped ? groupId : null, - content_base64: file ? await encodeFile(file) : null, + content_base64: contents?.base64 ?? null, content_type: file ? file.type || null : null, + digest: contents?.digest ?? null, }); setType(""); setPlatform(""); From 58354d97235f9a9507247b6cf63a2c5d38fd3c4c Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:31:00 +1200 Subject: [PATCH 076/130] record a releaser digest --- crates/canopy-api/src/generated.rs | 2 +- crates/public-server/openapi.json | 9 +++ crates/public-server/src/artifacts.rs | 22 ++++-- .../public-server/tests/it/artifact_scopes.rs | 73 +++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index ee34dc59d..acb2f45a5 100644 --- a/crates/canopy-api/src/generated.rs +++ b/crates/canopy-api/src/generated.rs @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.0"; /// BLAKE3 digest of that document, so a document that changed without the /// version moving with it can be told from one that did not. -pub const OPENAPI_BLAKE3: &str = "90101b6a8f49ebb4d7038e3153325fca10a048af43d65d3a5d45beea6a46dd28"; +pub const OPENAPI_BLAKE3: &str = "302fb747c1d1c53a54f849123e5d0eeb6d7a2d3af2f8169b13f3789551db9142"; /// Error types. pub mod error { diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index b72d1a1fd..c343aed69 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -148,6 +148,15 @@ "type": "string", "format": "uuid" } + }, + { + "name": "digest", + "in": "query", + "description": "Algorithm-prefixed digest of the bytes at the URL, e.g. `sha256:2cf24dba…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index dbd3448ef..d21a6a3d7 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -128,6 +128,7 @@ pub fn routes() -> OpenApiRouter { ("artifact_type" = String, Path), ("platform" = String, Path), ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused."), + ("digest" = Option, Query, description = "Algorithm-prefixed digest of the bytes at the URL, e.g. `sha256:2cf24dba…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked."), ), request_body(content = String, description = "Download URL for the artifact, as a plain-text body."), responses( @@ -142,7 +143,7 @@ async fn create( device: ReleaserDevice, State(db): State, Path((version, artifact_type, platform)): Path<(String, String, String)>, - Query(scope): Query, + Query(named): Query, headers: axum::http::HeaderMap, url: String, ) -> Result> { @@ -152,7 +153,7 @@ async fn create( // any group, so the group-scoped path is not reachable from this endpoint // at all rather than being refused per group. // spec: ART#registration - if scope.group.is_some() { + if named.group.is_some() { return Err(AppError::AuthInsufficientPermissions { required: "authorisation for the named group".into(), }); @@ -168,6 +169,11 @@ async fn create( )); } + // A blank digest is no digest: recorded, it says the bytes were checked + // against something when nothing was. + // spec: ART#digests + let digest = named.digest.filter(|d| !d.trim().is_empty()); + let mut db = db.get().await?; let device_id = device.0.0.id; @@ -217,7 +223,7 @@ async fn create( group_id: None, content: None, content_type: None, - digest: None, + digest, run_id: None, }, ) @@ -227,8 +233,14 @@ async fn create( Ok(Json(Artifact::offered(row, &base, &version))) } -/// The group a registration names, where it names one. +/// What a registration names beside the path. #[derive(Debug, serde::Deserialize)] -struct RegisterScope { +struct RegisterQuery { + /// The group the artifact is for, where it names one. group: Option, + /// The digest whoever registers it records, where they record one. An + /// unscoped artifact is fetched from its location by the caller rather + /// than by Canopy, so this is what that caller checks against. + // spec: ART#digests + digest: Option, } diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 4017a068f..20044f754 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -696,3 +696,76 @@ async fn an_archived_machine_s_credential_keeps_no_group() { ) .await } + +/// A read that serves everyone downgrades a credential it cannot place to +/// anonymous. A fault reaching that decision is not a credential it cannot +/// place: answered anonymously it hands a machine that has a group the +/// unscoped set and presents it as that machine's answer. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn a_fault_placing_a_credential_is_not_anonymity() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + // Resolving an identity reads device_keys. Taking the table away is + // the only way from here to make that read fail rather than miss. + conn.batch_execute("DROP TABLE device_keys CASCADE") + .await + .expect("drop device_keys"); + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!( + response.status_code(), + StatusCode::INTERNAL_SERVER_ERROR, + "a caller that cannot be placed is told so, not answered as nobody" + ); + }, + ) + .await +} + +/// An unscoped artifact is read from its location by the caller rather than by +/// Canopy, so its digest is what that caller checks what it fetched against. +/// A releaser records one where it has one, and an artifact registered without +/// one is fetched unchecked rather than fetched against a blank. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn a_releaser_records_the_digest_it_publishes() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let recorded = public + .post("/artifacts/2.60.0/installer/windows?digest=sha256:abcd") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + recorded.assert_status_ok(); + let recorded: serde_json::Value = recorded.json(); + assert_eq!(recorded["digest"], "sha256:abcd"); + + for query in ["", "?digest=", "?digest=%20%20"] { + let response = public + .post(&format!("/artifacts/2.60.0/installer/linux{query}")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.deb") + .await; + response.assert_status_ok(); + let artifact: serde_json::Value = response.json(); + assert!( + artifact["digest"].is_null(), + "a blank digest is no digest, but got {artifact}" + ); + } + }, + ) + .await +} From 5ed9d42ea124ea1470098a4c023b72e5cd4c1082 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:54:27 +1200 Subject: [PATCH 077/130] answer an exact version for itself --- .workhorse/specs/platform/artifacts.md | 4 ++ crates/public-server/src/versions.rs | 26 +++++++-- .../public-server/tests/it/artifact_scopes.rs | 58 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 135bf7475..e2c9ed47d 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -38,6 +38,10 @@ It is offered one artifact per type and platform, and where the bytes rest is no ## What a version offers +A read names an exact version or a range. +An exact version is answered for itself, since a caller naming one is saying what it runs rather than asking where to go, and what is published for the version a caller is on does not depend on whether that version is one to move to. +A range is answered for the latest published version it covers that no known issue covers, since a range is a question about where to go and a version Canopy has vouched against is not an answer to it. + Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known. Where several match, the most specific is offered. An artifact scoped to the caller's group is more specific than one belonging to no group. diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 920aff351..3dda17053 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -88,6 +88,26 @@ async fn latest_matching_ready( .ok_or(AppError::NoMatchingVersions) } +/// The version a read names. +/// +/// An exact version is answered for itself: a known issue says a version is not +/// one to move to, which is a fact about where a fleet should go rather than +/// about what is published for where it already is. A range is a question about +/// where to go, so it resolves to the latest ready version it covers. +// spec: ART#what-a-version-offers +async fn version_named(conn: &mut AsyncPgConnection, named: &str) -> Result { + if let Ok(exact) = node_semver::Version::parse(named) { + let version = + Version::get_by_version(conn, commons_types::version::VersionStr(exact)).await?; + if version.status != commons_types::version::VersionStatus::Published { + return Err(AppError::NoMatchingVersions); + } + return Ok(version); + } + + latest_matching_ready(conn, VersionRange::from_str(named)?.0).await +} + pub fn routes() -> OpenApiRouter { let api = OpenApiRouter::new() .routes(routes!(list)) @@ -513,8 +533,7 @@ async fn list_artifacts( headers: axum::http::HeaderMap, ) -> Result>> { let mut db = db.get().await?; - let version = VersionRange::from_str(&version)?; - let version = latest_matching_ready(&mut db, version.0).await?; + let version = version_named(&mut db, &version).await?; let scope = caller_scope(&mut db, device).await?; Ok(Json( @@ -646,8 +665,7 @@ async fn download_artifact( use uuid::Uuid; let mut db = db.get().await?; - let version = VersionRange::from_str(&version)?; - let version = latest_matching_ready(&mut db, version.0).await?; + let version = version_named(&mut db, &version).await?; let scope = caller_scope(&mut db, device).await?; let artifact_uuid = Uuid::parse_str(&artifact_id) diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 20044f754..0d3893adf 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -769,3 +769,61 @@ async fn a_releaser_records_the_digest_it_publishes() { ) .await } + +/// A device is offered the artifacts of the version it reports running, and a +/// known issue on that version does not withhold them. A known issue says a +/// version is not one to move to, and the versions carrying one are exactly the +/// ones a fleet is still sitting on and still needs a schema for. A range is a +/// question about where to go, so it still resolves past the version. +// spec: ART#what-a-version-offers +#[tokio::test(flavor = "multi_thread")] +async fn a_known_issue_does_not_withhold_a_version_s_own_artifacts() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + // An open known issue has no upper bound, so it covers 2.60.0 and + // every later patch of that line. + conn.batch_execute( + "INSERT INTO version_known_issues + (author, description, min_major, min_minor, min_patch) + VALUES ('admin', 'broken', 2, 60, 0)", + ) + .await + .expect("seed a known issue over 2.60.0"); + + let offered = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + offered.assert_status_ok(); + let artifacts: Vec = offered.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!( + artifacts[0]["id"], THEIRS, + "the group's own schema for the version it is on" + ); + + let bytes = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + bytes.assert_status_ok(); + assert_eq!(bytes.text(), "group a schema"); + + // The same known issue still keeps a range off that version. + let ranged = public + .get("/versions/2.60.x/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + ranged.status_code(), + StatusCode::NOT_FOUND, + "a range has nothing ready to land on" + ); + }, + ) + .await +} From f74ae93d9fe0c8e537ef90097bd56a1aa05f0fd4 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:06:53 +1200 Subject: [PATCH 078/130] settle what clears the check --- .workhorse/specs/public-server/reporting-schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index bc6acada8..563ad598a 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -80,6 +80,6 @@ The device compares what its application runs with what it is offered, applies t A failed build raises a reporting-schema check on the group's central Tamanu application, carrying the failure description (see [CHK](../monitoring/checks.md)). The check is a warning rather than a failure, and does not escalate: the application is up and its reports return rows, and a schema that cannot be built for the version its group is moving to is for whoever maintains the reports rather than whoever is on call. A replica that failed to restore or come up is the restore's own health rather than a build failure, and is dispatched again as any unhealthy restore is. -The check recovers when the pair is built, and an operator asking for the build is what clears it. +The check recovers when the pair is built, and nothing else clears it: an operator asking for the build puts the pair back on the worklist, and the warning stands until a build lands, since asking changes nothing about whether the group's applications can be offered a schema. Pairs are presented per group, showing which have a schema, which are being built, and which failed, so whether a group's applications can be offered the schema for the version they run or are moving to is answered in one place. From dad51b0768e88f8baddd2d3f2ef8c29073b2ed5a Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:59:58 +1200 Subject: [PATCH 079/130] name a pair's servers --- crates/database/src/applications.rs | 9 +++ crates/database/src/reporting_schemas.rs | 45 ++++++++++- crates/database/src/statuses.rs | 9 +-- crates/database/tests/it/reporting_schemas.rs | 79 +++++++++++++++++++ private-web/e2e/reporting-schemas.spec.ts | 9 +++ private-web/openapi.json | 10 ++- private-web/src/api-types.ts | 6 ++ .../components/ReportingSchemasSection.tsx | 29 +++++++ 8 files changed, 186 insertions(+), 10 deletions(-) diff --git a/crates/database/src/applications.rs b/crates/database/src/applications.rs index cd67afd50..5def9dfc6 100644 --- a/crates/database/src/applications.rs +++ b/crates/database/src/applications.rs @@ -802,6 +802,15 @@ impl Application { /// All live (non-archived) applications in a group, ordered by name. Used to /// expand a group-wide restore-replica declaration into per-server entries. + /// What to call this application to an operator: the name it was given, + /// else the host it answers on, else its id. + pub fn label(&self) -> String { + self.name + .clone() + .or_else(|| self.host.as_ref().map(|h| h.0.to_string())) + .unwrap_or_else(|| self.id.to_string()) + } + pub async fn list_live_in_group( db: &mut AsyncPgConnection, group_id_: Uuid, diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 74bfc0b2e..786d630b8 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -247,6 +247,10 @@ pub struct Pair { pub error: Option, /// Whether an operator has asked for this pair to be built again. pub requested: bool, + /// The group's Tamanu applications reporting this version, by name. Empty + /// where the pair comes from the open plan rather than from something + /// running it. + pub applications: Vec, } /// The pairs of a group: every published version its Tamanu applications report @@ -262,6 +266,7 @@ pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result< } let versions = versions_for_group(db, group).await?; + let running = applications_by_version(db, group).await?; let mut pairs = Vec::with_capacity(versions.len()); for version in versions { @@ -274,10 +279,12 @@ pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result< Some(build) => (PairState::Failed, build.error.clone()), }; + let version_str = version.as_semver().to_string(); pairs.push(Pair { group_id: group, version_id: version.id, - version: version.as_semver().to_string(), + applications: running.get(&version_str).cloned().unwrap_or_default(), + version: version_str, state, error, requested, @@ -287,6 +294,42 @@ pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result< Ok(pairs) } +/// Which of a group's Tamanu applications report each version, by name. +/// +/// A pair is per version, so the row an operator reads covers every application +/// on that version and names none of them without this. +// spec: RPT#pairs +async fn applications_by_version( + db: &mut AsyncPgConnection, + group: Uuid, +) -> Result>> { + let applications = crate::applications::Application::list_live_in_group(db, group).await?; + let tamanu: Vec<&crate::applications::Application> = applications + .iter() + .filter(|a| a.r#type.software() == "tamanu") + .collect(); + + let ids: Vec = tamanu.iter().map(|a| a.id).collect(); + let reported = crate::reported_detail::ReportedDetail::last_versions(db, &ids).await?; + + let mut by_version: std::collections::HashMap> = + std::collections::HashMap::new(); + for application in tamanu { + let Some(version) = reported.get(&application.id) else { + continue; + }; + by_version + .entry(version.to_string()) + .or_default() + .push(application.label()); + } + for names in by_version.values_mut() { + names.sort(); + } + + Ok(by_version) +} + /// Every published version a group's Tamanu applications report running, plus /// the version its open plan moves it to. /// diff --git a/crates/database/src/statuses.rs b/crates/database/src/statuses.rs index 2a3383740..8d0e80d2f 100644 --- a/crates/database/src/statuses.rs +++ b/crates/database/src/statuses.rs @@ -59,13 +59,6 @@ const GRACE_LOOKBACK_SQL: &str = "NOW() - INTERVAL '30 days'"; /// caller-supplied point in time rather than to `NOW()`. const GRACE_LOOKBACK: SignedDuration = SignedDuration::from_hours(24 * 30); -fn server_label(s: &Application) -> String { - s.name - .clone() - .or_else(|| s.host.as_ref().map(|h| h.0.to_string())) - .unwrap_or_else(|| s.id.to_string()) -} - fn machine_label(m: &crate::machines::Machine) -> String { m.name.clone().unwrap_or_else(|| m.id.to_string()) } @@ -381,7 +374,7 @@ impl Status { for server in &swept { let graded = grade_reachability( "Application", - &server_label(server), + &server.label(), server.alert_when_down_for.0, expected.get(&server.id).map(Vec::as_slice).unwrap_or(&[]), status_map.get(&server.id).copied(), diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index 473d5ac4f..f2e3fca21 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -756,3 +756,82 @@ async fn a_newer_snapshot_does_not_unsettle_a_pair() { }) .await; } + +/// A pair names the applications on its version. One row stands for every +/// application running it, and a row that names none leaves an operator reading +/// a bare version string against a group of eight servers. +/// +/// spec: RPT#pairs +#[tokio::test(flavor = "multi_thread")] +async fn a_pair_names_the_applications_on_its_version() { + TestDb::run(|mut conn, _url| async move { + let (older, newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + + let pairs = pairs_for_group(&mut conn, group()).await.expect("pairs"); + let named = |version: Uuid| { + pairs + .iter() + .find(|p| p.version_id == version) + .map(|p| p.applications.clone()) + .expect("the pair") + }; + + assert_eq!(named(newer), vec!["central".to_owned()]); + assert_eq!(named(older), vec!["facility".to_owned()]); + + // Two applications on one version are one pair, and the row has to + // account for both of them rather than for whichever was read last. + conn.batch_execute(&format!( + "UPDATE application_reported_detail SET version = '2.60.0' + WHERE application_id = '{FACILITY}'" + )) + .await + .expect("move the facility onto the central's version"); + + let pairs = pairs_for_group(&mut conn, group()).await.expect("pairs"); + let both = pairs + .iter() + .find(|p| p.version_id == newer) + .expect("the pair"); + assert_eq!( + both.applications, + vec!["central".to_owned(), "facility".to_owned()] + ); + }) + .await; +} + +/// A version only an open plan contributes has nothing running it, so the pair +/// names no applications rather than borrowing the ones on another version. +/// +/// spec: RPT#pairs +#[tokio::test(flavor = "multi_thread")] +async fn a_planned_pair_names_no_applications() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + + conn.batch_execute(&format!( + "UPDATE application_reported_detail SET version = '2.59.0'; + + INSERT INTO upgrade_plans (group_id, target_version_id, created_by) + VALUES ('{GROUP}', '{newer}', 'seed@bes.au')" + )) + .await + .expect("plan the upgrade"); + + let pairs = pairs_for_group(&mut conn, group()).await.expect("pairs"); + let planned = pairs + .iter() + .find(|p| p.version_id == newer) + .expect("the plan's target is a pair"); + + assert!( + planned.applications.is_empty(), + "nothing runs it: {:?}", + planned.applications + ); + }) + .await; +} diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index 9b828a2c3..44ff6882d 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -93,6 +93,15 @@ test.describe("reporting schemas", () => { // Nothing has been built yet, so both are awaiting one. await expect(section.getByText("Awaiting build")).toHaveCount(2); + + // A version string alone does not say which servers the row covers, and + // the group page carries no running version anywhere else. + const older = section + .getByTestId("reporting-schema-row") + .filter({ hasText: "2.59.0" }); + await expect(older.getByText("1 server")).toBeVisible(); + await older.getByText("1 server").hover(); + await expect(page.getByRole("tooltip")).toHaveText("facility"); }); /// An operator asking for a build is what reinstates a pair, so the ask has diff --git a/private-web/openapi.json b/private-web/openapi.json index 852e71fac..65ea8f86f 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -14125,9 +14125,17 @@ "version_id", "version", "state", - "requested" + "requested", + "applications" ], "properties": { + "applications": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The group's Tamanu applications reporting this version, by name. Empty\nwhere the pair comes from the open plan rather than from something\nrunning it." + }, "error": { "type": [ "string", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 4b1ebba1d..bd216c3fc 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -7965,6 +7965,12 @@ export interface components { }; /** @description One pair of group and Tamanu version, and where it stands. */ Pair: { + /** + * @description The group's Tamanu applications reporting this version, by name. Empty + * where the pair comes from the open plan rather than from something + * running it. + */ + applications: string[]; /** @description What went wrong, where a build failed. */ error?: string | null; /** diff --git a/private-web/src/components/ReportingSchemasSection.tsx b/private-web/src/components/ReportingSchemasSection.tsx index 04702c2b1..43e644a70 100644 --- a/private-web/src/components/ReportingSchemasSection.tsx +++ b/private-web/src/components/ReportingSchemasSection.tsx @@ -87,6 +87,7 @@ export default function ReportingSchemasSection({ Version Schema + On @@ -99,6 +100,9 @@ export default function ReportingSchemasSection({ + + + {pair.requested ? ( @@ -122,6 +126,31 @@ export default function ReportingSchemasSection({ ); } +/// Which of the group's applications a pair covers. +/// +/// A pair is per version, so one row stands for every application on it. The +/// count is what an operator sizes the row by; the names are behind it because +/// a group of any size would otherwise make the table taller than it is wide. +function Running({ applications }: { applications: string[] }) { + if (applications.length === 0) { + return ( + + upgrade plan + + ); + } + + return ( + + + {applications.length === 1 + ? "1 server" + : `${applications.length} servers`} + + + ); +} + function StateChip({ state, error, From 51c2e99924da2eeb03dea1dad6e84b4daa54a2da Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:33:47 +1200 Subject: [PATCH 080/130] cover the servers column --- .../ReportingSchemasSection.test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/private-web/src/components/ReportingSchemasSection.test.tsx b/private-web/src/components/ReportingSchemasSection.test.tsx index df91640ec..e4294f68c 100644 --- a/private-web/src/components/ReportingSchemasSection.test.tsx +++ b/private-web/src/components/ReportingSchemasSection.test.tsx @@ -9,6 +9,7 @@ type Pair = { state: "awaiting" | "built" | "failed"; error?: string | null; requested: boolean; + applications: string[]; }; const GROUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; @@ -21,6 +22,7 @@ function pair(over: Partial = {}): Pair { state: "awaiting", error: null, requested: false, + applications: ["kamaka-central"], ...over, }; } @@ -86,6 +88,30 @@ describe("a pair's state reads off the chip", () => { }); }); +describe("which servers a pair covers", () => { + it("counts them on the row and names them behind it", async () => { + stubApi([ + pair({ + applications: ["kamaka-central", "kamaka-clinic-north"], + }), + ]); + render(); + + fireEvent.mouseOver(await screen.findByText("2 servers")); + expect( + await screen.findByText("kamaka-central, kamaka-clinic-north"), + ).toBeTruthy(); + }); + + it("says where a pair comes from the plan rather than from a server", async () => { + stubApi([pair({ applications: [] })]); + render(); + + expect(await screen.findByText("upgrade plan")).toBeTruthy(); + expect(screen.queryByText("0 servers")).toBeNull(); + }); +}); + describe("asking for a build", () => { it("offers a first build on an unbuilt pair and a rebuild on a settled one", async () => { stubApi([ From e27645e2be00138ac84087087eae121309f60698 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:49:58 +1200 Subject: [PATCH 081/130] settle where an artifact rests --- crates/commons-errors/src/lib.rs | 2 +- crates/commons-servers/src/device_auth/mod.rs | 51 +++++++- crates/database/src/artifacts.rs | 114 +++++++++++++----- crates/private-server/src/fns/versions.rs | 45 +++---- crates/private-server/tests/it/artifacts.rs | 68 +++++++++++ crates/public-server/src/versions.rs | 15 ++- .../public-server/tests/it/artifact_scopes.rs | 40 ++++++ private-web/src/routes/VersionDetail.tsx | 13 +- 8 files changed, 285 insertions(+), 63 deletions(-) diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index 4eeec61b1..4c30ae22e 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -251,7 +251,7 @@ impl AppError { /// /// Note the arms are ordered, so a variant listed twice silently takes /// the first match. Keep each one in exactly one arm. - pub fn to_http_status(&self) -> StatusCode { + fn to_http_status(&self) -> StatusCode { match self { Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, Self::NoMatchingVersions => StatusCode::NOT_FOUND, diff --git a/crates/commons-servers/src/device_auth/mod.rs b/crates/commons-servers/src/device_auth/mod.rs index 36896715b..d250b5c23 100644 --- a/crates/commons-servers/src/device_auth/mod.rs +++ b/crates/commons-servers/src/device_auth/mod.rs @@ -88,6 +88,28 @@ device_role_struct!(ServerDevice, DeviceRole::Machine); device_role_struct!(ReleaserDevice, DeviceRole::Releaser); device_role_struct!(BackupRestoreDevice, DeviceRole::BackupRestore); +/// Whether Canopy could not place this credential, as against refusing it or +/// failing on the way to the answer. +/// +/// A credential it cannot place is anonymous: a stale certificate must not fail +/// a path that serves everyone. A refusal or a fault propagates, since serving +/// the unscoped set to a machine that has a group presents it as that machine's +/// answer. The variants are named rather than tested by status, so which +/// callers are served does not follow from an unrelated mapping and a new +/// variant is a decision somebody makes. +fn unplaceable(err: &AppError) -> bool { + matches!( + err, + AppError::AuthMissingHeader(_) + | AppError::AuthMissingCertificate + | AppError::AuthInvalidCertificate(_) + | AppError::AuthCertificateNotFound + | AppError::AuthFailed { .. } + | AppError::AuthTokenNotValid + | AppError::AuthTailnetIdentityMissing + ) +} + /// A read that is open to everyone but answers a device for its own group /// takes `Option`: absent identity is not a refusal, it just /// narrows what the caller is offered. @@ -106,12 +128,7 @@ where ) -> Result, Self::Rejection> { match >::from_request_parts(parts, state).await { Ok(device) => Ok(Some(device)), - // A credential Canopy cannot place is anonymous, not refused: a - // stale certificate must not fail a path that serves everyone. - // A fault on the way to that answer places nothing either way, and - // answering it anonymously serves the unscoped set to a machine - // that has a group and calls it the truth. - Err(err) if err.to_http_status().is_client_error() => Ok(None), + Err(err) if unplaceable(&err) => Ok(None), Err(err) => Err(err), } } @@ -183,3 +200,25 @@ where Ok(Self(device, method)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A refusal is not an absent credential. Downgrading one to anonymous + /// hands a machine that has a group the unscoped set and presents it as + /// that machine's answer. + #[test] + fn a_refusal_is_not_an_unplaceable_credential() { + assert!(!unplaceable(&AppError::AuthInsufficientPermissions { + required: "releaser".into() + })); + assert!(!unplaceable(&AppError::AuthTailnetNodeNotPermitted)); + assert!(!unplaceable(&AppError::AuthTailnetDirectoryUnavailable)); + assert!(!unplaceable(&AppError::DeviceHasNoServer)); + + assert!(unplaceable(&AppError::AuthMissingCertificate)); + assert!(unplaceable(&AppError::AuthCertificateNotFound)); + assert!(unplaceable(&AppError::AuthTokenNotValid)); + } +} diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 34193785e..88cb713de 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -28,6 +28,15 @@ impl Scope { None => Self::Unscoped, } } + + /// Whether an artifact of this group is in scope. + fn sees(self, group: Option) -> bool { + match self { + Self::Unscoped => group.is_none(), + Self::Group(caller) => group.is_none() || group == Some(caller), + Self::Fleet => true, + } + } } /// A downloadable artifact belonging to a release version: an installer, @@ -102,6 +111,43 @@ pub fn digest_of(bytes: &[u8]) -> String { format!("sha256:{}", hex::encode(Sha256::digest(bytes))) } +/// A blank URL is no location at all. The constraint only tests for NULL, so an +/// empty string passes it and leaves an artifact nothing can be fetched from. +// spec: ART#where-an-artifact-rests +fn location(url: Option) -> Option { + url.filter(|url| !url.trim().is_empty()) +} + +impl NewArtifact { + /// Settle where this artifact rests, refusing a registration that names + /// neither place or both. + /// + /// The database constrains the same shape, so a write that skips this + /// answers a caller with a 500 rather than a refusal. + // spec: ART#where-an-artifact-rests + fn resting(mut self) -> Result { + self.download_url = location(self.download_url); + + match (self.group_id.is_some(), self.download_url.is_some()) { + (true, true) => Err(AppError::BadRequest( + "an artifact Canopy holds has no download URL".into(), + )), + (false, false) => Err(AppError::BadRequest( + "an artifact needs a download URL or a group".into(), + )), + (true, false) if self.content.is_none() || self.digest.is_none() => { + Err(AppError::BadRequest( + "a group-scoped artifact must carry its bytes and their digest".into(), + )) + } + (false, true) if self.content_type.is_some() || self.content.is_some() => Err( + AppError::BadRequest("only a group-scoped artifact carries bytes".into()), + ), + _ => Ok(self), + } + } +} + impl Artifact { /// The artifacts of a version that `scope` may see, one per type and /// platform, most specific first. @@ -111,34 +157,32 @@ impl Artifact { target_version_id: Uuid, scope: Scope, ) -> Result> { - let mut artifacts = Self::matching(db, target_version_id, scope).await?; - - // Keep the first (most specific) artifact per platform+artifact_type. - // Not `dedup_by_key`: that only drops *consecutive* duplicates, and the - // specificity sort has just destroyed the adjacency the SQL `ORDER BY` - // gave us — every exact artifact now precedes every range one, so two - // artifacts of the same type+platform are only neighbours when they - // happen to be equally specific. - let mut seen = std::collections::HashSet::new(); - artifacts.retain(|a| seen.insert((a.artifact_type.clone(), a.platform.clone()))); - - Ok(artifacts) + let artifacts = Self::get_for_version_all_matches(db, target_version_id, scope).await?; + Ok(Self::offered(artifacts, scope)) } - /// Every artifact of a version that `scope` may see, including the ones - /// specificity passed over. For operator views. + /// The artifacts of a sorted match set that `scope` is actually served: + /// the most specific of each type and platform it can see. + /// + /// Not `dedup_by_key`: that only drops *consecutive* duplicates, and the + /// specificity sort has destroyed the adjacency the SQL `ORDER BY` gave us + /// — every exact artifact now precedes every range one, so two artifacts of + /// the same type+platform are only neighbours when they happen to be + /// equally specific. // spec: ART#what-a-version-offers - pub async fn get_for_version_all_matches( - db: &mut AsyncPgConnection, - target_version_id: Uuid, - scope: Scope, - ) -> Result> { - Self::matching(db, target_version_id, scope).await + fn offered(artifacts: Vec, scope: Scope) -> Vec { + let mut seen = std::collections::HashSet::new(); + artifacts + .into_iter() + .filter(|a| scope.sees(a.group_id)) + .filter(|a| seen.insert((a.artifact_type.clone(), a.platform.clone()))) + .collect() } - /// Artifacts of a version visible to `scope`, sorted most specific first - /// and not deduplicated. - async fn matching( + /// Every artifact of a version that `scope` may see, sorted most specific + /// first and not deduplicated. For operator views. + // spec: ART#what-a-version-offers + pub async fn get_for_version_all_matches( db: &mut AsyncPgConnection, target_version_id: Uuid, scope: Scope, @@ -298,6 +342,8 @@ impl Artifact { pub async fn register(db: &mut AsyncPgConnection, input: NewArtifact) -> Result { use crate::schema::artifacts::dsl::*; + let input = input.resting()?; + diesel::insert_into(artifacts) .values(&input) .on_conflict(( @@ -340,10 +386,7 @@ impl Artifact { .first(db) .await .map_err(AppError::from)?; - // A blank URL is no location at all. The constraint only tests for NULL, - // so an empty string would pass it and leave an artifact nothing can be - // fetched from. - let new_url = new_url.filter(|url| !url.trim().is_empty()); + let new_url = location(new_url); match (scoped.is_some(), new_url.is_some()) { (true, true) => { return Err(AppError::Conflict( @@ -426,13 +469,22 @@ impl Artifact { let mut public_api_ids: std::collections::HashSet = std::collections::HashSet::new(); for scope in scopes { - for offered in Self::get_for_version(db, target_version_id, scope).await? { - public_api_ids.insert(offered.id); + let mut seen = std::collections::HashSet::new(); + for a in matching_artifacts.iter().filter(|a| scope.sees(a.group_id)) { + if seen.insert((a.artifact_type.as_str(), a.platform.as_str())) { + public_api_ids.insert(a.id); + } } } + // Only a range artifact can be the one an exact artifact displaces, so + // the rest of the table has no bearing on the answer. use crate::schema::artifacts::*; - let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; + let ranges: Vec = table + .select(Self::as_select()) + .filter(version_range_pattern.is_not_null()) + .load(db) + .await?; let semver = version.as_semver(); @@ -440,7 +492,7 @@ impl Artifact { .into_iter() .map(|a| { let is_exact = a.version_id == Some(target_version_id); - let has_range_override = Self::overridden_range(&all_artifacts, &a, &semver); + let has_range_override = Self::overridden_range(&ranges, &a, &semver); let is_used_in_public_api = public_api_ids.contains(&a.id); (a, is_exact, has_range_override, is_used_in_public_api) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index d7893ef95..387107566 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -717,35 +717,38 @@ pub async fn create_artifact( "only a group-scoped artifact carries bytes".into(), )); } - (None, None) => (None, None), + (None, None) => { + // The media type describes bytes Canopy holds, and it holds none + // for an unscoped artifact. + // spec: ART#where-an-artifact-rests + if args.content_type.is_some() { + return Err(AppError::BadRequest( + "only a group-scoped artifact carries a media type".into(), + )); + } + // A digest against a location is what whoever fetches the artifact + // checks the bytes it got against, so it is recorded rather than + // dropped. + // spec: ART#digests + let claimed = args + .digest + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(str::to_owned); + (None, claimed) + } }; - // A blank URL is no location at all, and the constraint only tests for NULL. - let download_url = args.download_url.filter(|url| !url.trim().is_empty()); - - // An artifact rests in one place or the other, so a registration naming a - // group and a location together is refused rather than written and caught - // by the constraint. - // spec: ART#where-an-artifact-rests - if args.group_id.is_some() && download_url.is_some() { - return Err(AppError::BadRequest( - "an artifact Canopy holds has no download URL".into(), - )); - } - - if args.group_id.is_none() && download_url.is_none() { - return Err(AppError::BadRequest( - "an artifact needs a download URL or a group".into(), - )); - } - + // Where the artifact rests, and the refusal when it names neither place or + // both, is `Artifact::register`'s to settle. let artifact = Artifact::register( &mut conn, NewArtifact { version_id: Some(args.version_id), artifact_type: args.artifact_type, platform: args.platform, - download_url, + download_url: args.download_url, device_id: None, version_range_pattern: None, group_id: args.group_id, diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index e773d9721..9c8174c18 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -233,6 +233,74 @@ async fn an_operator_registers_a_group_scoped_artifact() { .await } +/// A media type describes bytes Canopy holds, and it holds none for an +/// artifact that names no group. Passed through, it trips the check constraint, +/// so operator input answers 500 instead of being refused. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_artifact_carries_no_media_type() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-2222-0000-0000-111111111111"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "windows", + "download_url": "https://example.com/x.exe", + "content_type": "text/html", + })) + .await; + assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + }) + .await +} + +/// A digest against a location is what whoever fetches the artifact checks the +/// bytes it got against. Dropped, a caller that supplied one gets no error and +/// no digest, and the fetch is unchecked. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn a_digest_against_a_location_is_recorded() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-3333-0000-0000-111111111111"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "windows", + "download_url": "https://example.com/x.exe", + "digest": database::artifacts::digest_of(b"kamaka installer"), + })) + .await; + created.assert_status_ok(); + + let artifact: serde_json::Value = created.json(); + assert_eq!( + artifact["digest"], + database::artifacts::digest_of(b"kamaka installer") + ); + }) + .await +} + /// A blank location is no location. The check constraint only tests for NULL, /// so an empty string would pass it and leave an artifact nothing can be /// fetched from. diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 3dda17053..b0a23b014 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -695,9 +695,22 @@ async fn download_artifact( .content_type .unwrap_or_else(|| "application/octet-stream".to_owned()); + // Held bytes are served from the same origin as this server's own HTML + // pages, and the media type is whatever the registration named, so + // nothing here may be rendered by a browser. return Ok(( StatusCode::OK, - [(header::CONTENT_TYPE, content_type)], + [ + (header::CONTENT_TYPE, content_type), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", artifact.id), + ), + ( + header::HeaderName::from_static("x-content-type-options"), + "nosniff".to_owned(), + ), + ], Body::from(held.bytes), ) .into_response()); diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 0d3893adf..5ed5430dc 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -561,6 +561,46 @@ async fn held_bytes_are_served_as_the_type_they_were_registered_with() { .await } +/// Held bytes come off the same origin as this server's own HTML pages, and +/// the media type is whatever the registration named, so a schema registered +/// as `text/html` would otherwise render as same-origin content. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn held_bytes_are_never_rendered_by_a_browser() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute(&format!( + "UPDATE artifacts SET content_type = 'text/html' WHERE id = '{THEIRS}'" + )) + .await + .expect("set the media type"); + + let served = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!( + served.header("x-content-type-options").to_str().unwrap(), + "nosniff" + ); + assert!( + served + .header("content-disposition") + .to_str() + .unwrap() + .starts_with("attachment"), + "held bytes are downloaded, not displayed" + ); + }, + ) + .await +} + /// An operator device registers either kind, but the group-scoped path is not /// reachable from this endpoint at all, so an admin naming a group is refused /// exactly as a releaser is. Publishing into a group is the private server's. diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 86104a084..7c239ed5b 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -694,7 +694,7 @@ function CreateArtifactForm({ const submit = async (e: React.FormEvent) => { e.preventDefault(); try { - const contents = file ? await readFile(file) : null; + const contents = scoped && file ? await readFile(file) : null; await action.call({ version_id: versionId, artifact_type: type, @@ -702,7 +702,7 @@ function CreateArtifactForm({ download_url: scoped ? null : url, group_id: scoped ? groupId : null, content_base64: contents?.base64 ?? null, - content_type: file ? file.type || null : null, + content_type: contents ? file?.type || null : null, digest: contents?.digest ?? null, }); setType(""); @@ -746,7 +746,14 @@ function CreateArtifactForm({ select label="Group" value={groupId} - onChange={(e) => setGroupId(e.target.value)} + onChange={(e) => { + setGroupId(e.target.value); + // The file input is only rendered for a group, so + // an over-limit file left behind disables Create + // with nothing on screen to clear. + setFile(null); + setFileError(null); + }} disabled={action.pending || groups.status === "error"} // Falling back to an empty list silently offers only // "Every group", which reads as a fleet with no groups From 1c64e624c32b5cad3be7e173f961af26ce872bdc Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:13:41 +1200 Subject: [PATCH 082/130] keep a build's output out of its own trigger --- crates/database/src/artifacts.rs | 13 +- crates/database/src/reporting_schemas.rs | 252 +++++++++--------- crates/database/src/restore.rs | 58 +++- crates/database/tests/it/reporting_schemas.rs | 62 ++++- crates/private-server/src/fns/versions.rs | 7 +- crates/public-server/src/artifacts.rs | 37 ++- crates/public-server/src/restore.rs | 50 +++- .../tests/it/reporting_schemas.rs | 229 +++++++++++++++- 8 files changed, 543 insertions(+), 165 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 001ff516f..44206746d 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -100,6 +100,11 @@ pub struct ArtifactContent { pub digest: String, } +/// Cap on the bytes Canopy will hold for one artifact. A reporting schema is a +/// SQL file; anything approaching this is not one, and the rows live in Postgres +/// alongside everything else. +pub const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; + /// The digest Canopy records and verifies bytes against. pub fn digest_of(bytes: &[u8]) -> String { format!("sha256:{}", hex::encode(Sha256::digest(bytes))) @@ -270,10 +275,13 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } - /// When any artifact of this version was last registered. + /// When any artifact a build reads was last registered for this version. /// /// A schema built from a superseded release of a version is not the schema - /// that version describes, so this is what a build is held against. + /// that version describes, so this is what a build is held against. Only + /// the unscoped artifacts count: a group-scoped one is a build's own output, + /// and registering it would put every group's pair for the version back on + /// the worklist, including the pair that just produced it. // spec: RPT#pairs pub async fn newest_change_for_version( db: &mut AsyncPgConnection, @@ -283,6 +291,7 @@ impl Artifact { let newest: Option = dsl::artifacts .filter(dsl::version_id.eq(version)) + .filter(dsl::group_id.is_null()) .select(diesel::dsl::max(dsl::updated_at)) .first(db) .await diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 786d630b8..d0d131c99 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -120,6 +120,32 @@ impl ReportingSchemaBuild { .map_err(AppError::from) } + /// The most recent build of each of a group's pairs, by version. + /// + /// One query rather than one per version: this backs both the operator page + /// and the sweep, which walk every version a group runs. + pub async fn latest_by_version_for_group( + db: &mut AsyncPgConnection, + group: Uuid, + ) -> Result> { + use crate::schema::{backup_restore_checks, reporting_schema_builds}; + + let builds: Vec = reporting_schema_builds::table + .inner_join( + backup_restore_checks::table + .on(backup_restore_checks::id.eq(reporting_schema_builds::check_id)), + ) + .filter(reporting_schema_builds::group_id.eq(group)) + .order_by(backup_restore_checks::reported_at.asc()) + .select(Self::as_select()) + .load(db) + .await + .map_err(AppError::from)?; + + // Ascending, so the last write per version is the newest. + Ok(builds.into_iter().map(|b| (b.version_id, b)).collect()) + } + /// Whether a pair is settled: it has been built or has failed, and either /// way is not dispatched again until the version's artifacts change or an /// operator asks. @@ -204,6 +230,23 @@ impl ReportingSchemaRequest { .is_some()) } + /// Which of a group's versions have an ask pending, in one query. + pub async fn pending_for_group( + db: &mut AsyncPgConnection, + group: Uuid, + ) -> Result> { + use crate::schema::reporting_schema_requests::dsl; + + let versions: Vec = dsl::reporting_schema_requests + .filter(dsl::group_id.eq(group)) + .select(dsl::version_id) + .load(db) + .await + .map_err(AppError::from)?; + + Ok(versions.into_iter().collect()) + } + async fn clear(db: &mut AsyncPgConnection, group: Uuid, version: Uuid) -> Result<()> { use crate::schema::reporting_schema_requests::dsl; @@ -265,71 +308,32 @@ pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result< return Ok(Vec::new()); } - let versions = versions_for_group(db, group).await?; - let running = applications_by_version(db, group).await?; + let versions = versions_and_applications(db, group).await?; + let builds = ReportingSchemaBuild::latest_by_version_for_group(db, group).await?; + let requests = ReportingSchemaRequest::pending_for_group(db, group).await?; let mut pairs = Vec::with_capacity(versions.len()); - for version in versions { - let latest = ReportingSchemaBuild::latest_for_pair(db, group, version.id).await?; - let requested = ReportingSchemaRequest::pending(db, group, version.id).await?; - - let (state, error) = match &latest { + for (version, applications) in versions { + let (state, error) = match builds.get(&version.id) { None => (PairState::Awaiting, None), Some(build) if build.built => (PairState::Built, None), Some(build) => (PairState::Failed, build.error.clone()), }; - let version_str = version.as_semver().to_string(); pairs.push(Pair { group_id: group, version_id: version.id, - applications: running.get(&version_str).cloned().unwrap_or_default(), - version: version_str, + applications, + version: version.as_semver().to_string(), state, error, - requested, + requested: requests.contains(&version.id), }); } Ok(pairs) } -/// Which of a group's Tamanu applications report each version, by name. -/// -/// A pair is per version, so the row an operator reads covers every application -/// on that version and names none of them without this. -// spec: RPT#pairs -async fn applications_by_version( - db: &mut AsyncPgConnection, - group: Uuid, -) -> Result>> { - let applications = crate::applications::Application::list_live_in_group(db, group).await?; - let tamanu: Vec<&crate::applications::Application> = applications - .iter() - .filter(|a| a.r#type.software() == "tamanu") - .collect(); - - let ids: Vec = tamanu.iter().map(|a| a.id).collect(); - let reported = crate::reported_detail::ReportedDetail::last_versions(db, &ids).await?; - - let mut by_version: std::collections::HashMap> = - std::collections::HashMap::new(); - for application in tamanu { - let Some(version) = reported.get(&application.id) else { - continue; - }; - by_version - .entry(version.to_string()) - .or_default() - .push(application.label()); - } - for names in by_version.values_mut() { - names.sort(); - } - - Ok(by_version) -} - /// Every published version a group's Tamanu applications report running, plus /// the version its open plan moves it to. /// @@ -338,40 +342,73 @@ async fn applications_by_version( /// artifacts. // spec: RPT#pairs pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { + Ok(versions_and_applications(db, group) + .await? + .into_iter() + .map(|(version, _)| version) + .collect()) +} + +/// A group's pairs, and which of its Tamanu applications report each one. +/// +/// The applications are carried alongside the version rather than joined back +/// on a stringified semver: a `Version` row holds major, minor and patch alone, +/// so a reported `2.60.0-rc1` resolves to the 2.60.0 row and would never match +/// its own key, and the pair would read as an upgrade plan while a server runs +/// it. +// spec: RPT#pairs +async fn versions_and_applications( + db: &mut AsyncPgConnection, + group: Uuid, +) -> Result)>> { use commons_types::version::VersionStatus; let applications = crate::applications::Application::list_live_in_group(db, group).await?; - let tamanu: Vec = applications + let tamanu: Vec<&crate::applications::Application> = applications .iter() .filter(|a| a.r#type.software() == "tamanu") - .map(|a| a.id) .collect(); - let mut versions = Vec::new(); + let ids: Vec = tamanu.iter().map(|a| a.id).collect(); + let reported = crate::reported_detail::ReportedDetail::last_versions(db, &ids).await?; - let reported = crate::reported_detail::ReportedDetail::last_versions(db, &tamanu).await?; - for shown in reported.into_values() { + let mut pairs: Vec<(Version, Vec)> = Vec::new(); + for application in tamanu { + let Some(shown) = reported.get(&application.id) else { + continue; + }; // A version Canopy holds no release row for is not a pair: a build needs // that version's migrations, which reach a builder as published artifacts. - if let Ok(version) = Version::get_by_version(db, shown).await - && version.status == VersionStatus::Published - { - versions.push(version); + let Ok(version) = Version::get_by_version(db, shown.clone()).await else { + continue; + }; + if version.status != VersionStatus::Published { + continue; + } + + // A pair is unique per group and version, so two applications on one + // version are one pair carrying both names. + match pairs.iter_mut().find(|(v, _)| v.id == version.id) { + Some((_, names)) => names.push(application.label()), + None => pairs.push((version, vec![application.label()])), } } - if let Some(target) = crate::upgrade_plans::planned_target(db, group).await? { - versions.push(target); + // A plan moving a group to a version something already runs adds no pair. + // Dispatch counts a restore and a migrate per entry, so a duplicate here is + // paid for rather than merely untidy. + if let Some(target) = crate::upgrade_plans::planned_target(db, group).await? + && !pairs.iter().any(|(v, _)| v.id == target.id) + { + pairs.push((target, Vec::new())); } - // A pair is unique per group and version, so two applications on one - // version are one pair, and a plan moving a group to a version something - // already runs adds none. Dispatch counts a restore and a migrate per - // entry, so a duplicate here is paid for rather than merely untidy. - versions.sort_by_key(|v| (v.major, v.minor, v.patch)); - versions.dedup_by_key(|v| v.id); + for (_, names) in &mut pairs { + names.sort(); + } + pairs.sort_by_key(|(v, _)| (v.major, v.minor, v.patch)); - Ok(versions) + Ok(pairs) } /// File the reporting-schema check for every group that has a builder. @@ -384,9 +421,8 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { use crate::{ applications::Application, backup::refs, - issues::{ - CheckInstance, GradedInstance, InstancedCheckFiling, Scope, file_check_instances, - }, + issues::{CheckInstance, GradedInstance, Scope}, + restore::{RestoreCheck, file_restore_check}, server_groups::ServerGroup, }; use commons_types::status::CheckResult; @@ -420,52 +456,18 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { }) .collect(); - // An empty set is not nothing to do: a check already open has to be - // closed, or it stays open forever once its last pair goes away. - if instances.is_empty() { - let open = crate::backup::staleness::open_server_issue_active( - db, - central, - refs::REPORTING_SCHEMA, - ) - .await?; - if open { - crate::issues::file_check( - db, - crate::issues::CheckFiling { - source: crate::statuses::CANOPY_SOURCE, - scope: Scope::Application(central), - device_id: None, - check: refs::REPORTING_SCHEMA, - observed: CheckResult::Passed, - detail: None, - message: &format!("No reporting schema is owed for {}", group.name), - title: Some("reporting schema not built"), - default_ceiling: CheckResult::Warning, - default_escalates: false, - documentation: Some(refs::REPORTING_SCHEMA_DOC), - }, - ) - .await?; - } - continue; - } - let name = group.name.clone(); let total = instances.len(); - file_check_instances( + file_restore_check( db, - InstancedCheckFiling { - source: crate::statuses::CANOPY_SOURCE, - scope: Scope::Application(central), - device_id: None, - check: refs::REPORTING_SCHEMA, - title: Some("reporting schema not built"), - instances, - default_ceiling: CheckResult::Warning, - default_escalates: false, - documentation: Some(refs::REPORTING_SCHEMA_DOC), + Scope::Application(central), + RestoreCheck { + r#ref: refs::REPORTING_SCHEMA, + documentation: refs::REPORTING_SCHEMA_DOC, + title: "reporting schema not built", + gone: &format!("No reporting schema is owed for {}", group.name), }, + instances, &move |degraded: &[GradedInstance]| match degraded { [] => format!("Reporting schemas are built for every version {name} runs"), [one] => format!( @@ -494,22 +496,24 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { } /// Whether a group has an enabled declaration whose intent builds schemas. +/// +/// The same predicate that authorises a builder to publish the group's schema, +/// asked of each of its consumers: dispatching builds a group would then refuse +/// to accept is the divergence worth not having. async fn group_builds_schemas(db: &mut AsyncPgConnection, group: Uuid) -> Result { - use crate::restore::{RestoreConsumerCapability, RestoreReplica}; - use commons_types::backup::semantics; + use crate::restore::RestoreReplica; - for declaration in RestoreReplica::list_for_group(db, group).await? { - if !declaration.enabled { - continue; - } - let advertises = - RestoreConsumerCapability::list_for_consumer(db, declaration.consumer_device_id) - .await? - .into_iter() - .any(|d| { - d.intent == declaration.intent && d.has_semantic(semantics::REPORTING_SCHEMA) - }); - if advertises { + let mut consumers: Vec = RestoreReplica::list_for_group(db, group) + .await? + .into_iter() + .filter(|d| d.enabled) + .map(|d| d.consumer_device_id) + .collect(); + consumers.sort_unstable(); + consumers.dedup(); + + for consumer in consumers { + if RestoreReplica::authorizes_schema_artifacts(db, consumer, group).await? { return Ok(true); } } diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index e7e95b433..4d3a2715f 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -350,6 +350,52 @@ impl RestoreReplica { Ok(n > 0) } + /// Whether a run id is already recorded against a different consumer or + /// group. + /// + /// A run id is minted by the device performing the run, so one Canopy has + /// not seen is ordinary: an artifact is registered mid-restore, before the + /// report of that restore lands. One already recorded for somebody else is + /// a claim on their run, and provenance a party can forge for itself is + /// worth nothing to the operator reading it. + pub async fn run_claimed_elsewhere( + db: &mut AsyncPgConnection, + run: Uuid, + consumer_device_id: Uuid, + group_id: Uuid, + ) -> Result { + use crate::schema::{backup_restore_checks, backup_runs}; + + let checks: i64 = backup_restore_checks::table + .filter(backup_restore_checks::run_id.eq(Some(run))) + .filter( + backup_restore_checks::consumer_device_id + .ne(consumer_device_id) + .or(backup_restore_checks::group_id.ne(group_id)), + ) + .count() + .get_result(db) + .await + .map_err(AppError::from)?; + if checks > 0 { + return Ok(true); + } + + let runs: i64 = backup_runs::table + .filter(backup_runs::id.eq(run)) + .filter( + backup_runs::device_id + .ne(consumer_device_id) + .or(backup_runs::group_id.ne(group_id)), + ) + .count() + .get_result(db) + .await + .map_err(AppError::from)?; + + Ok(runs > 0) + } + /// Whether an enabled declaration covers `(consumer, group, type)` — the /// authorization check for issuing restore credentials. A server-scoped or /// a group-wide declaration both satisfy it. @@ -1694,11 +1740,11 @@ async fn file_migration( /// The fixed parts of one restore check: what it is called, the documentation it /// ships with, its headline when degraded, and what it says once a server has no /// instances of it left. -struct RestoreCheck<'a> { - r#ref: &'a str, - documentation: &'a str, - title: &'a str, - gone: &'a str, +pub(crate) struct RestoreCheck<'a> { + pub(crate) r#ref: &'a str, + pub(crate) documentation: &'a str, + pub(crate) title: &'a str, + pub(crate) gone: &'a str, } /// File one of a server's restore checks from its instances, and say whether it @@ -1710,7 +1756,7 @@ struct RestoreCheck<'a> { /// instances is recovered on its own — with no instances there is nothing left /// to grade, so it is filed as the plain passing check it has become rather /// than left open with nothing that could ever clear it. -async fn file_restore_check( +pub(crate) async fn file_restore_check( db: &mut AsyncPgConnection, scope: Scope, check: RestoreCheck<'_>, diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index f2e3fca21..a1921b2c4 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -325,6 +325,38 @@ async fn a_new_artifact_for_the_version_reinstates_the_pair() { .await; } +/// A build's own output is not a change a build reads. Counted, a second +/// group's schema for the version unsettles the first group's pair, whose +/// rebuild unsettles the second, and neither pair ever settles: a restore and a +/// migrate per pass, forever. +#[tokio::test(flavor = "multi_thread")] +async fn a_group_s_own_schema_does_not_reinstate_the_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + let other = "12121212-1212-1212-1212-121212121212"; + + record_build(&mut conn, newer, true).await; + + conn.batch_execute(&format!( + "INSERT INTO server_groups (id, name) VALUES ('{other}', 'drifting'); + INSERT INTO artifacts + (version_id, artifact_type, platform, group_id, content, content_type, digest) + VALUES ('{newer}', 'reporting-schema', 'any', '{other}', + convert_to('CREATE VIEW ...', 'UTF8'), 'application/sql', 'sha256:00')", + )) + .await + .expect("another group registers its schema"); + + assert!( + ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "a schema is another group's output, not a change to the version" + ); + }) + .await; +} + /// A build records the artifacts it registered, so an operator can see what came /// out of it rather than only that something did. #[tokio::test(flavor = "multi_thread")] @@ -467,17 +499,22 @@ async fn the_reporting_schema_check_cannot_escalate() { .await; } -/// The check recovers when the pair is built. +/// The check recovers when the pair that failed is built. #[tokio::test(flavor = "multi_thread")] async fn a_built_pair_grades_the_check_passed() { TestDb::run(|mut conn, _url| async move { let (older, _newer) = seed(&mut conn).await; declare_builder(&mut conn, true).await; - record_build(&mut conn, older, true).await; + record_build(&mut conn, older, false).await; database::reporting_schemas::sweep(&mut conn) .await - .expect("sweep"); + .expect("sweep the failure"); + + record_build(&mut conn, older, true).await; + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep the recovery"); let issues = schema_issues(&mut conn).await; assert_eq!(issues.len(), 1); @@ -490,6 +527,25 @@ async fn a_built_pair_grades_the_check_passed() { .await; } +/// A group whose pairs are all built and has never had a finding gets no +/// passing row: a check filed for it seeds a catalog entry nothing ever graded. +#[tokio::test(flavor = "multi_thread")] +async fn a_group_that_never_failed_files_nothing() { + TestDb::run(|mut conn, _url| async move { + let (older, newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + record_build(&mut conn, older, true).await; + record_build(&mut conn, newer, true).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + + assert!(schema_issues(&mut conn).await.is_empty()); + }) + .await; +} + /// A pair still awaiting its first build is not a failure: nothing has gone /// wrong yet, and the worklist is what moves it along. #[tokio::test(flavor = "multi_thread")] diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index d7893ef95..c9e5865a2 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -9,7 +9,7 @@ use commons_errors::{AppError, ProblemDetailsSchema, Result}; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ - artifacts::{Artifact, NewArtifact, Scope, digest_of}, + artifacts::{Artifact, MAX_HELD_ARTIFACT_BYTES, NewArtifact, Scope, digest_of}, server_groups::ServerGroup, version_known_issues::VersionKnownIssue, versions::Version, @@ -21,11 +21,6 @@ use uuid::Uuid; use crate::state::AppState; -/// Cap on the bytes Canopy will hold for one artifact. A reporting schema is a -/// SQL file; anything approaching this is not one, and the rows live in -/// Postgres alongside everything else. -const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; - /// Body budget for `create_artifact`. Base64 inflates the bytes by a third, and /// sizing above that keeps an over-limit upload the handler's structured /// refusal rather than axum's plain-text 413. diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index e81095df5..f7cfff1aa 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, Query, State}, + extract::{DefaultBodyLimit, Path, Query, State}, }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; @@ -11,7 +11,7 @@ use commons_types::{ }; use database::{ Db, - artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, digest_of}, + artifacts::{Artifact as ArtifactRow, MAX_HELD_ARTIFACT_BYTES, NewArtifact, Scope, digest_of}, machines::Machine, restore::RestoreReplica, versions::{NewVersion, Version}, @@ -100,7 +100,12 @@ pub(crate) async fn caller_scope( } pub fn routes() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(create)) + // Sized from the held-bytes cap so an over-limit upload is the handler's + // structured refusal naming the limit, rather than axum's plain-text 413 + // from a default an order of magnitude below it. + OpenApiRouter::new() + .routes(routes!(create)) + .layer(DefaultBodyLimit::max(MAX_HELD_ARTIFACT_BYTES)) } /// Register an artifact for a version or version range. @@ -179,6 +184,17 @@ async fn create( None } Some(group) => { + // What a schema builder is authorised for is the artifact its + // declaration names. Any other type registered under it would + // displace the releaser's own for every machine in the group, and + // those machines fetch and run what they are offered. + // spec: ART#registration + if artifact_type != REPORTING_SCHEMA_TYPE { + return Err(AppError::AuthInsufficientPermissions { + required: format!("a group-scoped artifact to be a {REPORTING_SCHEMA_TYPE}"), + }); + } + let authorised = role == DeviceRole::Admin || RestoreReplica::authorizes_schema_artifacts(&mut db, device_id, group).await?; if !authorised { @@ -200,6 +216,17 @@ async fn create( )); } + // Provenance is what an operator reads to answer what produced the + // bytes, so a run already recorded for somebody else is not one + // this registration may name. + if let Some(run) = named.run + && RestoreReplica::run_claimed_elsewhere(&mut db, run, device_id, group).await? + { + return Err(AppError::BadRequest( + "the named run belongs to another consumer or group".into(), + )); + } + Some(group) } }; @@ -320,9 +347,5 @@ struct RegisterQuery { digest: Option, } -/// Cap on the bytes Canopy will hold for one artifact, matching the operator -/// path. A reporting schema is a SQL file; anything approaching this is not one. -const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; - /// The artifact type a reporting-schema build publishes. const REPORTING_SCHEMA_TYPE: &str = "reporting-schema"; diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index ae030d5a7..b53973775 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -239,6 +239,13 @@ async fn worklist( // keys on. A group-wide and a machine-scoped declaration with different names // are two replicas of that machine, and both are dispatched. let mut seen: HashSet<(Uuid, String)> = HashSet::new(); + // A schema build is keyed on the pair, not the machine, so two declarations + // covering one group with schema-building intents would each emit the whole + // pair list: a restore and a migrate paid for twice per build. + let mut pairs: HashSet<(Uuid, Uuid)> = HashSet::new(); + // Resolving a group's pairs walks its applications and their reported + // versions, so a group covered by several declarations is resolved once. + let mut version_cache: HashMap> = HashMap::new(); // Per-group caches so a group referenced by several declarations is resolved // once: the latest produced snapshot per (machine, type), and the latest // healthy-verified snapshot per (machine, type, intent) for `once` suppression. @@ -311,6 +318,18 @@ async fn worklist( continue; } + // A build restores the group's canonical central, so a declaration + // pinned to a machine names something this dispatch cannot honour. + // Retargeting it silently would build against a box the operator + // did not declare. + if d.machine_id.is_some() { + tracing::warn!( + replica = %d.id, + "a machine-scoped declaration builds no reporting schema; a build is per group" + ); + continue; + } + // Sending the masking parameters unset is what tells a consumer not // to redact, so an intent advertising both has to be told here as // well rather than inheriting the defaults declared with it. @@ -333,9 +352,17 @@ async fn worklist( database::machines::Machine::get_by_id(&mut conn, central.machine_id).await?; let latest = snapshots.get(&(machine.id, d.r#type.clone())); - for version in - database::reporting_schemas::versions_for_group(&mut conn, d.group_id).await? - { + if let std::collections::hash_map::Entry::Vacant(e) = version_cache.entry(d.group_id) { + e.insert( + database::reporting_schemas::versions_for_group(&mut conn, d.group_id).await?, + ); + } + + for version in version_cache[&d.group_id].clone() { + if !pairs.insert((d.group_id, version.id)) { + continue; + } + if once && database::reporting_schemas::ReportingSchemaBuild::is_settled( &mut conn, d.group_id, version.id, @@ -968,6 +995,23 @@ async fn verification( // A build rides the migrate pathway, so a report may carry both; the // build is the one that settles the pair. (_, Some(build)) => { + // A build report settles the pair: it stops the pair being + // dispatched again and clears an operator's ask. Nothing but a + // consumer authorised to publish the group's schema may say so, or + // a plain verify consumer settles a pair no schema was built for. + // spec: RPT#the-build-contract + if !RestoreReplica::authorizes_schema_artifacts( + &mut conn, + consumer_device_id, + args.group, + ) + .await? + { + return Err(AppError::AuthInsufficientPermissions { + required: "an enabled declaration building this group's schemas".into(), + }); + } + let version_id = resolve_build_target(&mut conn, &build).await?; // The build is held against the group's central application, which is // the one whose database the schema followed from and the one the diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index dd6b7e9e7..8042e6630 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -83,6 +83,78 @@ async fn a_build_is_dispatched_per_pair_on_the_central() { .await } +/// A pair is dispatched once however many declarations cover its group. Each +/// entry costs a restore and a migrate, so a second declaration doubling the +/// list is paid for. +#[tokio::test(flavor = "multi_thread")] +async fn a_second_declaration_dispatches_no_second_build() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "INSERT INTO restore_replicas + (consumer_device_id, group_id, type, intent, name, enabled) + VALUES ('{device_id}', '{GROUP}', 'tamanu-postgres', 'schema-build', + 'schemas-weekly', true)" + )) + .await + .expect("a second schema declaration"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + assert_eq!( + entries + .iter() + .filter(|e| e["intent"] == "schema-build") + .count(), + 1, + "one entry for the group's one pair" + ); + }, + ) + .await +} + +/// A build restores the group's canonical central, so a declaration pinned to a +/// machine names something this dispatch cannot honour. Retargeting it silently +/// would build against a box the operator did not declare. +#[tokio::test(flavor = "multi_thread")] +async fn a_machine_scoped_declaration_builds_no_schema() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE restore_replicas SET machine_id = '{MACHINE}' + WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("pin the declaration to a machine"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + assert!( + entries.iter().all(|e| e["intent"] != "schema-build"), + "a build is per group, not per machine" + ); + }, + ) + .await +} + /// `once` is keyed to the pair rather than the snapshot, so a pair that has been /// built drops off the worklist and stays off while the snapshot moves on. #[tokio::test(flavor = "multi_thread")] @@ -323,20 +395,6 @@ async fn a_schema_registered_against_a_range_is_refused() { .text("CREATE VIEW ...") .await; assert_eq!(ranged.status_code(), StatusCode::BAD_REQUEST); - - let other_type = public - .post(&format!( - "/artifacts/2.60.x/installer/windows?group={GROUP}" - )) - .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) - .text("installer bytes") - .await; - other_type.assert_status_ok(); - let registered: serde_json::Value = other_type.json(); - assert_eq!( - registered["version_range_pattern"], "2.60.x", - "a range is still how any other artifact type covers a minor" - ); }, ) .await @@ -407,6 +465,149 @@ async fn restoring_for_a_group_does_not_authorise_publishing_its_schema() { .await } +/// A build report settles the pair: it stops the pair being dispatched again +/// and clears an operator's ask. A plain verify or migrate consumer declared +/// for the group can otherwise settle a pair no schema was ever built for, and +/// inject its own error string into the group's check. +#[tokio::test(flavor = "multi_thread")] +async fn restoring_for_a_group_does_not_authorise_settling_its_pairs() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + let replica = declaration_id(&mut conn).await; + + conn.batch_execute(&format!( + "UPDATE restore_consumer_capabilities + SET semantics = '[\"check\", \"once\", \"migrate\"]'::jsonb + WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("withdraw the semantic"); + + let refused = public + .post("/restore-verification") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&build_report( + replica, + serde_json::json!({ "target_version": "2.60.0", "built": true }), + )) + .await; + + assert_eq!(refused.status_code(), StatusCode::FORBIDDEN); + }, + ) + .await +} + +/// The artifacts route carries a body limit sized from the held-bytes cap, so a +/// schema past axum's 2 MiB default is taken in rather than answered with a +/// plain-text 413 for a limit sixteen times below the documented one. +#[tokio::test(flavor = "multi_thread")] +async fn a_schema_over_axum_s_default_is_taken_in() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let sql = "-- ".to_owned() + &"x".repeat(3 * 1024 * 1024); + let response = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text(sql) + .await; + + response.assert_status_ok(); + }, + ) + .await +} + +/// A builder is authorised for the artifact its declaration names. Any other +/// type registered under that authority outranks the releaser's own for every +/// machine in the group, and those machines fetch and run what they are +/// offered. +#[tokio::test(flavor = "multi_thread")] +async fn a_builder_cannot_displace_the_group_s_installer() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let installer = public + .post(&format!( + "/artifacts/2.60.0/installer/windows?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/octet-stream") + .text("MZ...") + .await; + assert_eq!(installer.status_code(), StatusCode::FORBIDDEN); + + let schema = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + schema.assert_status_ok(); + }, + ) + .await +} + +/// Provenance a party can forge for itself answers nothing an operator asks of +/// it, so a run already recorded for another consumer is not one this +/// registration may name. A run Canopy has not seen is ordinary: the artifact +/// lands mid-restore, before the report of that restore does. +#[tokio::test(flavor = "multi_thread")] +async fn a_run_another_consumer_reported_cannot_be_claimed() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let run = "77777777-7777-7777-7777-777777777777"; + let stranger = "88888888-8888-8888-8888-888888888888"; + conn.batch_execute(&format!( + "INSERT INTO devices (id, role) VALUES ('{stranger}', 'backup-restore'); + INSERT INTO backup_runs + (id, device_id, group_id, machine_id, type, purpose, outcome, reported_at) + VALUES ('{run}', '{stranger}', '{GROUP}', '{MACHINE}', + 'tamanu-postgres', 'restore', 'success', now())" + )) + .await + .expect("another consumer's run"); + + let claimed = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}&run={run}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + assert_eq!(claimed.status_code(), StatusCode::BAD_REQUEST); + + let own = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}&run=99999999-9999-9999-9999-999999999999" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + own.assert_status_ok(); + }, + ) + .await +} + /// The declaration `seed` made, which a report has to name. async fn declaration_id(conn: &mut database::diesel_async::AsyncPgConnection) -> uuid::Uuid { use diesel::{QueryableByName, sql_query, sql_types}; From b8de277eea2e2bff12adff5c085bff08d3206581 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:51:54 +1200 Subject: [PATCH 083/130] tighten artifact registration --- crates/canopy-api/src/generated.rs | 7 +- crates/public-server/openapi.json | 2 +- crates/public-server/src/artifacts.rs | 69 ++++++++++++------- .../tests/it/reporting_schemas.rs | 33 +++++++++ 4 files changed, 81 insertions(+), 30 deletions(-) diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index 81dc5302e..01178ca95 100644 --- a/crates/canopy-api/src/generated.rs +++ b/crates/canopy-api/src/generated.rs @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.0"; /// BLAKE3 digest of that document, so a document that changed without the /// version moving with it can be told from one that did not. -pub const OPENAPI_BLAKE3: &str = "537ffaf234f7e02ef068b3ec2c50e33fc2e42e58c65583195c687cf53e2fcf48"; +pub const OPENAPI_BLAKE3: &str = "93afb023ce550a4506e35edaf020d1807af8509b7bbb645b60505061237fc727"; /// Error types. pub mod error { @@ -4077,10 +4077,11 @@ impl crate::CanopyClient { /// request body is the plain-text URL clients should download the /// artifact from. /// - /// When an exact version is given and it doesn't exist yet, it is created + /// When a releaser gives an exact version that doesn't exist yet, it is created /// automatically as an unpublished draft so the artifact has a version to /// attach to; publishing that version later (via the version-creation - /// endpoint) is a separate step. When a range pattern is given instead, + /// endpoint) is a separate step. A group-scoped registration names a version + /// Canopy already holds and drafts none. When a range pattern is given instead, /// the artifact isn't tied to one version — it matches whichever /// published version currently satisfies the range at lookup time. /// diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index 61db5d703..4138bc0d6 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -111,7 +111,7 @@ "artifacts" ], "summary": "Register an artifact for a version or version range.", - "description": "A releaser registers an artifact that rests elsewhere, naming its location.\nA component that produces a group's artifacts registers one for that group,\nsending the bytes on this connection; Canopy holds them and is issued no\ncredential to any store. The\npath identifies the version the artifact belongs to — either an exact\nversion (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`,\n`^2.10.0`) — followed by the artifact's type and target platform. The\nrequest body is the plain-text URL clients should download the\nartifact from.\n\nWhen an exact version is given and it doesn't exist yet, it is created\nautomatically as an unpublished draft so the artifact has a version to\nattach to; publishing that version later (via the version-creation\nendpoint) is a separate step. When a range pattern is given instead,\nthe artifact isn't tied to one version — it matches whichever\npublished version currently satisfies the range at lookup time.\n\nReturns the created artifact record. Returns 400 if the version or\nrange syntax can't be parsed.", + "description": "A releaser registers an artifact that rests elsewhere, naming its location.\nA component that produces a group's artifacts registers one for that group,\nsending the bytes on this connection; Canopy holds them and is issued no\ncredential to any store. The\npath identifies the version the artifact belongs to — either an exact\nversion (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`,\n`^2.10.0`) — followed by the artifact's type and target platform. The\nrequest body is the plain-text URL clients should download the\nartifact from.\n\nWhen a releaser gives an exact version that doesn't exist yet, it is created\nautomatically as an unpublished draft so the artifact has a version to\nattach to; publishing that version later (via the version-creation\nendpoint) is a separate step. A group-scoped registration names a version\nCanopy already holds and drafts none. When a range pattern is given instead,\nthe artifact isn't tied to one version — it matches whichever\npublished version currently satisfies the range at lookup time.\n\nReturns the created artifact record. Returns 400 if the version or\nrange syntax can't be parsed.", "operationId": "register_artifact", "parameters": [ { diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index f7cfff1aa..7513008dc 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -99,13 +99,15 @@ pub(crate) async fn caller_scope( Ok(Scope::for_caller(machine.and_then(|m| m.group_id))) } +/// Body budget for a registration. Sized above the held-bytes cap so an +/// over-limit upload is the handler's structured refusal naming the limit, +/// rather than axum's plain-text 413. +const MAX_REGISTER_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; + pub fn routes() -> OpenApiRouter { - // Sized from the held-bytes cap so an over-limit upload is the handler's - // structured refusal naming the limit, rather than axum's plain-text 413 - // from a default an order of magnitude below it. OpenApiRouter::new() .routes(routes!(create)) - .layer(DefaultBodyLimit::max(MAX_HELD_ARTIFACT_BYTES)) + .layer(DefaultBodyLimit::max(MAX_REGISTER_BODY_BYTES)) } /// Register an artifact for a version or version range. @@ -120,10 +122,11 @@ pub fn routes() -> OpenApiRouter { /// request body is the plain-text URL clients should download the /// artifact from. /// -/// When an exact version is given and it doesn't exist yet, it is created +/// When a releaser gives an exact version that doesn't exist yet, it is created /// automatically as an unpublished draft so the artifact has a version to /// attach to; publishing that version later (via the version-creation -/// endpoint) is a separate step. When a range pattern is given instead, +/// endpoint) is a separate step. A group-scoped registration names a version +/// Canopy already holds and drafts none. When a range pattern is given instead, /// the artifact isn't tied to one version — it matches whichever /// published version currently satisfies the range at lookup time. /// @@ -234,12 +237,27 @@ async fn create( let (version_id, version_range_pattern) = if let Ok(semver) = SemverVersion::parse(&version) { let version_str = VersionStr(semver); - // The version an artifact names may not exist yet: it is created as a - // draft so the artifact has something to attach to, and publishing it - // stays a separate step. - let version_id = match Version::get_by_version(&mut db, version_str.clone()).await { - Ok(version) => version.id, - Err(_) => { + let existing = match Version::get_by_version(&mut db, version_str.clone()).await { + Ok(version) => Some(version), + Err(AppError::DatabaseQuery(diesel::result::Error::NotFound)) => None, + Err(error) => return Err(error), + }; + + let version_id = match existing { + Some(version) => version.id, + // A build is dispatched for a pair whose version Canopy already + // holds, so a group-scoped registration names one rather than + // drafting a release nobody has cut. + // spec: RPT#pairs + None if held.is_some() => { + return Err(AppError::BadRequest(format!( + "no version {version} to register a group-scoped artifact against" + ))); + } + // The version a releaser names may not exist yet: it is created as a + // draft so the artifact has something to attach to, and publishing it + // stays a separate step. + None => { let new_version = NewVersion { major: version_str.0.major as _, minor: version_str.0.minor as _, @@ -284,9 +302,13 @@ async fn create( // spec: ART#digests let named_digest = named.digest.filter(|d| !d.trim().is_empty()); - let download_url = match held { + // Canopy holds a group-scoped artifact, so it records the digest of what it + // actually took in. An unscoped one is fetched from its location by the + // caller, so its digest is whatever that caller recorded. + // spec: ART#digests + let (download_url, digest, content) = match held { None => { - let url = String::from_utf8(body.to_vec()) + let url = String::from_utf8(body.into()) .map_err(|_| AppError::BadRequest("download URL is not valid UTF-8".into()))?; // A blank body is no location at all. The constraint only tests for // NULL, so an empty string would pass it and leave an artifact @@ -297,9 +319,12 @@ async fn create( "an artifact needs a download URL".into(), )); } - Some(url) + (Some(url), named_digest, None) + } + Some(_) => { + let digest = digest_of(&body); + (None, Some(digest), Some(Vec::from(body))) } - Some(_) => None, }; let row = ArtifactRow::register( @@ -312,16 +337,8 @@ async fn create( device_id: Some(device_id), version_range_pattern, group_id: held, - // Canopy holds a group-scoped artifact, so it records the digest of - // what it actually took in. An unscoped one is fetched from its - // location by the caller, so its digest is whatever that caller - // recorded. - // spec: ART#digests - digest: match held { - Some(_) => Some(digest_of(&body)), - None => named_digest, - }, - content: held.map(|_| body.to_vec()), + digest, + content, content_type: held.and(content_type), run_id: named.run, }, diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index 8042e6630..d4241b195 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -400,6 +400,39 @@ async fn a_schema_registered_against_a_range_is_refused() { .await } +/// A build is dispatched for a pair whose version Canopy already holds, so a +/// registration naming one it does not is refused. Drafting a release row for +/// it would put a builder's near-miss of a real version into the catalog every +/// machine reads. +#[tokio::test(flavor = "multi_thread")] +async fn a_schema_for_an_unknown_version_drafts_none() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + let refused = public + .post(&format!( + "/artifacts/9999.0.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + assert_eq!(refused.status_code(), StatusCode::BAD_REQUEST); + + let catalog = database::versions::Version::get_all_including_drafts(&mut conn) + .await + .expect("the version catalog"); + assert!( + !catalog.iter().any(|v| v.major == 9999), + "no release row is drafted for it" + ); + }, + ) + .await +} + /// A declaration an operator has turned off does not authorise anything. It is /// the enabled declaration that covers a group, so a builder whose declaration /// is disabled is refused its own group's artifacts. From 98e949ad6c4ebe2abf745d6fab708470e9489ab6 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:51:54 +1200 Subject: [PATCH 084/130] count range artifacts --- crates/database/src/artifacts.rs | 59 +++++++++++++++++-- crates/database/tests/it/reporting_schemas.rs | 28 +++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index b15e84fbf..884fa3674 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -331,17 +331,66 @@ impl Artifact { db: &mut AsyncPgConnection, version: Uuid, ) -> Result> { + let version = Version::get_by_id(db, version).await?; + let newest = Self::newest_change_for_versions(db, std::slice::from_ref(&version)).await?; + Ok(newest.get(&version.id).copied()) + } + + /// When any artifact a build reads was last registered for each of these + /// versions, in two queries however many versions are asked about. + /// + /// A range artifact counts for every version it covers, since that is how + /// one is resolved for a build. + // spec: RPT#pairs + pub async fn newest_change_for_versions( + db: &mut AsyncPgConnection, + versions: &[Version], + ) -> Result> { use crate::schema::artifacts::dsl; - let newest: Option = dsl::artifacts - .filter(dsl::version_id.eq(version)) + let ids: Vec = versions.iter().map(|v| v.id).collect(); + let exact: Vec<(Option, Option)> = dsl::artifacts + .filter(dsl::version_id.eq_any(&ids)) .filter(dsl::group_id.is_null()) - .select(diesel::dsl::max(dsl::updated_at)) - .first(db) + .group_by(dsl::version_id) + .select((dsl::version_id, diesel::dsl::max(dsl::updated_at))) + .load(db) + .await + .map_err(AppError::from)?; + + let mut newest: std::collections::HashMap = exact + .into_iter() + .filter_map(|(id, at)| Some((id?, at?.into()))) + .collect(); + + let ranges: Vec<(Option, jiff_diesel::Timestamp)> = dsl::artifacts + .filter(dsl::version_id.is_null()) + .filter(dsl::group_id.is_null()) + .select((dsl::version_range_pattern, dsl::updated_at)) + .load(db) .await .map_err(AppError::from)?; - Ok(newest.map(Into::into)) + for (pattern, at) in ranges { + // An unparseable pattern matches nothing rather than everything, + // as it does where the artifact is offered. + let Some(range) = pattern + .as_deref() + .and_then(|pattern| node_semver::Range::parse(pattern).ok()) + else { + continue; + }; + let at: jiff::Timestamp = at.into(); + + for version in versions.iter().filter(|v| range.satisfies(&v.as_semver())) { + newest + .entry(version.id) + .and_modify(|held| *held = (*held).max(at)) + .or_insert(at); + } + } + + Ok(newest) } /// The bytes Canopy holds for an artifact, where it holds any. diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index a1921b2c4..f179ace20 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -325,6 +325,34 @@ async fn a_new_artifact_for_the_version_reinstates_the_pair() { .await; } +/// Canopy resolves a range artifact for every version it covers, so one +/// registered over the pair's version is a change the next build reads and +/// reinstates the pair the same way an exact one does. +#[tokio::test(flavor = "multi_thread")] +async fn a_range_artifact_covering_the_version_reinstates_the_pair() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + + record_build(&mut conn, newer, true).await; + + conn.batch_execute( + "INSERT INTO artifacts + (version_range_pattern, artifact_type, platform, download_url) + VALUES ('2.60.x', 'migrations', 'any', 'https://example.com/m.tar')", + ) + .await + .expect("register a range artifact"); + + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "a range covering the version is one of its artifacts" + ); + }) + .await; +} + /// A build's own output is not a change a build reads. Counted, a second /// group's schema for the version unsettles the first group's pair, whose /// rebuild unsettles the second, and neither pair ever settles: a restore and a From 9aa0f5208e7f310d78772dcd8adbfc579fd41d96 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:51:54 +1200 Subject: [PATCH 085/130] index artifact lookups --- .../down.sql | 1 + .../up.sql | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 migrations/2026-09-08-221635-0000_artifacts_version_updated/down.sql create mode 100644 migrations/2026-09-08-221635-0000_artifacts_version_updated/up.sql diff --git a/migrations/2026-09-08-221635-0000_artifacts_version_updated/down.sql b/migrations/2026-09-08-221635-0000_artifacts_version_updated/down.sql new file mode 100644 index 000000000..9ba33e111 --- /dev/null +++ b/migrations/2026-09-08-221635-0000_artifacts_version_updated/down.sql @@ -0,0 +1 @@ +DROP INDEX artifacts_version_updated; diff --git a/migrations/2026-09-08-221635-0000_artifacts_version_updated/up.sql b/migrations/2026-09-08-221635-0000_artifacts_version_updated/up.sql new file mode 100644 index 000000000..4cf00ff2e --- /dev/null +++ b/migrations/2026-09-08-221635-0000_artifacts_version_updated/up.sql @@ -0,0 +1,8 @@ +-- Whether a pair is settled asks when a version's artifacts last changed, once +-- per pair on every worklist poll. Without an index leading on version_id that +-- is a sequential scan of artifacts each time: artifacts_identity leads with +-- artifact_type, and artifacts_group_id with group_id. The partial predicate +-- matches the query, which counts the unscoped artifacts alone. +CREATE INDEX artifacts_version_updated + ON artifacts (version_id, updated_at) + WHERE group_id IS NULL; From 29de010dbc440095bb9e947c4159fbf8882d4e7a Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:51:54 +1200 Subject: [PATCH 086/130] settle pairs per group --- crates/database/src/reporting_schemas.rs | 136 ++++++++++++++++------- crates/database/src/versions.rs | 42 +++++++ crates/public-server/src/restore.rs | 23 ++-- 3 files changed, 151 insertions(+), 50 deletions(-) diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index d0d131c99..6f3787b8e 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -136,13 +136,16 @@ impl ReportingSchemaBuild { .on(backup_restore_checks::id.eq(reporting_schema_builds::check_id)), ) .filter(reporting_schema_builds::group_id.eq(group)) - .order_by(backup_restore_checks::reported_at.asc()) + .distinct_on(reporting_schema_builds::version_id) + .order_by(( + reporting_schema_builds::version_id, + backup_restore_checks::reported_at.desc(), + )) .select(Self::as_select()) .load(db) .await .map_err(AppError::from)?; - // Ascending, so the last write per version is the newest. Ok(builds.into_iter().map(|b| (b.version_id, b)).collect()) } @@ -155,22 +158,53 @@ impl ReportingSchemaBuild { group: Uuid, version: Uuid, ) -> Result { - if ReportingSchemaRequest::pending(db, group, version).await? { - return Ok(false); + let row = Version::get_by_id(db, version).await?; + let settlement = Settlement::for_group(db, group, std::slice::from_ref(&row)).await?; + Ok(settlement.settled(version)) + } +} + +/// Where every pair of a group stands, answered from memory. +/// +/// The worklist asks this of each of a group's versions on every poll, and +/// every restore consumer polls on a schedule, so the three lookups it takes +/// are made once for the group rather than once per pair. +// spec: RPT#pairs +pub struct Settlement { + requested: std::collections::HashSet, + builds: std::collections::HashMap, + changed: std::collections::HashMap, +} + +impl Settlement { + pub async fn for_group( + db: &mut AsyncPgConnection, + group: Uuid, + versions: &[Version], + ) -> Result { + Ok(Self { + requested: ReportingSchemaRequest::pending_for_group(db, group).await?, + builds: ReportingSchemaBuild::latest_by_version_for_group(db, group).await?, + changed: crate::artifacts::Artifact::newest_change_for_versions(db, versions).await?, + }) + } + + pub fn settled(&self, version: Uuid) -> bool { + if self.requested.contains(&version) { + return false; } - let Some(build) = Self::latest_for_pair(db, group, version).await? else { - return Ok(false); + let Some(build) = self.builds.get(&version) else { + return false; }; // A schema built from a superseded release of the version is not the // schema that version describes, so an artifact registered since the // build puts the pair back on the worklist. - let changed = crate::artifacts::Artifact::newest_change_for_version(db, version).await?; - Ok(match changed { - Some(at) => at <= build.built_at, + match self.changed.get(&version) { + Some(at) => *at <= build.built_at, None => true, - }) + } } } @@ -216,20 +250,6 @@ impl ReportingSchemaRequest { Ok(()) } - pub async fn pending(db: &mut AsyncPgConnection, group: Uuid, version: Uuid) -> Result { - use crate::schema::reporting_schema_requests::dsl; - - Ok(dsl::reporting_schema_requests - .filter(dsl::group_id.eq(group)) - .filter(dsl::version_id.eq(version)) - .select(dsl::group_id) - .first::(db) - .await - .optional() - .map_err(AppError::from)? - .is_some()) - } - /// Which of a group's versions have an ask pending, in one query. pub async fn pending_for_group( db: &mut AsyncPgConnection, @@ -308,7 +328,19 @@ pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result< return Ok(Vec::new()); } - let versions = versions_and_applications(db, group).await?; + let members = crate::applications::Application::list_live_in_group(db, group).await?; + pairs_of_members(db, group, &members).await +} + +/// The pairs of a group already known to have a builder, from members already +/// in hand. +// spec: RPT#pairs +async fn pairs_of_members( + db: &mut AsyncPgConnection, + group: Uuid, + members: &[crate::applications::Application], +) -> Result> { + let versions = versions_and_applications(db, group, members).await?; let builds = ReportingSchemaBuild::latest_by_version_for_group(db, group).await?; let requests = ReportingSchemaRequest::pending_for_group(db, group).await?; @@ -342,7 +374,9 @@ pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result< /// artifacts. // spec: RPT#pairs pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { - Ok(versions_and_applications(db, group) + let members = crate::applications::Application::list_live_in_group(db, group).await?; + + Ok(versions_and_applications(db, group, &members) .await? .into_iter() .map(|(version, _)| version) @@ -360,10 +394,10 @@ pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Resu async fn versions_and_applications( db: &mut AsyncPgConnection, group: Uuid, + applications: &[crate::applications::Application], ) -> Result)>> { use commons_types::version::VersionStatus; - let applications = crate::applications::Application::list_live_in_group(db, group).await?; let tamanu: Vec<&crate::applications::Application> = applications .iter() .filter(|a| a.r#type.software() == "tamanu") @@ -372,6 +406,11 @@ async fn versions_and_applications( let ids: Vec = tamanu.iter().map(|a| a.id).collect(); let reported = crate::reported_detail::ReportedDetail::last_versions(db, &ids).await?; + let mut wanted: Vec = reported.values().cloned().collect(); + wanted.sort_by(|a, b| a.0.cmp(&b.0)); + wanted.dedup_by(|a, b| a.0 == b.0); + let released = Version::get_by_versions(db, &wanted).await?; + let mut pairs: Vec<(Version, Vec)> = Vec::new(); for application in tamanu { let Some(shown) = reported.get(&application.id) else { @@ -379,7 +418,14 @@ async fn versions_and_applications( }; // A version Canopy holds no release row for is not a pair: a build needs // that version's migrations, which reach a builder as published artifacts. - let Ok(version) = Version::get_by_version(db, shown.clone()).await else { + let Some(version) = released.iter().find(|v| { + (v.major, v.minor, v.patch) + == ( + shown.0.major as i32, + shown.0.minor as i32, + shown.0.patch as i32, + ) + }) else { continue; }; if version.status != VersionStatus::Published { @@ -390,7 +436,7 @@ async fn versions_and_applications( // version are one pair carrying both names. match pairs.iter_mut().find(|(v, _)| v.id == version.id) { Some((_, names)) => names.push(application.label()), - None => pairs.push((version, vec![application.label()])), + None => pairs.push((version.clone(), vec![application.label()])), } } @@ -437,22 +483,28 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { continue; }; - let pairs = pairs_for_group(db, group.id).await?; + let pairs = pairs_of_members(db, group.id, &members).await?; let instances: Vec = pairs .iter() .filter(|p| p.state != PairState::Awaiting) - .map(|pair| CheckInstance { - label: pair.version.clone(), - observed: match pair.state { - PairState::Built => CheckResult::Passed, - _ => CheckResult::Warning, - }, - detail: Some(serde_json::json!({ - "version": pair.version, - "why": pair.error.clone().unwrap_or_else(|| { - format!("no schema could be built for {}", pair.version) - }), - })), + .map(|pair| { + let mut detail = serde_json::json!({ "version": pair.version }); + if pair.state != PairState::Built { + detail["why"] = pair + .error + .clone() + .unwrap_or_else(|| format!("no schema could be built for {}", pair.version)) + .into(); + } + + CheckInstance { + label: pair.version.clone(), + observed: match pair.state { + PairState::Built => CheckResult::Passed, + _ => CheckResult::Warning, + }, + detail: Some(detail), + } }) .collect(); diff --git a/crates/database/src/versions.rs b/crates/database/src/versions.rs index 01d87c9dc..3ae8950ee 100644 --- a/crates/database/src/versions.rs +++ b/crates/database/src/versions.rs @@ -168,6 +168,48 @@ impl Version { .map_err(AppError::from) } + /// The release rows for these exact versions, in one query. A version with + /// no row is absent from the result rather than an error. + pub async fn get_by_versions( + db: &mut AsyncPgConnection, + wanted: &[VersionStr], + ) -> Result> { + use crate::schema::versions::dsl::*; + + type Predicate = Box< + dyn diesel::BoxableExpression< + crate::schema::versions::table, + diesel::pg::Pg, + SqlType = diesel::sql_types::Bool, + >, + >; + + let mut wants: Option = None; + for want in wanted { + let one: Predicate = Box::new( + major + .eq(want.0.major as i32) + .and(minor.eq(want.0.minor as i32)) + .and(patch.eq(want.0.patch as i32)), + ); + wants = Some(match wants { + Some(so_far) => Box::new(so_far.or(one)), + None => one, + }); + } + + let Some(wants) = wants else { + return Ok(Vec::new()); + }; + + versions + .filter(wants) + .select(Version::as_select()) + .load(db) + .await + .map_err(AppError::from) + } + pub async fn get_by_id(db: &mut AsyncPgConnection, version_id: Uuid) -> Result { use crate::schema::versions::dsl::*; diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index b53973775..01533f586 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -246,6 +246,10 @@ async fn worklist( // Resolving a group's pairs walks its applications and their reported // versions, so a group covered by several declarations is resolved once. let mut version_cache: HashMap> = HashMap::new(); + // Where each of a group's pairs stands, resolved once for the group rather + // than per pair: every restore consumer polls this on a schedule. + let mut settlement_cache: HashMap = + HashMap::new(); // Per-group caches so a group referenced by several declarations is resolved // once: the latest produced snapshot per (machine, type), and the latest // healthy-verified snapshot per (machine, type, intent) for `once` suppression. @@ -353,22 +357,25 @@ async fn worklist( let latest = snapshots.get(&(machine.id, d.r#type.clone())); if let std::collections::hash_map::Entry::Vacant(e) = version_cache.entry(d.group_id) { - e.insert( - database::reporting_schemas::versions_for_group(&mut conn, d.group_id).await?, + let versions = + database::reporting_schemas::versions_for_group(&mut conn, d.group_id).await?; + settlement_cache.insert( + d.group_id, + database::reporting_schemas::Settlement::for_group( + &mut conn, d.group_id, &versions, + ) + .await?, ); + e.insert(versions); } + let settlement = &settlement_cache[&d.group_id]; for version in version_cache[&d.group_id].clone() { if !pairs.insert((d.group_id, version.id)) { continue; } - if once - && database::reporting_schemas::ReportingSchemaBuild::is_settled( - &mut conn, d.group_id, version.id, - ) - .await? - { + if once && settlement.settled(version.id) { continue; } From 5cb2ae08ad23fe664fe93ef461e211918330ab6b Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:51:54 +1200 Subject: [PATCH 087/130] unstick a doc comment --- crates/database/src/applications.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/database/src/applications.rs b/crates/database/src/applications.rs index 5def9dfc6..f89ff05ca 100644 --- a/crates/database/src/applications.rs +++ b/crates/database/src/applications.rs @@ -800,8 +800,6 @@ impl Application { .map_err(AppError::from) } - /// All live (non-archived) applications in a group, ordered by name. Used to - /// expand a group-wide restore-replica declaration into per-server entries. /// What to call this application to an operator: the name it was given, /// else the host it answers on, else its id. pub fn label(&self) -> String { @@ -811,6 +809,8 @@ impl Application { .unwrap_or_else(|| self.id.to_string()) } + /// All live (non-archived) applications in a group, ordered by name. Used to + /// expand a group-wide restore-replica declaration into per-server entries. pub async fn list_live_in_group( db: &mut AsyncPgConnection, group_id_: Uuid, From f257a06dc2fa1cb098384e7874505f9b2c221694 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:55:50 +1200 Subject: [PATCH 088/130] count every pair --- crates/database/src/reporting_schemas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 6f3787b8e..db2aa8410 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -509,7 +509,7 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { .collect(); let name = group.name.clone(); - let total = instances.len(); + let total = pairs.len(); file_restore_check( db, Scope::Application(central), From f3b4c683497af3434505a15cbb1cbe30ef1205b0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:12 +1200 Subject: [PATCH 089/130] gate schema publishing --- crates/database/src/restore.rs | 28 +++++++++++++++---- crates/database/src/schema.rs | 1 + crates/database/tests/it/reporting_schemas.rs | 4 +-- crates/database/tests/it/restore.rs | 2 ++ .../down.sql | 1 + .../up.sql | 10 +++++++ 6 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 migrations/2026-09-09-014829-0000_replica_publishes_schemas/down.sql create mode 100644 migrations/2026-09-09-014829-0000_replica_publishes_schemas/up.sql diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 4d3a2715f..5122623f0 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -81,6 +81,12 @@ pub struct RestoreReplica { /// the whole of the operator's say in it, and it answers on its own /// whether a replica that came up unmasked is a finding. pub redacts: bool, + /// Whether this declaration's consumer may publish the group's reporting + /// schema. Only an operator sets it: what a consumer advertises is the + /// consumer's own claim, and every machine in the group runs what is + /// published for it. + // spec: RPT#the-build-contract + pub publishes_schemas: bool, /// Whether this declaration is currently active. When disabled, it /// produces no work and grants no access, but is kept for reference. pub enabled: bool, @@ -107,6 +113,7 @@ pub struct NewRestoreReplica { pub overdue_after: Option, pub params: serde_json::Value, pub redacts: bool, + pub publishes_schemas: bool, pub created_by: Option, } @@ -124,6 +131,7 @@ pub struct RestoreReplicaUpdate { pub overdue_after: Option, pub params: serde_json::Value, pub redacts: bool, + pub publishes_schemas: bool, pub enabled: bool, } @@ -261,6 +269,7 @@ impl RestoreReplica { dsl::overdue_after.eq(update.overdue_after), dsl::params.eq(update.params), dsl::redacts.eq(update.redacts), + dsl::publishes_schemas.eq(update.publishes_schemas), dsl::enabled.eq(update.enabled), )) .returning(Self::as_select()) @@ -312,12 +321,16 @@ impl RestoreReplica { } /// Whether a consumer may register group-scoped artifacts for this group: - /// it has an enabled declaration covering the group whose intent it - /// advertises as building reporting schemas, and no other group. + /// an operator has marked an enabled declaration of theirs covering the + /// group as publishing its schema, and that declaration is one a build is + /// actually dispatched for. /// - /// The authorisation is defined with the artifact rather than granted to - /// restore consumers at large, so a consumer that restores for a group but - /// builds nothing publishes nothing. + /// The operator's flag is what grants this, not the semantics the consumer + /// advertises: a device registers its own capability set, so a semantic is + /// a claim the claimant controls, and what is published here is offered to + /// every machine in the group and run. The advertised semantic still has to + /// be there, since a consumer that cannot build a schema has no business + /// publishing one, but it grants nothing on its own. // spec: ART#registration, RPT#the-build-contract pub async fn authorizes_schema_artifacts( db: &mut AsyncPgConnection, @@ -342,6 +355,11 @@ impl RestoreReplica { .filter(dsl::group_id.eq(group_id)) .filter(dsl::intent.eq_any(building.iter().map(|i| i.0.clone()).collect::>())) .filter(dsl::enabled.eq(true)) + .filter(dsl::publishes_schemas.eq(true)) + // Dispatch builds no schema from a redacting or machine-scoped + // declaration, and one nothing is dispatched for publishes nothing. + .filter(dsl::redacts.eq(false)) + .filter(dsl::machine_id.is_null()) .count() .get_result(db) .await diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index fac8576a5..68fd12eaa 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -655,6 +655,7 @@ diesel::table! { updated_at -> Timestamptz, params -> Jsonb, redacts -> Bool, + publishes_schemas -> Bool, } } diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index f179ace20..0ae774960 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -435,9 +435,9 @@ async fn declare_builder(conn: &mut AsyncPgConnection, enabled: bool) { '[\"check\",\"once\",\"migrate\",\"reporting-schema\"]'::jsonb, '[]'::jsonb); INSERT INTO restore_replicas - (consumer_device_id, group_id, type, intent, name, enabled, params) + (consumer_device_id, group_id, type, intent, name, enabled, params, publishes_schemas) VALUES ('{CONSUMER}', '{GROUP}', 'tamanu-postgres', 'reporting-schema', - 'kamaka-schemas', {enabled}, '{{}}'::jsonb)", + 'kamaka-schemas', {enabled}, '{{}}'::jsonb, true)", )) .await .expect("declare builder"); diff --git a/crates/database/tests/it/restore.rs b/crates/database/tests/it/restore.rs index 8d4d04ccb..db4fb7041 100644 --- a/crates/database/tests/it/restore.rs +++ b/crates/database/tests/it/restore.rs @@ -164,6 +164,7 @@ fn new_replica( overdue_after: None, params: serde_json::json!({}), redacts: false, + publishes_schemas: false, created_by: Some("op@example.com".into()), } } @@ -181,6 +182,7 @@ fn update_from(r: &RestoreReplica) -> RestoreReplicaUpdate { overdue_after: r.overdue_after, params: r.params.clone(), redacts: r.redacts, + publishes_schemas: r.publishes_schemas, enabled: r.enabled, } } diff --git a/migrations/2026-09-09-014829-0000_replica_publishes_schemas/down.sql b/migrations/2026-09-09-014829-0000_replica_publishes_schemas/down.sql new file mode 100644 index 000000000..612ee218a --- /dev/null +++ b/migrations/2026-09-09-014829-0000_replica_publishes_schemas/down.sql @@ -0,0 +1 @@ +ALTER TABLE restore_replicas DROP COLUMN publishes_schemas; diff --git a/migrations/2026-09-09-014829-0000_replica_publishes_schemas/up.sql b/migrations/2026-09-09-014829-0000_replica_publishes_schemas/up.sql new file mode 100644 index 000000000..256122639 --- /dev/null +++ b/migrations/2026-09-09-014829-0000_replica_publishes_schemas/up.sql @@ -0,0 +1,10 @@ +-- ── Who may publish a group's reporting schema ────────────────────────────── +-- +-- Publishing a group-scoped artifact is a privilege over every machine in the +-- group: they are offered what is registered and they run it. The intent +-- semantics a consumer advertises are the consumer's own claim, registered by +-- the device itself, so they shape dispatch but cannot be what grants this. +-- An operator sets this flag on the declaration through the admin API, and it +-- is the whole of the authorisation. +ALTER TABLE restore_replicas + ADD COLUMN publishes_schemas BOOLEAN NOT NULL DEFAULT FALSE; From d9c2ce59174784de95f1c0d5173ca5a2f1f47e77 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:12 +1200 Subject: [PATCH 090/130] dispatch marked builders --- crates/public-server/src/restore.rs | 8 ++ .../tests/it/reporting_schemas.rs | 102 +++++++++++++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 01533f586..4fe059152 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -315,6 +315,14 @@ async fn worklist( // the version it is migrated to. // spec: RPT#the-build-contract if builds_schema { + // A build nobody may publish the result of is a restore and a + // migrate spent for nothing, so the operator's flag gates dispatch + // as well as publishing. + // spec: RPT#the-build-contract + if !d.publishes_schemas { + continue; + } + // Masking alters the configuration a schema follows from, so a // redacting declaration builds nothing rather than building from a // database that is no longer the group's. diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index d4241b195..22035ec65 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -43,8 +43,8 @@ async fn seed(conn: &mut database::diesel_async::AsyncPgConnection, consumer: uu '[\"check\", \"once\", \"migrate\", \"reporting-schema\"]'::jsonb, '{{}}'::jsonb); INSERT INTO restore_replicas - (consumer_device_id, group_id, type, intent, name, enabled) - VALUES ('{consumer}', '{GROUP}', 'tamanu-postgres', 'schema-build', 'schemas', true)", + (consumer_device_id, group_id, type, intent, name, enabled, publishes_schemas) + VALUES ('{consumer}', '{GROUP}', 'tamanu-postgres', 'schema-build', 'schemas', true, true)", )) .await .expect("seed"); @@ -95,9 +95,9 @@ async fn a_second_declaration_dispatches_no_second_build() { conn.batch_execute(&format!( "INSERT INTO restore_replicas - (consumer_device_id, group_id, type, intent, name, enabled) + (consumer_device_id, group_id, type, intent, name, enabled, publishes_schemas) VALUES ('{device_id}', '{GROUP}', 'tamanu-postgres', 'schema-build', - 'schemas-weekly', true)" + 'schemas-weekly', true, true)" )) .await .expect("a second schema declaration"); @@ -498,6 +498,100 @@ async fn restoring_for_a_group_does_not_authorise_publishing_its_schema() { .await } +/// A consumer registers its own capability set, so the semantics an intent +/// carries are its own claim: a device declared for the group can put +/// `reporting-schema` back on its intent in one request. What the operator set +/// on the declaration is what decides, so the refusal stands. +/// +/// spec: RPT#the-build-contract +#[tokio::test(flavor = "multi_thread")] +async fn a_consumer_cannot_advertise_itself_into_publishing() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + // An operator has this consumer restoring for the group, and has + // not made it the group's publisher. + conn.batch_execute(&format!( + "UPDATE restore_replicas SET publishes_schemas = false + WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("the operator has not granted publishing"); + + let readvertised = public + .post("/restore-capabilities") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .json(&serde_json::json!({ + "intents": [{ + "intent": "schema-build", + "description": "builds schemas", + "semantics": ["check", "once", "migrate", "reporting-schema"], + "params": {}, + }], + })) + .await; + assert_eq!( + readvertised.status_code(), + StatusCode::NO_CONTENT, + "a consumer may advertise what it likes" + ); + + let refused = public + .post(&format!( + "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + + assert_eq!( + refused.status_code(), + StatusCode::FORBIDDEN, + "advertising the semantic grants nothing" + ); + }, + ) + .await +} + +/// The flag is the operator's, and it is what the group's builds and the +/// operator page follow: a declaration without it is dispatched no build, so +/// Canopy never asks for one it would refuse to accept. +/// +/// spec: RPT#the-build-contract +#[tokio::test(flavor = "multi_thread")] +async fn a_declaration_that_does_not_publish_is_dispatched_no_build() { + commons_tests::server::run_with_device_auth( + "backup-restore", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn, device_id).await; + + conn.batch_execute(&format!( + "UPDATE restore_replicas SET publishes_schemas = false + WHERE consumer_device_id = '{device_id}'" + )) + .await + .expect("withdraw publishing"); + + let response = public + .get("/restore-worklist") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let entries: Vec = response.json(); + + assert!( + !entries.iter().any(|e| e["intent"] == "schema-build"), + "no build is dispatched for it: {entries:?}" + ); + }, + ) + .await +} + /// A build report settles the pair: it stops the pair being dispatched again /// and clears an operator's ask. A plain verify or migrate consumer declared /// for the group can otherwise settle a pair no schema was ever built for, and From 054b733788d7886252c12ba1ee5551f79820d431 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:12 +1200 Subject: [PATCH 091/130] expose publish flag --- .../src/fns/restore_replicas.rs | 59 ++++++++++++++++++- private-web/openapi.json | 18 ++++++ private-web/src/api-types.ts | 23 ++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/private-server/src/fns/restore_replicas.rs b/crates/private-server/src/fns/restore_replicas.rs index 2bc35f7b5..b0fda4c04 100644 --- a/crates/private-server/src/fns/restore_replicas.rs +++ b/crates/private-server/src/fns/restore_replicas.rs @@ -94,6 +94,12 @@ pub struct RestoreReplicaView { /// True when the intent carries the `redact` semantic, so the declaration /// can be switched to redacting. pub can_redact: bool, + /// Whether this declaration's consumer may publish the group's reporting + /// schema. Only an operator sets it. + pub publishes_schemas: bool, + /// True when the intent carries the `reporting-schema` semantic, so the + /// declaration can be made the group's publisher. + pub can_publish_schemas: bool, /// Servers this declaration covers that cannot currently be redacted: /// either their product publishes no masking manifest, or the version /// they report has none published. Each is withheld from the worklist @@ -191,6 +197,12 @@ pub struct RestoreReplicasCreateArgs { /// to set. Defaults to false. #[serde(default)] pub redacts: bool, + /// Whether this consumer may publish the group's reporting schema. + /// Accepted only for a group-wide, non-redacting declaration whose intent + /// carries the `reporting-schema` semantic. Defaults to false, so a + /// consumer publishes only where an operator has said it may. + #[serde(default)] + pub publishes_schemas: bool, } /// Request to update an existing declaration. @@ -236,6 +248,11 @@ pub struct RestoreReplicasUpdateArgs { /// intent carrying the `redact` semantic. Defaults to false. #[serde(default)] pub redacts: bool, + /// Whether this consumer may publish the group's reporting schema. + /// Accepted only for a group-wide, non-redacting declaration whose intent + /// carries the `reporting-schema` semantic. Defaults to false. + #[serde(default)] + pub publishes_schemas: bool, /// Whether the declaration should be active. pub enabled: bool, } @@ -262,15 +279,18 @@ fn overdue_after_to_pg(overdue_after: Option<&str>) -> Result } /// Resolve human-unit strings in operator-supplied parameter values to their -/// raw stored form and validate them against the consumer's advertised schema -/// for `intent`. If the intent is not advertised (a gap) there is no schema to -/// resolve or check against, so the values are accepted as-is. +/// raw stored form, validate them against the consumer's advertised schema for +/// `intent`, and refuse a flag the declaration cannot carry. If the intent is +/// not advertised (a gap) there is no schema to resolve or check against, so +/// the values are accepted as-is. async fn normalized_params_for_intent( conn: &mut AsyncPgConnection, consumer_device_id: Uuid, intent: &RestoreIntent, params: &ParamValues, redacts: bool, + publishes_schemas: bool, + machine_id: Option, ) -> Result { let descriptors = RestoreConsumerCapability::list_for_consumer(conn, consumer_device_id).await?; @@ -289,6 +309,28 @@ async fn normalized_params_for_intent( "intent {intent} cannot redact: it does not carry the `redact` semantic" ))); } + + // A schema is built per group from its canonical central, from data the + // masking manifest has not altered, so a declaration Canopy would never + // dispatch a build to cannot be the group's publisher either. + // spec: RPT#the-build-contract + if publishes_schemas { + if !desc.has_semantic(semantics::REPORTING_SCHEMA) { + return Err(AppError::BadRequest(format!( + "intent {intent} cannot publish a reporting schema: it does not carry the `reporting-schema` semantic" + ))); + } + if redacts { + return Err(AppError::BadRequest( + "a redacting declaration cannot publish a reporting schema".into(), + )); + } + if machine_id.is_some() { + return Err(AppError::BadRequest( + "a machine-scoped declaration cannot publish a reporting schema: a build is per group".into(), + )); + } + } let params = if owns_masking { ¶ms .iter() @@ -393,6 +435,11 @@ async fn to_views( .get(&r.consumer_device_id) .and_then(|descs| descs.iter().find(|d| d.intent == r.intent)) .is_some_and(|d| d.has_semantic(semantics::REDACT)), + can_publish_schemas: caps + .get(&r.consumer_device_id) + .and_then(|descs| descs.iter().find(|d| d.intent == r.intent)) + .is_some_and(|d| d.has_semantic(semantics::REPORTING_SCHEMA)), + publishes_schemas: r.publishes_schemas, redacts: r.redacts, redaction_gaps: gaps.remove(&r.id).unwrap_or_default(), consumer_name: names.get(&r.consumer_device_id).cloned().flatten(), @@ -737,6 +784,8 @@ pub async fn create( &args.intent, &args.params, args.redacts, + args.publishes_schemas, + args.machine_id, ) .await?; let replica = RestoreReplica::create( @@ -751,6 +800,7 @@ pub async fn create( overdue_after: overdue_after_to_pg(args.overdue_after.as_deref())?, params: serde_json::to_value(¶ms).expect("params serialize"), redacts: args.redacts, + publishes_schemas: args.publishes_schemas, created_by: Some(admin.login), }, ) @@ -803,6 +853,8 @@ pub async fn update( &args.intent, &args.params, args.redacts, + args.publishes_schemas, + args.machine_id, ) .await?; let replica = RestoreReplica::update( @@ -818,6 +870,7 @@ pub async fn update( overdue_after: overdue_after_to_pg(args.overdue_after.as_deref())?, params: serde_json::to_value(¶ms).expect("params serialize"), redacts: args.redacts, + publishes_schemas: args.publishes_schemas, enabled: args.enabled, }, ) diff --git a/private-web/openapi.json b/private-web/openapi.json index 65ea8f86f..d5bd3cfd0 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -15415,6 +15415,8 @@ "params", "redacts", "can_redact", + "publishes_schemas", + "can_publish_schemas", "redaction_gaps", "enabled", "gap", @@ -15422,6 +15424,10 @@ "updated_at" ], "properties": { + "can_publish_schemas": { + "type": "boolean", + "description": "True when the intent carries the `reporting-schema` semantic, so the\ndeclaration can be made the group's publisher." + }, "can_redact": { "type": "boolean", "description": "True when the intent carries the `redact` semantic, so the declaration\ncan be switched to redacting." @@ -15494,6 +15500,10 @@ "type": "object", "description": "Operator-supplied parameter values (name → value). Values of\n`duration` and `bytes` parameters are formatted as human-friendly\nstrings (e.g. `2h 30m`, `20Gi`) when the intent's schema is known;\n`create` and `update` accept these strings back." }, + "publishes_schemas": { + "type": "boolean", + "description": "Whether this declaration's consumer may publish the group's reporting\nschema. Only an operator sets it." + }, "redaction_gaps": { "type": "array", "items": { @@ -15563,6 +15573,10 @@ "type": "object", "description": "Parameter values for the intent (name → value), validated against the\nconsumer's advertised parameter schema. `duration` and `bytes`\nparameters accept human-unit strings (e.g. `2h 30m`, `20Gi`) as well\nas raw integer seconds/bytes. Defaults to empty." }, + "publishes_schemas": { + "type": "boolean", + "description": "Whether this consumer may publish the group's reporting schema.\nAccepted only for a group-wide, non-redacting declaration whose intent\ncarries the `reporting-schema` semantic. Defaults to false, so a\nconsumer publishes only where an operator has said it may." + }, "redacts": { "type": "boolean", "description": "Whether the replica is served de-identified. Accepted only for an\nintent carrying the `redact` semantic; Canopy resolves the masking\nmanifest itself from the server's product, so there is nothing else\nto set. Defaults to false." @@ -15646,6 +15660,10 @@ "type": "object", "description": "New parameter values (name → value), validated against the intent's\nadvertised parameter schema. `duration` and `bytes` parameters accept\nhuman-unit strings (e.g. `2h 30m`, `20Gi`) as well as raw integer\nseconds/bytes. Defaults to empty." }, + "publishes_schemas": { + "type": "boolean", + "description": "Whether this consumer may publish the group's reporting schema.\nAccepted only for a group-wide, non-redacting declaration whose intent\ncarries the `reporting-schema` semantic. Defaults to false." + }, "redacts": { "type": "boolean", "description": "Whether the replica is served de-identified. Accepted only for an\nintent carrying the `redact` semantic. Defaults to false." diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index bd216c3fc..f23b7c491 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -8772,6 +8772,11 @@ export interface components { * read access to the covered backups while it is enabled. */ RestoreReplicaView: { + /** + * @description True when the intent carries the `reporting-schema` semantic, so the + * declaration can be made the group's publisher. + */ + can_publish_schemas: boolean; /** * @description True when the intent carries the `redact` semantic, so the declaration * can be switched to redacting. @@ -8836,6 +8841,11 @@ export interface components { * `create` and `update` accept these strings back. */ params: Record; + /** + * @description Whether this declaration's consumer may publish the group's reporting + * schema. Only an operator sets it. + */ + publishes_schemas: boolean; /** * @description Servers this declaration covers that cannot currently be redacted: * either their product publishes no masking manifest, or the version @@ -8895,6 +8905,13 @@ export interface components { * as raw integer seconds/bytes. Defaults to empty. */ params?: Record; + /** + * @description Whether this consumer may publish the group's reporting schema. + * Accepted only for a group-wide, non-redacting declaration whose intent + * carries the `reporting-schema` semantic. Defaults to false, so a + * consumer publishes only where an operator has said it may. + */ + publishes_schemas?: boolean; /** * @description Whether the replica is served de-identified. Accepted only for an * intent carrying the `redact` semantic; Canopy resolves the masking @@ -8969,6 +8986,12 @@ export interface components { * seconds/bytes. Defaults to empty. */ params?: Record; + /** + * @description Whether this consumer may publish the group's reporting schema. + * Accepted only for a group-wide, non-redacting declaration whose intent + * carries the `reporting-schema` semantic. Defaults to false. + */ + publishes_schemas?: boolean; /** * @description Whether the replica is served de-identified. Accepted only for an * intent carrying the `redact` semantic. Defaults to false. From 218c2389c425b4947864388fa30eec6d225c47c1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:12 +1200 Subject: [PATCH 092/130] add publish switch --- private-web/e2e/reporting-schemas.spec.ts | 6 +- private-web/e2e/restore-replicas.spec.ts | 81 ++++++++++++ private-web/e2e/seed.ts | 12 +- .../src/components/RestoreReplicasSection.tsx | 122 +++++++++++++++++- 4 files changed, 210 insertions(+), 11 deletions(-) diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts index 44ff6882d..f9bd4ef9b 100644 --- a/private-web/e2e/reporting-schemas.spec.ts +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -14,8 +14,9 @@ import { import { expect, test } from "./test-fixtures"; /// A consumer that advertises a schema-building intent, declared against the -/// group. That declaration is what brings the group's pairs into being: canopy -/// owes a schema only where something is there to build one. +/// group and marked by an operator as publishing its schema. That mark is what +/// brings the group's pairs into being: canopy owes a schema only where an +/// operator has put something there to build one. /// /// spec: RPT#pairs async function declareBuilder(sql: Sql, groupId: string): Promise { @@ -34,6 +35,7 @@ async function declareBuilder(sql: Sql, groupId: string): Promise { groupId, intent: "reporting-schema", name: "kamaka-schemas", + publishesSchemas: true, }); return consumer.id; } diff --git a/private-web/e2e/restore-replicas.spec.ts b/private-web/e2e/restore-replicas.spec.ts index d79b4f558..58ff07007 100644 --- a/private-web/e2e/restore-replicas.spec.ts +++ b/private-web/e2e/restore-replicas.spec.ts @@ -178,6 +178,87 @@ test.describe("restore replicas", () => { expect(rows[0]?.redacts).toBe(true); }); + /** A consumer advertising an intent that builds reporting schemas. */ + async function schemaBuildingConsumer(sql: Sql): Promise { + const consumer = await seedDevice(sql, { role: "backup-restore" }); + await seedRestoreConsumerCapability(sql, { + deviceId: consumer.id, + intents: [ + { + intent: "schema-build", + semantics: ["check", "once", "migrate", "reporting-schema"], + }, + ], + }); + return consumer.id; + } + + /// Publishing a group's schema is the operator's grant, so it is set on the + /// declaration rather than followed from what the consumer advertises. + /// + /// spec: RPT#the-build-contract + test("an operator marks which declaration publishes the group's schema", async ({ + page, + sql, + }) => { + const consumer = await schemaBuildingConsumer(sql); + const groupId = await groupWithBackups(sql, "publish-declare"); + await seedServer(sql, { groupId, name: "publish-srv" }); + + await page.goto(`/fleet/groups/${groupId}/backups`); + await page.getByRole("button", { name: /declare replica/i }).click(); + + const dialog = page.getByRole("dialog"); + const publishes = dialog.getByRole("switch", { + name: /publish this group's reporting schema/i, + }); + await expect(publishes).not.toBeChecked(); + await publishes.check(); + await dialog.getByRole("button", { name: /^declare$/i }).click(); + + await expect(dialog).toHaveCount(0); + const rows = await sql.query<{ publishes_schemas: boolean }>( + `SELECT publishes_schemas FROM restore_replicas WHERE consumer_device_id = $1`, + [consumer], + ); + expect(rows[0]?.publishes_schemas).toBe(true); + + await expect( + page.getByRole("row", { name: /publish-declare/ }).first(), + ).toBeVisible(); + await expect(page.getByText("publishes schema").first()).toBeVisible(); + }); + + /// A build is dispatched per group from data the masking manifest has not + /// altered, so a declaration narrowed to one machine cannot be the group's + /// publisher. + /// + /// spec: RPT#the-build-contract + test("a machine-scoped declaration cannot publish the schema", async ({ + page, + sql, + }) => { + await schemaBuildingConsumer(sql); + const groupId = await groupWithBackups(sql, "publish-scope"); + await seedServer(sql, { groupId, name: "publish-one" }); + + await page.goto(`/fleet/groups/${groupId}/backups`); + await page.getByRole("button", { name: /declare replica/i }).click(); + + const dialog = page.getByRole("dialog"); + const publishes = dialog.getByRole("switch", { + name: /publish this group's reporting schema/i, + }); + await publishes.check(); + await expect(publishes).toBeChecked(); + + await dialog.getByLabel("Machine").click(); + await page.getByRole("option", { name: "publish-one" }).click(); + + await expect(publishes).toBeDisabled(); + await expect(publishes).not.toBeChecked(); + }); + test("a partial redaction shows against the report that carried it", async ({ page, sql, diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index b25dd0508..538a384eb 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -1475,6 +1475,8 @@ export async function seedRestoreReplica( enabled?: boolean; /** Whether the replica is served de-identified. */ redacts?: boolean; + /** Whether the operator has made this the group's schema publisher. */ + publishesSchemas?: boolean; }, ): Promise { const id = randomUUID(); @@ -1483,8 +1485,8 @@ export async function seedRestoreReplica( if (overdue == null) { await sql.query( `INSERT INTO restore_replicas - (id, consumer_device_id, group_id, machine_id, type, intent, name, params, enabled, redacts) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)`, + (id, consumer_device_id, group_id, machine_id, type, intent, name, params, enabled, redacts, publishes_schemas) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`, [ id, opts.consumerDeviceId, @@ -1496,13 +1498,14 @@ export async function seedRestoreReplica( params, opts.enabled ?? true, opts.redacts ?? false, + opts.publishesSchemas ?? false, ], ); } else { await sql.query( `INSERT INTO restore_replicas - (id, consumer_device_id, group_id, machine_id, type, intent, name, overdue_after, params, enabled, redacts) - VALUES ($1, $2, $3, $4, $5, $6, $7, make_interval(secs => $8), $9::jsonb, $10, $11)`, + (id, consumer_device_id, group_id, machine_id, type, intent, name, overdue_after, params, enabled, redacts, publishes_schemas) + VALUES ($1, $2, $3, $4, $5, $6, $7, make_interval(secs => $8), $9::jsonb, $10, $11, $12)`, [ id, opts.consumerDeviceId, @@ -1515,6 +1518,7 @@ export async function seedRestoreReplica( params, opts.enabled ?? true, opts.redacts ?? false, + opts.publishesSchemas ?? false, ], ); } diff --git a/private-web/src/components/RestoreReplicasSection.tsx b/private-web/src/components/RestoreReplicasSection.tsx index 0818875d3..64f371135 100644 --- a/private-web/src/components/RestoreReplicasSection.tsx +++ b/private-web/src/components/RestoreReplicasSection.tsx @@ -131,6 +131,7 @@ export default function RestoreReplicasSection({ overdue_after: r.overdue_after, params: r.params as Record, redacts: r.redacts, + publishes_schemas: r.publishes_schemas, enabled, }); reload(); @@ -217,6 +218,15 @@ export default function RestoreReplicasSection({ )} + {r.publishes_schemas && ( + + + + )} {r.overdue_after ?? "no bound"} @@ -527,6 +537,46 @@ function RedactionField({ ); } +/** The publishing switch, shown only for an intent that builds reporting + * schemas. A build is per group from data the masking manifest has not + * altered, so a machine-scoped or redacting declaration cannot carry it. */ +function PublishesSchemasField({ + value, + onChange, + disabled, + why, +}: { + value: boolean; + onChange: (value: boolean) => void; + disabled: boolean; + why: string; +}) { + return ( + onChange(e.target.checked)} + /> + } + label={ + + + Publish this group's reporting schema + + + {disabled + ? why + : "Lets this consumer register the schema every application in the group is offered."} + + + } + /> + ); +} + /** Convert the typed form fields into the wire params object, omitting any the * operator left unset (the consumer resolves those to their default or null). * Returns an error message string if a numeric field doesn't parse. */ @@ -616,6 +666,8 @@ function useIntentSchema( const advertised = (selectedDescriptor?.params as Record | undefined) ?? {}; const canRedact = selectedDescriptor?.semantics?.includes("redact") ?? false; + const canPublishSchemas = + selectedDescriptor?.semantics?.includes("reporting-schema") ?? false; // Canopy owns the masking parameters for a `redact` intent in both states, // so they get no field: the redaction switch is the whole of the operator's // say in it. @@ -626,7 +678,13 @@ function useIntentSchema( ), ) : advertised; - return { intentOptions, selectedDescriptor, paramSchema, canRedact }; + return { + intentOptions, + selectedDescriptor, + paramSchema, + canRedact, + canPublishSchemas, + }; } /** Consumer, server (or whole-group), type, and intent selects, shared by the @@ -781,11 +839,17 @@ function CreateReplicaDialog({ const [overdue, setOverdue] = useState(""); const [paramValues, setParamValues] = useState>({}); const [redacts, setRedacts] = useState(false); + const [publishesSchemas, setPublishesSchemas] = useState(false); const [pending, setPending] = useState(false); const [error, setError] = useState(null); - const { intentOptions, selectedDescriptor, paramSchema, canRedact } = - useIntentSchema(consumers, consumerId, intent); + const { + intentOptions, + selectedDescriptor, + paramSchema, + canRedact, + canPublishSchemas, + } = useIntentSchema(consumers, consumerId, intent); // Auto-select the sole consumer, if there's only one to choose from. useEffect(() => { @@ -814,6 +878,12 @@ function CreateReplicaDialog({ if (!canRedact) setRedacts(false); }, [canRedact]); + // A build is dispatched per group from unmasked data, so narrowing the + // declaration to a machine or turning redaction on drops the flag with it. + useEffect(() => { + if (!canPublishSchemas || redacts || serverId) setPublishesSchemas(false); + }, [canPublishSchemas, redacts, serverId]); + // Suggest a name from the group, (if picked) server, and intent, until the // operator types their own. The intent is part of it because names are // unique per consumer: without it, declaring a second intent for the same @@ -863,6 +933,7 @@ function CreateReplicaDialog({ overdue_after, params, redacts, + publishes_schemas: publishesSchemas, }); onCreated(); } catch (err) { @@ -920,6 +991,19 @@ function CreateReplicaDialog({ )} + {canPublishSchemas && ( + + )} + >(() => { const initialDescriptor = consumers .find((c) => c.device_id === replica.consumer_device_id) @@ -987,8 +1074,13 @@ function EditReplicaDialog({ const [pending, setPending] = useState(false); const [error, setError] = useState(null); - const { intentOptions, selectedDescriptor, paramSchema, canRedact } = - useIntentSchema(consumers, consumerId, intent); + const { + intentOptions, + selectedDescriptor, + paramSchema, + canRedact, + canPublishSchemas, + } = useIntentSchema(consumers, consumerId, intent); // Retargeting to an intent that can't redact drops the flag with it, so the // declaration doesn't carry an intent the new consumer can't honour. @@ -996,6 +1088,12 @@ function EditReplicaDialog({ if (!canRedact) setRedacts(false); }, [canRedact]); + // A build is dispatched per group from unmasked data, so narrowing the + // declaration to a machine or turning redaction on drops the flag with it. + useEffect(() => { + if (!canPublishSchemas || redacts || serverId) setPublishesSchemas(false); + }, [canPublishSchemas, redacts, serverId]); + // Re-derive parameter values whenever the consumer or intent changes: keep // values for parameter names the new schema still has, drop the rest. useEffect(() => { @@ -1038,6 +1136,7 @@ function EditReplicaDialog({ overdue_after, params, redacts, + publishes_schemas: publishesSchemas, enabled, }); onUpdated(); @@ -1103,6 +1202,19 @@ function EditReplicaDialog({ )} + {canPublishSchemas && ( + + )} + Date: Wed, 9 Sep 2026 14:15:12 +1200 Subject: [PATCH 093/130] respec schema publishing --- .workhorse/specs/public-server/reporting-schemas.md | 10 ++++++---- .workhorse/specs/public-server/restore-replicas.md | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 563ad598a..055ae960b 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -21,7 +21,7 @@ A **schema builder** produces a reporting schema from a database Canopy has rest It is a restore consumer (see [RST](restore-replicas.md)): a build operates on a replica, so the builder is dispatched, credentialled, and reports over the replica pathways and authorisations, and it advertises an intent carrying `reporting-schema`. How the builder produces a schema is the builder's own. -An **operator** declares which groups have a builder, reads which schema each application runs, and asks for the builds the derivation does not produce. +An **operator** declares which groups have a builder, marks the declaration that publishes a group's schema, reads which schema each application runs, and asks for the builds the derivation does not produce. The **device of a machine a Tamanu application runs on** fetches the schema Canopy offers that application and applies it (see [DID](machine-identity.md)). @@ -30,8 +30,8 @@ Canopy owns which pairs exist, the replica a build is given, the artifact that r ## Pairs A reporting schema is unique per pair of group and Tamanu version, and Canopy holds zero or one per pair. -The pairs are, for each group covered by an enabled declaration of a `reporting-schema` intent, each version a Tamanu application of the group reports running and the version its open plan moves it to (see [UPG](../private-server/upgrade-plans.md)). -That declaration is what covers a group: it names the group, is enabled or disabled, and is audited (see [RST](restore-replicas.md)). +The pairs are, for each group covered by an enabled declaration marked as publishing its schema, each version a Tamanu application of the group reports running and the version its open plan moves it to (see [UPG](../private-server/upgrade-plans.md)). +That declaration is what covers a group: it names the group, is enabled or disabled, carries the operator's mark, and is audited (see [RST](restore-replicas.md)). Only a published version is in a pair, since a version's migrations reach a builder as its published artifacts (see [ART](../platform/artifacts.md)) and an unpublished one has none. A pair with no schema is built, and a pair with one is settled. @@ -51,7 +51,9 @@ The builder obtains read credentials for the restore per run as any consumer doe In the run it reports, the builder registers the **reporting schema** as an artifact of the exact version being built for, scoped to the group, of type `reporting-schema` on platform `any`, carrying a digest and the bytes themselves, which Canopy holds and serves (see [ART](../platform/artifacts.md)). It may register further artifacts beside the schema for the same version and group, under types of its choosing, which Canopy offers as it offers any artifact. -The builder is authorised to register artifacts for a group its enabled `reporting-schema` declaration covers and for no other, and is the one device other than a releaser that registers artifacts (see [ART](../platform/artifacts.md)). +The builder is authorised to register artifacts for a group whose enabled declaration an operator has marked as publishing its reporting schema, and for no other, and is the one device other than a releaser that registers artifacts (see [ART](../platform/artifacts.md)). +The mark is the operator's alone, and is the whole of the authorisation: a consumer registers the set of semantics it advertises itself, so they shape what Canopy dispatches to it and grant it nothing, and what is published for a group is offered to every machine in it and applied. +Only a group-wide, non-redacting declaration of an intent carrying `reporting-schema` can carry the mark, which is the same declaration a build is dispatched for, so Canopy asks for no build it would refuse the result of. A schema is published for the exact version and never for a range, since it follows from the migrations that version applies, and one built against a patch is not the schema another patch of the same minor describes. diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index 0cbc2e2cb..9052e6af1 100644 --- a/.workhorse/specs/public-server/restore-replicas.md +++ b/.workhorse/specs/public-server/restore-replicas.md @@ -100,6 +100,7 @@ The recognised semantics are: `once` for such an intent is keyed to the snapshot and the target version together (see [Pre-upgrade migration testing](#pre-upgrade-migration-testing)). - **reporting-schema** — the intent builds a Tamanu reporting schema from the replica it restores and registers it as an artifact (see [RPT](reporting-schemas.md)). It carries `migrate` alongside, and its entries name the version of the pair of group and Tamanu version being built for, on a central server of the group, rather than the server's candidate. + Canopy dispatches a build only for a declaration an operator has marked as publishing its group's schema, and accepts a published schema only from one (see [RPT](reporting-schemas.md)). `once` for such an intent is keyed to the group and the version rather than the snapshot, so a newer snapshot does not rebuild a schema the pair already has, and a failed build settles the pair. A settled pair is reinstated when the version's artifacts change or an operator asks for the build (see [RPT](reporting-schemas.md)). - **redact** — the intent can de-identify the restored data before serving it. @@ -129,6 +130,7 @@ Each declaration carries: - a human-readable **name**, distinct from every other declaration assigned to the same consumer; - **parameter values** for the intent's schema, defaulted where the schema provides one; - whether the replica **redacts**, offered only for an intent carrying `redact` (see [Redaction](#redaction)); +- whether the declaration **publishes its group's reporting schema**, offered only for a group-wide, non-redacting declaration of an intent carrying `reporting-schema` (see [RPT](reporting-schemas.md)); - an **overdue bound**: the maximum time a replica may go without meeting its intent's health expectation before Canopy considers it overdue, interpreted per the intent's semantics (see [Alerting](#alerting)); - whether the declaration is **enabled**. From cc8a82d38a6e979b1f253be227c364565bbc2988 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:55:01 +1200 Subject: [PATCH 094/130] refuse rather than fault --- crates/database/src/artifacts.rs | 13 +++- crates/private-server/src/fns/versions.rs | 10 +++ crates/private-server/tests/it/artifacts.rs | 67 +++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 88cb713de..d21a7fc85 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -365,7 +365,18 @@ impl Artifact { .returning(Self::as_select()) .get_result(db) .await - .map_err(AppError::from) + .map_err(|error| match error { + // A registration naming a group or version Canopy does not + // hold is the caller's own input, so it is refused rather than + // left to surface as a database fault. + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::ForeignKeyViolation, + _, + ) => AppError::BadRequest( + "the registration names a group or version Canopy does not hold".into(), + ), + error => AppError::from(error), + }) } pub async fn update( diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 387107566..cd3521111 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -674,6 +674,16 @@ pub async fn create_artifact( ) -> Result> { let mut conn = state.db.get().await?; + // The media type is served back as a header, so a value no header can carry + // leaves an artifact nothing can download. + if let Some(media_type) = &args.content_type + && axum::http::HeaderValue::from_str(media_type).is_err() + { + return Err(AppError::BadRequest( + "content_type is not a usable media type".into(), + )); + } + // An artifact is either for a group, in which case Canopy holds its bytes, // or for every group, in which case Canopy records where it rests. // spec: ART#where-an-artifact-rests diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 9c8174c18..b7212e4e8 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -264,6 +264,73 @@ async fn an_unscoped_artifact_carries_no_media_type() { .await } +/// The media type is served back as a header when the bytes are read, so one no +/// header can carry would leave an artifact that answers a fault on every +/// download and can only be mended by registering it again. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_media_type_no_header_can_carry_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-4444-0000-0000-111111111111"; + let group = "cccccccc-4444-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "reporting-schema", + "platform": "any", + "group_id": group, + "content_base64": "a2FtYWthIHNjaGVtYQ==", + "content_type": "application/sql\r\nx-injected: yes", + "digest": database::artifacts::digest_of(b"kamaka schema"), + })) + .await; + assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + }) + .await +} + +/// The group an artifact names is a foreign key, so an id that names no group +/// answers the operator's own input with a database fault instead of a refusal. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_naming_no_group_that_exists_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-5555-0000-0000-111111111111"; + let gone = "cccccccc-5555-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "reporting-schema", + "platform": "any", + "group_id": gone, + "content_base64": "a2FtYWthIHNjaGVtYQ==", + "digest": database::artifacts::digest_of(b"kamaka schema"), + })) + .await; + assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + }) + .await +} + /// A digest against a location is what whoever fetches the artifact checks the /// bytes it got against. Dropped, a caller that supplied one gets no error and /// no digest, and the fetch is unchecked. From b6d94f190cae809624f5579b2fdbeab4446100ed Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:55:01 +1200 Subject: [PATCH 095/130] never render proxied bytes --- crates/public-server/src/versions.rs | 20 ++++++++++++++++---- crates/public-server/tests/it/versions.rs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index b0a23b014..900a878bd 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -695,9 +695,9 @@ async fn download_artifact( .content_type .unwrap_or_else(|| "application/octet-stream".to_owned()); - // Held bytes are served from the same origin as this server's own HTML - // pages, and the media type is whatever the registration named, so - // nothing here may be rendered by a browser. + // Bytes are served from the same origin as this server's own HTML pages, + // and the media type is whatever was registered or fetched, so nothing + // this endpoint answers may be rendered by a browser. return Ok(( StatusCode::OK, [ @@ -739,5 +739,17 @@ async fn download_artifact( let body = Body::from_stream(response.bytes_stream()); - Ok((status, [(header::CONTENT_TYPE, content_type)], body).into_response()) + Ok(( + status, + [ + (header::CONTENT_TYPE, content_type), + (header::CONTENT_DISPOSITION, "attachment".to_owned()), + ( + header::HeaderName::from_static("x-content-type-options"), + "nosniff".to_owned(), + ), + ], + body, + ) + .into_response()) } diff --git a/crates/public-server/tests/it/versions.rs b/crates/public-server/tests/it/versions.rs index fcb7b9004..a1fbae682 100644 --- a/crates/public-server/tests/it/versions.rs +++ b/crates/public-server/tests/it/versions.rs @@ -575,6 +575,22 @@ async fn artifact_download_proxy_with_mock_server() { .await; response.assert_status_ok(); + // Proxied bytes come off the same origin as this server's own HTML + // pages, carrying an upstream media type, so they are downloaded rather + // than displayed. + // spec: ART#where-an-artifact-rests + assert_eq!( + response.header("x-content-type-options").to_str().unwrap(), + "nosniff" + ); + assert!( + response + .header("content-disposition") + .to_str() + .unwrap() + .starts_with("attachment") + ); + // Verify we got the content let text = response.text(); assert_eq!(text.as_bytes(), test_content); From a347b521a102bec5e62f11bd0447c0c1a16099d9 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:57:36 +1200 Subject: [PATCH 096/130] spell out intent semantics --- crates/commons-types/src/backup.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/commons-types/src/backup.rs b/crates/commons-types/src/backup.rs index e14ca925e..1ccf2a6ce 100644 --- a/crates/commons-types/src/backup.rs +++ b/crates/commons-types/src/backup.rs @@ -503,9 +503,16 @@ pub struct IntentDescriptor { /// Human-readable description of the intent, if provided. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - /// Behaviours this intent opts into; see [`semantics`] for what each one - /// grants. Unrecognised values are stored but have no effect, so a consumer - /// may advertise ahead of Canopy support. + /// Behaviours this intent opts into. Recognised values are `check` (a + /// health report is expected for each replica), `once` (a given snapshot + /// is only ever dispatched to a replica once, rather than repeatedly until + /// overdue), `url` (a replica's health report includes a link to it), + /// `migrate` (Canopy names a target version and the replica applies that + /// version's migrations), `redact` (the replica de-identifies the restored + /// data before serving it), and `reporting-schema` (the replica builds a + /// Tamanu reporting schema and registers it for the group). Unrecognised + /// values are stored but have no effect, so a consumer may advertise ahead + /// of Canopy support. #[serde(default)] pub semantics: Vec, /// Configurable parameters this intent accepts per replica, keyed by From 0a81a86d87093bdfa51948ec4fdb12c8df1016a4 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:02:28 +1200 Subject: [PATCH 097/130] sri digests and raw uploads --- .workhorse/specs/platform/artifacts.md | 6 +- Cargo.lock | 1 + crates/canopy-api/src/generated.rs | 30 +- crates/database/src/artifacts.rs | 41 ++- crates/database/src/schema.rs | 2 +- crates/database/tests/it/artifact_scopes.rs | 32 ++- crates/private-server/src/fns/versions.rs | 252 +++++++++-------- crates/private-server/tests/it/artifacts.rs | 261 ++++++------------ crates/public-server/Cargo.toml | 1 + crates/public-server/openapi.json | 16 +- crates/public-server/src/artifacts.rs | 33 +-- crates/public-server/src/versions.rs | 3 +- .../public-server/tests/it/artifact_scopes.rs | 40 ++- .../up.sql | 2 +- private-web/e2e/artifact-scopes.spec.ts | 9 +- private-web/e2e/seed.ts | 2 +- private-web/openapi.json | 150 +++++++--- private-web/src/api-types.ts | 109 ++++++-- private-web/src/api.ts | 117 ++++++-- private-web/src/routes/VersionDetail.tsx | 62 ++--- 20 files changed, 698 insertions(+), 471 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index e2c9ed47d..005785e39 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -39,8 +39,8 @@ It is offered one artifact per type and platform, and where the bytes rest is no ## What a version offers A read names an exact version or a range. -An exact version is answered for itself, since a caller naming one is saying what it runs rather than asking where to go, and what is published for the version a caller is on does not depend on whether that version is one to move to. -A range is answered for the latest published version it covers that no known issue covers, since a range is a question about where to go and a version Canopy has vouched against is not an answer to it. +An exact version is answered for itself, since a caller naming one is saying what it runs rather than asking where to go. +A range is answered for the latest published version it covers that no known issue covers. Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known. Where several match, the most specific is offered. @@ -77,6 +77,8 @@ Canopy records which device registered an artifact and, where the registration n ## Digests An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always. +A digest is carried as Subresource Integrity writes it, `sha256-` followed by the base64 of the hash, so what a caller is offered is a value it can hand to a checker unchanged. +A registration naming anything else is refused, since a digest nothing can check the bytes against says they were verified when they cannot be. Canopy verifies a group-scoped artifact's bytes against its digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again. It verifies them again as it serves them and refuses them on a mismatch, so an artifact corrupted after it was taken in fails the read rather than reaching a server as the artifact it is not. An unscoped artifact is read from its location by the caller rather than by Canopy, so its digest is what that caller checks what it fetched against, and an artifact registered without one is fetched unchecked. diff --git a/Cargo.lock b/Cargo.lock index 334012f33..9c94276ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5032,6 +5032,7 @@ dependencies = [ "diesel", "diesel-async", "futures", + "hex", "http 1.5.0", "jiff", "k8s-openapi", diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index acb2f45a5..c4aa56ccb 100644 --- a/crates/canopy-api/src/generated.rs +++ b/crates/canopy-api/src/generated.rs @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.0"; /// BLAKE3 digest of that document, so a document that changed without the /// version moving with it can be told from one that did not. -pub const OPENAPI_BLAKE3: &str = "302fb747c1d1c53a54f849123e5d0eeb6d7a2d3af2f8169b13f3789551db9142"; +pub const OPENAPI_BLAKE3: &str = "626c2115b5bedb1e43372e734c98bcbf018e1c8f5ccb81683f2d1b13c4ddcee1"; /// Error types. pub mod error { @@ -232,13 +232,14 @@ impl ::std::fmt::Display for ApplicationType { self.0.fmt(f) } } -///An artifact as it is offered to a caller. +/**A downloadable artifact belonging to a release version: an installer, +package, or other file published for a given type and platform.*/ /// ///
JSON schema /// /// ```json ///{ -/// "description": "An artifact as it is offered to a caller.", +/// "description": "A downloadable artifact belonging to a release version: an installer,\npackage, or other file published for a given type and platform.", /// "type": "object", /// "required": [ /// "artifact_type", @@ -260,24 +261,16 @@ impl ::std::fmt::Display for ApplicationType { /// "format": "uuid" /// }, /// "digest": { -/// "description": "Algorithm-prefixed digest of the artifact's bytes, e.g.\n`sha256:2cf24dba…`, where one was recorded.", +/// "description": "Subresource Integrity digest of the artifact's bytes, e.g.\n`sha256-LCTbqp…`, where one was recorded.", /// "type": [ /// "string", /// "null" /// ] /// }, /// "download_url": { -/// "description": "URL the artifact can be downloaded from. For an artifact whose bytes\nCanopy holds, this is Canopy's own download endpoint for it.", +/// "description": "URL the artifact can be downloaded from.", /// "type": "string" /// }, -/// "group_id": { -/// "description": "The group this artifact is for. `null` for an artifact that is for\nevery group.", -/// "type": [ -/// "string", -/// "null" -/// ], -/// "format": "uuid" -/// }, /// "id": { /// "description": "Unique identifier of the artifact.", /// "type": "string", @@ -316,17 +309,12 @@ pub struct Artifact { releaser device rather than created by an operator.*/ #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] pub device_id: ::std::option::Option<::uuid::Uuid>, - /**Algorithm-prefixed digest of the artifact's bytes, e.g. -`sha256:2cf24dba…`, where one was recorded.*/ + /**Subresource Integrity digest of the artifact's bytes, e.g. +`sha256-LCTbqp…`, where one was recorded.*/ #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] pub digest: ::std::option::Option<::std::string::String>, - /**URL the artifact can be downloaded from. For an artifact whose bytes -Canopy holds, this is Canopy's own download endpoint for it.*/ + ///URL the artifact can be downloaded from. pub download_url: ::std::string::String, - /**The group this artifact is for. `null` for an artifact that is for -every group.*/ - #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] - pub group_id: ::std::option::Option<::uuid::Uuid>, ///Unique identifier of the artifact. pub id: ::uuid::Uuid, ///The platform the artifact targets (e.g. an OS or architecture name). diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index d21a7fc85..526f383e8 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -1,3 +1,4 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use commons_errors::{AppError, Result}; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; @@ -74,9 +75,9 @@ pub struct Artifact { pub group_id: Option, /// Media type of the bytes Canopy holds, where the registration named one. pub content_type: Option, - /// Algorithm-prefixed digest of the artifact's bytes, e.g. - /// `sha256:2cf24dba…`. Always set for a group-scoped artifact. - pub digest: Option, + /// SHA-256 of the artifact's bytes. Always set for a group-scoped + /// artifact. + pub digest: Option>, /// The run that produced this artifact, where the registration named one. pub run_id: Option, } @@ -95,7 +96,7 @@ pub struct NewArtifact { pub group_id: Option, pub content: Option>, pub content_type: Option, - pub digest: Option, + pub digest: Option>, pub run_id: Option, } @@ -103,12 +104,36 @@ pub struct NewArtifact { pub struct ArtifactContent { pub bytes: Vec, pub content_type: Option, - pub digest: String, + pub digest: Vec, } /// The digest Canopy records and verifies bytes against. -pub fn digest_of(bytes: &[u8]) -> String { - format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +pub fn digest_of(bytes: &[u8]) -> Vec { + Sha256::digest(bytes).to_vec() +} + +/// A digest as Subresource Integrity writes it, which is the form every +/// interface carries it in. +// spec: ART#digests +pub fn sri(digest: &[u8]) -> String { + format!("sha256-{}", BASE64.encode(digest)) +} + +/// The digest an SRI string names, refusing anything that cannot be one. +/// +/// A value nothing can check the bytes against is worse than none: it says the +/// bytes were verified when they cannot be. +// spec: ART#digests +pub fn parse_sri(value: &str) -> Result> { + let refuse = || AppError::BadRequest(format!("{value:?} is not a sha256 SRI digest")); + + let encoded = value.trim().strip_prefix("sha256-").ok_or_else(refuse)?; + let digest = BASE64.decode(encoded).map_err(|_| refuse())?; + if digest.len() != 32 { + return Err(refuse()); + } + + Ok(digest) } /// A blank URL is no location at all. The constraint only tests for NULL, so an @@ -318,7 +343,7 @@ impl Artifact { ) -> Result> { use crate::schema::artifacts::dsl::*; - let row: Option<(Option>, Option, Option)> = artifacts + let row: Option<(Option>, Option, Option>)> = artifacts .filter(id.eq(artifact_id)) .select((content, content_type, digest)) .first(db) diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index 1a69948c3..f66b34041 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -101,7 +101,7 @@ diesel::table! { group_id -> Nullable, content -> Nullable, content_type -> Nullable, - digest -> Nullable, + digest -> Nullable, run_id -> Nullable, } } diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 26190a56a..dfcc8b4d9 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -4,7 +4,7 @@ use commons_tests::db::TestDb; use database::{ - artifacts::{Artifact, NewArtifact, Scope, digest_of}, + artifacts::{Artifact, NewArtifact, Scope, digest_of, parse_sri, sri}, diesel_async::AsyncPgConnection, }; use diesel_async::{RunQueryDsl, SimpleAsyncConnection}; @@ -267,15 +267,33 @@ async fn the_operator_view_marks_every_group_s_own_as_offered() { .await; } -/// The digest is a prefixed sha256 of the bytes. Pinned against a known answer -/// rather than against `digest_of` of the same input, which would hold just as -/// well if the function returned a constant. +/// The digest is a sha256 of the bytes, carried as Subresource Integrity writes +/// it. Pinned against a known answer rather than against `digest_of` of the +/// same input, which would hold just as well if the function returned a +/// constant. +// spec: ART#digests #[test] -fn the_digest_is_a_prefixed_sha256() { +fn the_digest_is_a_sha256_in_sri_form() { assert_eq!( - digest_of(b""), - "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + sri(&digest_of(b"")), + "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" ); + assert_eq!( + parse_sri("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap(), + digest_of(b"") + ); + + // A value nothing can check the bytes against is refused rather than + // recorded as though they had been. + for claimed in [ + "", + "sha256:abcd", + "notadigest", + "sha256-abcd", + "sha512-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", + ] { + assert!(parse_sri(claimed).is_err(), "{claimed:?} is not a digest"); + } } /// A range artifact registered twice replaces itself. Before the identity diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index cd3521111..7bef2696f 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -2,21 +2,21 @@ use std::collections::BTreeMap; use std::str::FromStr; use axum::Json; -use axum::extract::{DefaultBodyLimit, State}; -use base64::{Engine as _, prelude::BASE64_STANDARD}; +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, Query, State}; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ - artifacts::{Artifact, NewArtifact, Scope, digest_of}, + artifacts::{Artifact, NewArtifact, Scope, digest_of, parse_sri, sri}, server_groups::ServerGroup, version_known_issues::VersionKnownIssue, versions::Version, }; use jiff::Timestamp; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::state::AppState; @@ -26,10 +26,9 @@ use crate::state::AppState; /// Postgres alongside everything else. const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; -/// Body budget for `create_artifact`. Base64 inflates the bytes by a third, and -/// sizing above that keeps an over-limit upload the handler's structured -/// refusal rather than axum's plain-text 413. -const MAX_CREATE_ARTIFACT_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES / 3 * 4 + 64 * 1024; +/// Body budget for `upload_artifact`. Sizing above the cap keeps an over-limit +/// upload the handler's structured refusal rather than axum's plain-text 413. +const MAX_UPLOAD_ARTIFACT_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; /// A single released (or draft) software version. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -200,7 +199,8 @@ pub struct ArtifactData { pub group_id: Option, /// Name of that group, for display. pub group_name: Option, - /// Algorithm-prefixed digest recorded for the artifact, where there is one. + /// Subresource Integrity digest recorded for the artifact, where there is + /// one. pub digest: Option, /// `true` when Canopy holds this artifact's bytes rather than a location. pub canopy_holds_bytes: bool, @@ -229,10 +229,11 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(update_version_status)) .routes(routes!(update_version_changelog)) .routes(routes!(update_artifact)) + .routes(routes!(create_artifact)) .merge( OpenApiRouter::new() - .routes(routes!(create_artifact)) - .layer(DefaultBodyLimit::max(MAX_CREATE_ARTIFACT_BODY_BYTES)), + .routes(routes!(upload_artifact)) + .layer(DefaultBodyLimit::max(MAX_UPLOAD_ARTIFACT_BODY_BYTES)), ) .routes(routes!(delete_artifact)) .routes(routes!(list_known_issues)) @@ -488,7 +489,7 @@ async fn artifacts_of( download_url: a.download_url, group_name: a.group_id.and_then(|g| group_names.get(&g).cloned()), group_id: a.group_id, - digest: a.digest, + digest: a.digest.as_deref().map(sri), is_exact, version_range_pattern: a.version_range_pattern, has_range_override, @@ -629,7 +630,11 @@ pub async fn update_artifact( Ok(Json(())) } -/// A new artifact to register against a version. +/// A new artifact to register against a version, at a location Canopy records. +/// +/// An artifact whose bytes Canopy holds is registered through +/// `upload_artifact` instead, since the bytes are the body there. +// spec: ART#where-an-artifact-rests #[derive(Deserialize, ToSchema)] pub struct CreateArtifactArgs { /// Id of the version to attach the new artifact to. @@ -638,18 +643,11 @@ pub struct CreateArtifactArgs { pub artifact_type: String, /// Target platform. pub platform: String, - /// Download URL, for an artifact Canopy records a location for. - pub download_url: Option, - /// The group this artifact is for. Naming one makes Canopy hold the bytes. - pub group_id: Option, - /// The artifact's bytes, base64-encoded. Required when a group is named. - pub content_base64: Option, - /// Media type of those bytes. - pub content_type: Option, - /// Algorithm-prefixed digest of those bytes, e.g. `sha256:2cf24dba…`. - /// Required when a group is named: Canopy checks the bytes against it as - /// they arrive and refuses the registration on a mismatch, so a corrupted - /// upload is refused while whoever sent it is still there to send it again. + /// URL the artifact is downloaded from. + pub download_url: String, + /// Subresource Integrity digest of the bytes at that URL, e.g. + /// `sha256-LCTbqp…`, where one is recorded. Whoever fetches the artifact + /// checks what it got against this. pub digest: Option, } @@ -665,6 +663,7 @@ pub struct CreateArtifactArgs { request_body = CreateArtifactArgs, responses( (status = 200, body = ArtifactData), + (status = 400, body = ProblemDetailsSchema), ), )] pub async fn create_artifact( @@ -674,81 +673,17 @@ pub async fn create_artifact( ) -> Result> { let mut conn = state.db.get().await?; - // The media type is served back as a header, so a value no header can carry - // leaves an artifact nothing can download. - if let Some(media_type) = &args.content_type - && axum::http::HeaderValue::from_str(media_type).is_err() - { - return Err(AppError::BadRequest( - "content_type is not a usable media type".into(), - )); - } - - // An artifact is either for a group, in which case Canopy holds its bytes, - // or for every group, in which case Canopy records where it rests. - // spec: ART#where-an-artifact-rests - let (content, digest) = match (&args.group_id, &args.content_base64) { - (Some(_), Some(encoded)) => { - let bytes = BASE64_STANDARD - .decode(encoded) - .map_err(|_| AppError::BadRequest("content_base64 is not valid base64".into()))?; - if bytes.len() > MAX_HELD_ARTIFACT_BYTES { - return Err(AppError::BadRequest(format!( - "artifact is larger than the {} MiB limit", - MAX_HELD_ARTIFACT_BYTES / (1024 * 1024) - ))); - } - let Some(claimed) = args - .digest - .as_deref() - .map(str::trim) - .filter(|d| !d.is_empty()) - else { - return Err(AppError::BadRequest( - "a group-scoped artifact must carry the digest of its bytes".into(), - )); - }; - // spec: ART#digests - let digest = digest_of(&bytes); - if claimed != digest { - return Err(AppError::BadRequest(format!( - "the bytes are {digest}, not the {claimed} the registration names" - ))); - } - (Some(bytes), Some(digest)) - } - (Some(_), None) => { - return Err(AppError::BadRequest( - "a group-scoped artifact must carry its bytes".into(), - )); - } - (None, Some(_)) => { - return Err(AppError::BadRequest( - "only a group-scoped artifact carries bytes".into(), - )); - } - (None, None) => { - // The media type describes bytes Canopy holds, and it holds none - // for an unscoped artifact. - // spec: ART#where-an-artifact-rests - if args.content_type.is_some() { - return Err(AppError::BadRequest( - "only a group-scoped artifact carries a media type".into(), - )); - } - // A digest against a location is what whoever fetches the artifact - // checks the bytes it got against, so it is recorded rather than - // dropped. - // spec: ART#digests - let claimed = args - .digest - .as_deref() - .map(str::trim) - .filter(|d| !d.is_empty()) - .map(str::to_owned); - (None, claimed) - } - }; + // A digest against a location is what whoever fetches the artifact checks + // the bytes it got against, so one that cannot be checked against is + // refused rather than published. + // spec: ART#digests + let digest = args + .digest + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(parse_sri) + .transpose()?; // Where the artifact rests, and the refusal when it names neither place or // both, is `Artifact::register`'s to settle. @@ -758,25 +693,124 @@ pub async fn create_artifact( version_id: Some(args.version_id), artifact_type: args.artifact_type, platform: args.platform, - download_url: args.download_url, + download_url: Some(args.download_url), device_id: None, version_range_pattern: None, - group_id: args.group_id, - content, - content_type: args.content_type, + group_id: None, + content: None, + content_type: None, digest, run_id: None, }, ) .await?; - // Read back through the listing rather than describing the row a second - // time here: whether it overrides a range and whether it is the one served - // follow from the version's other artifacts, not from this registration. - artifacts_of(&mut conn, args.version_id) + registered(&mut conn, args.version_id, artifact.id).await +} + +/// What an upload names beside its bytes. +// spec: ART#where-an-artifact-rests +#[derive(Deserialize, IntoParams)] +pub struct UploadArtifactQuery { + /// Id of the version to attach the new artifact to. + pub version_id: Uuid, + /// Artifact type. + pub artifact_type: String, + /// Target platform. + pub platform: String, + /// The group this artifact is for. + pub group_id: Uuid, + /// Subresource Integrity digest of the body, e.g. `sha256-LCTbqp…`. + /// Canopy checks the bytes against it as they arrive and refuses the + /// registration on a mismatch, so a corrupted upload is refused while + /// whoever sent it is still there to send it again. + pub digest: String, +} + +/// Register an artifact whose bytes Canopy holds, for one group. +/// +/// The body is the artifact itself and its `Content-Type` is what the bytes +/// are served back as. Returns the created artifact. +#[utoipa::path( + post, + path = "/upload_artifact", + tag = "versions", + security(("tailscale-admin" = [])), + params(UploadArtifactQuery), + request_body(content = Vec, content_type = "application/octet-stream", description = "The artifact's bytes."), + responses( + (status = 200, body = ArtifactData), + (status = 400, body = ProblemDetailsSchema), + ), +)] +pub async fn upload_artifact( + State(state): State, + _admin: TailscaleAdmin, + Query(named): Query, + headers: axum::http::HeaderMap, + body: Bytes, +) -> Result> { + let mut conn = state.db.get().await?; + + if body.len() > MAX_HELD_ARTIFACT_BYTES { + return Err(AppError::BadRequest(format!( + "artifact is larger than the {} MiB limit", + MAX_HELD_ARTIFACT_BYTES / (1024 * 1024) + ))); + } + + let claimed = parse_sri(&named.digest)?; + let digest = digest_of(&body); + if claimed != digest { + return Err(AppError::BadRequest(format!( + "the bytes are {}, not the {} the registration names", + sri(&digest), + sri(&claimed) + ))); + } + + // The media type is served back as a header, and the browser sends none + // for a file it cannot type, so an absent one is the artifact's own + // default rather than a refusal. + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .filter(|media_type| media_type != "application/octet-stream"); + + let artifact = Artifact::register( + &mut conn, + NewArtifact { + version_id: Some(named.version_id), + artifact_type: named.artifact_type, + platform: named.platform, + download_url: None, + device_id: None, + version_range_pattern: None, + group_id: Some(named.group_id), + content: Some(body.to_vec()), + content_type, + digest: Some(digest), + run_id: None, + }, + ) + .await?; + + registered(&mut conn, named.version_id, artifact.id).await +} + +/// Read a just-registered artifact back through the listing rather than +/// describing the row a second time: whether it overrides a range and whether +/// it is the one served follow from the version's other artifacts. +async fn registered( + conn: &mut database::diesel_async::AsyncPgConnection, + version_id: Uuid, + artifact_id: Uuid, +) -> Result> { + artifacts_of(conn, version_id) .await? .into_iter() - .find(|a| a.id == artifact.id) + .find(|a| a.id == artifact_id) .map(Json) .ok_or_else(|| AppError::custom("the artifact just registered is not listed")) } diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index b7212e4e8..5aaa95dc0 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -1,7 +1,13 @@ use commons_tests::diesel_async::SimpleAsyncConnection; +use database::artifacts::{digest_of, sri}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// The digest an upload names for the bytes it carries. +fn sri_of(bytes: &[u8]) -> String { + sri(&digest_of(bytes)) +} + #[derive(Debug, Deserialize, Serialize)] pub struct ArtifactData { pub id: Uuid, @@ -85,56 +91,47 @@ async fn a_registration_that_rests_nowhere_is_refused() { .await .unwrap(); - let refusals = [ - // Neither a location nor a group. - serde_json::json!({ - "version_id": version, "artifact_type": "installer", "platform": "any", - }), - // A group and a location together: it rests in one place or the other. - serde_json::json!({ - "version_id": version, "artifact_type": "installer", "platform": "any", - "group_id": group, "content_base64": "aGVsbG8=", - "digest": database::artifacts::digest_of(b"hello"), - "download_url": "https://example.com/x.exe", - }), - // A group with no bytes to hold. - serde_json::json!({ - "version_id": version, "artifact_type": "installer", "platform": "any", - "group_id": group, - }), - // Bytes with no group to hold them for. - serde_json::json!({ - "version_id": version, "artifact_type": "installer", "platform": "any", - "content_base64": "aGVsbG8=", "download_url": "https://example.com/x.exe", - }), - // Bytes that are not base64. - serde_json::json!({ - "version_id": version, "artifact_type": "installer", "platform": "any", - "group_id": group, "content_base64": "not base64 at all!!", - "digest": database::artifacts::digest_of(b"hello"), - }), - // Bytes that are not the digest the registration names. - serde_json::json!({ - "version_id": version, "artifact_type": "installer", "platform": "any", - "group_id": group, "content_base64": "aGVsbG8=", - "digest": database::artifacts::digest_of(b"something else"), - }), - // Bytes with no digest to check them against. - serde_json::json!({ + // A location that is no location. + let refused = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ "version_id": version, "artifact_type": "installer", "platform": "any", - "group_id": group, "content_base64": "aGVsbG8=", - }), - ]; - - for args in refusals { - let response = private - .post("/api/versions/create_artifact") - .json(&args) + "download_url": " ", + })) + .await; + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + + // Bytes that are not the digest the upload names. + let mismatched = private + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "installer") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"something else")) + .content_type("application/sql") + .bytes("hello".into()) + .await; + assert_eq!( + mismatched.status_code(), + axum::http::StatusCode::BAD_REQUEST + ); + + // A digest nothing can check the bytes against. + for claimed in ["", " ", "sha256:abcd", "notadigest", "sha256-abcd"] { + let refused = private + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "installer") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", claimed) + .bytes("hello".into()) .await; assert_eq!( - response.status_code(), + refused.status_code(), axum::http::StatusCode::BAD_REQUEST, - "refused as a client mistake: {args}" + "refused as a client mistake: {claimed:?}" ); } }) @@ -205,18 +202,15 @@ async fn an_operator_registers_a_group_scoped_artifact() { .await .unwrap(); - // "kamaka schema" — the digest asserted below is of exactly these bytes. let response = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "reporting-schema", - "platform": "any", - "group_id": group, - "content_base64": "a2FtYWthIHNjaGVtYQ==", - "content_type": "application/sql", - "digest": database::artifacts::digest_of(b"kamaka schema"), - })) + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"kamaka schema")) + .content_type("application/sql") + .bytes("kamaka schema".into()) .await; response.assert_status_ok(); @@ -225,76 +219,7 @@ async fn an_operator_registers_a_group_scoped_artifact() { assert!(artifact["download_url"].is_null(), "it rests in Canopy"); assert_eq!(artifact["group_id"], group); assert_eq!(artifact["group_name"], "kamaka"); - assert_eq!( - artifact["digest"], - database::artifacts::digest_of(b"kamaka schema") - ); - }) - .await -} - -/// A media type describes bytes Canopy holds, and it holds none for an -/// artifact that names no group. Passed through, it trips the check constraint, -/// so operator input answers 500 instead of being refused. -// spec: ART#where-an-artifact-rests -#[tokio::test(flavor = "multi_thread")] -async fn an_unscoped_artifact_carries_no_media_type() { - commons_tests::server::run(async |mut conn, _public, private| { - let version = "11111111-2222-0000-0000-111111111111"; - - conn.batch_execute(&format!( - "INSERT INTO versions (id, major, minor, patch, changelog, status) - VALUES ('{version}', 2, 60, 0, '', 'published')", - )) - .await - .unwrap(); - - let created = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "installer", - "platform": "windows", - "download_url": "https://example.com/x.exe", - "content_type": "text/html", - })) - .await; - assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); - }) - .await -} - -/// The media type is served back as a header when the bytes are read, so one no -/// header can carry would leave an artifact that answers a fault on every -/// download and can only be mended by registering it again. -// spec: ART#where-an-artifact-rests -#[tokio::test(flavor = "multi_thread")] -async fn a_media_type_no_header_can_carry_is_refused() { - commons_tests::server::run(async |mut conn, _public, private| { - let version = "11111111-4444-0000-0000-111111111111"; - let group = "cccccccc-4444-0000-0000-cccccccccccc"; - - conn.batch_execute(&format!( - "INSERT INTO versions (id, major, minor, patch, changelog, status) - VALUES ('{version}', 2, 60, 0, '', 'published'); - INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", - )) - .await - .unwrap(); - - let created = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "reporting-schema", - "platform": "any", - "group_id": group, - "content_base64": "a2FtYWthIHNjaGVtYQ==", - "content_type": "application/sql\r\nx-injected: yes", - "digest": database::artifacts::digest_of(b"kamaka schema"), - })) - .await; - assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!(artifact["digest"], sri_of(b"kamaka schema")); }) .await } @@ -316,15 +241,13 @@ async fn a_registration_naming_no_group_that_exists_is_refused() { .unwrap(); let created = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "reporting-schema", - "platform": "any", - "group_id": gone, - "content_base64": "a2FtYWthIHNjaGVtYQ==", - "digest": database::artifacts::digest_of(b"kamaka schema"), - })) + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", gone) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) .await; assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); }) @@ -354,16 +277,13 @@ async fn a_digest_against_a_location_is_recorded() { "artifact_type": "installer", "platform": "windows", "download_url": "https://example.com/x.exe", - "digest": database::artifacts::digest_of(b"kamaka installer"), + "digest": sri_of(b"kamaka installer"), })) .await; created.assert_status_ok(); let artifact: serde_json::Value = created.json(); - assert_eq!( - artifact["digest"], - database::artifacts::digest_of(b"kamaka installer") - ); + assert_eq!(artifact["digest"], sri_of(b"kamaka installer")); }) .await } @@ -420,7 +340,7 @@ async fn a_blank_download_url_is_not_a_location() { .await } -/// The create route carries a body limit sized from the held-bytes cap, so an +/// The upload route carries a body limit sized from the held-bytes cap, so an /// upload well past axum's 2 MB default is accepted, and one past the cap is /// refused by the handler naming the limit rather than by axum with a /// plain-text 413 the SPA has nothing structured to render. @@ -439,32 +359,27 @@ async fn an_upload_over_the_limit_is_told_what_it_is() { .await .unwrap(); - // "AAAA" decodes to three zero bytes, so the repeat count sets the size. - let four_mib_bytes = 3 * (4 * 1024 * 1024 / 3); - let four_mib = "A".repeat(4 * (four_mib_bytes / 3)); + let four_mib = vec![0u8; 4 * 1024 * 1024]; let accepted = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "reporting-schema", - "platform": "any", - "group_id": group, - "content_base64": four_mib, - "digest": database::artifacts::digest_of(&vec![0u8; four_mib_bytes]), - })) + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(&four_mib)) + .bytes(four_mib.into()) .await; accepted.assert_status_ok(); - let over_limit = "A".repeat(4 * (32 * 1024 * 1024 / 3 + 1)); + let over_limit = vec![0u8; 32 * 1024 * 1024 + 1]; let refused = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "reporting-schema", - "platform": "linux", - "group_id": group, - "content_base64": over_limit, - })) + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "linux") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(&over_limit)) + .bytes(over_limit.into()) .await; assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); @@ -509,7 +424,7 @@ async fn the_listing_says_which_artifacts_are_offered() { VALUES ('{range_installer}', NULL, 'windows', 'installer', '2.60.x', 'https://example.com/range.exe'); INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) - VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'kamaka schema', 'application/sql', 'sha256:x')", + VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'kamaka schema', 'application/sql', '\\x00'::bytea)", )) .await .unwrap(); @@ -571,21 +486,19 @@ async fn a_registration_answers_what_it_overrides() { VALUES (NULL, 'any', 'reporting-schema', '2.60.x', 'https://example.com/range.sql'); INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, group_id, content, content_type, digest) - VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'theirs', 'application/octet-stream', 'sha256:x')", + VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'theirs', 'application/octet-stream', '\\x00'::bytea)", )) .await .unwrap(); let held = private - .post("/api/versions/create_artifact") - .json(&serde_json::json!({ - "version_id": version, - "artifact_type": "reporting-schema", - "platform": "any", - "group_id": ours, - "content_base64": "a2FtYWthIHNjaGVtYQ==", - "digest": database::artifacts::digest_of(b"kamaka schema"), - })) + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", ours) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) .await; held.assert_status_ok(); let held: serde_json::Value = held.json(); diff --git a/crates/public-server/Cargo.toml b/crates/public-server/Cargo.toml index c4e2973ca..8df557487 100644 --- a/crates/public-server/Cargo.toml +++ b/crates/public-server/Cargo.toml @@ -66,6 +66,7 @@ aws-sdk-sts = { workspace = true, features = ["test-util"] } aws-smithy-mocks.workspace = true axum-test.workspace = true commons-tests = { path = "../commons-tests" } +hex = "0.4.3" http.workspace = true percent-encoding = "2.3.2" rcgen = "0.14.8" diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index c343aed69..108fa7cf0 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -152,7 +152,7 @@ { "name": "digest", "in": "query", - "description": "Algorithm-prefixed digest of the bytes at the URL, e.g. `sha256:2cf24dba…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked.", + "description": "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked.", "required": false, "schema": { "type": "string" @@ -1686,7 +1686,7 @@ }, "Artifact": { "type": "object", - "description": "An artifact as it is offered to a caller.", + "description": "A downloadable artifact belonging to a release version: an installer,\npackage, or other file published for a given type and platform.", "required": [ "id", "artifact_type", @@ -1711,19 +1711,11 @@ "string", "null" ], - "description": "Algorithm-prefixed digest of the artifact's bytes, e.g.\n`sha256:2cf24dba…`, where one was recorded." + "description": "Subresource Integrity digest of the artifact's bytes, e.g.\n`sha256-LCTbqp…`, where one was recorded." }, "download_url": { "type": "string", - "description": "URL the artifact can be downloaded from. For an artifact whose bytes\nCanopy holds, this is Canopy's own download endpoint for it." - }, - "group_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The group this artifact is for. `null` for an artifact that is for\nevery group." + "description": "URL the artifact can be downloaded from." }, "id": { "type": "string", diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index d21a6a3d7..3b6e1ebd4 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -8,7 +8,7 @@ use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ Db, - artifacts::{Artifact as ArtifactRow, NewArtifact, Scope}, + artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, parse_sri, sri}, machines::Machine, versions::{NewVersion, Version}, }; @@ -19,7 +19,8 @@ use uuid::Uuid; use crate::state::AppState; -/// An artifact as it is offered to a caller. +/// A downloadable artifact belonging to a release version: an installer, +/// package, or other file published for a given type and platform. #[derive(Debug, Clone, Serialize, serde::Deserialize, utoipa::ToSchema)] pub struct Artifact { /// Unique identifier of the artifact. @@ -32,8 +33,7 @@ pub struct Artifact { pub artifact_type: String, /// The platform the artifact targets (e.g. an OS or architecture name). pub platform: String, - /// URL the artifact can be downloaded from. For an artifact whose bytes - /// Canopy holds, this is Canopy's own download endpoint for it. + /// URL the artifact can be downloaded from. pub download_url: String, /// The device that registered this artifact, if it was registered by a /// releaser device rather than created by an operator. @@ -42,11 +42,8 @@ pub struct Artifact { /// shared across a range of versions rather than pinned to one. `null` /// for exact-version artifacts. pub version_range_pattern: Option, - /// The group this artifact is for. `null` for an artifact that is for - /// every group. - pub group_id: Option, - /// Algorithm-prefixed digest of the artifact's bytes, e.g. - /// `sha256:2cf24dba…`, where one was recorded. + /// Subresource Integrity digest of the artifact's bytes, e.g. + /// `sha256-LCTbqp…`, where one was recorded. pub digest: Option, } @@ -71,8 +68,7 @@ impl Artifact { download_url, device_id: row.device_id, version_range_pattern: row.version_range_pattern, - group_id: row.group_id, - digest: row.digest, + digest: row.digest.as_deref().map(sri), } } } @@ -128,7 +124,7 @@ pub fn routes() -> OpenApiRouter { ("artifact_type" = String, Path), ("platform" = String, Path), ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused."), - ("digest" = Option, Query, description = "Algorithm-prefixed digest of the bytes at the URL, e.g. `sha256:2cf24dba…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked."), + ("digest" = Option, Query, description = "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked."), ), request_body(content = String, description = "Download URL for the artifact, as a plain-text body."), responses( @@ -172,7 +168,11 @@ async fn create( // A blank digest is no digest: recorded, it says the bytes were checked // against something when nothing was. // spec: ART#digests - let digest = named.digest.filter(|d| !d.trim().is_empty()); + let digest = named + .digest + .filter(|d| !d.trim().is_empty()) + .map(|d| parse_sri(&d)) + .transpose()?; let mut db = db.get().await?; let device_id = device.0.0.id; @@ -238,9 +238,10 @@ async fn create( struct RegisterQuery { /// The group the artifact is for, where it names one. group: Option, - /// The digest whoever registers it records, where they record one. An - /// unscoped artifact is fetched from its location by the caller rather - /// than by Canopy, so this is what that caller checks against. + /// The Subresource Integrity digest whoever registers it records, where + /// they record one. An unscoped artifact is fetched from its location by + /// the caller rather than by Canopy, so this is what that caller checks + /// against. // spec: ART#digests digest: Option, } diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 900a878bd..eb0a79cc7 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -682,8 +682,7 @@ async fn download_artifact( .ok_or(AppError::ArtifactNotFound)?; if let Some(held) = ArtifactRow::content_for(&mut db, artifact.id).await? { - let recomputed = database::artifacts::digest_of(&held.bytes); - if recomputed != held.digest { + if database::artifacts::digest_of(&held.bytes) != held.digest { tracing::error!( artifact = %artifact.id, "held artifact does not match its digest; refusing to serve" diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 5ed5430dc..cf31524ea 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -3,7 +3,7 @@ //! spec: ART use axum::http::StatusCode; -use database::artifacts::digest_of; +use database::artifacts::{digest_of, sri}; use diesel_async::SimpleAsyncConnection; const VERSION: &str = "11111111-1111-1111-1111-111111111111"; @@ -15,7 +15,7 @@ const GROUP_B: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; /// One published version, two groups, and a `reporting-schema` artifact for /// each of the unscoped and group-A cases. async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { - let digest = digest_of(b"group a schema"); + let digest = hex::encode(digest_of(b"group a schema")); conn.batch_execute(&format!( "INSERT INTO versions (id, major, minor, patch, changelog, status) VALUES ('{VERSION}', 2, 60, 0, '', 'published'); @@ -28,7 +28,7 @@ async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) VALUES ('{THEIRS}', '{VERSION}', 'any', 'reporting-schema', '{GROUP_A}', - 'group a schema'::bytea, 'application/sql', '{digest}')", + 'group a schema'::bytea, 'application/sql', '\\x{digest}'::bytea)", )) .await .expect("seed"); @@ -62,7 +62,6 @@ async fn an_anonymous_read_sees_only_unscoped_artifacts() { assert_eq!(artifacts.len(), 1); assert_eq!(artifacts[0]["id"], UNSCOPED); - assert!(artifacts[0]["group_id"].is_null()); }) .await } @@ -87,7 +86,16 @@ async fn a_machine_is_offered_its_own_group_s_artifact() { assert_eq!(artifacts.len(), 1, "never offered both"); assert_eq!(artifacts[0]["id"], THEIRS); - assert_eq!(artifacts[0]["group_id"], GROUP_A); + + // A caller is offered one artifact per type and platform. Which + // group it belongs to is how Canopy chose it, not part of what + // the caller is told. + // spec: ART#what-a-version-offers + assert!( + artifacts[0].get("group_id").is_none(), + "the scope is not disclosed, but got {}", + artifacts[0] + ); }, ) .await @@ -783,14 +791,16 @@ async fn a_releaser_records_the_digest_it_publishes() { async |mut conn, cert, _device_id, public, _| { seed(&mut conn).await; + let claimed = sri(&digest_of(b"the installer")); let recorded = public - .post("/artifacts/2.60.0/installer/windows?digest=sha256:abcd") + .post("/artifacts/2.60.0/installer/windows") + .add_query_param("digest", &claimed) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .text("https://example.com/x.exe") .await; recorded.assert_status_ok(); let recorded: serde_json::Value = recorded.json(); - assert_eq!(recorded["digest"], "sha256:abcd"); + assert_eq!(recorded["digest"], claimed); for query in ["", "?digest=", "?digest=%20%20"] { let response = public @@ -805,6 +815,22 @@ async fn a_releaser_records_the_digest_it_publishes() { "a blank digest is no digest, but got {artifact}" ); } + + // A digest nothing can check the bytes against is refused rather + // than published to every device that fetches the artifact. + for claimed in ["sha256:abcd", "notadigest", "sha256-abcd"] { + let response = public + .post("/artifacts/2.60.0/installer/macos") + .add_query_param("digest", claimed) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.dmg") + .await; + assert_eq!( + response.status_code(), + StatusCode::BAD_REQUEST, + "{claimed:?} is not a digest" + ); + } }, ) .await diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql index 1faff4bdb..48b93f2b4 100644 --- a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql @@ -9,7 +9,7 @@ ALTER TABLE artifacts ADD COLUMN group_id UUID REFERENCES server_groups(id) ON DELETE CASCADE, ADD COLUMN content BYTEA, ADD COLUMN content_type TEXT, - ADD COLUMN digest TEXT, + ADD COLUMN digest BYTEA, ADD COLUMN run_id UUID; ALTER TABLE artifacts ALTER COLUMN download_url DROP NOT NULL; diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts index 4699bd370..25737e65d 100644 --- a/private-web/e2e/artifact-scopes.spec.ts +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -56,7 +56,7 @@ test.describe("group-scoped artifacts", () => { // The group's artifact says whose it is and shows its digest rather // than a location, because Canopy holds the bytes. await expect(page.getByText("Held by Canopy for kamaka")).toBeVisible(); - await expect(page.getByText(/^sha256:/)).toBeVisible(); + await expect(page.getByText(/^sha256-/)).toBeVisible(); // The unscoped one still shows where it rests. await expect( @@ -199,16 +199,15 @@ test.describe("group-scoped artifacts", () => { digest: string | null; content: string | null; }>( - `SELECT download_url, digest, encode(content, 'escape') AS content + `SELECT download_url, encode(digest, 'base64') AS digest, + encode(content, 'escape') AS content FROM artifacts WHERE version_id = $1`, [version.id], ); expect(rows).toHaveLength(1); expect(rows[0].download_url).toBeNull(); expect(rows[0].content).toBe("kamaka schema"); - expect(rows[0].digest).toBe( - "sha256:214b3ad41c660e2837e03418fe87c70b1e82cc7c3531d78efeff9a3409ea91d9", - ); + expect(rows[0].digest).toBe("IUs61BxmDig34DQY/ofHCx6CzHw1MdeO/v+aNAnqkdk="); }); /// An artifact Canopy holds has no location to edit. Replacing its bytes is diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index a54f79d77..0a180cfbd 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -1054,7 +1054,7 @@ export async function seedArtifact( const scoped = opts.groupId != null; const content = opts.content ?? "held bytes"; const digest = scoped - ? `sha256:${createHash("sha256").update(content).digest("hex")}` + ? createHash("sha256").update(content).digest() : null; await sql.query( diff --git a/private-web/openapi.json b/private-web/openapi.json index a16849c89..42611252e 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -7866,6 +7866,16 @@ } } } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } } }, "security": [ @@ -8238,6 +8248,108 @@ } ] } + }, + "/api/versions/upload_artifact": { + "post": { + "tags": [ + "versions" + ], + "summary": "Register an artifact whose bytes Canopy holds, for one group.", + "description": "The body is the artifact itself and its `Content-Type` is what the bytes\nare served back as. Returns the created artifact.", + "operationId": "upload_artifact", + "parameters": [ + { + "name": "version_id", + "in": "query", + "description": "Id of the version to attach the new artifact to.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "artifact_type", + "in": "query", + "description": "Artifact type.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "platform", + "in": "query", + "description": "Target platform.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "group_id", + "in": "query", + "description": "The group this artifact is for.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "digest", + "in": "query", + "description": "Subresource Integrity digest of the body, e.g. `sha256-LCTbqp…`.\nCanopy checks the bytes against it as they arrive and refuses the\nregistration on a mismatch, so a corrupted upload is refused while\nwhoever sent it is still there to send it again.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The artifact's bytes.", + "content": { + "application/octet-stream": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactData" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + } + }, + "security": [ + { + "tailscale-admin": [] + } + ] + } } }, "components": { @@ -8665,7 +8777,7 @@ "string", "null" ], - "description": "Algorithm-prefixed digest recorded for the artifact, where there is one." + "description": "Subresource Integrity digest recorded for the artifact, where there is\none." }, "download_url": { "type": [ @@ -9983,52 +10095,28 @@ }, "CreateArtifactArgs": { "type": "object", - "description": "A new artifact to register against a version.", + "description": "A new artifact to register against a version, at a location Canopy records.\n\nAn artifact whose bytes Canopy holds is registered through\n`upload_artifact` instead, since the bytes are the body there.", "required": [ "version_id", "artifact_type", - "platform" + "platform", + "download_url" ], "properties": { "artifact_type": { "type": "string", "description": "Artifact type." }, - "content_base64": { - "type": [ - "string", - "null" - ], - "description": "The artifact's bytes, base64-encoded. Required when a group is named." - }, - "content_type": { - "type": [ - "string", - "null" - ], - "description": "Media type of those bytes." - }, "digest": { "type": [ "string", "null" ], - "description": "Algorithm-prefixed digest of those bytes, e.g. `sha256:2cf24dba…`.\nRequired when a group is named: Canopy checks the bytes against it as\nthey arrive and refuses the registration on a mismatch, so a corrupted\nupload is refused while whoever sent it is still there to send it again." + "description": "Subresource Integrity digest of the bytes at that URL, e.g.\n`sha256-LCTbqp…`, where one is recorded. Whoever fetches the artifact\nchecks what it got against this." }, "download_url": { - "type": [ - "string", - "null" - ], - "description": "Download URL, for an artifact Canopy records a location for." - }, - "group_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The group this artifact is for. Naming one makes Canopy hold the bytes." + "type": "string", + "description": "URL the artifact is downloaded from." }, "platform": { "type": "string", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 64205f47c..58cbc51ef 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -4198,6 +4198,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/versions/upload_artifact": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register an artifact whose bytes Canopy holds, for one group. + * @description The body is the artifact itself and its `Content-Type` is what the bytes + * are served back as. Returns the created artifact. + */ + post: operations["upload_artifact"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -4467,7 +4488,10 @@ export interface components { artifact_type: string; /** @description `true` when Canopy holds this artifact's bytes rather than a location. */ canopy_holds_bytes: boolean; - /** @description Algorithm-prefixed digest recorded for the artifact, where there is one. */ + /** + * @description Subresource Integrity digest recorded for the artifact, where there is + * one. + */ digest?: string | null; /** * @description URL clients use to download this artifact. `null` when Canopy holds @@ -5368,28 +5392,23 @@ export interface components { /** @description The rolled-up health over these checks, by the one classifier. */ health_state: components["schemas"]["HealthState"]; }; - /** @description A new artifact to register against a version. */ + /** + * @description A new artifact to register against a version, at a location Canopy records. + * + * An artifact whose bytes Canopy holds is registered through + * `upload_artifact` instead, since the bytes are the body there. + */ CreateArtifactArgs: { /** @description Artifact type. */ artifact_type: string; - /** @description The artifact's bytes, base64-encoded. Required when a group is named. */ - content_base64?: string | null; - /** @description Media type of those bytes. */ - content_type?: string | null; /** - * @description Algorithm-prefixed digest of those bytes, e.g. `sha256:2cf24dba…`. - * Required when a group is named: Canopy checks the bytes against it as - * they arrive and refuses the registration on a mismatch, so a corrupted - * upload is refused while whoever sent it is still there to send it again. + * @description Subresource Integrity digest of the bytes at that URL, e.g. + * `sha256-LCTbqp…`, where one is recorded. Whoever fetches the artifact + * checks what it got against this. */ digest?: string | null; - /** @description Download URL, for an artifact Canopy records a location for. */ - download_url?: string | null; - /** - * Format: uuid - * @description The group this artifact is for. Naming one makes Canopy hold the bytes. - */ - group_id?: string | null; + /** @description URL the artifact is downloaded from. */ + download_url: string; /** @description Target platform. */ platform: string; /** @@ -15958,6 +15977,14 @@ export interface operations { "application/json": components["schemas"]["ArtifactData"]; }; }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; }; }; delete_artifact: { @@ -16211,4 +16238,52 @@ export interface operations { }; }; }; + upload_artifact: { + parameters: { + query: { + /** @description Id of the version to attach the new artifact to. */ + version_id: string; + /** @description Artifact type. */ + artifact_type: string; + /** @description Target platform. */ + platform: string; + /** @description The group this artifact is for. */ + group_id: string; + /** + * @description Subresource Integrity digest of the body, e.g. `sha256-LCTbqp…`. + * Canopy checks the bytes against it as they arrive and refuses the + * registration on a mismatch, so a corrupted upload is refused while + * whoever sent it is still there to send it again. + */ + digest: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + /** @description The artifact's bytes. */ + requestBody: { + content: { + "application/octet-stream": number[]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactData"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + }; + }; } diff --git a/private-web/src/api.ts b/private-web/src/api.ts index 4cb4ce92a..ebeb32d6a 100644 --- a/private-web/src/api.ts +++ b/private-web/src/api.ts @@ -35,33 +35,61 @@ export async function callApi< signal, }); - if (!response.ok) { - let detail: unknown = null; - try { - detail = await response.json(); - } catch { - detail = await response.text().catch(() => null); - } - // Surface the problem-details title (and detail line, if present) - // in the thrown error's message so action.error?.message in the UI - // shows the actual server-side cause, not just the HTTP status. - let extra = ""; - if ( - detail && - typeof detail === "object" && - "title" in detail && - typeof (detail as { title?: unknown }).title === "string" - ) { - extra = `: ${(detail as { title: string }).title}`; - } - throw new ApiError( - response.status, - `server fn ${module}.${fn} failed: ${response.status}${extra}`, - detail, - ); + return (await answered(response, module, fn)) as T; +} + +async function answered( + response: Response, + module: string, + fn: string, +): Promise { + if (response.ok) return await response.json(); + + let detail: unknown = null; + try { + detail = await response.json(); + } catch { + detail = await response.text().catch(() => null); + } + // Surface the problem-details title (and detail line, if present) + // in the thrown error's message so action.error?.message in the UI + // shows the actual server-side cause, not just the HTTP status. + let extra = ""; + if ( + detail && + typeof detail === "object" && + "title" in detail && + typeof (detail as { title?: unknown }).title === "string" + ) { + extra = `: ${(detail as { title: string }).title}`; } + throw new ApiError( + response.status, + `server fn ${module}.${fn} failed: ${response.status}${extra}`, + detail, + ); +} + +// An endpoint whose body is the bytes themselves: everything it is told about +// them travels in the query string. +export async function uploadApi( + module: string, + fn: string, + query: Record, + body: Blob, +): Promise { + const response = await fetch( + `/api/${module}/${fn}?${new URLSearchParams(query)}`, + { + method: "POST", + headers: { + "content-type": body.type || "application/octet-stream", + }, + body, + }, + ); - return (await response.json()) as T; + return (await answered(response, module, fn)) as T; } export type ApiState = @@ -204,3 +232,42 @@ export function useApiAction< return { call, pending, error, reset }; } + +/** + * `useApiAction` for an endpoint whose body is the bytes themselves. + */ +export function useApiUpload( + module: string, + fn: string, +): { + call: (query: Record, body: Blob) => Promise; + pending: boolean; + error: Error | null; + reset: () => void; +} { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const call = useCallback( + async (query: Record, body: Blob): Promise => { + setPending(true); + setError(null); + try { + const result = await uploadApi(module, fn, query, body); + document.dispatchEvent(new Event("canopy-data-changed")); + return result; + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + setError(e); + throw e; + } finally { + setPending(false); + } + }, + [module, fn], + ); + + const reset = useCallback(() => setError(null), []); + + return { call, pending, error, reset }; +} diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 7c239ed5b..17e494b37 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -33,7 +33,7 @@ import { useParams } from "react-router-dom"; import Markdown from "../components/Markdown"; import TimeAgo from "../components/TimeAgo"; import VersionStatusChip from "../components/VersionStatusChip"; -import { useApi, useApiAction } from "../api"; +import { useApi, useApiAction, useApiUpload } from "../api"; import { useIsAdmin } from "../hooks/useIsAdmin"; import { usePageTitle } from "../hooks/usePageTitle"; import { prettifyVersionRange } from "../lib/versionRange"; @@ -649,25 +649,13 @@ function EditArtifactRow({ ); } -/// Canopy holds a group-scoped artifact's bytes, and the API takes them in the -/// JSON body, so the file is read here rather than posted as a multipart form. -/// The digest goes with them: Canopy checks the bytes it received against it and -/// refuses the registration on a mismatch. -async function readFile(file: File): Promise<{ base64: string; digest: string }> { - const buffer = await file.arrayBuffer(); - const bytes = new Uint8Array(buffer); - - const chunks: string[] = []; - for (let i = 0; i < bytes.length; i += 0x8000) { - chunks.push(String.fromCharCode(...bytes.subarray(i, i + 0x8000))); - } - - const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", buffer)); - const hex = Array.from(hash) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - return { base64: btoa(chunks.join("")), digest: `sha256:${hex}` }; +/// The digest travels with the bytes: Canopy checks what it received against +/// it and refuses the registration on a mismatch. +async function digestOf(file: File): Promise { + const hash = new Uint8Array( + await crypto.subtle.digest("SHA-256", await file.arrayBuffer()), + ); + return `sha256-${btoa(String.fromCharCode(...hash))}`; } const MAX_HELD_ARTIFACT_BYTES = 32 * 1024 * 1024; @@ -686,25 +674,35 @@ function CreateArtifactForm({ const [groupId, setGroupId] = useState(""); const [file, setFile] = useState(null); const [fileError, setFileError] = useState(null); - const action = useApiAction("versions", "create_artifact"); + const create = useApiAction("versions", "create_artifact"); + const upload = useApiUpload("versions", "upload_artifact"); const groups = useApi("fleet/groups", "list", {}, []); const scoped = groupId !== ""; + const action = scoped ? upload : create; const submit = async (e: React.FormEvent) => { e.preventDefault(); try { - const contents = scoped && file ? await readFile(file) : null; - await action.call({ - version_id: versionId, - artifact_type: type, - platform, - download_url: scoped ? null : url, - group_id: scoped ? groupId : null, - content_base64: contents?.base64 ?? null, - content_type: contents ? file?.type || null : null, - digest: contents?.digest ?? null, - }); + if (scoped && file) { + await upload.call( + { + version_id: versionId, + artifact_type: type, + platform, + group_id: groupId, + digest: await digestOf(file), + }, + file, + ); + } else { + await create.call({ + version_id: versionId, + artifact_type: type, + platform, + download_url: url, + }); + } setType(""); setPlatform(""); setUrl(""); From 33b7d9517021ca57694ad49a294fc3f7b747281c Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:20:00 +1200 Subject: [PATCH 098/130] split group registration out --- .workhorse/specs/platform/artifacts.md | 4 +- .../specs/public-server/reporting-schemas.md | 2 +- crates/canopy-api/src/generated.rs | 33 +- crates/public-server/openapi.json | 136 ++++++- crates/public-server/src/artifacts.rs | 366 ++++++++++-------- .../public-server/tests/it/artifact_scopes.rs | 8 +- .../tests/it/reporting_schemas.rs | 28 +- 7 files changed, 373 insertions(+), 204 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 005785e39..2fa1f4fa0 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -63,7 +63,9 @@ Canopy passes a group-scoped artifact's bytes only to a caller it is offered to. ## Registration -A registration names the version or range, the type, the platform, and the group where the artifact has one, and carries either the location of an unscoped artifact or the bytes of a group-scoped one. +Registering an unscoped artifact and registering a group's are separate: they name different things, carry different bodies, and are authorised differently, so each is its own path rather than one path that changes shape on a parameter. +An unscoped registration names the version or range, the type and the platform, and carries the artifact's location. +A group-scoped one names the group as well, carries the artifact's bytes, and names an exact version Canopy already holds rather than drafting one. The group is named on the registration rather than inferred from the caller. A releaser device registers unscoped artifacts, and carries no authorisation for any group. diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index 055ae960b..ed8946575 100644 --- a/.workhorse/specs/public-server/reporting-schemas.md +++ b/.workhorse/specs/public-server/reporting-schemas.md @@ -50,7 +50,7 @@ The replica is migrated to the named version before the build reads it, and is n The builder obtains read credentials for the restore per run as any consumer does, and no storage credential of any kind for what it publishes (see [RST](restore-replicas.md)). In the run it reports, the builder registers the **reporting schema** as an artifact of the exact version being built for, scoped to the group, of type `reporting-schema` on platform `any`, carrying a digest and the bytes themselves, which Canopy holds and serves (see [ART](../platform/artifacts.md)). -It may register further artifacts beside the schema for the same version and group, under types of its choosing, which Canopy offers as it offers any artifact. +The schema is the only type it may register: what is published for a group is offered to every machine in it and fetched, so the authorisation stays defined with the artifact it was written for. The builder is authorised to register artifacts for a group whose enabled declaration an operator has marked as publishing its reporting schema, and for no other, and is the one device other than a releaser that registers artifacts (see [ART](../platform/artifacts.md)). The mark is the operator's alone, and is the whole of the authorisation: a consumer registers the set of semantics it advertises itself, so they shape what Canopy dispatches to it and grant it nothing, and what is published for a group is offered to every machine in it and applied. Only a group-wide, non-redacting declaration of an intent carrying `reporting-schema` can carry the mark, which is the same declaration a build is dispatched for, so Canopy asks for no build it would refuse the result of. diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index 0800bd74f..123b033c8 100644 --- a/crates/canopy-api/src/generated.rs +++ b/crates/canopy-api/src/generated.rs @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.0"; /// BLAKE3 digest of that document, so a document that changed without the /// version moving with it can be told from one that did not. -pub const OPENAPI_BLAKE3: &str = "085b186daccc6f0157a99a3ff74a13e5fe4c486182d84395fb5b8926bf320117"; +pub const OPENAPI_BLAKE3: &str = "3cfc9a09c64793448410328da3f666a868f4ba77d17b0f6b9031e7c9b669be3b"; /// Error types. pub mod error { @@ -4060,23 +4060,38 @@ impl crate::CanopyClient { pub async fn applications_self(&self) -> crate::Result { self.call_json(::http::Method::GET, "/applications/self", None::<&()>).await } - /// Register an artifact for a version or version range. + /// Register a reporting schema for one group, carrying its bytes. /// - /// A releaser registers an artifact that rests elsewhere, naming its location. - /// A component that produces a group's artifacts registers one for that group, - /// sending the bytes on this connection; Canopy holds them and is issued no - /// credential to any store. The + /// Requires a device certificate whose restore declaration for the named group + /// advertises that it builds reporting schemas. The bytes travel on this + /// connection and Canopy holds them, so the builder is issued no credential to + /// any store. The path names the group the artifact is for, the exact version + /// it was built against, and the artifact's type and target platform. + /// + /// The version must be one Canopy already holds: a build is dispatched for a + /// group and version Canopy knows about, so a version that does not exist is + /// refused rather than drafted. A range pattern is refused for the same reason: + /// a schema follows the migrations one exact version applies. + /// + /// Returns the created artifact record. + /// + /// `POST /artifacts/groups/{group}/{version}/{artifact_type}/{platform}` + pub async fn artifacts_groups(&self, group: &str, version: &str, artifact_type: &str, platform: &str) -> crate::Result { + self.call_json(::http::Method::POST, &format!("/artifacts/groups/{}/{}/{}/{}", group, version, artifact_type, platform), None::<&()>).await + } + /// Register a downloadable artifact for a version or version range. + /// + /// Requires a device certificate with the releaser role (or admin). The /// path identifies the version the artifact belongs to — either an exact /// version (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`, /// `^2.10.0`) — followed by the artifact's type and target platform. The /// request body is the plain-text URL clients should download the /// artifact from. /// - /// When a releaser gives an exact version that doesn't exist yet, it is created + /// When an exact version is given and it doesn't exist yet, it is created /// automatically as an unpublished draft so the artifact has a version to /// attach to; publishing that version later (via the version-creation - /// endpoint) is a separate step. A group-scoped registration names a version - /// Canopy already holds and drafts none. When a range pattern is given instead, + /// endpoint) is a separate step. When a range pattern is given instead, /// the artifact isn't tied to one version — it matches whichever /// published version currently satisfies the range at lookup time. /// diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index aa72a08fe..869ce8ce3 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -105,19 +105,29 @@ ] } }, - "/artifacts/{version}/{artifact_type}/{platform}": { + "/artifacts/groups/{group}/{version}/{artifact_type}/{platform}": { "post": { "tags": [ "artifacts" ], - "summary": "Register an artifact for a version or version range.", - "description": "A releaser registers an artifact that rests elsewhere, naming its location.\nA component that produces a group's artifacts registers one for that group,\nsending the bytes on this connection; Canopy holds them and is issued no\ncredential to any store. The\npath identifies the version the artifact belongs to — either an exact\nversion (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`,\n`^2.10.0`) — followed by the artifact's type and target platform. The\nrequest body is the plain-text URL clients should download the\nartifact from.\n\nWhen a releaser gives an exact version that doesn't exist yet, it is created\nautomatically as an unpublished draft so the artifact has a version to\nattach to; publishing that version later (via the version-creation\nendpoint) is a separate step. A group-scoped registration names a version\nCanopy already holds and drafts none. When a range pattern is given instead,\nthe artifact isn't tied to one version — it matches whichever\npublished version currently satisfies the range at lookup time.\n\nReturns the created artifact record. Returns 400 if the version or\nrange syntax can't be parsed.", - "operationId": "register_artifact", + "summary": "Register a reporting schema for one group, carrying its bytes.", + "description": "Requires a device certificate whose restore declaration for the named group\nadvertises that it builds reporting schemas. The bytes travel on this\nconnection and Canopy holds them, so the builder is issued no credential to\nany store. The path names the group the artifact is for, the exact version\nit was built against, and the artifact's type and target platform.\n\nThe version must be one Canopy already holds: a build is dispatched for a\ngroup and version Canopy knows about, so a version that does not exist is\nrefused rather than drafted. A range pattern is refused for the same reason:\na schema follows the migrations one exact version applies.\n\nReturns the created artifact record.", + "operationId": "register_group_artifact", "parameters": [ + { + "name": "group", + "in": "path", + "description": "Group the artifact is for.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "version", "in": "path", - "description": "Exact semver (e.g. `2.10.5`) or range pattern (e.g. `2.10.x`, `^2.10.0`).", + "description": "Exact semver (e.g. `2.10.5`) the schema was built against.", "required": true, "schema": { "type": "string" @@ -126,6 +136,7 @@ { "name": "artifact_type", "in": "path", + "description": "Must be `reporting-schema`: the authorisation is defined with that artifact.", "required": true, "schema": { "type": "string" @@ -140,19 +151,119 @@ } }, { - "name": "group", + "name": "run", "in": "query", - "description": "Group the artifact is for. A releaser credential carries no authorisation for any group; a component that produces a group's artifacts is authorised for that group alone.", + "description": "The run that produced the artifact, where one produced it.", "required": false, "schema": { "type": "string", "format": "uuid" } + } + ], + "requestBody": { + "description": "The artifact's bytes, which Canopy holds and records the digest of.", + "content": { + "application/octet-stream": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Artifact" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + } + }, + "security": [ { - "name": "run", + "backup-restore-device": [] + } + ] + } + }, + "/artifacts/{version}/{artifact_type}/{platform}": { + "post": { + "tags": [ + "artifacts" + ], + "summary": "Register a downloadable artifact for a version or version range.", + "description": "Requires a device certificate with the releaser role (or admin). The\npath identifies the version the artifact belongs to — either an exact\nversion (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`,\n`^2.10.0`) — followed by the artifact's type and target platform. The\nrequest body is the plain-text URL clients should download the\nartifact from.\n\nWhen an exact version is given and it doesn't exist yet, it is created\nautomatically as an unpublished draft so the artifact has a version to\nattach to; publishing that version later (via the version-creation\nendpoint) is a separate step. When a range pattern is given instead,\nthe artifact isn't tied to one version — it matches whichever\npublished version currently satisfies the range at lookup time.\n\nReturns the created artifact record. Returns 400 if the version or\nrange syntax can't be parsed.", + "operationId": "register_artifact", + "parameters": [ + { + "name": "version", + "in": "path", + "description": "Exact semver (e.g. `2.10.5`) or range pattern (e.g. `2.10.x`, `^2.10.0`).", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "artifact_type", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "platform", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "group", "in": "query", - "description": "The run that produced the artifact, where one produced it.", + "description": "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused.", "required": false, "schema": { "type": "string", @@ -162,7 +273,7 @@ { "name": "digest", "in": "query", - "description": "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`, for an unscoped artifact. Whoever fetches it checks what it got against this; one registered without a digest is fetched unchecked. Ignored for a group-scoped artifact, whose digest Canopy takes of the bytes itself.", + "description": "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked.", "required": false, "schema": { "type": "string" @@ -170,7 +281,7 @@ } ], "requestBody": { - "description": "For an unscoped artifact, its download URL as a plain-text body. For a group-scoped one, the artifact's bytes, which Canopy holds and verifies against the digest it takes of them.", + "description": "Download URL for the artifact, as a plain-text body.", "content": { "text/plain": { "schema": { @@ -225,9 +336,6 @@ "security": [ { "releaser-device": [] - }, - { - "backup-restore-device": [] } ] } diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index ccb8f32ac..b4791b995 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -4,7 +4,7 @@ use axum::{ }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; -use commons_servers::device_auth::AuthDevice; +use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; use commons_types::{ device::DeviceRole, version::{VersionStatus, VersionStr}, @@ -98,34 +98,35 @@ pub(crate) async fn caller_scope( Ok(Scope::for_caller(machine.and_then(|m| m.group_id))) } -/// Body budget for a registration. Sized above the held-bytes cap so an +/// Body budget for a schema upload. Sized above the held-bytes cap so an /// over-limit upload is the handler's structured refusal naming the limit, /// rather than axum's plain-text 413. -const MAX_REGISTER_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; +const MAX_UPLOAD_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; + +/// The artifact type a reporting-schema build publishes. +const REPORTING_SCHEMA_TYPE: &str = "reporting-schema"; pub fn routes() -> OpenApiRouter { - OpenApiRouter::new() - .routes(routes!(create)) - .layer(DefaultBodyLimit::max(MAX_REGISTER_BODY_BYTES)) + OpenApiRouter::new().routes(routes!(create)).merge( + OpenApiRouter::new() + .routes(routes!(register_for_group)) + .layer(DefaultBodyLimit::max(MAX_UPLOAD_BODY_BYTES)), + ) } -/// Register an artifact for a version or version range. +/// Register a downloadable artifact for a version or version range. /// -/// A releaser registers an artifact that rests elsewhere, naming its location. -/// A component that produces a group's artifacts registers one for that group, -/// sending the bytes on this connection; Canopy holds them and is issued no -/// credential to any store. The +/// Requires a device certificate with the releaser role (or admin). The /// path identifies the version the artifact belongs to — either an exact /// version (e.g. `2.10.5`) or a semver range pattern (e.g. `2.10.x`, /// `^2.10.0`) — followed by the artifact's type and target platform. The /// request body is the plain-text URL clients should download the /// artifact from. /// -/// When a releaser gives an exact version that doesn't exist yet, it is created +/// When an exact version is given and it doesn't exist yet, it is created /// automatically as an unpublished draft so the artifact has a version to /// attach to; publishing that version later (via the version-creation -/// endpoint) is a separate step. A group-scoped registration names a version -/// Canopy already holds and drafts none. When a range pattern is given instead, +/// endpoint) is a separate step. When a range pattern is given instead, /// the artifact isn't tied to one version — it matches whichever /// published version currently satisfies the range at lookup time. /// @@ -136,19 +137,15 @@ pub fn routes() -> OpenApiRouter { path = "/{version}/{artifact_type}/{platform}", operation_id = "register_artifact", tag = "artifacts", - security( - ("releaser-device" = []), - ("backup-restore-device" = []), - ), + security(("releaser-device" = [])), params( ("version" = String, Path, description = "Exact semver (e.g. `2.10.5`) or range pattern (e.g. `2.10.x`, `^2.10.0`)."), ("artifact_type" = String, Path), ("platform" = String, Path), - ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group; a component that produces a group's artifacts is authorised for that group alone."), - ("run" = Option, Query, description = "The run that produced the artifact, where one produced it."), - ("digest" = Option, Query, description = "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`, for an unscoped artifact. Whoever fetches it checks what it got against this; one registered without a digest is fetched unchecked. Ignored for a group-scoped artifact, whose digest Canopy takes of the bytes itself."), + ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused."), + ("digest" = Option, Query, description = "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked."), ), - request_body(content = String, description = "For an unscoped artifact, its download URL as a plain-text body. For a group-scoped one, the artifact's bytes, which Canopy holds and verifies against the digest it takes of them."), + request_body(content = String, description = "Download URL for the artifact, as a plain-text body."), responses( (status = 200, body = Artifact), (status = 400, body = ProblemDetailsSchema), @@ -158,105 +155,56 @@ pub fn routes() -> OpenApiRouter { )] #[axum::debug_handler] async fn create( - device: AuthDevice, + device: ReleaserDevice, State(db): State, Path((version, artifact_type, platform)): Path<(String, String, String)>, Query(named): Query, headers: axum::http::HeaderMap, - body: axum::body::Bytes, + url: String, ) -> Result> { use node_semver::{Range, Version as SemverVersion}; - let mut db = db.get().await?; - let device_id = device.0.id; - let role = device.0.role; - - // Who may register what. A releaser registers unscoped artifacts and - // carries no authorisation for any group. A component that produces a - // group's artifacts registers for that group under an authorisation - // defined with those artifacts, and for no other. + // A releaser registers unscoped artifacts and carries no authorisation for + // any group, so the group-scoped path is not reachable from this endpoint + // at all rather than being refused per group. // spec: ART#registration - let held = match named.group { - None => { - if !matches!(role, DeviceRole::Releaser | DeviceRole::Admin) { - return Err(AppError::AuthInsufficientPermissions { - required: "releaser or admin".into(), - }); - } - None - } - Some(group) => { - // What a schema builder is authorised for is the artifact its - // declaration names. Any other type registered under it would - // displace the releaser's own for every machine in the group, and - // those machines fetch and run what they are offered. - // spec: ART#registration - if artifact_type != REPORTING_SCHEMA_TYPE { - return Err(AppError::AuthInsufficientPermissions { - required: format!("a group-scoped artifact to be a {REPORTING_SCHEMA_TYPE}"), - }); - } - - let authorised = role == DeviceRole::Admin - || RestoreReplica::authorizes_schema_artifacts(&mut db, device_id, group).await?; - if !authorised { - // Refused the same way whether the group exists or not, so the - // endpoint is not a directory of which groups have a builder. - return Err(AppError::AuthInsufficientPermissions { - required: "an enabled declaration building this group's artifacts".into(), - }); - } + if named.group.is_some() { + return Err(AppError::AuthInsufficientPermissions { + required: "authorisation for the named group".into(), + }); + } - if body.len() > MAX_HELD_ARTIFACT_BYTES { - return Err(AppError::BadRequest(format!( - "artifact is larger than the {MAX_HELD_ARTIFACT_BYTES} byte limit" - ))); - } - if body.is_empty() { - return Err(AppError::BadRequest( - "a group-scoped artifact carries its bytes".into(), - )); - } + // A blank body is no location at all. The constraint only tests for NULL, + // so an empty string would pass it and leave an artifact nothing can be + // fetched from. + // spec: ART#where-an-artifact-rests + if url.trim().is_empty() { + return Err(AppError::BadRequest( + "an artifact needs a download URL".into(), + )); + } - // Provenance is what an operator reads to answer what produced the - // bytes, so a run already recorded for somebody else is not one - // this registration may name. - if let Some(run) = named.run - && RestoreReplica::run_claimed_elsewhere(&mut db, run, device_id, group).await? - { - return Err(AppError::BadRequest( - "the named run belongs to another consumer or group".into(), - )); - } + // A blank digest is no digest: recorded, it says the bytes were checked + // against something when nothing was. + // spec: ART#digests + let digest = named + .digest + .filter(|d| !d.trim().is_empty()) + .map(|d| parse_sri(&d)) + .transpose()?; - Some(group) - } - }; + let mut db = db.get().await?; + let device_id = device.0.0.id; let (version_id, version_range_pattern) = if let Ok(semver) = SemverVersion::parse(&version) { let version_str = VersionStr(semver); - let existing = match Version::get_by_version(&mut db, version_str.clone()).await { - Ok(version) => Some(version), - Err(AppError::DatabaseQuery(diesel::result::Error::NotFound)) => None, - Err(error) => return Err(error), - }; - - let version_id = match existing { - Some(version) => version.id, - // A build is dispatched for a pair whose version Canopy already - // holds, so a group-scoped registration names one rather than - // drafting a release nobody has cut. - // spec: RPT#pairs - None if held.is_some() => { - return Err(AppError::BadRequest(format!( - "no version {version} to register a group-scoped artifact against" - ))); - } - // The version a releaser names may not exist yet: it is created as a - // draft so the artifact has something to attach to, and publishing it - // stays a separate step. - None => { + // The version an artifact names may not exist yet: it is created as a + // draft so the artifact has something to attach to, and publishing it + // stays a separate step. + let version_id = match Version::get_by_version(&mut db, version_str.clone()).await { + Ok(version) => version.id, + Err(_) => { let new_version = NewVersion { major: version_str.0.major as _, minor: version_str.0.minor as _, @@ -277,72 +225,167 @@ async fn create( (Some(version_id), None) } else { - // A schema follows the migrations one exact version applies, and Canopy - // resolves a range artifact for every version it covers. - // spec: RPT#the-build-contract - if artifact_type == REPORTING_SCHEMA_TYPE { - return Err(AppError::BadRequest( - "a reporting schema is registered against an exact version, not a range".into(), - )); - } - Range::parse(&version).map_err(|_| AppError::custom("Invalid version or version range"))?; (None, Some(version.clone())) }; + let row = ArtifactRow::register( + &mut db, + NewArtifact { + version_id, + platform, + artifact_type, + download_url: Some(url), + device_id: Some(device_id), + version_range_pattern, + group_id: None, + content: None, + content_type: None, + digest, + run_id: None, + }, + ) + .await?; + + let base = crate::versions::public_base_url(&headers); + Ok(Json(Artifact::offered(row, &base, &version))) +} + +/// Register a reporting schema for one group, carrying its bytes. +/// +/// Requires a device certificate whose restore declaration for the named group +/// advertises that it builds reporting schemas. The bytes travel on this +/// connection and Canopy holds them, so the builder is issued no credential to +/// any store. The path names the group the artifact is for, the exact version +/// it was built against, and the artifact's type and target platform. +/// +/// The version must be one Canopy already holds: a build is dispatched for a +/// group and version Canopy knows about, so a version that does not exist is +/// refused rather than drafted. A range pattern is refused for the same reason: +/// a schema follows the migrations one exact version applies. +/// +/// Returns the created artifact record. +#[utoipa::path( + post, + path = "/groups/{group}/{version}/{artifact_type}/{platform}", + operation_id = "register_group_artifact", + tag = "artifacts", + security(("backup-restore-device" = [])), + params( + ("group" = Uuid, Path, description = "Group the artifact is for."), + ("version" = String, Path, description = "Exact semver (e.g. `2.10.5`) the schema was built against."), + ("artifact_type" = String, Path, description = "Must be `reporting-schema`: the authorisation is defined with that artifact."), + ("platform" = String, Path), + ("run" = Option, Query, description = "The run that produced the artifact, where one produced it."), + ), + request_body(content = Vec, content_type = "application/octet-stream", description = "The artifact's bytes, which Canopy holds and records the digest of."), + responses( + (status = 200, body = Artifact), + (status = 400, body = ProblemDetailsSchema), + (status = 401, body = ProblemDetailsSchema), + (status = 403, body = ProblemDetailsSchema), + ), +)] +#[axum::debug_handler] +async fn register_for_group( + device: AuthDevice, + State(db): State, + Path((group, version, artifact_type, platform)): Path<(Uuid, String, String, String)>, + Query(named): Query, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> Result> { + use node_semver::Version as SemverVersion; + + let mut db = db.get().await?; + let device_id = device.0.id; + + // What a schema builder is authorised for is the artifact its declaration + // names. Any other type registered under it would displace the releaser's + // own for every machine in the group, and those machines fetch and run what + // they are offered. + // spec: ART#registration + if artifact_type != REPORTING_SCHEMA_TYPE { + return Err(AppError::AuthInsufficientPermissions { + required: format!("a group-scoped artifact to be a {REPORTING_SCHEMA_TYPE}"), + }); + } + + let authorised = device.0.role == DeviceRole::Admin + || RestoreReplica::authorizes_schema_artifacts(&mut db, device_id, group).await?; + if !authorised { + // Refused the same way whether the group exists or not, so the endpoint + // is not a directory of which groups have a builder. + return Err(AppError::AuthInsufficientPermissions { + required: "an enabled declaration building this group's artifacts".into(), + }); + } + + if body.len() > MAX_HELD_ARTIFACT_BYTES { + return Err(AppError::BadRequest(format!( + "artifact is larger than the {MAX_HELD_ARTIFACT_BYTES} byte limit" + ))); + } + if body.is_empty() { + return Err(AppError::BadRequest( + "a group-scoped artifact carries its bytes".into(), + )); + } + + // Provenance is what an operator reads to answer what produced the bytes, + // so a run already recorded for somebody else is not one this registration + // may name. + if let Some(run) = named.run + && RestoreReplica::run_claimed_elsewhere(&mut db, run, device_id, group).await? + { + return Err(AppError::BadRequest( + "the named run belongs to another consumer or group".into(), + )); + } + + // A schema follows the migrations one exact version applies, and Canopy + // resolves a range artifact for every version it covers. + // spec: RPT#the-build-contract + let semver = SemverVersion::parse(&version) + .map_err(|_| AppError::BadRequest("a reporting schema names an exact version".into()))?; + + // A build is dispatched for a pair whose version Canopy already holds, so + // this names one rather than drafting a release nobody has cut. + // spec: RPT#pairs + let version_row = match Version::get_by_version(&mut db, VersionStr(semver)).await { + Ok(version) => version, + Err(AppError::DatabaseQuery(diesel::result::Error::NotFound)) => { + return Err(AppError::BadRequest(format!( + "no version {version} to register a group-scoped artifact against" + ))); + } + Err(error) => return Err(error), + }; + let content_type = headers .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(str::to_owned); - // A blank digest is no digest: recorded, it says the bytes were checked - // against something when nothing was. + // Canopy holds these bytes, so it records the digest of what it actually + // took in rather than one the registration claims for them. // spec: ART#digests - let named_digest = named - .digest - .filter(|d| !d.trim().is_empty()) - .map(|d| parse_sri(&d)) - .transpose()?; - - // Canopy holds a group-scoped artifact, so it records the digest of what it - // actually took in. An unscoped one is fetched from its location by the - // caller, so its digest is whatever that caller recorded. - // spec: ART#digests - let (download_url, digest, content) = match held { - None => { - let url = String::from_utf8(body.into()) - .map_err(|_| AppError::BadRequest("download URL is not valid UTF-8".into()))?; - // A blank body is no location at all. The constraint only tests for - // NULL, so an empty string would pass it and leave an artifact - // nothing can be fetched from. - // spec: ART#where-an-artifact-rests - if url.trim().is_empty() { - return Err(AppError::BadRequest( - "an artifact needs a download URL".into(), - )); - } - (Some(url), named_digest, None) - } - Some(_) => { - let digest = digest_of(&body); - (None, Some(digest), Some(Vec::from(body))) - } - }; + let digest = digest_of(&body); let row = ArtifactRow::register( &mut db, NewArtifact { - version_id, + version_id: Some(version_row.id), platform, artifact_type, - download_url, + download_url: None, device_id: Some(device_id), - version_range_pattern, - group_id: held, - digest, - content, - content_type: held.and(content_type), + version_range_pattern: None, + group_id: Some(group), + content: Some(Vec::from(body)), + content_type, + digest: Some(digest), run_id: named.run, }, ) @@ -352,14 +395,18 @@ async fn create( Ok(Json(Artifact::offered(row, &base, &version))) } -/// What a registration names beyond the path: the group an artifact is for, -/// the run that produced it, and the digest of an unscoped one. +/// What a group-scoped registration names beside the path. +#[derive(Debug, serde::Deserialize)] +struct GroupRegisterQuery { + /// The run that produced the artifact, where one produced it. + run: Option, +} + +/// What a registration names beside the path. #[derive(Debug, serde::Deserialize)] struct RegisterQuery { /// The group the artifact is for, where it names one. group: Option, - /// The run that produced the artifact, where one produced it. - run: Option, /// The Subresource Integrity digest whoever registers it records, where /// they record one. An unscoped artifact is fetched from its location by /// the caller rather than by Canopy, so this is what that caller checks @@ -367,6 +414,3 @@ struct RegisterQuery { // spec: ART#digests digest: Option, } - -/// The artifact type a reporting-schema build publishes. -const REPORTING_SCHEMA_TYPE: &str = "reporting-schema"; diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 4122155cf..791255dd0 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -207,7 +207,7 @@ async fn a_releaser_cannot_register_for_a_group() { let response = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP_A}" + "/artifacts/groups/{GROUP_A}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .text("https://example.com/x.sql") @@ -622,7 +622,7 @@ async fn an_admin_device_registers_for_any_group() { let scoped = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP_A}" + "/artifacts/groups/{GROUP_A}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -672,9 +672,9 @@ async fn a_registration_with_nothing_in_it_is_refused() { } let malformed = public - .post("/artifacts/2.60.0/installer/windows?group=not-a-uuid") + .post("/artifacts/groups/not-a-uuid/2.60.0/reporting-schema/any") .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) - .text("https://example.com/x.exe") + .text("CREATE VIEW ...") .await; assert_eq!( malformed.status_code(), diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index 462077ccc..d6eb43c83 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -342,7 +342,7 @@ async fn a_builder_publishes_only_for_its_own_group() { let ours = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -352,7 +352,7 @@ async fn a_builder_publishes_only_for_its_own_group() { let theirs = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={OTHER_GROUP}" + "/artifacts/groups/{OTHER_GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .text("CREATE VIEW ...") @@ -361,7 +361,7 @@ async fn a_builder_publishes_only_for_its_own_group() { let nowhere = public .post( - "/artifacts/2.60.0/reporting-schema/any?group=99999999-9999-9999-9999-999999999999", + "/artifacts/groups/99999999-9999-9999-9999-999999999999/2.60.0/reporting-schema/any", ) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .text("CREATE VIEW ...") @@ -388,7 +388,7 @@ async fn a_schema_registered_against_a_range_is_refused() { let ranged = public .post(&format!( - "/artifacts/2.60.x/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.x/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -413,7 +413,7 @@ async fn a_schema_for_an_unknown_version_drafts_none() { let refused = public .post(&format!( - "/artifacts/9999.0.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/9999.0.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -451,7 +451,7 @@ async fn a_disabled_declaration_authorises_nothing() { let refused = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -485,7 +485,7 @@ async fn restoring_for_a_group_does_not_authorise_publishing_its_schema() { let refused = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -540,7 +540,7 @@ async fn a_consumer_cannot_advertise_itself_into_publishing() { let refused = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -640,7 +640,7 @@ async fn a_schema_over_axum_s_default_is_taken_in() { let sql = "-- ".to_owned() + &"x".repeat(3 * 1024 * 1024); let response = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -666,7 +666,7 @@ async fn a_builder_cannot_displace_the_group_s_installer() { let installer = public .post(&format!( - "/artifacts/2.60.0/installer/windows?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/installer/windows" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/octet-stream") @@ -676,7 +676,7 @@ async fn a_builder_cannot_displace_the_group_s_installer() { let schema = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -713,7 +713,7 @@ async fn a_run_another_consumer_reported_cannot_be_claimed() { let claimed = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}&run={run}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any?run={run}" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -723,7 +723,7 @@ async fn a_run_another_consumer_reported_cannot_be_claimed() { let own = public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}&run=99999999-9999-9999-9999-999999999999" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any?run=99999999-9999-9999-9999-999999999999" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") @@ -992,7 +992,7 @@ async fn a_registered_schema_is_offered_back_byte_for_byte() { public .post(&format!( - "/artifacts/2.60.0/reporting-schema/any?group={GROUP}" + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" )) .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .add_header("content-type", "application/sql") From 9cd030062936f66872bb8a3f3635cc6667c003ef Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:37:18 +1200 Subject: [PATCH 099/130] settle artifact reads and digests --- crates/database/src/artifacts.rs | 121 ++++++++++++------ crates/database/tests/it/artifact_scopes.rs | 79 +++++++++++- crates/private-server/src/fns/versions.rs | 32 +++-- crates/private-server/tests/it/artifacts.rs | 48 +++++++ crates/public-server/src/artifacts.rs | 23 +--- crates/public-server/src/versions.rs | 24 +++- .../public-server/tests/it/artifact_scopes.rs | 46 +++++++ private-web/openapi.json | 9 ++ private-web/src/api-types.ts | 5 +- private-web/src/api.ts | 66 +++++----- 10 files changed, 345 insertions(+), 108 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 526f383e8..cc0af690a 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -138,9 +138,27 @@ pub fn parse_sri(value: &str) -> Result> { /// A blank URL is no location at all. The constraint only tests for NULL, so an /// empty string passes it and leaves an artifact nothing can be fetched from. +/// +/// The trimmed value is what is kept: a plain-text body picks up whatever +/// newline the shell that sent it added, and a URL nothing can parse is offered +/// to every device that asks. // spec: ART#where-an-artifact-rests fn location(url: Option) -> Option { - url.filter(|url| !url.trim().is_empty()) + url.map(|url| url.trim().to_owned()) + .filter(|url| !url.is_empty()) +} + +/// The digest a registration names, where it names one at all. +/// +/// A blank digest is no digest: recorded, it says the bytes were checked +/// against something when nothing was. +// spec: ART#digests +pub fn parse_sri_opt(value: Option<&str>) -> Result>> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(parse_sri) + .transpose() } impl NewArtifact { @@ -196,11 +214,27 @@ impl Artifact { /// equally specific. // spec: ART#what-a-version-offers fn offered(artifacts: Vec, scope: Scope) -> Vec { - let mut seen = std::collections::HashSet::new(); + let offered = Self::offered_ids(&artifacts, scope); artifacts .into_iter() + .filter(|a| offered.contains(&a.id)) + .collect() + } + + /// The ids of the artifacts a sorted match set offers `scope`. + /// + /// The operator view answers the same question per resolved scope, so the + /// rule lives here rather than being written out again beside it: the two + /// disagreeing would have the listing mark an artifact as served that the + /// public path passes over. + // spec: ART#what-a-version-offers + fn offered_ids(artifacts: &[Self], scope: Scope) -> std::collections::HashSet { + let mut seen = std::collections::HashSet::new(); + artifacts + .iter() .filter(|a| scope.sees(a.group_id)) - .filter(|a| seen.insert((a.artifact_type.clone(), a.platform.clone()))) + .filter(|a| seen.insert((a.artifact_type.as_str(), a.platform.as_str()))) + .map(|a| a.id) .collect() } @@ -340,11 +374,22 @@ impl Artifact { pub async fn content_for( db: &mut AsyncPgConnection, artifact_id: Uuid, + scope: Scope, ) -> Result> { use crate::schema::artifacts::dsl::*; - let row: Option<(Option>, Option, Option>)> = artifacts - .filter(id.eq(artifact_id)) + // The scope is part of the read rather than the caller's to remember: + // the bytes of a group's artifact are the thing the boundary exists to + // keep, and an id is guessable in a way a query is not. + // spec: ART#who-is-offered-a-group-scoped-artifact + let mut query = artifacts.filter(id.eq(artifact_id)).into_boxed(); + query = match scope { + Scope::Unscoped => query.filter(group_id.is_null()), + Scope::Group(caller) => query.filter(group_id.is_null().or(group_id.eq(caller))), + Scope::Fleet => query, + }; + + let row: Option<(Option>, Option, Option>)> = query .select((content, content_type, digest)) .first(db) .await @@ -416,9 +461,9 @@ impl Artifact { // An artifact Canopy holds has no location to change. Replacing its // bytes is a registration, which is what carries the digest. // spec: ART#where-an-artifact-rests - let scoped: Option = artifacts + let (scoped, current_url): (Option, Option) = artifacts .filter(id.eq(artifact_id)) - .select(group_id) + .select((group_id, download_url)) .first(db) .await .map_err(AppError::from)?; @@ -437,11 +482,18 @@ impl Artifact { _ => {} } + // A digest describes the bytes at a location, so it does not survive + // the location changing: kept, it has every device that honours it + // refuse a file that is the right one. + // spec: ART#digests + let moved = new_url != current_url; + match diesel::update(artifacts.filter(id.eq(artifact_id))) .set(( artifact_type.eq(new_type), platform.eq(new_platform), download_url.eq(new_url), + moved.then_some(digest.eq(None::>)), )) .execute(db) .await @@ -479,7 +531,6 @@ impl Artifact { target_version_id: Uuid, scope: Scope, ) -> Result> { - let version = crate::versions::Version::get_by_id(db, target_version_id).await?; let matching_artifacts = Self::get_for_version_all_matches(db, target_version_id, scope).await?; @@ -503,35 +554,32 @@ impl Artifact { resolved => vec![resolved], }; - let mut public_api_ids: std::collections::HashSet = std::collections::HashSet::new(); - for scope in scopes { - let mut seen = std::collections::HashSet::new(); - for a in matching_artifacts.iter().filter(|a| scope.sees(a.group_id)) { - if seen.insert((a.artifact_type.as_str(), a.platform.as_str())) { - public_api_ids.insert(a.id); - } - } - } - - // Only a range artifact can be the one an exact artifact displaces, so - // the rest of the table has no bearing on the answer. - use crate::schema::artifacts::*; - let ranges: Vec = table - .select(Self::as_select()) - .filter(version_range_pattern.is_not_null()) - .load(db) - .await?; + let public_api_ids: std::collections::HashSet = scopes + .into_iter() + .flat_map(|scope| Self::offered_ids(&matching_artifacts, scope)) + .collect(); - let semver = version.as_semver(); + // Only a range artifact can be the one an exact artifact displaces, and + // a range only reached this set by matching this version, so what + // displaces what is answerable from the set itself. + let ranges: Vec<&Self> = matching_artifacts + .iter() + .filter(|a| a.version_range_pattern.is_some()) + .collect(); let result = matching_artifacts - .into_iter() + .iter() .map(|a| { let is_exact = a.version_id == Some(target_version_id); - let has_range_override = Self::overridden_range(&ranges, &a, &semver); + let has_range_override = Self::overridden_range(&ranges, a); let is_used_in_public_api = public_api_ids.contains(&a.id); - (a, is_exact, has_range_override, is_used_in_public_api) + ( + a.clone(), + is_exact, + has_range_override, + is_used_in_public_api, + ) }) .collect(); @@ -539,12 +587,15 @@ impl Artifact { } /// Whether an exact artifact displaces a range artifact that also matches. - fn overridden_range(all: &[Self], artifact: &Self, semver: &node_semver::Version) -> bool { + /// + /// `ranges` are the range artifacts of the same match set, which are there + /// only because they match this version already. + fn overridden_range(ranges: &[&Self], artifact: &Self) -> bool { if artifact.version_id.is_none() { return false; } - all.iter().any(|other| { + ranges.iter().any(|other| { other.artifact_type == artifact.artifact_type && other.platform == artifact.platform // A range this artifact's own scope cannot see is not one it @@ -553,12 +604,6 @@ impl Artifact { // or an unscoped one. // spec: ART#what-a-version-offers && (other.group_id.is_none() || other.group_id == artifact.group_id) - && other.id != artifact.id - && other - .version_range_pattern - .as_deref() - .and_then(|pattern| node_semver::Range::parse(pattern).ok()) - .is_some_and(|range| range.satisfies(semver)) }) } } diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index dfcc8b4d9..0e8ea0564 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -195,7 +195,7 @@ async fn registering_again_replaces_the_bytes_it_held() { .expect("operator view"); assert_eq!(all.len(), 1, "a caller is never offered two of a kind"); - let content = Artifact::content_for(&mut conn, second.id) + let content = Artifact::content_for(&mut conn, second.id, Scope::Fleet) .await .expect("read content") .expect("bytes are held"); @@ -564,7 +564,7 @@ async fn deleting_an_artifact_takes_its_bytes() { .expect("delete"); assert!( - Artifact::content_for(&mut conn, artifact.id) + Artifact::content_for(&mut conn, artifact.id, Scope::Fleet) .await .expect("read content") .is_none() @@ -590,7 +590,7 @@ async fn an_unscoped_artifact_holds_no_bytes() { .expect("register"); assert!( - Artifact::content_for(&mut conn, artifact.id) + Artifact::content_for(&mut conn, artifact.id, Scope::Fleet) .await .expect("read content") .is_none() @@ -807,3 +807,76 @@ async fn the_group_scope_migration_reverses() { }) .await; } + +/// A digest describes the bytes at a location, so moving the location drops it. +/// Kept, every device that honours the digest refuses a file that is the right +/// one, and one that ignores it verifies new bytes against an old hash. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn moving_an_artifact_drops_the_digest_of_where_it_was() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let mut first = unscoped(version, "installer", "https://x/first.exe"); + first.digest = Some(digest_of(b"the first build")); + let artifact = Artifact::register(&mut conn, first) + .await + .expect("register"); + assert!(artifact.digest.is_some()); + + // Renaming without touching the location keeps it: the bytes it + // describes have not moved. + Artifact::update( + &mut conn, + artifact.id, + "installer".into(), + "windows".into(), + Some("https://x/first.exe".into()), + ) + .await + .expect("rename"); + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("list"); + assert!(all[0].digest.is_some(), "the bytes did not move"); + + Artifact::update( + &mut conn, + artifact.id, + "installer".into(), + "windows".into(), + Some("https://x/second.exe".into()), + ) + .await + .expect("move"); + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("list"); + assert!( + all[0].digest.is_none(), + "a digest for the old location is worse than none" + ); + }) + .await; +} + +/// A URL is stored as it was tested. A plain-text body picks up whatever the +/// shell that sent it added, and a location nothing can parse is handed to +/// every device that asks. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_location_is_recorded_trimmed() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let artifact = Artifact::register( + &mut conn, + unscoped(version, "installer", " https://x/y.exe\n"), + ) + .await + .expect("register"); + + assert_eq!(artifact.download_url.as_deref(), Some("https://x/y.exe")); + }) + .await; +} diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 7bef2696f..df023d6f2 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -9,7 +9,7 @@ use commons_errors::{AppError, ProblemDetailsSchema, Result}; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ - artifacts::{Artifact, NewArtifact, Scope, digest_of, parse_sri, sri}, + artifacts::{Artifact, NewArtifact, Scope, digest_of, parse_sri, parse_sri_opt, sri}, server_groups::ServerGroup, version_known_issues::VersionKnownIssue, versions::Version, @@ -26,6 +26,10 @@ use crate::state::AppState; /// Postgres alongside everything else. const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; +/// Header the SPA sets on an upload, which no cross-origin page can send +/// without the browser preflighting the request first. +const FETCH_HEADER: &str = "x-canopy-upload"; + /// Body budget for `upload_artifact`. Sizing above the cap keeps an over-limit /// upload the handler's structured refusal rather than axum's plain-text 413. const MAX_UPLOAD_ARTIFACT_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; @@ -677,13 +681,7 @@ pub async fn create_artifact( // the bytes it got against, so one that cannot be checked against is // refused rather than published. // spec: ART#digests - let digest = args - .digest - .as_deref() - .map(str::trim) - .filter(|d| !d.is_empty()) - .map(parse_sri) - .transpose()?; + let digest = parse_sri_opt(args.digest.as_deref())?; // Where the artifact rests, and the refusal when it names neither place or // both, is `Artifact::register`'s to settle. @@ -736,7 +734,10 @@ pub struct UploadArtifactQuery { path = "/upload_artifact", tag = "versions", security(("tailscale-admin" = [])), - params(UploadArtifactQuery), + params( + UploadArtifactQuery, + ("x-canopy-upload" = String, Header, description = "Any value. Required: it makes a browser preflight the request, so a cross-origin page cannot spend an operator's session on this endpoint."), + ), request_body(content = Vec, content_type = "application/octet-stream", description = "The artifact's bytes."), responses( (status = 200, body = ArtifactData), @@ -750,6 +751,17 @@ pub async fn upload_artifact( headers: axum::http::HeaderMap, body: Bytes, ) -> Result> { + // Every other write here carries a JSON body, which is not a content type a + // form can send, so the browser preflights it and a cross-origin page never + // reaches it. This one takes raw bytes, so it asks for a header of its own + // to the same end: the operator's tailnet identity is supplied by the proxy, + // and a page they merely visited must not be able to spend it. + if !headers.contains_key(FETCH_HEADER) { + return Err(AppError::BadRequest(format!( + "an upload must carry the {FETCH_HEADER} header" + ))); + } + let mut conn = state.db.get().await?; if body.len() > MAX_HELD_ARTIFACT_BYTES { @@ -788,7 +800,7 @@ pub async fn upload_artifact( device_id: None, version_range_pattern: None, group_id: Some(named.group_id), - content: Some(body.to_vec()), + content: Some(Vec::from(body)), content_type, digest: Some(digest), run_id: None, diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 5aaa95dc0..6592b46bb 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -104,6 +104,7 @@ async fn a_registration_that_rests_nowhere_is_refused() { // Bytes that are not the digest the upload names. let mismatched = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "installer") .add_query_param("platform", "any") @@ -121,6 +122,7 @@ async fn a_registration_that_rests_nowhere_is_refused() { for claimed in ["", " ", "sha256:abcd", "notadigest", "sha256-abcd"] { let refused = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "installer") .add_query_param("platform", "any") @@ -204,6 +206,7 @@ async fn an_operator_registers_a_group_scoped_artifact() { let response = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "reporting-schema") .add_query_param("platform", "any") @@ -242,6 +245,7 @@ async fn a_registration_naming_no_group_that_exists_is_refused() { let created = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "reporting-schema") .add_query_param("platform", "any") @@ -362,6 +366,7 @@ async fn an_upload_over_the_limit_is_told_what_it_is() { let four_mib = vec![0u8; 4 * 1024 * 1024]; let accepted = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "reporting-schema") .add_query_param("platform", "any") @@ -374,6 +379,7 @@ async fn an_upload_over_the_limit_is_told_what_it_is() { let over_limit = vec![0u8; 32 * 1024 * 1024 + 1]; let refused = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "reporting-schema") .add_query_param("platform", "linux") @@ -493,6 +499,7 @@ async fn a_registration_answers_what_it_overrides() { let held = private .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") .add_query_param("version_id", version) .add_query_param("artifact_type", "reporting-schema") .add_query_param("platform", "any") @@ -527,3 +534,44 @@ async fn a_registration_answers_what_it_overrides() { }) .await } + +/// Every other write here carries a JSON body, which a form cannot send, so the +/// browser preflights it and a cross-origin page never reaches it. This one +/// takes raw bytes, so it asks for a header of its own to the same end: the +/// operator's identity comes from the proxy, and a page they merely visited +/// must not be able to spend it. +#[tokio::test(flavor = "multi_thread")] +async fn an_upload_without_the_fetch_header_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "aaaaaaaa-9999-0000-0000-aaaaaaaaaaaa"; + let group = "cccccccc-9999-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let refused = private + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await; + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + + // Nothing was written by it. + let listed = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + let artifacts: Vec = listed.json(); + assert!(artifacts.is_empty(), "the refusal wrote nothing"); + }) + .await +} diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 3b6e1ebd4..815098d45 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -8,7 +8,7 @@ use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ Db, - artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, parse_sri, sri}, + artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, parse_sri_opt, sri}, machines::Machine, versions::{NewVersion, Version}, }; @@ -155,24 +155,9 @@ async fn create( }); } - // A blank body is no location at all. The constraint only tests for NULL, - // so an empty string would pass it and leave an artifact nothing can be - // fetched from. - // spec: ART#where-an-artifact-rests - if url.trim().is_empty() { - return Err(AppError::BadRequest( - "an artifact needs a download URL".into(), - )); - } - - // A blank digest is no digest: recorded, it says the bytes were checked - // against something when nothing was. - // spec: ART#digests - let digest = named - .digest - .filter(|d| !d.trim().is_empty()) - .map(|d| parse_sri(&d)) - .transpose()?; + // Where the artifact rests, and the refusal for a body that is no location + // at all, is `Artifact::register`'s to settle. + let digest = parse_sri_opt(named.digest.as_deref())?; let mut db = db.get().await?; let device_id = device.0.0.id; diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index eb0a79cc7..f2a99f6ee 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -671,24 +671,34 @@ async fn download_artifact( let artifact_uuid = Uuid::parse_str(&artifact_id) .map_err(|_| AppError::BadRequest("Invalid artifact ID".into()))?; - // Resolution is what enforces the boundary: an artifact scoped to a group - // this caller is not offered is simply not in the set, so it is missing in - // exactly the way an artifact that never existed is. + // The boundary is what the caller may see, not what it is currently offered: + // a URL Canopy handed out keeps working after a more specific artifact of + // the same type and platform is registered. An artifact this caller may not + // see is missing in exactly the way one that never existed is. // spec: ART#who-is-offered-a-group-scoped-artifact - let artifacts = ArtifactRow::get_for_version(&mut db, version.id, scope).await?; + let artifacts = ArtifactRow::get_for_version_all_matches(&mut db, version.id, scope).await?; let artifact = artifacts .into_iter() .find(|a| a.id == artifact_uuid) .ok_or(AppError::ArtifactNotFound)?; - if let Some(held) = ArtifactRow::content_for(&mut db, artifact.id).await? { - if database::artifacts::digest_of(&held.bytes) != held.digest { + if let Some(held) = ArtifactRow::content_for(&mut db, artifact.id, scope).await? { + // Hashing the whole artifact is tens of milliseconds with no await in + // it, and a fleet fetching one schema at once would spend that on the + // runtime's own threads. + let held = tokio::task::spawn_blocking(move || { + (database::artifacts::digest_of(&held.bytes) == held.digest).then_some(held) + }) + .await + .map_err(|err| AppError::custom(format!("verifying the artifact failed: {err}")))?; + + let Some(held) = held else { tracing::error!( artifact = %artifact.id, "held artifact does not match its digest; refusing to serve" ); return Err(AppError::ArtifactDigestMismatch); - } + }; let content_type = held .content_type diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index cf31524ea..c52c9b71e 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -893,3 +893,49 @@ async fn a_known_issue_does_not_withhold_a_version_s_own_artifacts() { ) .await } + +/// A URL Canopy handed out keeps working after a more specific artifact of the +/// same type and platform is registered. Fetchability is what a caller may see, +/// not which artifact currently wins the offer. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn a_displaced_artifact_is_still_fetchable() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + // A range artifact of group A's, which group A's exact one displaces. + let ranged = "44444444-4444-4444-4444-444444444444"; + let digest = hex::encode(digest_of(b"the range schema")); + conn.batch_execute(&format!( + "INSERT INTO artifacts + (id, version_id, platform, artifact_type, version_range_pattern, group_id, content, content_type, digest) + VALUES ('{ranged}', NULL, 'any', 'reporting-schema', '2.60.x', '{GROUP_A}', + 'the range schema'::bytea, 'application/sql', '\\x{digest}'::bytea)" + )) + .await + .expect("seed the range artifact"); + + let listed = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + listed.assert_status_ok(); + let artifacts: Vec = listed.json(); + assert_eq!(artifacts.len(), 1, "one artifact per type and platform"); + assert_eq!(artifacts[0]["id"], THEIRS, "the exact one is offered"); + + // The one specificity passed over is still fetchable by the URL it + // was offered under before. + let download = public + .get(&format!("/versions/2.60.0/artifacts/{ranged}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + download.assert_status_ok(); + assert_eq!(download.text(), "the range schema"); + }, + ) + .await +} diff --git a/private-web/openapi.json b/private-web/openapi.json index 42611252e..8207fc8c5 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -8304,6 +8304,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-canopy-upload", + "in": "header", + "description": "Any value. Required: it makes a browser preflight the request, so a cross-origin page cannot spend an operator's session on this endpoint.", + "required": true, + "schema": { + "type": "string" + } } ], "requestBody": { diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 58cbc51ef..4388b2448 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -16257,7 +16257,10 @@ export interface operations { */ digest: string; }; - header?: never; + header: { + /** @description Any value. Required: it makes a browser preflight the request, so a cross-origin page cannot spend an operator's session on this endpoint. */ + "x-canopy-upload": string; + }; path?: never; cookie?: never; }; diff --git a/private-web/src/api.ts b/private-web/src/api.ts index ebeb32d6a..9a4b91a2b 100644 --- a/private-web/src/api.ts +++ b/private-web/src/api.ts @@ -84,6 +84,10 @@ export async function uploadApi( method: "POST", headers: { "content-type": body.type || "application/octet-stream", + // A raw body is a content type a form can send, so this is what + // makes the browser preflight the request and keeps a + // cross-origin page off the endpoint. + "x-canopy-upload": "1", }, body, }, @@ -203,34 +207,12 @@ export function useApiAction< error: Error | null; reset: () => void; } { - const [pending, setPending] = useState(false); - const [error, setError] = useState(null); - - const call = useCallback( - async (params: Record = {}): Promise => { - setPending(true); - setError(null); - try { - const result = await callApi(module, fn, params); - // Broadcast so global, page-agnostic queries (e.g. the open- - // incidents nav badge) can refetch without the caller having - // to know they exist. Listeners hook via useReloadInterval. - document.dispatchEvent(new Event("canopy-data-changed")); - return result; - } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)); - setError(e); - throw e; - } finally { - setPending(false); - } - }, - [module, fn], + return useApiCall<[Record?], T>( + useCallback( + (params = {}) => callApi(module, fn, params), + [module, fn], + ), ); - - const reset = useCallback(() => setError(null), []); - - return { call, pending, error, reset }; } /** @@ -244,16 +226,40 @@ export function useApiUpload( pending: boolean; error: Error | null; reset: () => void; +} { + return useApiCall<[Record, Blob], T>( + useCallback( + (query, body) => uploadApi(module, fn, query, body), + [module, fn], + ), + ); +} + +/** + * The pending/error bookkeeping both write hooks share, so the two cannot + * differ on what they report or on telling the rest of the page that + * something changed. + */ +function useApiCall( + request: (...args: A) => Promise, +): { + call: (...args: A) => Promise; + pending: boolean; + error: Error | null; + reset: () => void; } { const [pending, setPending] = useState(false); const [error, setError] = useState(null); const call = useCallback( - async (query: Record, body: Blob): Promise => { + async (...args: A): Promise => { setPending(true); setError(null); try { - const result = await uploadApi(module, fn, query, body); + const result = await request(...args); + // Broadcast so global, page-agnostic queries (e.g. the open- + // incidents nav badge) can refetch without the caller having + // to know they exist. Listeners hook via useReloadInterval. document.dispatchEvent(new Event("canopy-data-changed")); return result; } catch (err) { @@ -264,7 +270,7 @@ export function useApiUpload( setPending(false); } }, - [module, fn], + [request], ); const reset = useCallback(() => setError(null), []); From ac4ae310e205a9e61142f3e0e169fb736a6f6753 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:10:14 +1200 Subject: [PATCH 100/130] one publisher and one sweep query --- crates/database/src/artifacts.rs | 34 ++++--- crates/database/src/reporting_schemas.rs | 79 +++++++++++------ crates/database/src/restore.rs | 8 ++ crates/database/src/versions.rs | 51 ++++++----- crates/public-server/src/artifacts.rs | 23 +++-- crates/public-server/src/restore.rs | 88 ++++++++++++------- .../tests/it/reporting_schemas.rs | 36 +++++++- .../down.sql | 1 + .../up.sql | 11 +++ 9 files changed, 213 insertions(+), 118 deletions(-) create mode 100644 migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/down.sql create mode 100644 migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/up.sql diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 3735d5987..11814efec 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -378,28 +378,16 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } - /// When any artifact a build reads was last registered for this version. + /// When any artifact a build reads was last registered for each of these + /// versions, in two queries however many versions are asked about. /// /// A schema built from a superseded release of a version is not the schema /// that version describes, so this is what a build is held against. Only /// the unscoped artifacts count: a group-scoped one is a build's own output, /// and registering it would put every group's pair for the version back on - /// the worklist, including the pair that just produced it. - // spec: RPT#pairs - pub async fn newest_change_for_version( - db: &mut AsyncPgConnection, - version: Uuid, - ) -> Result> { - let version = Version::get_by_id(db, version).await?; - let newest = Self::newest_change_for_versions(db, std::slice::from_ref(&version)).await?; - Ok(newest.get(&version.id).copied()) - } - - /// When any artifact a build reads was last registered for each of these - /// versions, in two queries however many versions are asked about. - /// - /// A range artifact counts for every version it covers, since that is how - /// one is resolved for a build. + /// the worklist, including the pair that just produced it. A range artifact + /// counts for every version it covers, since that is how one is resolved + /// for a build. // spec: RPT#pairs pub async fn newest_change_for_versions( db: &mut AsyncPgConnection, @@ -422,10 +410,17 @@ impl Artifact { .filter_map(|(id, at)| Some((id?, at?.into()))) .collect(); - let ranges: Vec<(Option, jiff_diesel::Timestamp)> = dsl::artifacts + // One row per distinct pattern rather than per artifact: the answer only + // needs the newest change under each, and every row returned costs a + // semver parse below. + let ranges: Vec<(Option, Option)> = dsl::artifacts .filter(dsl::version_id.is_null()) .filter(dsl::group_id.is_null()) - .select((dsl::version_range_pattern, dsl::updated_at)) + .group_by(dsl::version_range_pattern) + .select(( + dsl::version_range_pattern, + diesel::dsl::max(dsl::updated_at), + )) .load(db) .await .map_err(AppError::from)?; @@ -439,6 +434,7 @@ impl Artifact { else { continue; }; + let Some(at) = at else { continue }; let at: jiff::Timestamp = at.into(); for version in versions.iter().filter(|v| range.satisfies(&v.as_semver())) { diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index db2aa8410..8de10abb1 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -324,7 +324,7 @@ pub struct Pair { /// nothing will pick up. // spec: RPT#pairs pub async fn pairs_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { - if !group_builds_schemas(db, group).await? { + if !groups_building_schemas(db).await?.contains(&group) { return Ok(Vec::new()); } @@ -473,17 +473,28 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { }; use commons_types::status::CheckResult; - for group in ServerGroup::list_all(db).await? { - if !group_builds_schemas(db, group.id).await? { - continue; - } + // Which groups have a builder is one question of the whole fleet rather than + // one per group: asking per group walked every group's declarations and + // every declaration's consumer, once a minute, for groups that have none. + let builders = groups_building_schemas(db).await?; + for group in ServerGroup::list_all(db).await? { let members = Application::list_live_in_group(db, group.id).await?; let Some(central) = ServerGroup::canonical_central(&members).map(|a| a.id) else { continue; }; - let pairs = pairs_of_members(db, group.id, &members).await?; + // A group that has stopped building still has whatever this check filed + // while it did, and nothing else recovers it. Filing no instances is + // what says the finding is gone; where none was open this costs one + // query and writes nothing. + // spec: RPT#alerting + let pairs = if builders.contains(&group.id) { + pairs_of_members(db, group.id, &members).await? + } else { + Vec::new() + }; + let instances: Vec = pairs .iter() .filter(|p| p.state != PairState::Awaiting) @@ -547,28 +558,40 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { Ok(()) } -/// Whether a group has an enabled declaration whose intent builds schemas. +/// The groups an enabled declaration builds schemas for. /// -/// The same predicate that authorises a builder to publish the group's schema, -/// asked of each of its consumers: dispatching builds a group would then refuse -/// to accept is the divergence worth not having. -async fn group_builds_schemas(db: &mut AsyncPgConnection, group: Uuid) -> Result { - use crate::restore::RestoreReplica; - - let mut consumers: Vec = RestoreReplica::list_for_group(db, group) - .await? - .into_iter() - .filter(|d| d.enabled) - .map(|d| d.consumer_device_id) - .collect(); - consumers.sort_unstable(); - consumers.dedup(); - - for consumer in consumers { - if RestoreReplica::authorizes_schema_artifacts(db, consumer, group).await? { - return Ok(true); - } - } +/// The same conditions `RestoreReplica::authorizes_schema_artifacts` asks of one +/// consumer and one group, asked of the fleet at once: dispatching builds a +/// group would then refuse to accept is the divergence worth not having. +async fn groups_building_schemas( + db: &mut AsyncPgConnection, +) -> Result> { + use crate::schema::{restore_consumer_capabilities, restore_replicas}; + use diesel::dsl::sql; + use diesel::sql_types::Bool; + + let groups: Vec = restore_replicas::table + .inner_join( + restore_consumer_capabilities::table.on( + restore_consumer_capabilities::consumer_device_id + .eq(restore_replicas::consumer_device_id) + .and(restore_consumer_capabilities::intent.eq(restore_replicas::intent)), + ), + ) + .filter(restore_replicas::enabled.eq(true)) + .filter(restore_replicas::publishes_schemas.eq(true)) + // Dispatch builds no schema from a redacting or machine-scoped + // declaration, and one nothing is dispatched for publishes nothing. + .filter(restore_replicas::redacts.eq(false)) + .filter(restore_replicas::machine_id.is_null()) + .filter(sql::( + "restore_consumer_capabilities.semantics @> '[\"reporting-schema\"]'::jsonb", + )) + .select(restore_replicas::group_id) + .distinct() + .load(db) + .await + .map_err(AppError::from)?; - Ok(false) + Ok(groups.into_iter().collect()) } diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 5122623f0..b04cbe1f6 100644 --- a/crates/database/src/restore.rs +++ b/crates/database/src/restore.rs @@ -159,6 +159,14 @@ fn unique_violation(info: &dyn diesel::result::DatabaseErrorInformation) -> AppE Some("restore_replicas_consumer_name") | None => { AppError::Conflict("this consumer already has a restore replica with that name".into()) } + // What a builder registers is offered to every machine in the group and + // replaces what was registered before it, so two publishers overwrite + // each other and which schema a machine ends up on is whichever + // reported last. + // spec: RPT#the-build-contract + Some("restore_replicas_one_schema_publisher") => AppError::Conflict( + "another enabled declaration already publishes this group's reporting schema".into(), + ), Some(other) => { AppError::Conflict(format!("this declaration collides with another ({other})")) } diff --git a/crates/database/src/versions.rs b/crates/database/src/versions.rs index 3ae8950ee..88e923c36 100644 --- a/crates/database/src/versions.rs +++ b/crates/database/src/versions.rs @@ -176,38 +176,37 @@ impl Version { ) -> Result> { use crate::schema::versions::dsl::*; - type Predicate = Box< - dyn diesel::BoxableExpression< - crate::schema::versions::table, - diesel::pg::Pg, - SqlType = diesel::sql_types::Bool, - >, - >; - - let mut wants: Option = None; - for want in wanted { - let one: Predicate = Box::new( - major - .eq(want.0.major as i32) - .and(minor.eq(want.0.minor as i32)) - .and(patch.eq(want.0.patch as i32)), - ); - wants = Some(match wants { - Some(so_far) => Box::new(so_far.or(one)), - None => one, - }); + if wanted.is_empty() { + return Ok(Vec::new()); } - let Some(wants) = wants else { - return Ok(Vec::new()); - }; + // The SQL narrows on the major and the triple is matched here: one + // predicate per version builds a boxed OR chain as long as the fleet's + // version spread, for a set small enough to sift in memory. + let mut majors: Vec = wanted.iter().map(|want| want.0.major as i32).collect(); + majors.sort_unstable(); + majors.dedup(); - versions - .filter(wants) + let rows: Vec = versions + .filter(major.eq_any(majors)) .select(Version::as_select()) .load(db) .await - .map_err(AppError::from) + .map_err(AppError::from)?; + + Ok(rows + .into_iter() + .filter(|row| { + wanted.iter().any(|want| { + (row.major, row.minor, row.patch) + == ( + want.0.major as i32, + want.0.minor as i32, + want.0.patch as i32, + ) + }) + }) + .collect()) } pub async fn get_by_id(db: &mut AsyncPgConnection, version_id: Uuid) -> Result { diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 2eb92c63a..0a87b2e54 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -106,6 +106,10 @@ const MAX_UPLOAD_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; /// The artifact type a reporting-schema build publishes. const REPORTING_SCHEMA_TYPE: &str = "reporting-schema"; +/// The platform it publishes on. A schema follows the version's migrations +/// rather than anything about the machine reading it. +const SCHEMA_PLATFORM: &str = "any"; + pub fn routes() -> OpenApiRouter { OpenApiRouter::new().routes(routes!(create)).merge( OpenApiRouter::new() @@ -287,14 +291,17 @@ async fn register_for_group( let device_id = device.0.id; // What a schema builder is authorised for is the artifact its declaration - // names. Any other type registered under it would displace the releaser's - // own for every machine in the group, and those machines fetch and run what - // they are offered. - // spec: ART#registration - if artifact_type != REPORTING_SCHEMA_TYPE { - return Err(AppError::AuthInsufficientPermissions { - required: format!("a group-scoped artifact to be a {REPORTING_SCHEMA_TYPE}"), - }); + // names. Any other type or platform registered under it would displace the + // releaser's own for every machine in the group, and those machines fetch + // and run what they are offered. A schema is one artifact per version, so + // the platform it is published on is fixed too: left open, one builder + // registers a schema per platform and a group is offered every one of them. + // spec: ART#registration, RPT#the-build-contract + if artifact_type != REPORTING_SCHEMA_TYPE || platform != SCHEMA_PLATFORM { + return Err(AppError::BadRequest(format!( + "this registers a {REPORTING_SCHEMA_TYPE} on {SCHEMA_PLATFORM}, not a \ + {artifact_type} on {platform}" + ))); } let authorised = device.0.role == DeviceRole::Admin diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 4fe059152..8cdbcfef3 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -183,6 +183,44 @@ pub struct WorklistEntry { pub target_version_id: Option, } +/// What dispatching a group's schema builds needs of the group itself. +/// +/// A build restores the group's canonical central and differs per pair only in +/// the version it migrates to, so this is the same for every declaration +/// covering the group. +// spec: RPT#the-build-contract +struct SchemaGroup { + machine_id: Uuid, + central_type: commons_types::server::app_type::ApplicationType, + versions: Vec, + settlement: database::reporting_schemas::Settlement, +} + +/// Resolve a group's central and pairs, or `None` where it has no central to +/// build from. +async fn resolve_schema_group( + conn: &mut database::diesel_async::AsyncPgConnection, + group_id: Uuid, +) -> Result> { + let members = database::applications::Application::list_live_in_group(conn, group_id).await?; + let Some(central) = database::server_groups::ServerGroup::canonical_central(&members) else { + return Ok(None); + }; + let central_type = central.r#type.clone(); + let machine = database::machines::Machine::get_by_id(conn, central.machine_id).await?; + + let versions = database::reporting_schemas::versions_for_group(conn, group_id).await?; + let settlement = + database::reporting_schemas::Settlement::for_group(conn, group_id, &versions).await?; + + Ok(Some(SchemaGroup { + machine_id: machine.id, + central_type, + versions, + settlement, + })) +} + /// Fetch the full set of replicas this device should maintain. /// /// Returns the device's complete desired state, computed fresh on every call: @@ -243,13 +281,11 @@ async fn worklist( // covering one group with schema-building intents would each emit the whole // pair list: a restore and a migrate paid for twice per build. let mut pairs: HashSet<(Uuid, Uuid)> = HashSet::new(); - // Resolving a group's pairs walks its applications and their reported - // versions, so a group covered by several declarations is resolved once. - let mut version_cache: HashMap> = HashMap::new(); - // Where each of a group's pairs stands, resolved once for the group rather - // than per pair: every restore consumer polls this on a schedule. - let mut settlement_cache: HashMap = - HashMap::new(); + // Everything a build's dispatch needs of a group: its canonical central, + // the versions its pairs cover, and where each pair stands. Resolved once + // per group rather than per declaration, and the absence of a central is + // cached too, since every restore consumer polls this on a schedule. + let mut schema_groups: HashMap> = HashMap::new(); // Per-group caches so a group referenced by several declarations is resolved // once: the latest produced snapshot per (machine, type), and the latest // healthy-verified snapshot per (machine, type, intent) for `once` suppression. @@ -352,38 +388,22 @@ async fn worklist( params.clone() }; - let members = - database::applications::Application::list_live_in_group(&mut conn, d.group_id) - .await?; - let Some(central) = database::server_groups::ServerGroup::canonical_central(&members) - else { + if !schema_groups.contains_key(&d.group_id) { + let resolved = resolve_schema_group(&mut conn, d.group_id).await?; + schema_groups.insert(d.group_id, resolved); + } + let Some(group) = &schema_groups[&d.group_id] else { continue; }; - let central_type = central.r#type.clone(); - let machine = - database::machines::Machine::get_by_id(&mut conn, central.machine_id).await?; - let latest = snapshots.get(&(machine.id, d.r#type.clone())); - if let std::collections::hash_map::Entry::Vacant(e) = version_cache.entry(d.group_id) { - let versions = - database::reporting_schemas::versions_for_group(&mut conn, d.group_id).await?; - settlement_cache.insert( - d.group_id, - database::reporting_schemas::Settlement::for_group( - &mut conn, d.group_id, &versions, - ) - .await?, - ); - e.insert(versions); - } - let settlement = &settlement_cache[&d.group_id]; + let latest = snapshots.get(&(group.machine_id, d.r#type.clone())); - for version in version_cache[&d.group_id].clone() { + for version in &group.versions { if !pairs.insert((d.group_id, version.id)) { continue; } - if once && settlement.settled(version.id) { + if once && group.settlement.settled(version.id) { continue; } @@ -391,9 +411,9 @@ async fn worklist( out.push(WorklistEntry { replica_id: d.id, group_id: d.group_id, - machine_id: machine.id, - server_id: machine.id, - application_type: Some(central_type.clone()), + machine_id: group.machine_id, + server_id: group.machine_id, + application_type: Some(group.central_type.clone()), r#type: d.r#type.clone(), intent: d.intent.clone(), name: d.name.clone(), diff --git a/crates/public-server/tests/it/reporting_schemas.rs b/crates/public-server/tests/it/reporting_schemas.rs index d6eb43c83..4347fafc2 100644 --- a/crates/public-server/tests/it/reporting_schemas.rs +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -93,14 +93,31 @@ async fn a_second_declaration_dispatches_no_second_build() { async |mut conn, cert, device_id, public, _| { seed(&mut conn, device_id).await; + // A second publisher for the group is refused outright: what a + // builder registers replaces what was registered before it, so two + // would overwrite each other and which schema a machine ends up on + // would be whichever reported last. + // spec: RPT#the-build-contract + let second = conn + .batch_execute(&format!( + "INSERT INTO restore_replicas + (consumer_device_id, group_id, type, intent, name, enabled, publishes_schemas) + VALUES ('{device_id}', '{GROUP}', 'tamanu-postgres', 'schema-build', + 'schemas-weekly', true, true)" + )) + .await; + assert!(second.is_err(), "one publisher per group"); + + // A declaration of the same intent that does not publish is allowed, + // and dispatches nothing of its own. conn.batch_execute(&format!( "INSERT INTO restore_replicas (consumer_device_id, group_id, type, intent, name, enabled, publishes_schemas) VALUES ('{device_id}', '{GROUP}', 'tamanu-postgres', 'schema-build', - 'schemas-weekly', true, true)" + 'schemas-weekly', true, false)" )) .await - .expect("a second schema declaration"); + .expect("a non-publishing declaration"); let response = public .get("/restore-worklist") @@ -672,7 +689,20 @@ async fn a_builder_cannot_displace_the_group_s_installer() { .add_header("content-type", "application/octet-stream") .text("MZ...") .await; - assert_eq!(installer.status_code(), StatusCode::FORBIDDEN); + assert_eq!(installer.status_code(), StatusCode::BAD_REQUEST); + + // The platform is fixed for the same reason the type is: offering + // dedupes per type and platform, so a schema per platform would + // have a group offered every one of them. + let other_platform = public + .post(&format!( + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/windows" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .add_header("content-type", "application/sql") + .text("CREATE VIEW ...") + .await; + assert_eq!(other_platform.status_code(), StatusCode::BAD_REQUEST); let schema = public .post(&format!( diff --git a/migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/down.sql b/migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/down.sql new file mode 100644 index 000000000..7f0e90b1b --- /dev/null +++ b/migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/down.sql @@ -0,0 +1 @@ +DROP INDEX restore_replicas_one_schema_publisher; diff --git a/migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/up.sql b/migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/up.sql new file mode 100644 index 000000000..ce84575fd --- /dev/null +++ b/migrations/2026-09-09-105133-0000_one_schema_publisher_per_group/up.sql @@ -0,0 +1,11 @@ +-- ── One publisher per group ───────────────────────────────────────────────── +-- +-- What a builder registers is offered to every machine in the group, and a +-- registration replaces whatever is already registered for the same version, +-- type, platform and group. Two enabled declarations publishing for one group +-- are therefore both dispatched the same pairs and each overwrite the other's +-- schema, with which one a machine ends up on decided by whichever reported +-- last. The mark is the operator's, so the operator holds it to one. +CREATE UNIQUE INDEX restore_replicas_one_schema_publisher + ON restore_replicas (group_id) + WHERE publishes_schemas AND enabled; From 4e98362ebe62ebdacd35440723093a3d9365f9b1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:50:30 +1200 Subject: [PATCH 101/130] narrow the schema sweep --- crates/database/src/backup/staleness.rs | 23 +++++++++++ crates/database/src/reporting_schemas.rs | 36 +++++++++++++---- crates/database/tests/it/reporting_schemas.rs | 40 +++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/crates/database/src/backup/staleness.rs b/crates/database/src/backup/staleness.rs index 98f04cbe4..5ea049461 100644 --- a/crates/database/src/backup/staleness.rs +++ b/crates/database/src/backup/staleness.rs @@ -634,6 +634,29 @@ pub(crate) async fn open_server_issue_active( Ok(n > 0) } +/// The applications an active `(canopy, ref)` issue is open against, with the +/// group each belongs to. +/// +/// One question of the whole fleet: a sweep that files per group otherwise asks +/// it per group to learn whether it has anything to recover. +pub(crate) async fn applications_with_open_issue( + db: &mut AsyncPgConnection, + r#ref: &str, +) -> Result)>> { + use crate::schema::{applications, issues}; + + issues::table + .inner_join(applications::table.on(applications::id.nullable().eq(issues::application_id))) + .filter(issues::source.eq(refs::CANOPY_SOURCE)) + .filter(issues::ref_.eq(r#ref)) + .filter(issues::active.eq(true)) + .filter(issues::resolved_at.is_null()) + .select((applications::id, applications::group_id)) + .load(db) + .await + .map_err(Into::into) +} + /// Whether a machine-scoped `(canopy, ref)` check last *observed* something /// other than a pass. /// diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 8de10abb1..81f449774 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -466,7 +466,7 @@ async fn versions_and_applications( pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { use crate::{ applications::Application, - backup::refs, + backup::{refs, staleness::applications_with_open_issue}, issues::{CheckInstance, GradedInstance, Scope}, restore::{RestoreCheck, file_restore_check}, server_groups::ServerGroup, @@ -478,17 +478,39 @@ pub async fn sweep(db: &mut AsyncPgConnection) -> Result<()> { // every declaration's consumer, once a minute, for groups that have none. let builders = groups_building_schemas(db).await?; + // A group that has stopped building still has whatever this check filed + // while it did, and filing no instances is what says the finding is gone. + // Asked of the fleet at once, it is also what keeps this to the groups the + // sweep has something to say about: every other group is walked, its + // members loaded and its issues probed, once a minute, to file nothing. + // spec: RPT#alerting + let open = applications_with_open_issue(db, refs::REPORTING_SCHEMA).await?; + let walk: std::collections::HashSet = builders + .iter() + .copied() + .chain(open.iter().filter_map(|(_, group)| *group)) + .collect(); + for group in ServerGroup::list_all(db).await? { + if !walk.contains(&group.id) { + continue; + } + let members = Application::list_live_in_group(db, group.id).await?; - let Some(central) = ServerGroup::canonical_central(&members).map(|a| a.id) else { + // The check files on the group's central. A group that has lost it + // keeps the finding open against whichever application it was filed on, + // which is the only scope a recovery reaches it through. + let Some(central) = ServerGroup::canonical_central(&members) + .map(|a| a.id) + .or_else(|| { + open.iter() + .find(|(_, g)| *g == Some(group.id)) + .map(|(application, _)| *application) + }) + else { continue; }; - // A group that has stopped building still has whatever this check filed - // while it did, and nothing else recovers it. Filing no instances is - // what says the finding is gone; where none was open this costs one - // query and writes nothing. - // spec: RPT#alerting let pairs = if builders.contains(&group.id) { pairs_of_members(db, group.id, &members).await? } else { diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index 0ae774960..867c5cd71 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -657,6 +657,46 @@ async fn the_check_closes_once_the_group_owes_no_schema() { .await; } +/// The check files on the group's central, so a group that has lost it has an +/// open finding nothing else regrades. +#[tokio::test(flavor = "multi_thread")] +async fn a_group_that_lost_its_central_still_recovers() { + TestDb::run(|mut conn, _url| async move { + let (older, _newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + record_build(&mut conn, older, false).await; + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep"); + assert_eq!( + schema_issues(&mut conn).await[0].effective_result, + Some(commons_types::status::CheckResult::Warning), + "the warning stands while the pair is failed" + ); + + conn.batch_execute(&format!( + "UPDATE applications SET deleted_at = NOW() WHERE id = '{CENTRAL}'; + DELETE FROM reporting_schema_builds" + )) + .await + .expect("retire the central"); + + database::reporting_schemas::sweep(&mut conn) + .await + .expect("sweep again"); + + let issues = schema_issues(&mut conn).await; + assert_eq!(issues.len(), 1, "the same check, regraded"); + assert_eq!( + issues[0].effective_result, + Some(commons_types::status::CheckResult::Passed), + "a finding open against a former central is still recovered" + ); + }) + .await; +} + /// A group nothing builds schemas for is owed none, so it presents no pairs /// even where its applications report published versions. Listing them would /// offer an operator a build nothing will pick up. From e29827577a784d28c29629caae6fce659f2b5cbe Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:53:31 +1200 Subject: [PATCH 102/130] keep a later ask --- crates/database/src/reporting_schemas.rs | 40 +++++++++++++-- crates/database/tests/it/reporting_schemas.rs | 49 ++++++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 81f449774..437ba80b0 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -62,6 +62,10 @@ impl ReportingSchemaBuild { build: NewReportingSchemaBuild, ) -> Result { let restore_failed = report.outcome != RunOutcome::Success; + let began_at = match report.run_id { + Some(run) => run_started_at(db, run).await?.unwrap_or(report.observed_at), + None => report.observed_at, + }; let check_id = BackupRestoreCheck::record_report(db, report).await?; @@ -91,8 +95,10 @@ impl ReportingSchemaBuild { .await?; // An operator's ask is answered once the build it asked for lands, - // whichever way it went. - ReportingSchemaRequest::clear(db, build.group_id, build.version_id).await?; + // whichever way it went. A build takes half an hour, and an ask entered + // while it ran is for whatever changed after it began, so what answers + // that one is the next build rather than this. + ReportingSchemaRequest::clear(db, build.group_id, build.version_id, began_at).await?; Ok(check_id) } @@ -208,6 +214,25 @@ impl Settlement { } } +/// When the run behind a report began, read from the first credential it was +/// issued. +/// +/// A run reports once it is over, so its own timestamp is the far end of a +/// window half an hour wide, and what it started before is the question an ask +/// made inside that window turns on. +async fn run_started_at(db: &mut AsyncPgConnection, run: Uuid) -> Result> { + use crate::schema::backup_credential_issuances::dsl; + + let issued: Option = dsl::backup_credential_issuances + .filter(dsl::run_id.eq(Some(run))) + .select(diesel::dsl::min(dsl::issued_at)) + .first(db) + .await + .map_err(AppError::from)?; + + Ok(issued.map(Into::into)) +} + /// An operator asking for a pair's build. #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Selectable, utoipa::ToSchema)] #[diesel(table_name = crate::schema::reporting_schema_requests)] @@ -267,13 +292,20 @@ impl ReportingSchemaRequest { Ok(versions.into_iter().collect()) } - async fn clear(db: &mut AsyncPgConnection, group: Uuid, version: Uuid) -> Result<()> { + /// Clear a pair's ask, where it was made before `answered_at`. + async fn clear( + db: &mut AsyncPgConnection, + group: Uuid, + version: Uuid, + answered_at: Timestamp, + ) -> Result<()> { use crate::schema::reporting_schema_requests::dsl; diesel::delete( dsl::reporting_schema_requests .filter(dsl::group_id.eq(group)) - .filter(dsl::version_id.eq(version)), + .filter(dsl::version_id.eq(version)) + .filter(dsl::requested_at.lt(jiff_diesel::Timestamp::from(answered_at))), ) .execute(db) .await diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index 867c5cd71..2f7ca175b 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -90,6 +90,16 @@ fn report_for( /// Record a build against a throwaway restore report for the pair. async fn record_build(conn: &mut AsyncPgConnection, version: Uuid, built: bool) { + record_build_for_run(conn, version, built, None).await; +} + +/// The same, for a build reported as a named run. +async fn record_build_for_run( + conn: &mut AsyncPgConnection, + version: Uuid, + built: bool, + run_id: Option, +) { let report = NewBackupRestoreCheck { replica_id: None, replica_name: None, @@ -109,7 +119,7 @@ async fn record_build(conn: &mut AsyncPgConnection, version: Uuid, built: bool) s3_received_raw_bytes: None, s3_received_payload_bytes: None, health_details: None, - run_id: None, + run_id, redaction_outcome: None, redaction_manifest_version: None, redaction_columns_masked: None, @@ -232,6 +242,43 @@ async fn an_operator_ask_reinstates_a_settled_pair() { .await; } +/// A build runs for half an hour and reports at the end of it. An ask entered +/// while it ran is for whatever changed after it began, so the build it asked +/// for is the next one. +#[tokio::test(flavor = "multi_thread")] +async fn an_ask_made_while_the_build_ran_stands() { + TestDb::run(|mut conn, _url| async move { + let (_older, newer) = seed(&mut conn).await; + declare_builder(&mut conn, true).await; + + const RUN: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + conn.batch_execute(&format!( + "INSERT INTO backup_credential_issuances + (device_id, group_id, type, issued_at, expires_at, purpose, + sts_assumed_role, bucket, prefix, run_id) + VALUES ('{CONSUMER}', '{GROUP}', 'tamanu-postgres', + now() - interval '30 minutes', now(), 'restore', + 'arn:test', 'b', '', '{RUN}')" + )) + .await + .expect("issue the run its credentials"); + + ReportingSchemaRequest::enqueue(&mut conn, group(), newer, Some("someone@bes.au")) + .await + .expect("enqueue"); + + record_build_for_run(&mut conn, newer, true, Some(RUN.parse().unwrap())).await; + + assert!( + !ReportingSchemaBuild::is_settled(&mut conn, group(), newer) + .await + .unwrap(), + "the ask stands until a build that began after it lands" + ); + }) + .await; +} + /// A replica that failed to restore says nothing about whether the pair can be /// built, so it records no build and the pair stays on the worklist. #[tokio::test(flavor = "multi_thread")] From 1c0d2f7d92dcbfd57f70306e6bd5b8334aa0bd99 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:55:38 +1200 Subject: [PATCH 103/130] guard the publisher mark --- .../src/fns/restore_replicas.rs | 37 +++++++++++-------- .../tests/it/restore_replicas.rs | 30 +++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/private-server/src/fns/restore_replicas.rs b/crates/private-server/src/fns/restore_replicas.rs index b0fda4c04..124a8ddee 100644 --- a/crates/private-server/src/fns/restore_replicas.rs +++ b/crates/private-server/src/fns/restore_replicas.rs @@ -292,6 +292,23 @@ async fn normalized_params_for_intent( publishes_schemas: bool, machine_id: Option, ) -> Result { + // What a build is dispatched for does not depend on the intent's descriptor, + // and the mark is unique per group: a declaration accepted with it here + // holds the group's only publisher slot while authorising nothing. + // spec: RPT#the-build-contract + if publishes_schemas { + if redacts { + return Err(AppError::BadRequest( + "a redacting declaration cannot publish a reporting schema".into(), + )); + } + if machine_id.is_some() { + return Err(AppError::BadRequest( + "a machine-scoped declaration cannot publish a reporting schema: a build is per group".into(), + )); + } + } + let descriptors = RestoreConsumerCapability::list_for_consumer(conn, consumer_device_id).await?; let Some(desc) = descriptors.iter().find(|d| &d.intent == intent) else { @@ -314,22 +331,10 @@ async fn normalized_params_for_intent( // masking manifest has not altered, so a declaration Canopy would never // dispatch a build to cannot be the group's publisher either. // spec: RPT#the-build-contract - if publishes_schemas { - if !desc.has_semantic(semantics::REPORTING_SCHEMA) { - return Err(AppError::BadRequest(format!( - "intent {intent} cannot publish a reporting schema: it does not carry the `reporting-schema` semantic" - ))); - } - if redacts { - return Err(AppError::BadRequest( - "a redacting declaration cannot publish a reporting schema".into(), - )); - } - if machine_id.is_some() { - return Err(AppError::BadRequest( - "a machine-scoped declaration cannot publish a reporting schema: a build is per group".into(), - )); - } + if publishes_schemas && !desc.has_semantic(semantics::REPORTING_SCHEMA) { + return Err(AppError::BadRequest(format!( + "intent {intent} cannot publish a reporting schema: it does not carry the `reporting-schema` semantic" + ))); } let params = if owns_masking { ¶ms diff --git a/crates/private-server/tests/it/restore_replicas.rs b/crates/private-server/tests/it/restore_replicas.rs index 4b2d19206..4a31db38a 100644 --- a/crates/private-server/tests/it/restore_replicas.rs +++ b/crates/private-server/tests/it/restore_replicas.rs @@ -284,6 +284,36 @@ async fn an_intent_that_cannot_redact_refuses_the_flag() { .await; } +/// The publisher mark is unique per group, and a machine-scoped or redacting +/// declaration is one no build is dispatched to. Accepting the mark on one +/// takes the group's only slot and leaves the operator unable to declare the +/// publisher that would work. +#[tokio::test(flavor = "multi_thread")] +async fn a_declaration_no_build_is_dispatched_to_cannot_publish() { + commons_tests::server::run(async |mut conn, _public, private| { + let group = insert_group(&mut conn).await; + let consumer = insert_consumer(&mut conn).await; + let server = insert_server(&mut conn, group).await; + + // The intent is one the consumer has not advertised, so the descriptor + // this would otherwise be checked against does not exist yet. + private + .post("/api/restore_replicas/create") + .json(&serde_json::json!({ + "consumer_device_id": consumer, + "group_id": group, + "machine_id": server, + "type": "tamanu-postgres", + "intent": "reporting-schema", + "name": "one-box-publisher", + "publishes_schemas": true, + })) + .await + .assert_status_bad_request(); + }) + .await; +} + /// A server whose product publishes no manifest is withheld from the /// worklist, so the operator is shown which of the declaration's replicas /// aren't being restored and why. From cc74c5dcf736dc40c6015ec2946d6da371e14ea5 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:56:14 +1200 Subject: [PATCH 104/130] load a group's members once --- crates/database/src/reporting_schemas.rs | 10 +++++++++- crates/public-server/src/restore.rs | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index 437ba80b0..cbb3e6643 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -407,8 +407,16 @@ async fn pairs_of_members( // spec: RPT#pairs pub async fn versions_for_group(db: &mut AsyncPgConnection, group: Uuid) -> Result> { let members = crate::applications::Application::list_live_in_group(db, group).await?; + versions_of_members(db, group, &members).await +} - Ok(versions_and_applications(db, group, &members) +/// The versions of a group's pairs, from members already in hand. +pub async fn versions_of_members( + db: &mut AsyncPgConnection, + group: Uuid, + members: &[crate::applications::Application], +) -> Result> { + Ok(versions_and_applications(db, group, members) .await? .into_iter() .map(|(version, _)| version) diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 8cdbcfef3..6367dd8cc 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -209,7 +209,8 @@ async fn resolve_schema_group( let central_type = central.r#type.clone(); let machine = database::machines::Machine::get_by_id(conn, central.machine_id).await?; - let versions = database::reporting_schemas::versions_for_group(conn, group_id).await?; + let versions = + database::reporting_schemas::versions_of_members(conn, group_id, &members).await?; let settlement = database::reporting_schemas::Settlement::for_group(conn, group_id, &versions).await?; From 71f8a2962a8c025ebd05ef26becd991b5c144a66 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:57:00 +1200 Subject: [PATCH 105/130] narrow the version lookup --- crates/database/src/versions.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/database/src/versions.rs b/crates/database/src/versions.rs index 88e923c36..3e7f8b760 100644 --- a/crates/database/src/versions.rs +++ b/crates/database/src/versions.rs @@ -180,15 +180,26 @@ impl Version { return Ok(Vec::new()); } - // The SQL narrows on the major and the triple is matched here: one + // The SQL narrows on each component and the triple is matched here: one // predicate per version builds a boxed OR chain as long as the fleet's - // version spread, for a set small enough to sift in memory. + // version spread, while three set predicates leave Postgres a cross + // product small enough to sift in memory. let mut majors: Vec = wanted.iter().map(|want| want.0.major as i32).collect(); majors.sort_unstable(); majors.dedup(); + let mut minors: Vec = wanted.iter().map(|want| want.0.minor as i32).collect(); + minors.sort_unstable(); + minors.dedup(); + + let mut patches: Vec = wanted.iter().map(|want| want.0.patch as i32).collect(); + patches.sort_unstable(); + patches.dedup(); + let rows: Vec = versions .filter(major.eq_any(majors)) + .filter(minor.eq_any(minors)) + .filter(patch.eq_any(patches)) .select(Version::as_select()) .load(db) .await From 9a6693d6ef0b30d0c684a6ba8c15f6f870d9dfd0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:57:23 +1200 Subject: [PATCH 106/130] index restore checks by run --- .../2026-09-10-004500-0000_restore_checks_by_run/down.sql | 1 + .../2026-09-10-004500-0000_restore_checks_by_run/up.sql | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 migrations/2026-09-10-004500-0000_restore_checks_by_run/down.sql create mode 100644 migrations/2026-09-10-004500-0000_restore_checks_by_run/up.sql diff --git a/migrations/2026-09-10-004500-0000_restore_checks_by_run/down.sql b/migrations/2026-09-10-004500-0000_restore_checks_by_run/down.sql new file mode 100644 index 000000000..126d7c070 --- /dev/null +++ b/migrations/2026-09-10-004500-0000_restore_checks_by_run/down.sql @@ -0,0 +1 @@ +DROP INDEX backup_restore_checks_run; diff --git a/migrations/2026-09-10-004500-0000_restore_checks_by_run/up.sql b/migrations/2026-09-10-004500-0000_restore_checks_by_run/up.sql new file mode 100644 index 000000000..9d85714f0 --- /dev/null +++ b/migrations/2026-09-10-004500-0000_restore_checks_by_run/up.sql @@ -0,0 +1,7 @@ +-- A registration asks whether a run has already reported for somebody else, +-- which counts the checks carrying that run. The table is an audit trail kept +-- indefinitely and nothing else indexes run_id, so the count reads every row +-- ever reported. +CREATE INDEX backup_restore_checks_run + ON backup_restore_checks (run_id) + WHERE run_id IS NOT NULL; From af1b74025860cd38c228afef3473657891c311b0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:59:45 +1200 Subject: [PATCH 107/130] refuse an ask with no pair --- .../src/fns/reporting_schemas.rs | 13 +++- crates/private-server/tests/it/main.rs | 1 + .../tests/it/reporting_schemas.rs | 66 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 crates/private-server/tests/it/reporting_schemas.rs diff --git a/crates/private-server/src/fns/reporting_schemas.rs b/crates/private-server/src/fns/reporting_schemas.rs index 5ef0c82b6..ed7b338d6 100644 --- a/crates/private-server/src/fns/reporting_schemas.rs +++ b/crates/private-server/src/fns/reporting_schemas.rs @@ -1,7 +1,7 @@ use axum::Json; use axum::extract::State; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; -use commons_errors::{ProblemDetailsSchema, Result}; +use commons_errors::{AppError, ProblemDetailsSchema, Result}; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use database::reporting_schemas::{Pair, ReportingSchemaRequest}; use serde::Deserialize; @@ -78,6 +78,7 @@ pub struct BuildPairArgs { request_body = BuildPairArgs, responses( (status = 200), + (status = 400, body = ProblemDetailsSchema), (status = 401, body = ProblemDetailsSchema), (status = 403, body = ProblemDetailsSchema), ), @@ -89,6 +90,16 @@ pub async fn build( ) -> Result> { let mut conn = state.db.get().await?; let TailscaleAdmin(TailscaleUser { login, .. }) = admin; + + // An ask against a pair the group does not have is one nothing dispatches + // and nothing clears, so it would stand against the group for good. + let pairs = database::reporting_schemas::pairs_for_group(&mut conn, args.group_id).await?; + if !pairs.iter().any(|pair| pair.version_id == args.version_id) { + return Err(AppError::BadRequest( + "that group has no pair for that version".into(), + )); + } + ReportingSchemaRequest::enqueue(&mut conn, args.group_id, args.version_id, Some(&login)) .await?; Ok(Json(())) diff --git a/crates/private-server/tests/it/main.rs b/crates/private-server/tests/it/main.rs index 7bb123bcc..c45158bd0 100644 --- a/crates/private-server/tests/it/main.rs +++ b/crates/private-server/tests/it/main.rs @@ -28,6 +28,7 @@ mod openapi_spec; mod operator_presence; mod private_statuses; mod provision_credential; +mod reporting_schemas; mod restore_replicas; mod server_version_distance; mod sql; diff --git a/crates/private-server/tests/it/reporting_schemas.rs b/crates/private-server/tests/it/reporting_schemas.rs new file mode 100644 index 000000000..e9b08fe59 --- /dev/null +++ b/crates/private-server/tests/it/reporting_schemas.rs @@ -0,0 +1,66 @@ +//! Asking for a pair's build. +//! +//! spec: RPT + +use commons_tests::diesel_async::{AsyncPgConnection, SimpleAsyncConnection}; +use uuid::Uuid; + +/// A group whose central reports 2.60.0, and a published 2.59.0 nothing runs. +async fn seed(conn: &mut AsyncPgConnection) -> (Uuid, Uuid, Uuid) { + let group = Uuid::new_v4(); + let machine = Uuid::new_v4(); + let central = Uuid::new_v4(); + let consumer = Uuid::new_v4(); + let ran = Uuid::new_v4(); + let unrun = Uuid::new_v4(); + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) VALUES + ('{ran}', 2, 60, 0, '', 'published'), + ('{unrun}', 2, 59, 0, '', 'published'); + + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka'); + INSERT INTO machines (id, group_id) VALUES ('{machine}', '{group}'); + INSERT INTO applications (id, type, name, host, machine_id, group_id) VALUES + ('{central}', 'tamanu-central', 'central', 'https://c', '{machine}', '{group}'); + INSERT INTO application_reported_detail (application_id, source, reported_at, version) + VALUES ('{central}', 'tamanu', NOW(), '2.60.0'); + + INSERT INTO devices (id, role) VALUES ('{consumer}', 'backup-restore'); + INSERT INTO restore_consumer_capabilities + (consumer_device_id, intent, description, semantics, params) + VALUES ('{consumer}', 'reporting-schema', '', + '[\"once\",\"migrate\",\"reporting-schema\"]'::jsonb, '[]'::jsonb); + INSERT INTO restore_replicas + (consumer_device_id, group_id, type, intent, name, enabled, params, publishes_schemas) + VALUES ('{consumer}', '{group}', 'tamanu-postgres', 'reporting-schema', 'builds', + true, '{{}}'::jsonb, true)" + )) + .await + .expect("seed"); + + (group, ran, unrun) +} + +/// A version the group neither runs nor is moving to is not one of its pairs, +/// and an ask against it would stand for good: nothing dispatches it and +/// nothing clears it. +#[tokio::test(flavor = "multi_thread")] +async fn an_ask_for_a_version_the_group_does_not_run_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let (group, ran, unrun) = seed(&mut conn).await; + + private + .post("/api/reporting_schemas/build") + .json(&serde_json::json!({ "group_id": group, "version_id": ran })) + .await + .assert_status_ok(); + + private + .post("/api/reporting_schemas/build") + .json(&serde_json::json!({ "group_id": group, "version_id": unrun })) + .await + .assert_status_bad_request(); + }) + .await; +} From cd3a717d8faeafb767973036c8b5b5a3c65617d7 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:08:19 +1200 Subject: [PATCH 108/130] read range artifacts once --- crates/database/src/artifacts.rs | 76 ++++++++++++++---------- crates/database/src/reporting_schemas.rs | 8 ++- crates/public-server/src/restore.rs | 14 ++++- 3 files changed, 64 insertions(+), 34 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 11814efec..1f37b7f9e 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -379,7 +379,7 @@ impl Artifact { } /// When any artifact a build reads was last registered for each of these - /// versions, in two queries however many versions are asked about. + /// versions, in one query however many versions are asked about. /// /// A schema built from a superseded release of a version is not the schema /// that version describes, so this is what a build is held against. Only @@ -392,6 +392,7 @@ impl Artifact { pub async fn newest_change_for_versions( db: &mut AsyncPgConnection, versions: &[Version], + ranges: &RangeChanges, ) -> Result> { use crate::schema::artifacts::dsl; @@ -410,38 +411,12 @@ impl Artifact { .filter_map(|(id, at)| Some((id?, at?.into()))) .collect(); - // One row per distinct pattern rather than per artifact: the answer only - // needs the newest change under each, and every row returned costs a - // semver parse below. - let ranges: Vec<(Option, Option)> = dsl::artifacts - .filter(dsl::version_id.is_null()) - .filter(dsl::group_id.is_null()) - .group_by(dsl::version_range_pattern) - .select(( - dsl::version_range_pattern, - diesel::dsl::max(dsl::updated_at), - )) - .load(db) - .await - .map_err(AppError::from)?; - - for (pattern, at) in ranges { - // An unparseable pattern matches nothing rather than everything, - // as it does where the artifact is offered. - let Some(range) = pattern - .as_deref() - .and_then(|pattern| node_semver::Range::parse(pattern).ok()) - else { - continue; - }; - let Some(at) = at else { continue }; - let at: jiff::Timestamp = at.into(); - + for (range, at) in &ranges.0 { for version in versions.iter().filter(|v| range.satisfies(&v.as_semver())) { newest .entry(version.id) - .and_modify(|held| *held = (*held).max(at)) - .or_insert(at); + .and_modify(|held| *held = (*held).max(*at)) + .or_insert(*at); } } @@ -685,3 +660,44 @@ impl Artifact { }) } } + +/// When each unscoped range artifact last changed, with its pattern parsed. +/// +/// A range covers versions rather than naming one, so which of them it answers +/// for is decided in memory. Loaded once and handed to each version it is asked +/// about: the patterns do not vary by group, and a worklist poll asks the same +/// question of every group it covers. +// spec: RPT#pairs +pub struct RangeChanges(Vec<(node_semver::Range, jiff::Timestamp)>); + +impl RangeChanges { + /// One row per distinct pattern rather than per artifact: the answer only + /// needs the newest change under each, and every row returned costs a + /// semver parse. + pub async fn load(db: &mut AsyncPgConnection) -> Result { + use crate::schema::artifacts::dsl; + + let rows: Vec<(Option, Option)> = dsl::artifacts + .filter(dsl::version_id.is_null()) + .filter(dsl::group_id.is_null()) + .group_by(dsl::version_range_pattern) + .select(( + dsl::version_range_pattern, + diesel::dsl::max(dsl::updated_at), + )) + .load(db) + .await + .map_err(AppError::from)?; + + Ok(Self( + rows.into_iter() + .filter_map(|(pattern, at)| { + // An unparseable pattern matches nothing rather than + // everything, as it does where the artifact is offered. + let range = node_semver::Range::parse(pattern?).ok()?; + Some((range, at?.into())) + }) + .collect(), + )) + } +} diff --git a/crates/database/src/reporting_schemas.rs b/crates/database/src/reporting_schemas.rs index cbb3e6643..eddc55fc5 100644 --- a/crates/database/src/reporting_schemas.rs +++ b/crates/database/src/reporting_schemas.rs @@ -165,7 +165,9 @@ impl ReportingSchemaBuild { version: Uuid, ) -> Result { let row = Version::get_by_id(db, version).await?; - let settlement = Settlement::for_group(db, group, std::slice::from_ref(&row)).await?; + let ranges = crate::artifacts::RangeChanges::load(db).await?; + let settlement = + Settlement::for_group(db, group, std::slice::from_ref(&row), &ranges).await?; Ok(settlement.settled(version)) } } @@ -187,11 +189,13 @@ impl Settlement { db: &mut AsyncPgConnection, group: Uuid, versions: &[Version], + ranges: &crate::artifacts::RangeChanges, ) -> Result { Ok(Self { requested: ReportingSchemaRequest::pending_for_group(db, group).await?, builds: ReportingSchemaBuild::latest_by_version_for_group(db, group).await?, - changed: crate::artifacts::Artifact::newest_change_for_versions(db, versions).await?, + changed: crate::artifacts::Artifact::newest_change_for_versions(db, versions, ranges) + .await?, }) } diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 6367dd8cc..c672f60ab 100644 --- a/crates/public-server/src/restore.rs +++ b/crates/public-server/src/restore.rs @@ -201,6 +201,7 @@ struct SchemaGroup { async fn resolve_schema_group( conn: &mut database::diesel_async::AsyncPgConnection, group_id: Uuid, + ranges: &database::artifacts::RangeChanges, ) -> Result> { let members = database::applications::Application::list_live_in_group(conn, group_id).await?; let Some(central) = database::server_groups::ServerGroup::canonical_central(&members) else { @@ -212,7 +213,8 @@ async fn resolve_schema_group( let versions = database::reporting_schemas::versions_of_members(conn, group_id, &members).await?; let settlement = - database::reporting_schemas::Settlement::for_group(conn, group_id, &versions).await?; + database::reporting_schemas::Settlement::for_group(conn, group_id, &versions, ranges) + .await?; Ok(Some(SchemaGroup { machine_id: machine.id, @@ -287,6 +289,7 @@ async fn worklist( // per group rather than per declaration, and the absence of a central is // cached too, since every restore consumer polls this on a schedule. let mut schema_groups: HashMap> = HashMap::new(); + let mut range_changes: Option = None; // Per-group caches so a group referenced by several declarations is resolved // once: the latest produced snapshot per (machine, type), and the latest // healthy-verified snapshot per (machine, type, intent) for `once` suppression. @@ -390,7 +393,14 @@ async fn worklist( }; if !schema_groups.contains_key(&d.group_id) { - let resolved = resolve_schema_group(&mut conn, d.group_id).await?; + // The range artifacts a pair is held against are the same set for + // every group, so they are read once for the poll rather than + // once per group it covers. + if range_changes.is_none() { + range_changes = Some(database::artifacts::RangeChanges::load(&mut conn).await?); + } + let ranges = range_changes.as_ref().expect("loaded above"); + let resolved = resolve_schema_group(&mut conn, d.group_id, ranges).await?; schema_groups.insert(d.group_id, resolved); } let Some(group) = &schema_groups[&d.group_id] else { From 0e751942fa99bfd643d1c9d498ac5db1790f05b0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:09:06 +1200 Subject: [PATCH 109/130] hide the build button from viewers --- .../src/components/ReportingSchemasSection.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/private-web/src/components/ReportingSchemasSection.tsx b/private-web/src/components/ReportingSchemasSection.tsx index 43e644a70..d095f5e36 100644 --- a/private-web/src/components/ReportingSchemasSection.tsx +++ b/private-web/src/components/ReportingSchemasSection.tsx @@ -14,6 +14,7 @@ import { Typography, } from "@mui/material"; import { useApi, useApiAction } from "../api"; +import { useIsAdmin } from "../hooks/useIsAdmin"; type PairState = "awaiting" | "built" | "failed"; @@ -34,6 +35,7 @@ export default function ReportingSchemasSection({ [groupId], ); const build = useApiAction("reporting_schemas", "build"); + const isAdmin = useIsAdmin() === true; if (pairs.status === "loading" || pairs.status === "idle") { return ( @@ -109,13 +111,15 @@ export default function ReportingSchemasSection({ Build asked for ) : ( - + isAdmin && ( + + ) )} From f709f79631cbeb11024c909beed8ea4f4789cd77 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:10:21 +1200 Subject: [PATCH 110/130] regenerate the private api --- private-web/openapi.json | 10 ++++++++++ private-web/src/api-types.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/private-web/openapi.json b/private-web/openapi.json index aee20da4d..e1153bd82 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -6210,6 +6210,16 @@ "200": { "description": "" }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + }, "401": { "description": "", "content": { diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 13877a809..6a402d316 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -14963,6 +14963,14 @@ export interface operations { }; content?: never; }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; 401: { headers: { [name: string]: unknown; From ae34d7d19868bc687ba20c3a83883b8e4d38db4a Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:49:41 +1200 Subject: [PATCH 111/130] dedup before the identity index --- .../up.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql index 48b93f2b4..e1eb344dc 100644 --- a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql @@ -43,6 +43,19 @@ CREATE INDEX artifacts_group_id ON artifacts (group_id); ALTER TABLE artifacts DROP CONSTRAINT artifacts_type_platform_version_id; +-- Range rows had no uniqueness to conflict with, so a repeat registration of +-- one is a second row and the index below cannot be created over the pair. The +-- newest is the registration that would have replaced the others had this key +-- been in force, so that is the one kept. +DELETE FROM artifacts a +USING artifacts b +WHERE a.artifact_type = b.artifact_type + AND a.platform = b.platform + AND a.version_id IS NOT DISTINCT FROM b.version_id + AND a.version_range_pattern IS NOT DISTINCT FROM b.version_range_pattern + AND a.group_id IS NOT DISTINCT FROM b.group_id + AND (a.created_at, a.id) < (b.created_at, b.id); + CREATE UNIQUE INDEX artifacts_identity ON artifacts (artifact_type, platform, version_id, version_range_pattern, group_id) NULLS NOT DISTINCT; From c89bf27efe8071f6be32a13255bdc8ec2c3c19fe Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:53:22 +1200 Subject: [PATCH 112/130] refuse before drafting a version --- crates/database/src/artifacts.rs | 2 +- crates/public-server/src/artifacts.rs | 10 +++++++--- .../public-server/tests/it/artifact_scopes.rs | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index cc0af690a..d05957be3 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -143,7 +143,7 @@ pub fn parse_sri(value: &str) -> Result> { /// newline the shell that sent it added, and a URL nothing can parse is offered /// to every device that asks. // spec: ART#where-an-artifact-rests -fn location(url: Option) -> Option { +pub fn location(url: Option) -> Option { url.map(|url| url.trim().to_owned()) .filter(|url| !url.is_empty()) } diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 815098d45..6845245e7 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -8,7 +8,7 @@ use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ Db, - artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, parse_sri_opt, sri}, + artifacts::{Artifact as ArtifactRow, NewArtifact, Scope, location, parse_sri_opt, sri}, machines::Machine, versions::{NewVersion, Version}, }; @@ -155,9 +155,13 @@ async fn create( }); } - // Where the artifact rests, and the refusal for a body that is no location - // at all, is `Artifact::register`'s to settle. + // Settled before the draft version below is written: a body that is no + // location is refused inside `Artifact::register`, by which point the + // version named by a registration that came to nothing exists. let digest = parse_sri_opt(named.digest.as_deref())?; + let url = location(Some(url)).ok_or_else(|| { + AppError::BadRequest("an artifact needs a download URL or a group".into()) + })?; let mut db = db.get().await?; let device_id = device.0.0.id; diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index c52c9b71e..5dab9c745 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -677,6 +677,25 @@ async fn a_registration_with_nothing_in_it_is_refused() { "a group that is not a uuid is a client mistake, not a 500" ); + // The version a registration names is drafted so the artifact has + // something to attach to, so a refusal after that point leaves one + // standing for an artifact that was never registered. + let unknown = public + .post("/artifacts/9.9.9/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text(" ") + .await; + assert_eq!(unknown.status_code(), StatusCode::BAD_REQUEST); + assert!( + database::versions::Version::get_by_version( + &mut conn, + "9.9.9".parse().expect("a version"), + ) + .await + .is_err(), + "a refused registration drafts no version" + ); + // Nothing was written by any of them. let listed = public.get("/versions/2.60.0/artifacts").await; let artifacts: Vec = listed.json(); From 4e233d59741ea68959ae845cd6df1de7f078e55b Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:53:47 +1200 Subject: [PATCH 113/130] surface a digest failure --- private-web/src/routes/VersionDetail.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/private-web/src/routes/VersionDetail.tsx b/private-web/src/routes/VersionDetail.tsx index 17e494b37..65178d883 100644 --- a/private-web/src/routes/VersionDetail.tsx +++ b/private-web/src/routes/VersionDetail.tsx @@ -685,13 +685,26 @@ function CreateArtifactForm({ e.preventDefault(); try { if (scoped && file) { + // Digesting the file is the browser's own work rather than the + // upload's, so a failure here reaches no hook and would leave + // the form sitting there having done nothing. + let digest: string; + try { + digest = await digestOf(file); + } catch (err) { + setFileError( + err instanceof Error ? err.message : "could not read the file", + ); + return; + } + await upload.call( { version_id: versionId, artifact_type: type, platform, group_id: groupId, - digest: await digestOf(file), + digest, }, file, ); From 115f19ade0c7d368d7b88f66634e5c71c55617a4 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:02:03 +1200 Subject: [PATCH 114/130] read one artifact by id --- crates/database/src/artifacts.rs | 49 ++++++++++++++++++ crates/database/tests/it/artifact_scopes.rs | 55 +++++++++++++++++++++ crates/public-server/src/versions.rs | 6 +-- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index d05957be3..c1272d2b0 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -238,6 +238,55 @@ impl Artifact { .collect() } + /// One of a version's artifacts, as `scope` may see it. + /// + /// The version and the scope are both part of the read: an artifact of + /// another version, or of a group this caller is not in, is missing in + /// exactly the way one that never existed is. Read by id rather than by + /// taking the version's whole match set, which is every range artifact in + /// the table on the path every machine fetches from. + // spec: ART#who-is-offered-a-group-scoped-artifact + pub async fn of_version( + db: &mut AsyncPgConnection, + artifact_id: Uuid, + version: &Version, + scope: Scope, + ) -> Result> { + use crate::schema::artifacts::dsl::*; + + let mut query = artifacts.filter(id.eq(artifact_id)).into_boxed(); + query = match scope { + Scope::Unscoped => query.filter(group_id.is_null()), + Scope::Group(caller) => query.filter(group_id.is_null().or(group_id.eq(caller))), + Scope::Fleet => query, + }; + + let row: Option = query + .select(Self::as_select()) + .first(db) + .await + .optional() + .map_err(AppError::from)?; + + Ok(row.filter(|row| row.belongs_to_version(version))) + } + + /// Whether this artifact is one of `version`'s: named by it, or registered + /// for a range that covers it. + fn belongs_to_version(&self, version: &Version) -> bool { + if self.version_id == Some(version.id) { + return true; + } + + // An unparseable pattern matches nothing rather than everything, so a + // malformed range withholds a file instead of offering it to the whole + // fleet. + self.version_range_pattern + .as_deref() + .and_then(|pattern| node_semver::Range::parse(pattern).ok()) + .is_some_and(|range| range.satisfies(&version.as_semver())) + } + /// Every artifact of a version that `scope` may see, sorted most specific /// first and not deduplicated. For operator views. // spec: ART#what-a-version-offers diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 0e8ea0564..719bc21ef 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -82,6 +82,61 @@ fn held(version_id: Uuid, artifact_type: &str, group: Uuid, bytes: &[u8]) -> New } } +/// A download reads one artifact by id, and what it may return is the same set +/// the version offers: its own, a range covering it, and nothing of another +/// version or another group. +#[tokio::test(flavor = "multi_thread")] +async fn one_artifact_is_read_as_the_version_offers_it() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let elsewhere = seed_version(&mut conn, 2, 59, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + let other = seed_group(&mut conn, "drifting").await; + let row = database::versions::Version::get_by_id(&mut conn, version) + .await + .expect("the version"); + + let exact = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/y")) + .await + .expect("register exact"); + let covering = Artifact::register(&mut conn, ranged("package", "2.60.x", "https://x/z")) + .await + .expect("register covering range"); + let missing = Artifact::register(&mut conn, ranged("docs", "2.58.x", "https://x/w")) + .await + .expect("register range that misses"); + let older = Artifact::register(&mut conn, unscoped(elsewhere, "installer", "https://x/o")) + .await + .expect("register another version's"); + let scoped = Artifact::register(&mut conn, held(version, "reporting-schema", theirs, b"s")) + .await + .expect("register held"); + + for (artifact, found, why) in [ + (exact.id, true, "the version's own"), + (covering.id, true, "a range covering the version"), + (missing.id, false, "a range that does not cover it"), + (older.id, false, "another version's"), + (scoped.id, false, "another group's"), + ] { + let read = Artifact::of_version(&mut conn, artifact, &row, Scope::Group(other)) + .await + .expect("read one"); + assert_eq!(read.is_some(), found, "{why}"); + } + + let read = Artifact::of_version(&mut conn, scoped.id, &row, Scope::Group(theirs)) + .await + .expect("read one"); + assert_eq!( + read.map(|a| a.id), + Some(scoped.id), + "the owning group reads its own" + ); + }) + .await; +} + /// A group-scoped artifact and an unscoped one of the same type and platform /// are both recorded, each group is offered the one for it, and no caller is /// offered both. diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index f2a99f6ee..cdd92369c 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -676,10 +676,8 @@ async fn download_artifact( // the same type and platform is registered. An artifact this caller may not // see is missing in exactly the way one that never existed is. // spec: ART#who-is-offered-a-group-scoped-artifact - let artifacts = ArtifactRow::get_for_version_all_matches(&mut db, version.id, scope).await?; - let artifact = artifacts - .into_iter() - .find(|a| a.id == artifact_uuid) + let artifact = ArtifactRow::of_version(&mut db, artifact_uuid, &version, scope) + .await? .ok_or(AppError::ArtifactNotFound)?; if let Some(held) = ArtifactRow::content_for(&mut db, artifact.id, scope).await? { From e0752770601215d4a6d5e8a3fa0c45caefe4a3ee Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:05:02 +1200 Subject: [PATCH 115/130] hash before taking a connection --- crates/private-server/src/fns/versions.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index df023d6f2..3439921aa 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -762,8 +762,6 @@ pub async fn upload_artifact( ))); } - let mut conn = state.db.get().await?; - if body.len() > MAX_HELD_ARTIFACT_BYTES { return Err(AppError::BadRequest(format!( "artifact is larger than the {} MiB limit", @@ -772,7 +770,14 @@ pub async fn upload_artifact( } let claimed = parse_sri(&named.digest)?; - let digest = digest_of(&body); + // Hashing the whole artifact is tens of milliseconds with no await in it, + // and the pool it would be holding while it ran is five connections wide. + let digest = { + let body = body.clone(); + tokio::task::spawn_blocking(move || digest_of(&body)) + .await + .map_err(|err| AppError::custom(format!("digesting the artifact failed: {err}")))? + }; if claimed != digest { return Err(AppError::BadRequest(format!( "the bytes are {}, not the {} the registration names", @@ -790,6 +795,7 @@ pub async fn upload_artifact( .map(str::to_owned) .filter(|media_type| media_type != "application/octet-stream"); + let mut conn = state.db.get().await?; let artifact = Artifact::register( &mut conn, NewArtifact { From 70ebf5ab59ea95054b038df4bef453ef870435be Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:17:51 +1200 Subject: [PATCH 116/130] name only the groups listed --- crates/database/src/server_groups.rs | 13 ++++++++++--- crates/database/tests/it/artifact_scopes.rs | 2 +- crates/private-server/src/fns/versions.rs | 6 +++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/database/src/server_groups.rs b/crates/database/src/server_groups.rs index 8bc4bde33..bfbfdcb74 100644 --- a/crates/database/src/server_groups.rs +++ b/crates/database/src/server_groups.rs @@ -169,14 +169,21 @@ impl ServerGroup { .map_err(AppError::from) } - /// Every group's name by id, including archived ones, so a reference to a - /// group can be shown by name whatever state the group is in. - pub async fn names_by_id( + /// The named groups' names by id, including archived ones, so a reference + /// to a group can be shown by name whatever state the group is in. + pub async fn names_by_ids( db: &mut AsyncPgConnection, + ids: &[Uuid], ) -> Result> { use crate::schema::server_groups::dsl; + + if ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + Ok(dsl::server_groups .select((dsl::id, dsl::name)) + .filter(dsl::id.eq_any(ids)) .load::<(Uuid, String)>(db) .await .map_err(AppError::from)? diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 719bc21ef..2334e0ceb 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -726,7 +726,7 @@ async fn an_archived_group_is_still_named() { .await .expect("archive the group"); - let names = database::server_groups::ServerGroup::names_by_id(&mut conn) + let names = database::server_groups::ServerGroup::names_by_ids(&mut conn, &[theirs]) .await .expect("names"); assert_eq!(names.get(&theirs).map(String::as_str), Some("kamaka")); diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 3439921aa..1e9bf575d 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -481,7 +481,11 @@ async fn artifacts_of( ) -> Result> { let artifacts_with_metadata = Artifact::get_for_version_all_matches_with_metadata(conn, version_id, Scope::Fleet).await?; - let group_names = ServerGroup::names_by_id(conn).await?; + let groups: Vec = artifacts_with_metadata + .iter() + .filter_map(|(artifact, ..)| artifact.group_id) + .collect(); + let group_names = ServerGroup::names_by_ids(conn, &groups).await?; Ok(artifacts_with_metadata .into_iter() .map( From 867c58153f14c723d124028245ae9de98365ebe0 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:29:28 +1200 Subject: [PATCH 117/130] render the section without a provider --- .../ReportingSchemasSection.test.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/private-web/src/components/ReportingSchemasSection.test.tsx b/private-web/src/components/ReportingSchemasSection.test.tsx index e4294f68c..1e6075058 100644 --- a/private-web/src/components/ReportingSchemasSection.test.tsx +++ b/private-web/src/components/ReportingSchemasSection.test.tsx @@ -1,7 +1,12 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ReportingSchemasSection from "./ReportingSchemasSection"; +// The admin probe belongs to the page this section is mounted in, so the +// section is rendered here with the answer it would have been given. +const admin = vi.hoisted(() => ({ is: true as boolean | undefined })); +vi.mock("../hooks/useIsAdmin", () => ({ useIsAdmin: () => admin.is })); + type Pair = { group_id: string; version_id: string; @@ -51,6 +56,10 @@ function stubApi(pairs: Pair[], build: { status: number; body?: unknown } = { st return calls; } +beforeEach(() => { + admin.is = true; +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -125,6 +134,16 @@ describe("asking for a build", () => { expect(screen.getAllByText("Build again")).toHaveLength(2); }); + it("offers no build to an operator who cannot ask for one", async () => { + admin.is = false; + stubApi([pair({ state: "failed" }), pair({ version_id: "2", state: "built" })]); + render(); + + expect(await screen.findByText("Failed")).toBeTruthy(); + expect(screen.queryByText("Build sooner")).toBeNull(); + expect(screen.queryByText("Build again")).toBeNull(); + }); + it("names the pair rather than the group's latest version", async () => { const calls = stubApi([pair({ version_id: "abc", version: "2.59.0" })]); render(); From 795a2bf104042ea119df642ba97c4f7a52b16890 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:33:21 +1200 Subject: [PATCH 118/130] refuse a stale credential --- crates/commons-servers/src/device_auth/mod.rs | 60 ++++++++++++------- .../public-server/tests/it/artifact_scopes.rs | 34 ++++------- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/crates/commons-servers/src/device_auth/mod.rs b/crates/commons-servers/src/device_auth/mod.rs index d250b5c23..01c20df9b 100644 --- a/crates/commons-servers/src/device_auth/mod.rs +++ b/crates/commons-servers/src/device_auth/mod.rs @@ -88,24 +88,20 @@ device_role_struct!(ServerDevice, DeviceRole::Machine); device_role_struct!(ReleaserDevice, DeviceRole::Releaser); device_role_struct!(BackupRestoreDevice, DeviceRole::BackupRestore); -/// Whether Canopy could not place this credential, as against refusing it or -/// failing on the way to the answer. +/// Whether the caller presented no credential at all, as against presenting +/// one Canopy would not accept. /// -/// A credential it cannot place is anonymous: a stale certificate must not fail -/// a path that serves everyone. A refusal or a fault propagates, since serving -/// the unscoped set to a machine that has a group presents it as that machine's -/// answer. The variants are named rather than tested by status, so which -/// callers are served does not follow from an unrelated mapping and a new -/// variant is a decision somebody makes. -fn unplaceable(err: &AppError) -> bool { +/// Only an absent credential is anonymous. A credential that is presented and +/// rejected propagates, so a stale or unknown certificate is refused rather +/// than served the unscoped set as though it had identified itself. The +/// variants are named rather than tested by status, so which callers are served +/// does not follow from an unrelated mapping and a new variant is a decision +/// somebody makes. +fn no_credential(err: &AppError) -> bool { matches!( err, AppError::AuthMissingHeader(_) | AppError::AuthMissingCertificate - | AppError::AuthInvalidCertificate(_) - | AppError::AuthCertificateNotFound - | AppError::AuthFailed { .. } - | AppError::AuthTokenNotValid | AppError::AuthTailnetIdentityMissing ) } @@ -128,7 +124,7 @@ where ) -> Result, Self::Rejection> { match >::from_request_parts(parts, state).await { Ok(device) => Ok(Some(device)), - Err(err) if unplaceable(&err) => Ok(None), + Err(err) if no_credential(&err) => Ok(None), Err(err) => Err(err), } } @@ -209,16 +205,36 @@ mod tests { /// hands a machine that has a group the unscoped set and presents it as /// that machine's answer. #[test] - fn a_refusal_is_not_an_unplaceable_credential() { - assert!(!unplaceable(&AppError::AuthInsufficientPermissions { + fn a_refusal_is_not_an_absent_credential() { + assert!(!no_credential(&AppError::AuthInsufficientPermissions { required: "releaser".into() })); - assert!(!unplaceable(&AppError::AuthTailnetNodeNotPermitted)); - assert!(!unplaceable(&AppError::AuthTailnetDirectoryUnavailable)); - assert!(!unplaceable(&AppError::DeviceHasNoServer)); + assert!(!no_credential(&AppError::AuthTailnetNodeNotPermitted)); + assert!(!no_credential(&AppError::AuthTailnetDirectoryUnavailable)); + assert!(!no_credential(&AppError::DeviceHasNoServer)); + } + + /// A certificate that is presented and not accepted fails the read. Serving + /// it as anonymous would let an expired or revoked credential keep reading + /// by losing its identity. + #[test] + fn a_stale_certificate_fails_rather_than_going_anonymous() { + assert!(!no_credential(&AppError::AuthCertificateNotFound)); + assert!(!no_credential(&AppError::AuthInvalidCertificate( + "expired".into() + ))); + assert!(!no_credential(&AppError::AuthTokenNotValid)); + assert!(!no_credential(&AppError::AuthFailed { + reason: "mtls".into() + })); + } - assert!(unplaceable(&AppError::AuthMissingCertificate)); - assert!(unplaceable(&AppError::AuthCertificateNotFound)); - assert!(unplaceable(&AppError::AuthTokenNotValid)); + /// No credential at all is anonymous: these reads are open, and a caller + /// that never identified itself is offered the unscoped set. + #[test] + fn an_absent_credential_is_anonymous() { + assert!(no_credential(&AppError::AuthMissingCertificate)); + assert!(no_credential(&AppError::AuthMissingHeader("x"))); + assert!(no_credential(&AppError::AuthTailnetIdentityMissing)); } } diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index 5dab9c745..c0ddbbae0 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -487,13 +487,12 @@ async fn registering_again_over_the_wire_replaces() { .await } -/// A credential Canopy cannot place is anonymous rather than refused, so a -/// deactivated key still reads the unscoped artifacts instead of failing a path -/// that serves everyone. The same credential registering is still a refusal: -/// the downgrade widens nothing. +/// Deactivating a key stops it reading. A credential that is presented and +/// rejected fails rather than losing its identity and being served the unscoped +/// set, which would leave a revoked key reading for as long as it is presented. // spec: ART#who-is-offered-a-group-scoped-artifact #[tokio::test(flavor = "multi_thread")] -async fn a_deactivated_key_reads_as_anonymous_and_still_cannot_register() { +async fn a_deactivated_key_is_refused_rather_than_read_as_anonymous() { commons_tests::server::run_with_device_auth( "releaser", async |mut conn, cert, device_id, public, _| { @@ -506,18 +505,13 @@ async fn a_deactivated_key_reads_as_anonymous_and_still_cannot_register() { .await .expect("deactivate the key"); - // The read still answers, with the unscoped set rather than group A's. let response = public .get("/versions/2.60.0/artifacts") .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .await; - response.assert_status_ok(); - let artifacts: Vec = response.json(); - assert_eq!(artifacts.len(), 1); - assert_eq!(artifacts[0]["id"], UNSCOPED); + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); - // Registering with the same credential is refused: a path that - // needs an identity does not accept one Canopy cannot place. + // Registering with the same credential is refused too. let refused = public .post("/artifacts/2.60.0/installer/windows") .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) @@ -710,11 +704,11 @@ async fn a_registration_with_nothing_in_it_is_refused() { /// Archiving a machine takes its group with it. The device is unbound and its /// keys deactivated in one transaction, so the credential that was offered the -/// group's artifact reads as no identity at all afterwards rather than keeping -/// the group the box used to be in. +/// group's artifact stops reading rather than keeping the group the box used to +/// be in or falling back to the unscoped set. // spec: ART#who-is-offered-a-group-scoped-artifact, FLT#archival #[tokio::test(flavor = "multi_thread")] -async fn an_archived_machine_s_credential_keeps_no_group() { +async fn an_archived_machine_s_credential_stops_reading() { commons_tests::server::run_with_device_auth( "machine", async |mut conn, cert, device_id, public, _| { @@ -742,13 +736,7 @@ async fn an_archived_machine_s_credential_keeps_no_group() { .get("/versions/2.60.0/artifacts") .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) .await; - after.assert_status_ok(); - let artifacts: Vec = after.json(); - assert_eq!(artifacts.len(), 1); - assert_eq!( - artifacts[0]["id"], UNSCOPED, - "answered as a read carrying no identity is" - ); + assert_eq!(after.status_code(), StatusCode::UNAUTHORIZED); let refused = public .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) @@ -756,7 +744,7 @@ async fn an_archived_machine_s_credential_keeps_no_group() { .await; assert_eq!( refused.status_code(), - StatusCode::NOT_FOUND, + StatusCode::UNAUTHORIZED, "and the bytes it used to be served are out of reach" ); }, From 5ccb55edc659ad3a6c1919e54ad8a7c517334e95 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:33:21 +1200 Subject: [PATCH 119/130] constrain blanks in sql --- .../down.sql | 1 + .../up.sql | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql index 7a36d2619..83bc4a54c 100644 --- a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql @@ -9,6 +9,7 @@ ALTER TABLE artifacts ADD CONSTRAINT artifacts_type_platform_version_id UNIQUE ( DROP INDEX artifacts_group_id; ALTER TABLE artifacts DROP CONSTRAINT artifact_rests_by_scope; +ALTER TABLE artifacts DROP CONSTRAINT artifact_download_url_not_blank; ALTER TABLE artifacts DROP COLUMN group_id, diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql index e1eb344dc..5575fa6d9 100644 --- a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql @@ -29,6 +29,17 @@ ALTER TABLE artifacts ADD CONSTRAINT artifact_rests_by_scope CHECK ( AND digest IS NOT NULL) ); +-- A blank location is no location, and a digest that is not a SHA-256 is +-- nothing the bytes can be checked against. Both are enforced here so a writer +-- that skips the application cannot record one. +ALTER TABLE artifacts ADD CONSTRAINT artifact_download_url_not_blank CHECK ( + download_url IS NULL OR btrim(download_url) <> '' +); + +ALTER TABLE artifacts ADD CONSTRAINT artifact_digest_is_sha256 CHECK ( + digest IS NULL OR octet_length(digest) = 32 +); + CREATE INDEX artifacts_group_id ON artifacts (group_id); -- ── Identity ──────────────────────────────────────────────────────────────── From 64c6f8deee76ff719a23cbc4e93d379d211ff0ff Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:33:21 +1200 Subject: [PATCH 120/130] trim the artifact docs --- .workhorse/specs/platform/artifacts.md | 13 ++++++------ crates/database/src/artifacts.rs | 28 +++++++++----------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 005785e39..cfee41077 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -38,9 +38,8 @@ It is offered one artifact per type and platform, and where the bytes rest is no ## What a version offers -A read names an exact version or a range. -An exact version is answered for itself, since a caller naming one is saying what it runs rather than asking where to go. -A range is answered for the latest published version it covers that no known issue covers. +A read names a version range, and an exact version is a valid one. +An exact version is answered for itself; a range is answered for the latest published version it covers that no known issue covers. Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known. Where several match, the most specific is offered. @@ -76,9 +75,9 @@ Canopy records which device registered an artifact and, where the registration n ## Digests -An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always. -A digest is carried as Subresource Integrity writes it, `sha256-` followed by the base64 of the hash, so what a caller is offered is a value it can hand to a checker unchanged. -A registration naming anything else is refused, since a digest nothing can check the bytes against says they were verified when they cannot be. -Canopy verifies a group-scoped artifact's bytes against its digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again. +An artifact carries a digest where whoever registers it records one, and an artifact Canopy holds carries one always. +A digest is expressed in Subresource Integrity format, using SHA-256. +A registration naming anything else is refused. +Canopy verifies the bytes it holds against their digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again. It verifies them again as it serves them and refuses them on a mismatch, so an artifact corrupted after it was taken in fails the read rather than reaching a server as the artifact it is not. An unscoped artifact is read from its location by the caller rather than by Canopy, so its digest is what that caller checks what it fetched against, and an artifact registered without one is fetched unchecked. diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index c1272d2b0..1c1a55a80 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -60,8 +60,8 @@ pub struct Artifact { pub artifact_type: String, /// The platform the artifact targets (e.g. an OS or architecture name). pub platform: String, - /// URL the artifact can be downloaded from. `null` for a group-scoped - /// artifact, whose bytes Canopy holds instead. + /// URL the artifact can be downloaded from. `null` for an artifact whose + /// bytes Canopy holds. pub download_url: Option, /// The device that registered this artifact, if it was registered by a /// releaser device rather than created by an operator. @@ -73,10 +73,10 @@ pub struct Artifact { /// The group this artifact is for. `null` for an artifact that is for /// every group. pub group_id: Option, - /// Media type of the bytes Canopy holds, where the registration named one. + /// Media type of the bytes Canopy holds, when known. pub content_type: Option, - /// SHA-256 of the artifact's bytes. Always set for a group-scoped - /// artifact. + /// SHA-256 of the artifact's bytes. Always set for an artifact Canopy + /// holds. pub digest: Option>, /// The run that produced this artifact, where the registration named one. pub run_id: Option, @@ -112,17 +112,13 @@ pub fn digest_of(bytes: &[u8]) -> Vec { Sha256::digest(bytes).to_vec() } -/// A digest as Subresource Integrity writes it, which is the form every -/// interface carries it in. +/// A digest in Subresource Integrity format. // spec: ART#digests pub fn sri(digest: &[u8]) -> String { format!("sha256-{}", BASE64.encode(digest)) } /// The digest an SRI string names, refusing anything that cannot be one. -/// -/// A value nothing can check the bytes against is worse than none: it says the -/// bytes were verified when they cannot be. // spec: ART#digests pub fn parse_sri(value: &str) -> Result> { let refuse = || AppError::BadRequest(format!("{value:?} is not a sha256 SRI digest")); @@ -206,12 +202,6 @@ impl Artifact { /// The artifacts of a sorted match set that `scope` is actually served: /// the most specific of each type and platform it can see. - /// - /// Not `dedup_by_key`: that only drops *consecutive* duplicates, and the - /// specificity sort has destroyed the adjacency the SQL `ORDER BY` gave us - /// — every exact artifact now precedes every range one, so two artifacts of - /// the same type+platform are only neighbours when they happen to be - /// equally specific. // spec: ART#what-a-version-offers fn offered(artifacts: Vec, scope: Scope) -> Vec { let offered = Self::offered_ids(&artifacts, scope); @@ -531,9 +521,9 @@ impl Artifact { _ => {} } - // A digest describes the bytes at a location, so it does not survive - // the location changing: kept, it has every device that honours it - // refuse a file that is the right one. + // The digest describes the bytes at the old URL, so a new URL clears + // it rather than carrying a checksum for a file that is no longer + // there. // spec: ART#digests let moved = new_url != current_url; From f59f65df5afd9e992f275f06da0df580250b28f9 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:41:36 +1200 Subject: [PATCH 121/130] drop the slug anchor test --- ERRORS.md | 10 -------- crates/commons-errors/src/lib.rs | 42 -------------------------------- 2 files changed, 52 deletions(-) diff --git a/ERRORS.md b/ERRORS.md index 31e5d62a3..b5483282f 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -1,10 +1,5 @@ # API Errors -## Not implemented - -Issued when an endpoint exists but the behaviour behind it does not yet. -**501 Not Implemented**. - ## Environment Issued with an environment variable is not present or in the wrong format. @@ -58,11 +53,6 @@ Issued when a version range is syntactically valid, but not usable to obtain con Issued for the /timesync endpoint. -## Auth: missing header - -Issued when a header carrying the caller's identity is required but not -present. **401 Unauthorized**. - ## Auth: missing certificate Issued when a client certificate is required but not provided. diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index 4c30ae22e..bba9f04e2 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -395,48 +395,6 @@ impl<'de> Deserialize<'de> for AppError { mod tests { use super::*; - /// GitHub's own heading anchors: lowercased, punctuation dropped, spaces - /// hyphenated. - fn anchor(heading: &str) -> String { - heading - .trim() - .to_lowercase() - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == ' ' || *c == '-') - .map(|c| if c == ' ' { '-' } else { c }) - .collect() - } - - /// `/errors/{slug}` redirects into ERRORS.md by anchor, so a slug with no - /// heading drops the reader at the top of the file with no way to tell - /// which error was theirs. - #[test] - fn every_slug_has_a_heading_to_land_on() { - let arms = include_str!("lib.rs") - .split_once("slug = match self {") - .expect("the slug match") - .1 - .split_once("unreachable!()") - .expect("the end of it") - .0; - // Every string literal between those two points is a slug. - let slugs: Vec<&str> = arms.split('"').skip(1).step_by(2).collect(); - assert!(slugs.len() > 30, "the match was not read: {slugs:?}"); - - let anchors: Vec = include_str!("../../../ERRORS.md") - .lines() - .filter_map(|line| line.strip_prefix("## ")) - .map(anchor) - .collect(); - - for slug in slugs { - assert!( - anchors.iter().any(|a| a == slug), - "/errors/{slug} lands on an anchor ERRORS.md does not have" - ); - } - } - /// Input an operator or a client controls answers as their mistake, not as /// a fault, and carries the slug its documentation is written under. #[test] From 32d5c7b1ccc99dc2e81f1712e61e494ba68c97a9 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:55:16 +1200 Subject: [PATCH 122/130] seed real digests in fixtures --- crates/private-server/tests/it/artifacts.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 6592b46bb..bdd9cf944 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -430,7 +430,7 @@ async fn the_listing_says_which_artifacts_are_offered() { VALUES ('{range_installer}', NULL, 'windows', 'installer', '2.60.x', 'https://example.com/range.exe'); INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) - VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'kamaka schema', 'application/sql', '\\x00'::bytea)", + VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'kamaka schema', 'application/sql', sha256('kamaka schema'::bytea))", )) .await .unwrap(); @@ -492,7 +492,7 @@ async fn a_registration_answers_what_it_overrides() { VALUES (NULL, 'any', 'reporting-schema', '2.60.x', 'https://example.com/range.sql'); INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, group_id, content, content_type, digest) - VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'theirs', 'application/octet-stream', '\\x00'::bytea)", + VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'theirs', 'application/octet-stream', sha256('theirs'::bytea))", )) .await .unwrap(); From 52fd97ce2e5a256c910a15622c0ab76488a9a6e3 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:39:38 +1200 Subject: [PATCH 123/130] hold artifacts outside postgres --- Cargo.lock | 3 + crates/commons-servers/Cargo.toml | 3 + crates/commons-servers/src/artifact_store.rs | 242 ++++++++++++++++++ crates/commons-servers/src/lib.rs | 1 + crates/commons-tests/src/server.rs | 58 ++++- crates/database/src/artifacts.rs | 76 +++--- crates/database/src/schema.rs | 1 - crates/database/tests/it/artifact_scopes.rs | 41 +-- crates/private-server/src/fns/versions.rs | 59 ++++- crates/private-server/src/lib.rs | 1 + crates/private-server/src/state.rs | 11 + crates/private-server/tests/it/artifacts.rs | 98 ++++++- .../tests/it/device_admin_endpoints.rs | 1 + crates/private-server/tests/it/sql.rs | 1 + .../tests/it/tailnet_device_auth.rs | 1 + crates/public-server/src/artifacts.rs | 2 +- crates/public-server/src/state.rs | 9 + crates/public-server/src/versions.rs | 28 +- .../public-server/tests/it/artifact_scopes.rs | 67 ++++- crates/public-server/tests/it/backup.rs | 1 + .../down.sql | 1 - .../up.sql | 7 +- private-web/e2e/artifact-scopes.spec.ts | 10 +- private-web/e2e/fixture.ts | 3 + private-web/e2e/seed.ts | 8 +- 25 files changed, 596 insertions(+), 137 deletions(-) create mode 100644 crates/commons-servers/src/artifact_store.rs diff --git a/Cargo.lock b/Cargo.lock index 9c94276ae..71618a88d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1694,6 +1694,8 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-route53", + "aws-sdk-s3", + "aws-sdk-sts", "axum", "axum-client-ip", "axum-server-timing", @@ -1723,6 +1725,7 @@ dependencies = [ "tokio", "tower-http 0.7.0", "tracing", + "uuid", "x509-parser", ] diff --git a/crates/commons-servers/Cargo.toml b/crates/commons-servers/Cargo.toml index 4e2bf0ca3..2e804bf34 100644 --- a/crates/commons-servers/Cargo.toml +++ b/crates/commons-servers/Cargo.toml @@ -15,6 +15,8 @@ age = { version = "0.12.1", default-features = false } aws-config.workspace = true aws-credential-types = "1.3.0" aws-sdk-route53 = "1.118.0" +aws-sdk-s3.workspace = true +aws-sdk-sts.workspace = true axum = { workspace = true, features = ["json", "macros"] } axum-client-ip = { version = "1.3.1", features = ["forwarded-header"] } axum-server-timing = "3.0.1" @@ -56,6 +58,7 @@ tower-http = { version = "0.7.0", features = [ "trace", ] } tracing.workspace = true +uuid = { version = "1.23.1", features = ["v4"] } # `verify` gives `verify_signature` on a parsed CSR — proof the sender holds the # key it asks Canopy to certify. Backed by ring, already a dependency here. x509-parser = { version = "0.18.1", features = ["verify"] } diff --git a/crates/commons-servers/src/artifact_store.rs b/crates/commons-servers/src/artifact_store.rs new file mode 100644 index 000000000..600c6ed73 --- /dev/null +++ b/crates/commons-servers/src/artifact_store.rs @@ -0,0 +1,242 @@ +//! Where Canopy keeps the bytes of the artifacts it holds. +//! +//! A group-scoped artifact is carried to Canopy by the registration that +//! publishes it, and Canopy keeps it in storage of its own, apart from any +//! group's backup repo. Objects are addressed by the artifact's id, so a +//! re-registration replaces the one object and a deregistration removes it. +//! +//! No caller addresses the store: the boundary is enforced on the read, which +//! resolves the artifact against the caller's scope first and only then asks +//! for its bytes. +//! +//! `S3` is the real store; `Memory` is an in-process map for tests and the e2e +//! binary, mirroring [`crate::backup_secrets::BackupSecrets`]. +// spec: ART#where-an-artifact-rests + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use commons_errors::{AppError, Result}; +use uuid::Uuid; + +/// Bucket the artifacts Canopy holds are kept in. Unset ⇒ no store is +/// configured, and registering or serving held bytes reports that rather than +/// the binary failing to start. +pub const BUCKET_ENV: &str = "CANOPY_ARTIFACT_BUCKET"; +/// Region the bucket is in, where it is not the ambient one. +pub const REGION_ENV: &str = "CANOPY_ARTIFACT_REGION"; +/// Role to assume for the object calls, where the bucket lives in an account +/// the pod's own identity does not reach. +pub const ROLE_ARN_ENV: &str = "CANOPY_ARTIFACT_ROLE_ARN"; +/// Key prefix within the bucket, so artifacts can share a bucket with +/// something else. Defaults to [`DEFAULT_PREFIX`]. +pub const PREFIX_ENV: &str = "CANOPY_ARTIFACT_PREFIX"; +/// Env var that forces the in-memory store (no bucket needed). Set by the e2e +/// fixture; tests use [`ArtifactStore::memory`] directly. +const MEMORY_ENV: &str = "CANOPY_ARTIFACT_STORE_MEMORY"; + +pub const DEFAULT_PREFIX: &str = "artifacts/"; + +type MemoryStore = Arc>>>; + +/// The bytes Canopy holds for one artifact, and how to store, read and drop +/// them. +#[derive(Clone)] +pub enum ArtifactStore { + S3 { + client: aws_sdk_s3::Client, + bucket: String, + prefix: String, + }, + Memory(MemoryStore), +} + +impl std::fmt::Debug for ArtifactStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::S3 { bucket, prefix, .. } => f + .debug_struct("ArtifactStore::S3") + .field("bucket", bucket) + .field("prefix", prefix) + .finish_non_exhaustive(), + Self::Memory(_) => f.write_str("ArtifactStore::Memory"), + } + } +} + +impl ArtifactStore { + /// An in-process store for tests / the e2e binary. **Debug-only**: the + /// constructor — and therefore any way to reach the `Memory` variant — does + /// not exist in release builds, so a real instance can never keep artifacts + /// in a process-local map that vanishes with the pod. + #[cfg(debug_assertions)] + pub fn memory() -> Self { + Self::Memory(Arc::new(Mutex::new(BTreeMap::new()))) + } + + /// Build the store from the environment. Returns `None` (logged) when no + /// bucket is configured, so the endpoints that need one report it rather + /// than the binary failing to start. + pub async fn try_default() -> Option { + if std::env::var_os(MEMORY_ENV).is_some() { + #[cfg(debug_assertions)] + { + tracing::warn!("{MEMORY_ENV} set; holding artifacts in process memory"); + return Some(Self::memory()); + } + #[cfg(not(debug_assertions))] + tracing::error!( + "{MEMORY_ENV} is set but IGNORED: the in-memory artifact store is debug-only" + ); + } + + let Ok(bucket) = std::env::var(BUCKET_ENV) else { + tracing::warn!("{BUCKET_ENV} is unset; Canopy can hold no artifact"); + return None; + }; + let prefix = std::env::var(PREFIX_ENV).unwrap_or_else(|_| DEFAULT_PREFIX.to_owned()); + + let sdk = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let mut builder = aws_sdk_s3::config::Builder::from(&sdk); + if let Ok(role_arn) = std::env::var(ROLE_ARN_ENV) { + match assumed_credentials(&sdk, &role_arn).await { + Ok(creds) => builder = builder.credentials_provider(creds), + Err(err) => { + tracing::error!("cannot assume {role_arn} for the artifact store: {err}"); + return None; + } + } + } + if let Ok(region) = std::env::var(REGION_ENV) { + builder = builder.region(aws_sdk_s3::config::Region::new(region)); + } + + Some(Self::S3 { + client: aws_sdk_s3::Client::from_conf(builder.build()), + bucket, + prefix, + }) + } + + fn key(&self, artifact: Uuid) -> String { + match self { + Self::S3 { prefix, .. } => format!("{prefix}{artifact}"), + Self::Memory(_) => artifact.to_string(), + } + } + + /// Store an artifact's bytes, replacing whatever was under its id. + pub async fn put(&self, artifact: Uuid, bytes: Vec) -> Result<()> { + let key = self.key(artifact); + match self { + Self::S3 { client, bucket, .. } => { + client + .put_object() + .bucket(bucket) + .key(&key) + .body(bytes.into()) + .send() + .await + .map_err(|err| { + AppError::custom(format!("storing the artifact failed: {err}")) + })?; + } + Self::Memory(store) => { + store.lock().expect("artifact store").insert(key, bytes); + } + } + Ok(()) + } + + /// An artifact's bytes, or `None` where the store holds none under that id. + pub async fn get(&self, artifact: Uuid) -> Result>> { + let key = self.key(artifact); + match self { + Self::S3 { client, bucket, .. } => { + let object = match client.get_object().bucket(bucket).key(&key).send().await { + Ok(object) => object, + Err(err) if is_missing(&err) => return Ok(None), + Err(err) => { + return Err(AppError::custom(format!( + "reading the artifact failed: {err}" + ))); + } + }; + let bytes = object.body.collect().await.map_err(|err| { + AppError::custom(format!("reading the artifact failed: {err}")) + })?; + Ok(Some(bytes.to_vec())) + } + Self::Memory(store) => Ok(store.lock().expect("artifact store").get(&key).cloned()), + } + } + + /// Drop an artifact's bytes. Deleting what is not there is not an error: + /// Canopy keeps none of what it has stopped serving either way. + pub async fn delete(&self, artifact: Uuid) -> Result<()> { + let key = self.key(artifact); + match self { + Self::S3 { client, bucket, .. } => { + client + .delete_object() + .bucket(bucket) + .key(&key) + .send() + .await + .map_err(|err| { + AppError::custom(format!("dropping the artifact failed: {err}")) + })?; + } + Self::Memory(store) => { + store.lock().expect("artifact store").remove(&key); + } + } + Ok(()) + } +} + +/// What an endpoint reports when Canopy is asked to hold an artifact and has +/// nowhere to put it. +pub fn unconfigured() -> AppError { + AppError::custom(format!( + "Canopy holds no artifacts: {BUCKET_ENV} is not configured" + )) +} + +/// Whether a read found nothing there. A store that speaks S3 without modelling +/// `NoSuchKey` answers a plain `NotFound`, so both are an absent object. +fn is_missing( + err: &aws_sdk_s3::error::SdkError, +) -> bool { + use aws_sdk_s3::error::ProvideErrorMetadata as _; + + matches!( + err.as_service_error(), + Some(aws_sdk_s3::operation::get_object::GetObjectError::NoSuchKey(_)) + ) || matches!(err.code(), Some("NoSuchKey" | "NotFound")) +} + +async fn assumed_credentials( + sdk: &aws_config::SdkConfig, + role_arn: &str, +) -> std::result::Result { + let assumed = aws_sdk_sts::Client::new(sdk) + .assume_role() + .role_arn(role_arn) + .role_session_name("canopy-artifacts") + .send() + .await + .map_err(|err| format!("{err}"))?; + let creds = assumed + .credentials() + .ok_or_else(|| "AssumeRole returned no credentials".to_owned())?; + Ok(aws_sdk_s3::config::Credentials::new( + creds.access_key_id(), + creds.secret_access_key(), + Some(creds.session_token().to_string()), + None, + "canopy-artifacts", + )) +} diff --git a/crates/commons-servers/src/lib.rs b/crates/commons-servers/src/lib.rs index 777395340..cb7cc876c 100644 --- a/crates/commons-servers/src/lib.rs +++ b/crates/commons-servers/src/lib.rs @@ -13,6 +13,7 @@ use tower_http::{ use tracing::Span; pub mod acme; +pub mod artifact_store; pub mod backup_jobs; pub mod backup_secrets; pub mod csr; diff --git a/crates/commons-tests/src/server.rs b/crates/commons-tests/src/server.rs index 624ec54c5..76b792316 100644 --- a/crates/commons-tests/src/server.rs +++ b/crates/commons-tests/src/server.rs @@ -1,6 +1,10 @@ use ::time::OffsetDateTime; use axum_client_ip::ClientIpSource; use axum_test::TestServer; +use std::collections::BTreeMap; +use std::sync::Mutex; + +use commons_servers::artifact_store::ArtifactStore; use commons_servers::device_auth::mtls::ClientCertHeader; use commons_servers::router; use diesel::{QueryableByName, sql_query, sql_types}; @@ -120,6 +124,38 @@ where run_on(DEFAULT_CERT_HEADER, test).await } +/// The artifact store the servers of this run were built on, for a test that +/// seeds or reads the bytes Canopy holds. +/// +/// Keyed by the throwaway database's name, which is unique per run: a test +/// reaches its own store through the connection it already has rather than +/// every harness callback in the workspace growing an argument for it. +pub async fn artifacts(conn: &mut AsyncPgConnection) -> ArtifactStore { + #[derive(QueryableByName)] + struct Name { + #[diesel(sql_type = sql_types::Text)] + current_database: String, + } + + let name: Name = sql_query("SELECT current_database()") + .get_result(conn) + .await + .expect("read database name"); + store_for(&name.current_database) +} + +/// The store for one throwaway database, created on first ask. +fn store_for(database: &str) -> ArtifactStore { + static STORES: Mutex> = Mutex::new(BTreeMap::new()); + + STORES + .lock() + .expect("artifact stores") + .entry(database.to_owned()) + .or_insert_with(ArtifactStore::memory) + .clone() +} + /// [`run`] against an explicitly chosen client-certificate header. pub async fn run_on(cert_header: ClientCertHeader, test: F) -> T where @@ -130,6 +166,11 @@ where // One pool per state, shared between the RW and RO handles — a second // pool would double connections against the throwaway test cluster, // and this mirrors production with RO_DATABASE_URL unset. + // One store per run, shared by both servers: an artifact uploaded through + // the private API is the one the public download endpoint serves, the way + // a single bucket serves both pods. + let artifacts = store_for(database_name(&url)); + let public_db = database::init_to(&url); let public_state = public_server::state::AppState { client_cert_header: cert_header, @@ -141,6 +182,7 @@ where rate_limiter: Default::default(), sts: None, kube: None, + artifacts: Some(artifacts.clone()), // From the environment, so a test can configure zones before building // the server the way the real edge does. dns_zones: commons_types::dns::ManagedZone::list_from_env().unwrap_or_default(), @@ -152,11 +194,12 @@ where ClientIpSource::RightmostForwarded, ); let private_router = router( - private_server::routes( - private_server::state::AppState::from_db_url(&url) + private_server::routes(private_server::state::AppState { + artifacts: Some(artifacts.clone()), + ..private_server::state::AppState::from_db_url(&url) .await - .unwrap(), - ) + .unwrap() + }) .unwrap(), ClientIpSource::RightmostForwarded, ); @@ -320,6 +363,7 @@ where rate_limiter: Default::default(), sts: None, kube: None, + artifacts: Some(store_for(database_name(&url))), // From the environment, so a test can configure zones before building // the server the way the real edge does. dns_zones: commons_types::dns::ManagedZone::list_from_env().unwrap_or_default(), @@ -343,6 +387,7 @@ where prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(store_for(database_name(&url))), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), // This harness is for the tailnet-auth paths; no test on it @@ -366,3 +411,8 @@ where }) .await } + +/// The throwaway database's name, which is the last path segment of its URL. +fn database_name(url: &str) -> &str { + url.rsplit('/').next().expect("a database in the url") +} diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 1c1a55a80..b2b9cb311 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -43,8 +43,9 @@ impl Scope { /// A downloadable artifact belonging to a release version: an installer, /// package, or other file published for a given type and platform. /// -/// The bytes of a group-scoped artifact are not loaded here; they are large, -/// and every listing would carry them. Read them with [`Artifact::content_for`]. +/// An artifact whose bytes Canopy holds carries their digest and media type +/// here; the bytes themselves rest in Canopy's own storage under the +/// artifact's id. #[derive(Debug, Clone, Deserialize, Queryable, Selectable, Associations)] #[diesel(belongs_to(Version))] #[diesel(table_name = crate::schema::artifacts)] @@ -87,6 +88,10 @@ pub struct Artifact { #[diesel(table_name = crate::schema::artifacts)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct NewArtifact { + /// The id to register under, where the caller settled one already. An + /// artifact Canopy holds is stored under its id, so the bytes are put + /// there before the row naming them exists. + pub id: Option, pub version_id: Option, pub artifact_type: String, pub platform: String, @@ -94,19 +99,11 @@ pub struct NewArtifact { pub device_id: Option, pub version_range_pattern: Option, pub group_id: Option, - pub content: Option>, pub content_type: Option, pub digest: Option>, pub run_id: Option, } -/// The bytes Canopy holds for a group-scoped artifact. -pub struct ArtifactContent { - pub bytes: Vec, - pub content_type: Option, - pub digest: Vec, -} - /// The digest Canopy records and verifies bytes against. pub fn digest_of(bytes: &[u8]) -> Vec { Sha256::digest(bytes).to_vec() @@ -174,14 +171,12 @@ impl NewArtifact { (false, false) => Err(AppError::BadRequest( "an artifact needs a download URL or a group".into(), )), - (true, false) if self.content.is_none() || self.digest.is_none() => { - Err(AppError::BadRequest( - "a group-scoped artifact must carry its bytes and their digest".into(), - )) - } - (false, true) if self.content_type.is_some() || self.content.is_some() => Err( - AppError::BadRequest("only a group-scoped artifact carries bytes".into()), - ), + (true, false) if self.digest.is_none() => Err(AppError::BadRequest( + "a group-scoped artifact must carry the digest of its bytes".into(), + )), + (false, true) if self.content_type.is_some() => Err(AppError::BadRequest( + "only a group-scoped artifact carries bytes".into(), + )), _ => Ok(self), } } @@ -409,40 +404,28 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } - /// The bytes Canopy holds for an artifact, where it holds any. - pub async fn content_for( + /// The id an artifact of this identity is already registered under, where + /// one is. An artifact Canopy holds rests under its id, so a re-registration + /// puts the new bytes where the old ones were rather than leaving them for + /// nothing to reach. + // spec: ART#registration + pub async fn id_for_identity( db: &mut AsyncPgConnection, - artifact_id: Uuid, - scope: Scope, - ) -> Result> { + input: &NewArtifact, + ) -> Result> { use crate::schema::artifacts::dsl::*; - // The scope is part of the read rather than the caller's to remember: - // the bytes of a group's artifact are the thing the boundary exists to - // keep, and an id is guessable in a way a query is not. - // spec: ART#who-is-offered-a-group-scoped-artifact - let mut query = artifacts.filter(id.eq(artifact_id)).into_boxed(); - query = match scope { - Scope::Unscoped => query.filter(group_id.is_null()), - Scope::Group(caller) => query.filter(group_id.is_null().or(group_id.eq(caller))), - Scope::Fleet => query, - }; - - let row: Option<(Option>, Option, Option>)> = query - .select((content, content_type, digest)) + artifacts + .filter(artifact_type.eq(&input.artifact_type)) + .filter(platform.eq(&input.platform)) + .filter(version_id.is_not_distinct_from(input.version_id)) + .filter(version_range_pattern.is_not_distinct_from(&input.version_range_pattern)) + .filter(group_id.is_not_distinct_from(input.group_id)) + .select(id) .first(db) .await .optional() - .map_err(AppError::from)?; - - Ok(match row { - Some((Some(bytes), media_type, Some(recorded))) => Some(ArtifactContent { - bytes, - content_type: media_type, - digest: recorded, - }), - _ => None, - }) + .map_err(AppError::from) } /// Register an artifact, replacing whatever is already registered for the @@ -466,7 +449,6 @@ impl Artifact { .set(( download_url.eq(&input.download_url), device_id.eq(input.device_id), - content.eq(&input.content), content_type.eq(&input.content_type), digest.eq(&input.digest), run_id.eq(input.run_id), diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index f66b34041..494bf8f1b 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -99,7 +99,6 @@ diesel::table! { device_id -> Nullable, version_range_pattern -> Nullable, group_id -> Nullable, - content -> Nullable, content_type -> Nullable, digest -> Nullable, run_id -> Nullable, diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 2334e0ceb..6474db1b6 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -52,6 +52,7 @@ async fn seed_group(conn: &mut AsyncPgConnection, name: &str) -> Uuid { fn unscoped(version_id: Uuid, artifact_type: &str, url: &str) -> NewArtifact { NewArtifact { + id: None, version_id: Some(version_id), artifact_type: artifact_type.to_owned(), platform: "any".to_owned(), @@ -59,7 +60,6 @@ fn unscoped(version_id: Uuid, artifact_type: &str, url: &str) -> NewArtifact { device_id: None, version_range_pattern: None, group_id: None, - content: None, content_type: None, digest: None, run_id: None, @@ -68,6 +68,7 @@ fn unscoped(version_id: Uuid, artifact_type: &str, url: &str) -> NewArtifact { fn held(version_id: Uuid, artifact_type: &str, group: Uuid, bytes: &[u8]) -> NewArtifact { NewArtifact { + id: None, version_id: Some(version_id), artifact_type: artifact_type.to_owned(), platform: "any".to_owned(), @@ -75,7 +76,6 @@ fn held(version_id: Uuid, artifact_type: &str, group: Uuid, bytes: &[u8]) -> New device_id: None, version_range_pattern: None, group_id: Some(group), - content: Some(bytes.to_vec()), content_type: Some("application/sql".to_owned()), digest: Some(digest_of(bytes)), run_id: None, @@ -222,9 +222,9 @@ async fn group_scope_outranks_an_exact_unscoped_artifact() { } /// A registration replaces whatever is already registered for the same version, -/// type, platform and group, and the bytes it replaces do not survive. +/// type, platform and group, and what it replaces does not survive alongside it. #[tokio::test(flavor = "multi_thread")] -async fn registering_again_replaces_the_bytes_it_held() { +async fn registering_again_replaces_what_it_held() { TestDb::run(|mut conn, _url| async move { let version = seed_version(&mut conn, 2, 60, 0).await; let theirs = seed_group(&mut conn, "kamaka").await; @@ -250,12 +250,7 @@ async fn registering_again_replaces_the_bytes_it_held() { .expect("operator view"); assert_eq!(all.len(), 1, "a caller is never offered two of a kind"); - let content = Artifact::content_for(&mut conn, second.id, Scope::Fleet) - .await - .expect("read content") - .expect("bytes are held"); - assert_eq!(content.bytes, b"second build"); - assert_eq!(content.digest, digest_of(b"second build")); + assert_eq!(second.digest, Some(digest_of(b"second build"))); }) .await; } @@ -419,6 +414,7 @@ async fn a_held_artifact_cannot_be_given_a_url() { /// A range artifact, for the specificity rules that need one. fn ranged(artifact_type: &str, pattern: &str, url: &str) -> NewArtifact { NewArtifact { + id: None, version_id: None, artifact_type: artifact_type.to_owned(), platform: "any".to_owned(), @@ -426,7 +422,6 @@ fn ranged(artifact_type: &str, pattern: &str, url: &str) -> NewArtifact { device_id: None, version_range_pattern: Some(pattern.to_owned()), group_id: None, - content: None, content_type: None, digest: None, run_id: None, @@ -599,10 +594,10 @@ async fn provenance_is_recorded_and_replaced() { } /// Canopy keeps none of what it has stopped serving, so deleting an artifact -/// takes the bytes with it rather than leaving them addressable. +/// takes the registration the bytes rest under with it. // spec: ART#where-an-artifact-rests #[tokio::test(flavor = "multi_thread")] -async fn deleting_an_artifact_takes_its_bytes() { +async fn deleting_an_artifact_takes_its_registration() { TestDb::run(|mut conn, _url| async move { let version = seed_version(&mut conn, 2, 60, 0).await; let theirs = seed_group(&mut conn, "kamaka").await; @@ -618,12 +613,6 @@ async fn deleting_an_artifact_takes_its_bytes() { .await .expect("delete"); - assert!( - Artifact::content_for(&mut conn, artifact.id, Scope::Fleet) - .await - .expect("read content") - .is_none() - ); let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) .await .expect("operator view"); @@ -632,11 +621,11 @@ async fn deleting_an_artifact_takes_its_bytes() { .await; } -/// An artifact Canopy does not hold has no bytes to read, which is what makes -/// the download fall through to the location it recorded instead. +/// An artifact Canopy does not hold records a location and nothing else, which +/// is what makes the download fall through to that location instead. // spec: ART#where-an-artifact-rests #[tokio::test(flavor = "multi_thread")] -async fn an_unscoped_artifact_holds_no_bytes() { +async fn an_unscoped_artifact_records_only_a_location() { TestDb::run(|mut conn, _url| async move { let version = seed_version(&mut conn, 2, 60, 0).await; @@ -644,12 +633,8 @@ async fn an_unscoped_artifact_holds_no_bytes() { .await .expect("register"); - assert!( - Artifact::content_for(&mut conn, artifact.id, Scope::Fleet) - .await - .expect("read content") - .is_none() - ); + assert_eq!(artifact.download_url.as_deref(), Some("https://x/i")); + assert!(artifact.content_type.is_none()); }) .await; } diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 1e9bf575d..4f2160656 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -6,6 +6,7 @@ use axum::body::Bytes; use axum::extract::{DefaultBodyLimit, Query, State}; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; +use commons_servers::artifact_store; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::version::{VersionStatus, VersionStr}; use database::{ @@ -22,8 +23,8 @@ use uuid::Uuid; use crate::state::AppState; /// Cap on the bytes Canopy will hold for one artifact. A reporting schema is a -/// SQL file; anything approaching this is not one, and the rows live in -/// Postgres alongside everything else. +/// SQL file; anything approaching this is not one, and the whole of it is held +/// in memory to be digested before it is stored. const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; /// Header the SPA sets on an upload, which no cross-origin page can send @@ -692,6 +693,7 @@ pub async fn create_artifact( let artifact = Artifact::register( &mut conn, NewArtifact { + id: None, version_id: Some(args.version_id), artifact_type: args.artifact_type, platform: args.platform, @@ -699,7 +701,6 @@ pub async fn create_artifact( device_id: None, version_range_pattern: None, group_id: None, - content: None, content_type: None, digest, run_id: None, @@ -799,21 +800,43 @@ pub async fn upload_artifact( .map(str::to_owned) .filter(|media_type| media_type != "application/octet-stream"); + let store = state + .artifacts + .as_ref() + .ok_or_else(artifact_store::unconfigured)?; + + let input = NewArtifact { + id: None, + version_id: Some(named.version_id), + artifact_type: named.artifact_type, + platform: named.platform, + download_url: None, + device_id: None, + version_range_pattern: None, + group_id: Some(named.group_id), + content_type, + digest: Some(digest), + run_id: None, + }; + + // The bytes go in before the row that names them, under the id the artifact + // already has where one is registered: a replacement then lands where the + // bytes it replaces were, and nothing is left behind. + // spec: ART#where-an-artifact-rests + let mut conn = state.db.get().await?; + let id = Artifact::id_for_identity(&mut conn, &input) + .await? + .unwrap_or_else(Uuid::new_v4); + drop(conn); + + store.put(id, Vec::from(body)).await?; + let mut conn = state.db.get().await?; let artifact = Artifact::register( &mut conn, NewArtifact { - version_id: Some(named.version_id), - artifact_type: named.artifact_type, - platform: named.platform, - download_url: None, - device_id: None, - version_range_pattern: None, - group_id: Some(named.group_id), - content: Some(Vec::from(body)), - content_type, - digest: Some(digest), - run_id: None, + id: Some(id), + ..input }, ) .await?; @@ -863,6 +886,14 @@ pub async fn delete_artifact( _admin: TailscaleAdmin, Json(args): Json, ) -> Result> { + // The bytes go before the row: a store that refuses the drop leaves the + // artifact registered and the operator retrying, rather than a row gone and + // bytes nothing reaches. + // spec: ART#where-an-artifact-rests + if let Some(store) = &state.artifacts { + store.delete(args.artifact_id).await?; + } + let mut conn = state.db.get().await?; Artifact::delete(&mut conn, args.artifact_id).await?; Ok(Json(())) diff --git a/crates/private-server/src/lib.rs b/crates/private-server/src/lib.rs index a77be2727..2599e45e2 100644 --- a/crates/private-server/src/lib.rs +++ b/crates/private-server/src/lib.rs @@ -50,6 +50,7 @@ pub fn routes(state: crate::state::AppState) -> commons_errors::Result, /// recovery vault recipient public keys (`CANOPY_RECOVERY_VAULT_KEYS`), for the /// verification ceremony. `None` ⇒ the ceremony endpoints 502 (the backups /// pod is what hard-requires them, not this admin server). @@ -181,6 +187,7 @@ impl AppState { let kube = BackupSecrets::try_default().await; let prober = BucketProber::try_default().await; + let artifacts = ArtifactStore::try_default().await; // For the nested `/public` mount's backup-credential issuance. Building // the client needs no creds (they resolve per-call from the pod's IRSA // identity), so this is always `Some` in a real run. @@ -202,6 +209,7 @@ impl AppState { kube, sts, prober, + artifacts, recovery_recipients: recovery_recipients_from_env(), recovery_challenge: Arc::new(Mutex::new(None)), dns_zones: dns_zones_from_env(), @@ -232,6 +240,9 @@ impl AppState { // drives each probe state by naming the bucket — `…existing…` → kopia // repo, `…other…` → other content, `…denied…` → inaccessible, else empty. prober: BucketProber::Fake(None), + // In-process artifact store so upload and download are exercised in + // tests and the e2e fixture without a bucket. + artifacts: Some(ArtifactStore::memory()), // Read from env so the e2e fixture can exercise the recovery ceremony. recovery_recipients: recovery_recipients_from_env(), recovery_challenge: Arc::new(Mutex::new(None)), diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index bdd9cf944..2189a4c41 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -429,8 +429,8 @@ async fn the_listing_says_which_artifacts_are_offered() { INSERT INTO artifacts (id, version_id, platform, artifact_type, version_range_pattern, download_url) VALUES ('{range_installer}', NULL, 'windows', 'installer', '2.60.x', 'https://example.com/range.exe'); - INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) - VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'kamaka schema', 'application/sql', sha256('kamaka schema'::bytea))", + INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content_type, digest) + VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'application/sql', sha256('kamaka schema'::bytea))", )) .await .unwrap(); @@ -491,8 +491,8 @@ async fn a_registration_answers_what_it_overrides() { INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, download_url) VALUES (NULL, 'any', 'reporting-schema', '2.60.x', 'https://example.com/range.sql'); - INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, group_id, content, content_type, digest) - VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'theirs', 'application/octet-stream', sha256('theirs'::bytea))", + INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, group_id, content_type, digest) + VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'application/octet-stream', sha256('theirs'::bytea))", )) .await .unwrap(); @@ -575,3 +575,93 @@ async fn an_upload_without_the_fetch_header_is_refused() { }) .await } + +/// A registration replaces whatever is already registered for the same version, +/// type, platform and group, and the bytes it replaces go with it: the artifact +/// rests under its id, so a rebuild is put where the old build was rather than +/// beside it. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_rebuild_replaces_the_bytes_where_they_rest() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "dddddddd-0000-0000-0000-dddddddddddd"; + let group = "eeeeeeee-0000-0000-0000-eeeeeeeeeeee"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let upload = async |bytes: &'static [u8]| { + private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(bytes)) + .bytes(bytes.into()) + .await + }; + + let first: serde_json::Value = upload(b"first build").await.json(); + let second: serde_json::Value = upload(b"second build").await.json(); + assert_eq!(first["id"], second["id"], "replaced in place"); + + let id: Uuid = serde_json::from_value(second["id"].clone()).unwrap(); + let store = commons_tests::server::artifacts(&mut conn).await; + assert_eq!( + store.get(id).await.unwrap().as_deref(), + Some(&b"second build"[..]) + ); + }) + .await +} + +/// Canopy keeps none of what it has stopped serving, so deregistering an +/// artifact takes its bytes out of the store as well as its row. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn deleting_an_artifact_takes_its_bytes() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "ffffffff-0000-0000-0000-ffffffffffff"; + let group = "ffffffff-1111-0000-0000-ffffffffffff"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let registered: serde_json::Value = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await + .json(); + let id: Uuid = serde_json::from_value(registered["id"].clone()).unwrap(); + + let store = commons_tests::server::artifacts(&mut conn).await; + assert!(store.get(id).await.unwrap().is_some()); + + private + .post("/api/versions/delete_artifact") + .json(&serde_json::json!({ "artifact_id": id })) + .await + .assert_status_ok(); + + assert!(store.get(id).await.unwrap().is_none()); + }) + .await +} diff --git a/crates/private-server/tests/it/device_admin_endpoints.rs b/crates/private-server/tests/it/device_admin_endpoints.rs index 8c74694f3..aff564c21 100644 --- a/crates/private-server/tests/it/device_admin_endpoints.rs +++ b/crates/private-server/tests/it/device_admin_endpoints.rs @@ -44,6 +44,7 @@ async fn private_with_directory(url: &str, directory: TailnetDirectory) -> TestS prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(commons_servers::artifact_store::ArtifactStore::memory()), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), dns_zones: Vec::new(), diff --git a/crates/private-server/tests/it/sql.rs b/crates/private-server/tests/it/sql.rs index da984e2eb..8618f94d9 100644 --- a/crates/private-server/tests/it/sql.rs +++ b/crates/private-server/tests/it/sql.rs @@ -24,6 +24,7 @@ async fn private_with_ro_pool(url: &str) -> TestServer { prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(commons_servers::artifact_store::ArtifactStore::memory()), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), dns_zones: Vec::new(), diff --git a/crates/private-server/tests/it/tailnet_device_auth.rs b/crates/private-server/tests/it/tailnet_device_auth.rs index 63c6a4565..602999511 100644 --- a/crates/private-server/tests/it/tailnet_device_auth.rs +++ b/crates/private-server/tests/it/tailnet_device_auth.rs @@ -92,6 +92,7 @@ async fn unknown_tailnet_node_is_rejected_without_creating_a_row() { prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(commons_servers::artifact_store::ArtifactStore::memory()), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), dns_zones: Vec::new(), diff --git a/crates/public-server/src/artifacts.rs b/crates/public-server/src/artifacts.rs index 6845245e7..8b64f32de 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -203,6 +203,7 @@ async fn create( let row = ArtifactRow::register( &mut db, NewArtifact { + id: None, version_id, platform, artifact_type, @@ -210,7 +211,6 @@ async fn create( device_id: Some(device_id), version_range_pattern, group_id: None, - content: None, content_type: None, digest, run_id: None, diff --git a/crates/public-server/src/state.rs b/crates/public-server/src/state.rs index 26a388a48..bf223c642 100644 --- a/crates/public-server/src/state.rs +++ b/crates/public-server/src/state.rs @@ -48,6 +48,11 @@ pub struct AppState { /// Kube client for reading repo-password Secrets in canopy's namespace. /// `None` in tests / non-cluster runs ⇒ `GET /backup-target` returns 502. pub kube: Option, + /// Where the bytes of the artifacts Canopy holds rest. `None` where no + /// bucket is configured, in which case serving one reports that rather than + /// the edge failing to start. + // spec: ART#where-an-artifact-rests + pub artifacts: Option, /// The DNS zones Canopy may write records in, from its instance /// configuration. Empty when none are configured, in which case no name can /// be acted on — read once at startup, so a change takes effect on restart. @@ -102,6 +107,7 @@ impl AppState { let mut state = Self::from_db(database::init())?; state.sts = Some(Self::init_sts().await); state.kube = Self::init_kube().await; + state.artifacts = commons_servers::artifact_store::ArtifactStore::try_default().await; Ok(state) } @@ -142,6 +148,7 @@ impl AppState { rate_limiter: crate::ratelimit::RateLimiter::default(), sts: None, kube: None, + artifacts: None, dns_zones: dns_zones_from_env(), }) } @@ -157,11 +164,13 @@ impl AppState { tailnet_directory: Option, sts: Option, kube: Option, + artifacts: Option, ) -> Result { Ok(Self { client_cert_header: commons_servers::device_auth::mtls::ClientCertHeader::from_env(), sts, kube, + artifacts, ..Self::from_db_with_directory(db, tailnet_directory)? }) } diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index cdd92369c..00abe5b87 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -659,12 +659,12 @@ async fn update_for( async fn download_artifact( device: Option, - State(db): State, + State(state): State, Path((version, artifact_id)): Path<(String, String)>, ) -> Result { use uuid::Uuid; - let mut db = db.get().await?; + let mut db = state.db.get().await?; let version = version_named(&mut db, &version).await?; let scope = caller_scope(&mut db, device).await?; @@ -680,17 +680,29 @@ async fn download_artifact( .await? .ok_or(AppError::ArtifactNotFound)?; - if let Some(held) = ArtifactRow::content_for(&mut db, artifact.id, scope).await? { + // An artifact with no location of its own is one Canopy holds. + // spec: ART#where-an-artifact-rests + if artifact.download_url.is_none() { + let store = state + .artifacts + .as_ref() + .ok_or_else(commons_servers::artifact_store::unconfigured)?; + let (Some(bytes), Some(recorded)) = + (store.get(artifact.id).await?, artifact.digest.clone()) + else { + return Err(AppError::ArtifactNotFound); + }; + // Hashing the whole artifact is tens of milliseconds with no await in // it, and a fleet fetching one schema at once would spend that on the // runtime's own threads. - let held = tokio::task::spawn_blocking(move || { - (database::artifacts::digest_of(&held.bytes) == held.digest).then_some(held) + let bytes = tokio::task::spawn_blocking(move || { + (database::artifacts::digest_of(&bytes) == recorded).then_some(bytes) }) .await .map_err(|err| AppError::custom(format!("verifying the artifact failed: {err}")))?; - let Some(held) = held else { + let Some(bytes) = bytes else { tracing::error!( artifact = %artifact.id, "held artifact does not match its digest; refusing to serve" @@ -698,7 +710,7 @@ async fn download_artifact( return Err(AppError::ArtifactDigestMismatch); }; - let content_type = held + let content_type = artifact .content_type .unwrap_or_else(|| "application/octet-stream".to_owned()); @@ -718,7 +730,7 @@ async fn download_artifact( "nosniff".to_owned(), ), ], - Body::from(held.bytes), + Body::from(bytes), ) .into_response()); } diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs index c0ddbbae0..e3115060b 100644 --- a/crates/public-server/tests/it/artifact_scopes.rs +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -26,12 +26,23 @@ async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) VALUES ('{UNSCOPED}', '{VERSION}', 'any', 'reporting-schema', 'https://example.com/all.sql'); - INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content, content_type, digest) + INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content_type, digest) VALUES ('{THEIRS}', '{VERSION}', 'any', 'reporting-schema', '{GROUP_A}', - 'group a schema'::bytea, 'application/sql', '\\x{digest}'::bytea)", + 'application/sql', '\\x{digest}'::bytea)", )) .await .expect("seed"); + + hold(conn, THEIRS, b"group a schema").await; +} + +/// Put an artifact's bytes where Canopy holds them, as a registration would. +async fn hold(conn: &mut database::diesel_async::AsyncPgConnection, artifact: &str, bytes: &[u8]) { + commons_tests::server::artifacts(conn) + .await + .put(artifact.parse().expect("an artifact id"), bytes.to_vec()) + .await + .expect("hold the bytes"); } /// Put the authenticated device on a machine in the given group. @@ -178,11 +189,7 @@ async fn corrupted_bytes_fail_the_read() { seed(&mut conn).await; enrol(&mut conn, device_id, GROUP_A).await; - conn.batch_execute(&format!( - "UPDATE artifacts SET content = 'tampered'::bytea WHERE id = '{THEIRS}'" - )) - .await - .expect("corrupt the stored bytes"); + hold(&mut conn, THEIRS, b"tampered").await; let response = public .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) @@ -196,6 +203,41 @@ async fn corrupted_bytes_fail_the_read() { .await } +/// An artifact whose bytes are not where Canopy holds them is missing in +/// exactly the way one that never existed is: the read is refused rather than +/// answered with an empty file, and the caller learns nothing from which of the +/// two it was. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_artifact_whose_bytes_are_gone_is_missing() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + commons_tests::server::artifacts(&mut conn) + .await + .delete(THEIRS.parse().expect("an artifact id")) + .await + .expect("drop the bytes"); + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let absent = public + .get("/versions/2.60.0/artifacts/99999999-9999-9999-9999-999999999999/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); + assert_eq!(response.text(), absent.text()); + }, + ) + .await +} + /// A releaser credential carries no authorisation for any group, so a /// registration naming one is refused. #[tokio::test(flavor = "multi_thread")] @@ -425,11 +467,7 @@ async fn a_digest_mismatch_says_what_it_is() { seed(&mut conn).await; enrol(&mut conn, device_id, GROUP_A).await; - conn.batch_execute(&format!( - "UPDATE artifacts SET content = 'tampered'::bytea WHERE id = '{THEIRS}'" - )) - .await - .expect("corrupt the stored bytes"); + hold(&mut conn, THEIRS, b"tampered").await; let response = public .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) @@ -918,12 +956,13 @@ async fn a_displaced_artifact_is_still_fetchable() { let digest = hex::encode(digest_of(b"the range schema")); conn.batch_execute(&format!( "INSERT INTO artifacts - (id, version_id, platform, artifact_type, version_range_pattern, group_id, content, content_type, digest) + (id, version_id, platform, artifact_type, version_range_pattern, group_id, content_type, digest) VALUES ('{ranged}', NULL, 'any', 'reporting-schema', '2.60.x', '{GROUP_A}', - 'the range schema'::bytea, 'application/sql', '\\x{digest}'::bytea)" + 'application/sql', '\\x{digest}'::bytea)" )) .await .expect("seed the range artifact"); + hold(&mut conn, ranged, b"the range schema").await; let listed = public .get("/versions/2.60.0/artifacts") diff --git a/crates/public-server/tests/it/backup.rs b/crates/public-server/tests/it/backup.rs index 7e107bce9..5acac63fe 100644 --- a/crates/public-server/tests/it/backup.rs +++ b/crates/public-server/tests/it/backup.rs @@ -575,6 +575,7 @@ fn public_server_with_sts(url: &str, sts: aws_sdk_sts::Client) -> TestServer { rate_limiter: Default::default(), sts: Some(sts), kube: None, + artifacts: None, dns_zones: Vec::new(), }; let app = router( diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql index 83bc4a54c..916b54cd1 100644 --- a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql @@ -13,7 +13,6 @@ ALTER TABLE artifacts DROP CONSTRAINT artifact_download_url_not_blank; ALTER TABLE artifacts DROP COLUMN group_id, - DROP COLUMN content, DROP COLUMN content_type, DROP COLUMN digest, DROP COLUMN run_id; diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql index 5575fa6d9..5c1b3c9e4 100644 --- a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql @@ -7,7 +7,6 @@ ALTER TABLE artifacts ADD COLUMN group_id UUID REFERENCES server_groups(id) ON DELETE CASCADE, - ADD COLUMN content BYTEA, ADD COLUMN content_type TEXT, ADD COLUMN digest BYTEA, ADD COLUMN run_id UUID; @@ -15,17 +14,15 @@ ALTER TABLE artifacts ALTER TABLE artifacts ALTER COLUMN download_url DROP NOT NULL; -- An unscoped artifact rests at a location Canopy records and does not hold; a --- group-scoped one rests in Canopy and always carries a digest, which the read --- verifies the bytes against. +-- group-scoped one rests in Canopy's own storage under its id and always +-- carries a digest, which the read verifies the bytes against. ALTER TABLE artifacts ADD CONSTRAINT artifact_rests_by_scope CHECK ( (group_id IS NULL AND download_url IS NOT NULL - AND content IS NULL AND content_type IS NULL) OR (group_id IS NOT NULL AND download_url IS NULL - AND content IS NOT NULL AND digest IS NOT NULL) ); diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts index 25737e65d..67ae89eeb 100644 --- a/private-web/e2e/artifact-scopes.spec.ts +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -193,20 +193,18 @@ test.describe("group-scoped artifacts", () => { await expect(page.getByText("Held by Canopy for kamaka")).toBeVisible(); - // Canopy holds the bytes and records the digest of what it took in. + // Canopy holds the bytes elsewhere and records the digest of what it took + // in, so the row carries no location and the digest of the file. const rows = await sql.query<{ download_url: string | null; digest: string | null; - content: string | null; }>( - `SELECT download_url, encode(digest, 'base64') AS digest, - encode(content, 'escape') AS content + `SELECT download_url, encode(digest, 'base64') AS digest FROM artifacts WHERE version_id = $1`, [version.id], ); expect(rows).toHaveLength(1); expect(rows[0].download_url).toBeNull(); - expect(rows[0].content).toBe("kamaka schema"); expect(rows[0].digest).toBe("IUs61BxmDig34DQY/ofHCx6CzHw1MdeO/v+aNAnqkdk="); }); @@ -282,7 +280,7 @@ test.describe("group-scoped artifacts", () => { await expect(page.getByText("Held by Canopy for kamaka")).toHaveCount(0); const [held] = await sql.query<{ n: string }>( - "SELECT count(*) AS n FROM artifacts WHERE content IS NOT NULL", + "SELECT count(*) AS n FROM artifacts WHERE download_url IS NULL", ); expect(Number(held.n)).toBe(0); diff --git a/private-web/e2e/fixture.ts b/private-web/e2e/fixture.ts index 88be1a963..daaa2e5b9 100644 --- a/private-web/e2e/fixture.ts +++ b/private-web/e2e/fixture.ts @@ -198,6 +198,9 @@ export async function startStack(opts: StartOptions = {}): Promise // the bucket name (…existing… → kopia repo, …other… → other content, // …denied… → inaccessible, else empty). CANOPY_BACKUP_PROBER_FAKE: "1", + // No bucket in e2e: hold uploaded artifacts in the server's own + // process so registering and serving one is exercised end to end. + CANOPY_ARTIFACT_STORE_MEMORY: "1", // A throwaway age recipient (bestool-generated) so the recovery vault // ceremony page reports as configured. The matching private key isn't // needed: the e2e exercises status + challenge + wrong-answer, and the diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index 0a180cfbd..ba10ee0dd 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -1037,7 +1037,8 @@ export async function seedVersion( } /** Seed an artifact for a version. Naming a group makes Canopy hold the bytes - * rather than record a location. */ + * rather than record a location; `content` is what its digest is taken of, the + * bytes themselves resting in the store rather than the row. */ export async function seedArtifact( sql: Sql, opts: { @@ -1060,8 +1061,8 @@ export async function seedArtifact( await sql.query( `INSERT INTO artifacts (id, version_id, artifact_type, platform, download_url, version_range_pattern, - group_id, content, content_type, digest) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + group_id, content_type, digest) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ id, opts.versionId ?? null, @@ -1070,7 +1071,6 @@ export async function seedArtifact( scoped ? null : (opts.downloadUrl ?? "https://example.com/installer.exe"), opts.rangePattern ?? null, opts.groupId ?? null, - scoped ? Buffer.from(content) : null, scoped ? "application/sql" : null, digest, ], From ba49df8a2cc9aa55d400f55623d9560057daea21 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:53:12 +1200 Subject: [PATCH 124/130] note where the bytes rest --- crates/database/tests/it/artifact_scopes.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs index 6474db1b6..a70eac979 100644 --- a/crates/database/tests/it/artifact_scopes.rs +++ b/crates/database/tests/it/artifact_scopes.rs @@ -775,10 +775,11 @@ struct TextRow { value: String, } -/// Reverting drops the artifacts Canopy holds, since the bytes have nowhere to -/// go once the column does, and leaves the unscoped ones as they were. Nothing -/// else runs these migrations backwards, so a `down.sql` that cannot reverse -/// would only be found on the box it was needed on. +/// Reverting drops the artifacts Canopy holds, since a schema with no group has +/// nowhere to record one, and leaves the unscoped ones as they were. The bytes +/// outlive the rows that named them, so a revert leaves the store to be swept by +/// hand. Nothing else runs these migrations backwards, so a `down.sql` that +/// cannot reverse would only be found on the box it was needed on. #[tokio::test(flavor = "multi_thread")] async fn the_group_scope_migration_reverses() { TestDb::run(|mut conn, _url| async move { @@ -811,13 +812,13 @@ async fn the_group_scope_migration_reverses() { .map(|r| r.value.as_str()) .collect::>(), vec!["https://example.com/x.exe"], - "the held artifact goes with the column that held it" + "the held artifact goes with the group column that scoped it" ); let columns: Vec = diesel::sql_query( "SELECT column_name AS value FROM information_schema.columns \ WHERE table_name = 'artifacts' \ - AND column_name IN ('group_id', 'content', 'content_type', 'digest', 'run_id')", + AND column_name IN ('group_id', 'content_type', 'digest', 'run_id')", ) .load(&mut conn) .await From 69859dc77f34fc6e3ef3328d3401cb8b17521ebb Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:07:13 +1200 Subject: [PATCH 125/130] clean up a refused registration --- Cargo.lock | 1 + crates/commons-servers/Cargo.toml | 2 + crates/commons-servers/src/artifact_store.rs | 109 +++++++++++++++++++ crates/private-server/src/fns/versions.rs | 27 ++++- crates/private-server/tests/it/artifacts.rs | 42 +++++++ 5 files changed, 176 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71618a88d..409e56801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,6 +1696,7 @@ dependencies = [ "aws-sdk-route53", "aws-sdk-s3", "aws-sdk-sts", + "aws-smithy-mocks", "axum", "axum-client-ip", "axum-server-timing", diff --git a/crates/commons-servers/Cargo.toml b/crates/commons-servers/Cargo.toml index 2e804bf34..ce6657c70 100644 --- a/crates/commons-servers/Cargo.toml +++ b/crates/commons-servers/Cargo.toml @@ -64,4 +64,6 @@ uuid = { version = "1.23.1", features = ["v4"] } x509-parser = { version = "0.18.1", features = ["verify"] } [dev-dependencies] +aws-sdk-s3 = { workspace = true, features = ["test-util"] } +aws-smithy-mocks.workspace = true axum-test = { workspace = true } diff --git a/crates/commons-servers/src/artifact_store.rs b/crates/commons-servers/src/artifact_store.rs index 600c6ed73..df76bf44f 100644 --- a/crates/commons-servers/src/artifact_store.rs +++ b/crates/commons-servers/src/artifact_store.rs @@ -120,6 +120,22 @@ impl ArtifactStore { }) } + /// Every artifact the store holds. **Debug-only**, and only for the in-memory + /// variant: a test asserting nothing was left behind has to be able to see + /// what is there, and the S3 variant would need a listing to answer. + #[cfg(debug_assertions)] + pub fn held(&self) -> Vec { + match self { + Self::S3 { .. } => panic!("held() is for the in-memory store"), + Self::Memory(store) => store + .lock() + .expect("artifact store") + .keys() + .cloned() + .collect(), + } + } + fn key(&self, artifact: Uuid) -> String { match self { Self::S3 { prefix, .. } => format!("{prefix}{artifact}"), @@ -240,3 +256,96 @@ async fn assumed_credentials( "canopy-artifacts", )) } + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_s3::operation::get_object::GetObjectOutput; + use aws_sdk_s3::operation::put_object::PutObjectOutput; + use aws_sdk_s3::operation::get_object::GetObjectError; + use aws_sdk_s3::types::error::NoSuchKey; + use aws_smithy_mocks::{RuleMode, mock, mock_client}; + + const ARTIFACT: Uuid = Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0); + + fn s3(client: aws_sdk_s3::Client) -> ArtifactStore { + ArtifactStore::S3 { + client, + bucket: "bes-canopy-artifacts".into(), + prefix: DEFAULT_PREFIX.into(), + } + } + + /// An artifact rests under its own id beneath the configured prefix. The key + /// is what a re-registration overwrites and what a deregistration removes, so + /// a store that derived it any other way would leave the old bytes behind. + #[tokio::test] + async fn an_artifact_is_stored_under_its_id_beneath_the_prefix() { + let put = mock!(aws_sdk_s3::Client::put_object) + .match_requests(|req| { + req.bucket() == Some("bes-canopy-artifacts") + && req.key() == Some("artifacts/12345678-9abc-def0-1234-56789abcdef0") + }) + .then_output(|| PutObjectOutput::builder().build()); + + s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&put])) + .put(ARTIFACT, b"kamaka schema".to_vec()) + .await + .expect("stored"); + assert_eq!(put.num_calls(), 1); + } + + /// Bytes come back whole, from the key the id names. + #[tokio::test] + async fn an_artifact_is_read_back_from_its_own_key() { + let get = mock!(aws_sdk_s3::Client::get_object) + .match_requests(|req| req.key() == Some("artifacts/12345678-9abc-def0-1234-56789abcdef0")) + .then_output(|| { + GetObjectOutput::builder() + .body(b"kamaka schema".to_vec().into()) + .build() + }); + + let held = s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&get])) + .get(ARTIFACT) + .await + .expect("read"); + assert_eq!(held.as_deref(), Some(&b"kamaka schema"[..])); + } + + /// An object that is not there is `None` rather than an error, which is what + /// lets the read answer 404 — identically to an artifact the caller is not + /// offered — instead of reporting a fault. + // spec: ART#where-an-artifact-rests + #[tokio::test] + async fn a_missing_object_is_absent_rather_than_a_fault() { + let get = mock!(aws_sdk_s3::Client::get_object) + .then_error(|| GetObjectError::NoSuchKey(NoSuchKey::builder().build())); + + let held = s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&get])) + .get(ARTIFACT) + .await + .expect("a missing object is not an error"); + assert!(held.is_none()); + } + + /// A store that refuses the read is a fault, not an absent artifact. Reading + /// a refusal as "not there" would have a permissions problem present itself + /// as an artifact nobody registered. + #[tokio::test] + async fn a_refused_read_is_a_fault() { + let get = mock!(aws_sdk_s3::Client::get_object).then_error(|| { + GetObjectError::generic( + aws_sdk_s3::error::ErrorMetadata::builder() + .code("AccessDenied") + .message("Access Denied") + .build(), + ) + }); + + s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&get])) + .get(ARTIFACT) + .await + .expect_err("a refusal is not an absent artifact"); + } +} diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 4f2160656..7cd79c2c6 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -824,22 +824,39 @@ pub async fn upload_artifact( // bytes it replaces were, and nothing is left behind. // spec: ART#where-an-artifact-rests let mut conn = state.db.get().await?; - let id = Artifact::id_for_identity(&mut conn, &input) - .await? - .unwrap_or_else(Uuid::new_v4); + let existing = Artifact::id_for_identity(&mut conn, &input).await?; + let id = existing.unwrap_or_else(Uuid::new_v4); drop(conn); store.put(id, Vec::from(body)).await?; let mut conn = state.db.get().await?; - let artifact = Artifact::register( + let registered_row = Artifact::register( &mut conn, NewArtifact { id: Some(id), ..input }, ) - .await?; + .await; + + // A registration naming a group or version Canopy does not hold is refused + // by the row write, with the bytes already stored, so a mistyped id would + // leave an object nothing reaches. Only bytes put under an id minted here + // are dropped: under an id that was already registered they are the live + // artifact's, and a write that failed for any other reason must not take + // them with it. + let artifact = match registered_row { + Ok(artifact) => artifact, + Err(refusal) => { + if existing.is_none() + && let Err(err) = store.delete(id).await + { + tracing::error!(artifact = %id, "refused registration left its bytes: {err}"); + } + return Err(refusal); + } + }; registered(&mut conn, named.version_id, artifact.id).await } diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 2189a4c41..7abda6d39 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -665,3 +665,45 @@ async fn deleting_an_artifact_takes_its_bytes() { }) .await } + +/// A registration that is refused leaves nothing in the store. The bytes go in +/// before the row that names them, so a refusal the row write raises — a group +/// or version that does not exist — is the one case where an object can outlive +/// the registration that put it there, and it is reachable by typing an id +/// wrong rather than by a crash. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_refused_registration_leaves_no_bytes() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "aaaaaaaa-9999-0000-0000-aaaaaaaaaaaa"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let refused = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + // No such group. The row write is what refuses it, by which point + // the bytes have been stored. + .add_query_param("group_id", "dddddddd-9999-0000-0000-dddddddddddd") + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await; + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + + let store = commons_tests::server::artifacts(&mut conn).await; + assert!( + store.held().is_empty(), + "a refused registration left {:?} behind", + store.held() + ); + }) + .await +} From f146a26b05c0a82c353c43ff01f5377bdd5689bf Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:15:24 +1200 Subject: [PATCH 126/130] format the store tests --- crates/commons-servers/src/artifact_store.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/commons-servers/src/artifact_store.rs b/crates/commons-servers/src/artifact_store.rs index df76bf44f..553a7b550 100644 --- a/crates/commons-servers/src/artifact_store.rs +++ b/crates/commons-servers/src/artifact_store.rs @@ -260,9 +260,9 @@ async fn assumed_credentials( #[cfg(test)] mod tests { use super::*; + use aws_sdk_s3::operation::get_object::GetObjectError; use aws_sdk_s3::operation::get_object::GetObjectOutput; use aws_sdk_s3::operation::put_object::PutObjectOutput; - use aws_sdk_s3::operation::get_object::GetObjectError; use aws_sdk_s3::types::error::NoSuchKey; use aws_smithy_mocks::{RuleMode, mock, mock_client}; @@ -299,7 +299,9 @@ mod tests { #[tokio::test] async fn an_artifact_is_read_back_from_its_own_key() { let get = mock!(aws_sdk_s3::Client::get_object) - .match_requests(|req| req.key() == Some("artifacts/12345678-9abc-def0-1234-56789abcdef0")) + .match_requests(|req| { + req.key() == Some("artifacts/12345678-9abc-def0-1234-56789abcdef0") + }) .then_output(|| { GetObjectOutput::builder() .body(b"kamaka schema".to_vec().into()) From 6bda87923b633286a31bfd94b98785cb7c9b4b6c Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:19 +1200 Subject: [PATCH 127/130] sweep artifacts nothing reaches --- crates/commons-servers/src/artifact_store.rs | 89 +++++++++++- crates/database/src/artifacts.rs | 20 +++ crates/jobs/src/artifact_sweep.rs | 106 +++++++++++++++ crates/jobs/src/bin/backups.rs | 6 +- crates/jobs/src/lib.rs | 1 + crates/jobs/tests/it/artifact_sweep.rs | 135 +++++++++++++++++++ crates/jobs/tests/it/main.rs | 1 + 7 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 crates/jobs/src/artifact_sweep.rs create mode 100644 crates/jobs/tests/it/artifact_sweep.rs diff --git a/crates/commons-servers/src/artifact_store.rs b/crates/commons-servers/src/artifact_store.rs index 553a7b550..792aee83f 100644 --- a/crates/commons-servers/src/artifact_store.rs +++ b/crates/commons-servers/src/artifact_store.rs @@ -19,6 +19,7 @@ use std::{ }; use commons_errors::{AppError, Result}; +use jiff::Timestamp; use uuid::Uuid; /// Bucket the artifacts Canopy holds are kept in. Unset ⇒ no store is @@ -39,7 +40,7 @@ const MEMORY_ENV: &str = "CANOPY_ARTIFACT_STORE_MEMORY"; pub const DEFAULT_PREFIX: &str = "artifacts/"; -type MemoryStore = Arc>>>; +type MemoryStore = Arc, Timestamp)>>>; /// The bytes Canopy holds for one artifact, and how to store, read and drop /// them. @@ -136,6 +137,24 @@ impl ArtifactStore { } } + /// Backdate what the store holds for an artifact, so a test can reach a + /// sweep's age threshold without waiting for it. **Debug-only.** + #[cfg(debug_assertions)] + pub fn backdate(&self, artifact: Uuid, to: Timestamp) { + match self { + Self::S3 { .. } => panic!("backdate() is for the in-memory store"), + Self::Memory(store) => { + if let Some((_, at)) = store + .lock() + .expect("artifact store") + .get_mut(&self.key(artifact)) + { + *at = to; + } + } + } + } + fn key(&self, artifact: Uuid) -> String { match self { Self::S3 { prefix, .. } => format!("{prefix}{artifact}"), @@ -160,7 +179,10 @@ impl ArtifactStore { })?; } Self::Memory(store) => { - store.lock().expect("artifact store").insert(key, bytes); + store + .lock() + .expect("artifact store") + .insert(key, (bytes, Timestamp::now())); } } Ok(()) @@ -185,7 +207,68 @@ impl ArtifactStore { })?; Ok(Some(bytes.to_vec())) } - Self::Memory(store) => Ok(store.lock().expect("artifact store").get(&key).cloned()), + Self::Memory(store) => Ok(store + .lock() + .expect("artifact store") + .get(&key) + .map(|(bytes, _)| bytes.clone())), + } + } + + /// Every artifact the store holds, with when each was last written. + /// + /// The time is what keeps a sweep off an artifact still being registered: + /// the bytes go in before the row that names them, so an object younger than + /// that gap has a registration possibly still in flight behind it. + // spec: ART#where-an-artifact-rests + pub async fn stored(&self) -> Result> { + match self { + Self::S3 { + client, + bucket, + prefix, + } => { + let mut found = Vec::new(); + let mut pages = client + .list_objects_v2() + .bucket(bucket) + .prefix(prefix) + .into_paginator() + .send(); + + while let Some(page) = pages.next().await { + let page = page.map_err(|err| { + AppError::custom(format!("listing the artifacts failed: {err}")) + })?; + for object in page.contents() { + // Anything under the prefix that is not an artifact id + // was not put there by Canopy, so it is not Canopy's to + // sweep. + let Some(id) = object + .key() + .and_then(|key| key.strip_prefix(prefix.as_str())) + .and_then(|id| id.parse().ok()) + else { + continue; + }; + let Some(at) = object + .last_modified() + .and_then(|at| Timestamp::from_second(at.secs()).ok()) + else { + continue; + }; + found.push((id, at)); + } + } + + Ok(found) + } + Self::Memory(store) => Ok(store + .lock() + .expect("artifact store") + .iter() + .filter_map(|(key, (_, at))| Some((key.parse().ok()?, *at))) + .collect()), } } diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index b2b9cb311..12d7722bd 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use commons_errors::{AppError, Result}; use diesel::prelude::*; @@ -428,6 +430,24 @@ impl Artifact { .map_err(AppError::from) } + /// The ids of every artifact whose bytes Canopy holds. + /// + /// What a sweep of the store checks each object against: an object under an + /// id not in here is one no artifact reaches. + // spec: ART#where-an-artifact-rests + pub async fn held_ids(db: &mut AsyncPgConnection) -> Result> { + use crate::schema::artifacts::dsl::*; + + let ids: Vec = artifacts + .filter(download_url.is_null()) + .select(id) + .load(db) + .await + .map_err(AppError::from)?; + + Ok(ids.into_iter().collect()) + } + /// Register an artifact, replacing whatever is already registered for the /// same version or range, type, platform, and group. // spec: ART#registration diff --git a/crates/jobs/src/artifact_sweep.rs b/crates/jobs/src/artifact_sweep.rs new file mode 100644 index 000000000..0a61543c5 --- /dev/null +++ b/crates/jobs/src/artifact_sweep.rs @@ -0,0 +1,106 @@ +//! Sweeps the artifact store of bytes no artifact reaches. +//! +//! Canopy keeps none of what it has stopped serving, and a deregistration drops +//! an artifact's bytes as it drops its row. What this catches is the residue +//! nothing else can: a registration whose row write never landed after the bytes +//! did, and every held artifact's bytes after a revert of the migration that +//! gave artifacts a group. +//! +//! Deliberately not an age rule on the store itself. An artifact's bytes live as +//! long as its registration, and a group can sit on one version for a year +//! without a rebuild, so anything expiring by age alone would take live bytes +//! out from under a registered row and answer every device with a 404. +// spec: ART#where-an-artifact-rests + +use std::time::Duration; + +use commons_servers::artifact_store::ArtifactStore; +use database::artifacts::Artifact; +use jiff::Timestamp; +use tokio::{ + task::{self, JoinHandle}, + time::sleep, +}; +use tracing::{debug, error, warn}; + +/// How old an object has to be before the sweep will consider it. +/// +/// A registration stores the bytes before it writes the row that names them, so +/// an object younger than this may have a registration still in flight behind +/// it. Wide enough to cover any upload, since the cost of waiting is a day of +/// storage and the cost of being wrong is an artifact that was registered +/// successfully and cannot be served. +pub const GRACE: Duration = Duration::from_secs(24 * 3600); + +/// How often to sweep. The residue accrues a failed registration at a time, so +/// there is nothing to gain from looking often. +const TICK: Duration = Duration::from_secs(6 * 3600); + +/// One pass. Public so a test can drive it without waiting out [`TICK`]. +pub async fn tick( + db: &mut database::diesel_async::AsyncPgConnection, + store: &ArtifactStore, + now: Timestamp, +) { + let stored = match store.stored().await { + Ok(stored) => stored, + Err(e) => { + warn!("artifact-sweep: listing the store failed: {e}"); + return; + } + }; + + // Read the rows after listing the store, never before: an artifact + // registered while the listing ran is in here and so is not swept, where the + // other order would have it missing from both and drop bytes that had just + // arrived. + let held = match Artifact::held_ids(db).await { + Ok(held) => held, + Err(e) => { + warn!("artifact-sweep: reading the registered artifacts failed: {e}"); + return; + } + }; + + let cutoff = now - GRACE; + let mut swept = 0usize; + for (artifact, stored_at) in stored { + if held.contains(&artifact) || stored_at > cutoff { + continue; + } + match store.delete(artifact).await { + Ok(()) => swept += 1, + Err(e) => warn!(%artifact, "artifact-sweep: delete failed: {e}"), + } + } + + match swept { + 0 => debug!("artifact-sweep: nothing to sweep"), + n => warn!( + "artifact-sweep: dropped {n} artifact(s) no registration reached; a registration \ + failing after its bytes were stored is what leaves these" + ), + } +} + +/// Where no store is configured there is nothing holding artifacts to sweep, so +/// the pod carries on without one rather than refusing to start: the servers are +/// what report an artifact they cannot hold. +pub async fn spawn() -> JoinHandle<()> { + let Some(store) = ArtifactStore::try_default().await else { + warn!("artifact-sweep: no artifact store configured; not sweeping"); + return task::spawn(std::future::pending()); + }; + + let pool = database::init(); + task::spawn(async move { + loop { + sleep(TICK).await; + let Ok(mut db) = pool.get().await else { + error!("Failed to get database connection"); + continue; + }; + tick(&mut db, &store, Timestamp::now()).await; + } + }) +} diff --git a/crates/jobs/src/bin/backups.rs b/crates/jobs/src/bin/backups.rs index fc67f4f7d..f472fa358 100644 --- a/crates/jobs/src/bin/backups.rs +++ b/crates/jobs/src/bin/backups.rs @@ -75,6 +75,9 @@ async fn main() -> miette::Result<()> { let tag_reconcile = jobs::backup::tag_reconcile::spawn(); let progress_prune = jobs::backup::progress_prune::spawn(); let recovery_snapshot = jobs::backup::recovery_snapshot::spawn(worker, recovery_config); + + let artifact_sweep = jobs::artifact_sweep::spawn().await; + tokio::try_join!( preflight, maintenance, @@ -83,7 +86,8 @@ async fn main() -> miette::Result<()> { s3_metrics, tag_reconcile, progress_prune, - recovery_snapshot + recovery_snapshot, + artifact_sweep ) .into_diagnostic()?; Ok(()) diff --git a/crates/jobs/src/lib.rs b/crates/jobs/src/lib.rs index e07a3cbe7..96a3ded4a 100644 --- a/crates/jobs/src/lib.rs +++ b/crates/jobs/src/lib.rs @@ -4,5 +4,6 @@ //! read k8s Secrets; the pure scheduler helpers (jitter, due-ness, billing, //! retention floor) live in `commons_servers::backup_jobs`. +pub mod artifact_sweep; pub mod backup; pub mod domains; diff --git a/crates/jobs/tests/it/artifact_sweep.rs b/crates/jobs/tests/it/artifact_sweep.rs new file mode 100644 index 000000000..5466ffc01 --- /dev/null +++ b/crates/jobs/tests/it/artifact_sweep.rs @@ -0,0 +1,135 @@ +//! The sweep drops the bytes a failed registration left and nothing else. +//! +//! spec: ART + +use commons_servers::artifact_store::ArtifactStore; +use commons_tests::db::TestDb; +use commons_tests::diesel_async::SimpleAsyncConnection; +use database::artifacts::{Artifact, NewArtifact, digest_of}; +use jiff::Timestamp; +use uuid::Uuid; + +const VERSION: &str = "11111111-1111-1111-1111-111111111111"; +const GROUP: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{VERSION}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{GROUP}', 'kamaka')", + )) + .await + .expect("seed"); +} + +/// Register a held artifact and put its bytes where they rest, as an upload does. +async fn register( + conn: &mut database::diesel_async::AsyncPgConnection, + store: &ArtifactStore, + bytes: &[u8], +) -> Uuid { + let artifact = Artifact::register( + conn, + NewArtifact { + id: None, + version_id: Some(VERSION.parse().unwrap()), + artifact_type: "reporting-schema".into(), + platform: "any".into(), + download_url: None, + device_id: None, + version_range_pattern: None, + group_id: Some(GROUP.parse().unwrap()), + content_type: Some("application/sql".into()), + digest: Some(digest_of(bytes)), + run_id: None, + }, + ) + .await + .expect("register"); + + store.put(artifact.id, bytes.to_vec()).await.expect("store"); + artifact.id +} + +/// An artifact still registered keeps its bytes however old they are. A group +/// can sit on one version for a year without a rebuild, so age alone is never a +/// reason to drop what a row still names. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_registered_artifact_keeps_its_bytes_however_old() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + let store = ArtifactStore::memory(); + let artifact = register(&mut conn, &store, b"kamaka schema").await; + + let ancient = Timestamp::now() - std::time::Duration::from_secs(400 * 24 * 3600); + store.backdate(artifact, ancient); + + jobs::artifact_sweep::tick(&mut conn, &store, Timestamp::now()).await; + + assert_eq!( + store.get(artifact).await.unwrap().as_deref(), + Some(&b"kamaka schema"[..]), + "a registered artifact was swept" + ); + }) + .await; +} + +/// Bytes no artifact reaches are what the sweep is for: a registration whose row +/// write never landed leaves them, and nothing else can find them. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn bytes_no_registration_reaches_are_swept() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + let store = ArtifactStore::memory(); + let kept = register(&mut conn, &store, b"kamaka schema").await; + + let orphan = Uuid::new_v4(); + store + .put(orphan, b"nothing names these".to_vec()) + .await + .unwrap(); + store.backdate(orphan, Timestamp::now() - jobs::artifact_sweep::GRACE); + + jobs::artifact_sweep::tick(&mut conn, &store, Timestamp::now()).await; + + assert!( + store.get(orphan).await.unwrap().is_none(), + "the orphan survived" + ); + assert!( + store.get(kept).await.unwrap().is_some(), + "the registered one went" + ); + }) + .await; +} + +/// The bytes go in before the row that names them, so an object younger than the +/// grace has a registration possibly still in flight behind it. Sweeping on age +/// alone would race an upload and drop an artifact that registered successfully. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn bytes_still_within_the_grace_are_left_alone() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + let store = ArtifactStore::memory(); + + // Stored, with no row yet: exactly the window an upload passes through. + let in_flight = Uuid::new_v4(); + store + .put(in_flight, b"mid-registration".to_vec()) + .await + .unwrap(); + + jobs::artifact_sweep::tick(&mut conn, &store, Timestamp::now()).await; + + assert!( + store.get(in_flight).await.unwrap().is_some(), + "an upload still in flight was swept out from under itself" + ); + }) + .await; +} diff --git a/crates/jobs/tests/it/main.rs b/crates/jobs/tests/it/main.rs index 1dba210f9..7255ac242 100644 --- a/crates/jobs/tests/it/main.rs +++ b/crates/jobs/tests/it/main.rs @@ -3,4 +3,5 @@ // file, which keeps rebuilds from swamping the machine with I/O. // Nextest still runs every #[tokio::test] in parallel as usual. +mod artifact_sweep; mod domains; From 0ec420927ac4537f4191e117f8646ee1527978d9 Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:19:48 +1200 Subject: [PATCH 128/130] say what a deletion takes --- .workhorse/specs/platform/artifacts.md | 3 ++ crates/commons-servers/src/artifact_store.rs | 49 ++++++++------------ crates/commons-tests/src/server.rs | 6 +-- crates/database/src/artifacts.rs | 13 ++---- crates/jobs/src/artifact_sweep.rs | 44 ++++++------------ crates/private-server/src/fns/versions.rs | 24 ++++------ crates/private-server/tests/it/artifacts.rs | 8 ++-- private-web/openapi.json | 2 +- private-web/src/api-types.ts | 4 +- 9 files changed, 60 insertions(+), 93 deletions(-) diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index cfee41077..d9e6d7ed1 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -29,9 +29,12 @@ A publisher sends the bytes on the connection it registers over and is issued no Canopy holds such an artifact in storage of its own, apart from any group's backup repo, so an artifact carries the retention, access, and cost basis of an artifact rather than those a backup repo is kept under (see [BAK](../public-server/backup.md)). Where Canopy puts them is its own, and no caller addresses them there. Canopy holds an artifact's bytes for as long as that artifact is registered, and keeps none of what it has stopped serving. +A registration or a deregistration that fails once the bytes have moved can leave bytes no artifact reaches, and Canopy drops those as well rather than letting them accumulate. +It does not expire bytes by age: an artifact's bytes are kept as long as it is registered however long that is, so age alone never says an artifact is finished with. Canopy serves the bytes only to a caller the artifact is offered to. The boundary is therefore enforced on the read rather than resting on a location being hard to guess. +An artifact whose bytes Canopy cannot produce is answered as one that does not exist, identically to an artifact the caller is not offered, so what Canopy has failed to keep is not discoverable through the read. An artifact Canopy holds and an artifact Canopy records a location for are one thing to whoever is offered it. It is offered one artifact per type and platform, and where the bytes rest is not part of what it is offered. diff --git a/crates/commons-servers/src/artifact_store.rs b/crates/commons-servers/src/artifact_store.rs index 792aee83f..e2116a706 100644 --- a/crates/commons-servers/src/artifact_store.rs +++ b/crates/commons-servers/src/artifact_store.rs @@ -1,13 +1,9 @@ -//! Where Canopy keeps the bytes of the artifacts it holds. -//! -//! A group-scoped artifact is carried to Canopy by the registration that -//! publishes it, and Canopy keeps it in storage of its own, apart from any -//! group's backup repo. Objects are addressed by the artifact's id, so a +//! Where Canopy keeps the bytes of the artifacts it holds, apart from any +//! group's backup repo. An object is addressed by its artifact's id, so a //! re-registration replaces the one object and a deregistration removes it. //! -//! No caller addresses the store: the boundary is enforced on the read, which -//! resolves the artifact against the caller's scope first and only then asks -//! for its bytes. +//! Nothing here checks who is asking: a read resolves the artifact against the +//! caller's scope before it gets this far. //! //! `S3` is the real store; `Memory` is an in-process map for tests and the e2e //! binary, mirroring [`crate::backup_secrets::BackupSecrets`]. @@ -42,8 +38,6 @@ pub const DEFAULT_PREFIX: &str = "artifacts/"; type MemoryStore = Arc, Timestamp)>>>; -/// The bytes Canopy holds for one artifact, and how to store, read and drop -/// them. #[derive(Clone)] pub enum ArtifactStore { S3 { @@ -68,10 +62,10 @@ impl std::fmt::Debug for ArtifactStore { } impl ArtifactStore { - /// An in-process store for tests / the e2e binary. **Debug-only**: the - /// constructor — and therefore any way to reach the `Memory` variant — does - /// not exist in release builds, so a real instance can never keep artifacts - /// in a process-local map that vanishes with the pod. + /// An in-process store for tests / the e2e binary. **Debug-only**: with no + /// constructor in a release build there is no way to reach the `Memory` + /// variant, so a real instance can never keep artifacts in a process-local + /// map that vanishes with the pod. #[cfg(debug_assertions)] pub fn memory() -> Self { Self::Memory(Arc::new(Mutex::new(BTreeMap::new()))) @@ -121,9 +115,9 @@ impl ArtifactStore { }) } - /// Every artifact the store holds. **Debug-only**, and only for the in-memory - /// variant: a test asserting nothing was left behind has to be able to see - /// what is there, and the S3 variant would need a listing to answer. + /// What the store holds, by key. **Debug-only**: a test asserting nothing was + /// left behind has to be able to see what is there. [`Self::stored`] is the + /// same question asked of either variant. #[cfg(debug_assertions)] pub fn held(&self) -> Vec { match self { @@ -137,8 +131,8 @@ impl ArtifactStore { } } - /// Backdate what the store holds for an artifact, so a test can reach a - /// sweep's age threshold without waiting for it. **Debug-only.** + /// Backdate an artifact, so a test reaches the sweep's age threshold without + /// waiting out a day. **Debug-only.** #[cfg(debug_assertions)] pub fn backdate(&self, artifact: Uuid, to: Timestamp) { match self { @@ -215,11 +209,8 @@ impl ArtifactStore { } } - /// Every artifact the store holds, with when each was last written. - /// - /// The time is what keeps a sweep off an artifact still being registered: - /// the bytes go in before the row that names them, so an object younger than - /// that gap has a registration possibly still in flight behind it. + /// Every artifact the store holds, with when each was last written. The time + /// is what keeps a sweep off a registration still in flight. // spec: ART#where-an-artifact-rests pub async fn stored(&self) -> Result> { match self { @@ -242,8 +233,7 @@ impl ArtifactStore { })?; for object in page.contents() { // Anything under the prefix that is not an artifact id - // was not put there by Canopy, so it is not Canopy's to - // sweep. + // was not put there by Canopy. let Some(id) = object .key() .and_then(|key| key.strip_prefix(prefix.as_str())) @@ -272,8 +262,7 @@ impl ArtifactStore { } } - /// Drop an artifact's bytes. Deleting what is not there is not an error: - /// Canopy keeps none of what it has stopped serving either way. + /// Drop an artifact's bytes. Dropping what is not there is not an error. pub async fn delete(&self, artifact: Uuid) -> Result<()> { let key = self.key(artifact); match self { @@ -399,8 +388,8 @@ mod tests { } /// An object that is not there is `None` rather than an error, which is what - /// lets the read answer 404 — identically to an artifact the caller is not - /// offered — instead of reporting a fault. + /// lets the read answer as missing, identically to an artifact the caller is + /// not offered, instead of reporting a fault. // spec: ART#where-an-artifact-rests #[tokio::test] async fn a_missing_object_is_absent_rather_than_a_fault() { diff --git a/crates/commons-tests/src/server.rs b/crates/commons-tests/src/server.rs index 76b792316..700a5c6b0 100644 --- a/crates/commons-tests/src/server.rs +++ b/crates/commons-tests/src/server.rs @@ -127,9 +127,9 @@ where /// The artifact store the servers of this run were built on, for a test that /// seeds or reads the bytes Canopy holds. /// -/// Keyed by the throwaway database's name, which is unique per run: a test -/// reaches its own store through the connection it already has rather than -/// every harness callback in the workspace growing an argument for it. +/// Keyed by the throwaway database's name, so a test reaches its own store +/// through the connection it already has rather than every harness callback in +/// the workspace growing an argument for it. pub async fn artifacts(conn: &mut AsyncPgConnection) -> ArtifactStore { #[derive(QueryableByName)] struct Name { diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 12d7722bd..69a145b30 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -406,10 +406,9 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } - /// The id an artifact of this identity is already registered under, where - /// one is. An artifact Canopy holds rests under its id, so a re-registration - /// puts the new bytes where the old ones were rather than leaving them for - /// nothing to reach. + /// The id an artifact of this identity is already registered under, where one + /// is. An artifact rests under its id, so a re-registration that reuses it + /// puts the new bytes where the old ones were. // spec: ART#registration pub async fn id_for_identity( db: &mut AsyncPgConnection, @@ -430,10 +429,8 @@ impl Artifact { .map_err(AppError::from) } - /// The ids of every artifact whose bytes Canopy holds. - /// - /// What a sweep of the store checks each object against: an object under an - /// id not in here is one no artifact reaches. + /// The ids of every artifact whose bytes Canopy holds. An object under an id + /// not in here is one no artifact reaches. // spec: ART#where-an-artifact-rests pub async fn held_ids(db: &mut AsyncPgConnection) -> Result> { use crate::schema::artifacts::dsl::*; diff --git a/crates/jobs/src/artifact_sweep.rs b/crates/jobs/src/artifact_sweep.rs index 0a61543c5..14eec4fe1 100644 --- a/crates/jobs/src/artifact_sweep.rs +++ b/crates/jobs/src/artifact_sweep.rs @@ -1,15 +1,9 @@ -//! Sweeps the artifact store of bytes no artifact reaches. +//! Sweeps the artifact store of bytes no artifact reaches: a registration whose +//! row write never landed after its bytes did, and what a revert of the group +//! scoping leaves behind. //! -//! Canopy keeps none of what it has stopped serving, and a deregistration drops -//! an artifact's bytes as it drops its row. What this catches is the residue -//! nothing else can: a registration whose row write never landed after the bytes -//! did, and every held artifact's bytes after a revert of the migration that -//! gave artifacts a group. -//! -//! Deliberately not an age rule on the store itself. An artifact's bytes live as -//! long as its registration, and a group can sit on one version for a year -//! without a rebuild, so anything expiring by age alone would take live bytes -//! out from under a registered row and answer every device with a 404. +//! Not an age rule. A group can sit on one version for a year without a rebuild, +//! so expiring by age alone would take live bytes out from under a registered row. // spec: ART#where-an-artifact-rests use std::time::Duration; @@ -23,17 +17,11 @@ use tokio::{ }; use tracing::{debug, error, warn}; -/// How old an object has to be before the sweep will consider it. -/// -/// A registration stores the bytes before it writes the row that names them, so -/// an object younger than this may have a registration still in flight behind -/// it. Wide enough to cover any upload, since the cost of waiting is a day of -/// storage and the cost of being wrong is an artifact that was registered -/// successfully and cannot be served. +/// How old an object has to be before the sweep will consider it. A registration +/// stores the bytes before the row that names them, so anything younger may have +/// one still in flight behind it. pub const GRACE: Duration = Duration::from_secs(24 * 3600); -/// How often to sweep. The residue accrues a failed registration at a time, so -/// there is nothing to gain from looking often. const TICK: Duration = Duration::from_secs(6 * 3600); /// One pass. Public so a test can drive it without waiting out [`TICK`]. @@ -50,10 +38,8 @@ pub async fn tick( } }; - // Read the rows after listing the store, never before: an artifact - // registered while the listing ran is in here and so is not swept, where the - // other order would have it missing from both and drop bytes that had just - // arrived. + // After the listing, never before: an artifact registered while it ran is + // then in here, where the other order has it in neither and sweeps it. let held = match Artifact::held_ids(db).await { Ok(held) => held, Err(e) => { @@ -76,16 +62,12 @@ pub async fn tick( match swept { 0 => debug!("artifact-sweep: nothing to sweep"), - n => warn!( - "artifact-sweep: dropped {n} artifact(s) no registration reached; a registration \ - failing after its bytes were stored is what leaves these" - ), + n => warn!("artifact-sweep: dropped {n} artifact(s) no registration reached"), } } -/// Where no store is configured there is nothing holding artifacts to sweep, so -/// the pod carries on without one rather than refusing to start: the servers are -/// what report an artifact they cannot hold. +/// With no store configured the pod carries on rather than refusing to start: +/// the servers are what report an artifact they cannot hold. pub async fn spawn() -> JoinHandle<()> { let Some(store) = ArtifactStore::try_default().await else { warn!("artifact-sweep: no artifact store configured; not sweeping"); diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index 7cd79c2c6..cb38802a2 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -819,9 +819,8 @@ pub async fn upload_artifact( run_id: None, }; - // The bytes go in before the row that names them, under the id the artifact - // already has where one is registered: a replacement then lands where the - // bytes it replaces were, and nothing is left behind. + // Under the id the artifact already has where one is registered, so a + // replacement lands where the bytes it replaces were. // spec: ART#where-an-artifact-rests let mut conn = state.db.get().await?; let existing = Artifact::id_for_identity(&mut conn, &input).await?; @@ -840,12 +839,9 @@ pub async fn upload_artifact( ) .await; - // A registration naming a group or version Canopy does not hold is refused - // by the row write, with the bytes already stored, so a mistyped id would - // leave an object nothing reaches. Only bytes put under an id minted here - // are dropped: under an id that was already registered they are the live - // artifact's, and a write that failed for any other reason must not take - // them with it. + // The row write is what refuses a group or version Canopy does not hold, by + // which point the bytes are stored. Only an id minted here is dropped: under + // one already registered the bytes are the live artifact's. let artifact = match registered_row { Ok(artifact) => artifact, Err(refusal) => { @@ -886,8 +882,8 @@ pub struct ArtifactIdArgs { /// Permanently delete an artifact. /// -/// The artifact record is removed outright; the file it pointed to is not -/// touched. There is no undo. +/// An artifact Canopy holds loses its bytes along with its record. One that +/// records a location keeps whatever is at that location. There is no undo. #[utoipa::path( post, path = "/delete_artifact", @@ -903,9 +899,9 @@ pub async fn delete_artifact( _admin: TailscaleAdmin, Json(args): Json, ) -> Result> { - // The bytes go before the row: a store that refuses the drop leaves the - // artifact registered and the operator retrying, rather than a row gone and - // bytes nothing reaches. + // Before the row: a store that refuses the drop leaves the artifact + // registered and the operator retrying, rather than a row gone and bytes + // nothing reaches. // spec: ART#where-an-artifact-rests if let Some(store) = &state.artifacts { store.delete(args.artifact_id).await?; diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index 7abda6d39..149ac0a84 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -667,10 +667,10 @@ async fn deleting_an_artifact_takes_its_bytes() { } /// A registration that is refused leaves nothing in the store. The bytes go in -/// before the row that names them, so a refusal the row write raises — a group -/// or version that does not exist — is the one case where an object can outlive -/// the registration that put it there, and it is reachable by typing an id -/// wrong rather than by a crash. +/// before the row that names them, so a refusal the row write raises, meaning a +/// group or version that does not exist, is the one case where an object can +/// outlive the registration that put it there. Reachable by typing an id wrong +/// rather than by a crash. // spec: ART#where-an-artifact-rests #[tokio::test(flavor = "multi_thread")] async fn a_refused_registration_leaves_no_bytes() { diff --git a/private-web/openapi.json b/private-web/openapi.json index 8207fc8c5..0e49ae68e 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -7891,7 +7891,7 @@ "versions" ], "summary": "Permanently delete an artifact.", - "description": "The artifact record is removed outright; the file it pointed to is not\ntouched. There is no undo.", + "description": "An artifact Canopy holds loses its bytes along with its record. One that\nrecords a location keeps whatever is at that location. There is no undo.", "operationId": "delete_artifact", "requestBody": { "content": { diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 4388b2448..2dd496e7e 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -4010,8 +4010,8 @@ export interface paths { put?: never; /** * Permanently delete an artifact. - * @description The artifact record is removed outright; the file it pointed to is not - * touched. There is no undo. + * @description An artifact Canopy holds loses its bytes along with its record. One that + * records a location keeps whatever is at that location. There is no undo. */ post: operations["delete_artifact"]; delete?: never; From 04a0c35f707871d080d3885a7bf3ebe37024138f Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:24:00 +1200 Subject: [PATCH 129/130] note what raising the cap takes --- crates/private-server/src/fns/versions.rs | 5 +++++ crates/public-server/src/versions.rs | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index cb38802a2..13f6c2eb4 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -25,6 +25,11 @@ use crate::state::AppState; /// Cap on the bytes Canopy will hold for one artifact. A reporting schema is a /// SQL file; anything approaching this is not one, and the whole of it is held /// in memory to be digested before it is stored. +/// +/// Raising it takes streaming first, end to end: the upload buffers the body, +/// the store puts and gets whole `Vec`s, and the download reads and digests the +/// whole artifact before it answers. Until then a larger cap is what a handful +/// of concurrent fetches spend the server's memory on. const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; /// Header the SPA sets on an upload, which no cross-origin page can send diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 00abe5b87..50fc5c1e8 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -693,9 +693,10 @@ async fn download_artifact( return Err(AppError::ArtifactNotFound); }; - // Hashing the whole artifact is tens of milliseconds with no await in - // it, and a fleet fetching one schema at once would spend that on the - // runtime's own threads. + // The artifact is here in memory whole, which registration caps, and + // hashing it is tens of milliseconds with no await in it: a fleet + // fetching one schema at once would spend that on the runtime's own + // threads. let bytes = tokio::task::spawn_blocking(move || { (database::artifacts::digest_of(&bytes) == recorded).then_some(bytes) }) From 845e5335517b8259bce1350fe3bfe1de1195177b Mon Sep 17 00:00:00 2001 From: Daniel Nash <38335330+dannash100@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:59:06 +1200 Subject: [PATCH 130/130] rank the seeded plans --- crates/database/tests/it/reporting_schemas.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/database/tests/it/reporting_schemas.rs b/crates/database/tests/it/reporting_schemas.rs index db8836508..c0e62e3dd 100644 --- a/crates/database/tests/it/reporting_schemas.rs +++ b/crates/database/tests/it/reporting_schemas.rs @@ -850,8 +850,8 @@ async fn an_open_plan_s_target_is_a_pair_and_a_closed_one_is_not() { conn.batch_execute(&format!( "UPDATE application_reported_detail SET version = '2.59.0'; - INSERT INTO upgrade_plans (group_id, target_version_id, created_by) - VALUES ('{GROUP}', '{newer}', 'seed@bes.au')" + INSERT INTO upgrade_plans (group_id, rank, target_version_id, created_by) + VALUES ('{GROUP}', 'production', '{newer}', 'seed@bes.au')" )) .await .expect("plan the upgrade"); @@ -986,8 +986,8 @@ async fn a_planned_pair_names_no_applications() { conn.batch_execute(&format!( "UPDATE application_reported_detail SET version = '2.59.0'; - INSERT INTO upgrade_plans (group_id, target_version_id, created_by) - VALUES ('{GROUP}', '{newer}', 'seed@bes.au')" + INSERT INTO upgrade_plans (group_id, rank, target_version_id, created_by) + VALUES ('{GROUP}', 'production', '{newer}', 'seed@bes.au')" )) .await .expect("plan the upgrade");