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 01/52] 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 02/52] 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 03/52] 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 04/52] 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 05/52] 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 06/52] 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 07/52] 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 08/52] 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 09/52] 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 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 10/52] 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 11/52] 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 12/52] 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 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 13/52] 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 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 14/52] 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 15/52] 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 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 16/52] 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 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 17/52] 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 18/52] 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 19/52] 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 20/52] 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 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 21/52] 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 22/52] 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 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 23/52] 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 24/52] 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 25/52] 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 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 26/52] 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 27/52] 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 28/52] 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 29/52] 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 30/52] 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 31/52] 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 32/52] 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 33/52] 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 34/52] 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 35/52] 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 36/52] 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 37/52] 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 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 38/52] 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 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 39/52] 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 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 40/52] 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 41/52] 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 42/52] 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 43/52] 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 44/52] 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 45/52] 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 46/52] 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 47/52] 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 48/52] 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 49/52] 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 50/52] 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 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 51/52] 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 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 52/52] 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");