diff --git a/.workhorse/specs/platform/artifacts.md b/.workhorse/specs/platform/artifacts.md index 135bf7475..6dd5f4875 100644 --- a/.workhorse/specs/platform/artifacts.md +++ b/.workhorse/specs/platform/artifacts.md @@ -29,15 +29,21 @@ A publisher sends the bytes on the connection it registers over and is issued no Canopy holds such an artifact in storage of its own, apart from any group's backup repo, so an artifact carries the retention, access, and cost basis of an artifact rather than those a backup repo is kept under (see [BAK](../public-server/backup.md)). Where Canopy puts them is its own, and no caller addresses them there. Canopy holds an artifact's bytes for as long as that artifact is registered, and keeps none of what it has stopped serving. +A registration or a deregistration that fails once the bytes have moved can leave bytes no artifact reaches, and Canopy drops those as well rather than letting them accumulate. +It does not expire bytes by age: an artifact's bytes are kept as long as it is registered however long that is, so age alone never says an artifact is finished with. Canopy serves the bytes only to a caller the artifact is offered to. The boundary is therefore enforced on the read rather than resting on a location being hard to guess. +An artifact whose bytes Canopy cannot produce is answered as one that does not exist, identically to an artifact the caller is not offered, so what Canopy has failed to keep is not discoverable through the read. An artifact Canopy holds and an artifact Canopy records a location for are one thing to whoever is offered it. It is offered one artifact per type and platform, and where the bytes rest is not part of what it is offered. ## What a version offers +A read names a version range, and an exact version is a valid one. +An exact version is answered for itself; a range is answered for the latest published version it covers that no known issue covers. + Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known. Where several match, the most specific is offered. An artifact scoped to the caller's group is more specific than one belonging to no group. @@ -59,7 +65,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. @@ -72,7 +80,9 @@ Canopy records which device registered an artifact and, where the registration n ## Digests -An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always. -Canopy verifies a group-scoped artifact's bytes against its digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again. +An artifact carries a digest where whoever registers it records one, and an artifact Canopy holds carries one always. +A digest is expressed in Subresource Integrity format, using SHA-256. +A registration naming anything else is refused. +Canopy verifies the bytes it holds against their digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again. It verifies them again as it serves them and refuses them on a mismatch, so an artifact corrupted after it was taken in fails the read rather than reaching a server as the artifact it is not. An unscoped artifact is read from its location by the caller rather than by Canopy, so its digest is what that caller checks what it fetched against, and an artifact registered without one is fetched unchecked. diff --git a/.workhorse/specs/public-server/reporting-schemas.md b/.workhorse/specs/public-server/reporting-schemas.md index bc6acada8..ed8946575 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. @@ -50,8 +50,10 @@ 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 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 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. 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. @@ -80,6 +82,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. diff --git a/.workhorse/specs/public-server/restore-replicas.md b/.workhorse/specs/public-server/restore-replicas.md index b8f017dd2..70608ffcb 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**. diff --git a/Cargo.lock b/Cargo.lock index 334012f33..409e56801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1694,6 +1694,9 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-route53", + "aws-sdk-s3", + "aws-sdk-sts", + "aws-smithy-mocks", "axum", "axum-client-ip", "axum-server-timing", @@ -1723,6 +1726,7 @@ dependencies = [ "tokio", "tower-http 0.7.0", "tracing", + "uuid", "x509-parser", ] @@ -5032,6 +5036,7 @@ dependencies = [ "diesel", "diesel-async", "futures", + "hex", "http 1.5.0", "jiff", "k8s-openapi", diff --git a/ERRORS.md b/ERRORS.md index 4410fa97e..b5483282f 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -37,6 +37,14 @@ Issued when a database resource (such as a version, server, or other entity) can Issued when a version range is valid, but does not match any of the available versions. +## Artifact not found + +Issued when the version has no artifact with the given id that the caller is offered. An artifact scoped to a group the caller is not in is reported the same way as one that does not exist, so which groups hold an artifact cannot be discovered through this endpoint. + +## Artifact digest mismatch + +Issued when the bytes Canopy holds for an artifact do not match the digest recorded for it. The artifact is not served: a corrupted artifact fails the read rather than reaching a server as the artifact it is not. + ## Unusable range Issued when a version range is syntactically valid, but not usable to obtain concrete versions. diff --git a/crates/canopy-api/src/generated.rs b/crates/canopy-api/src/generated.rs index 6b480bdcd..c8b53f13c 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.1"; /// 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 = "7618bb56960b10e83dac565b3dde53060531f2ef31bc1a7fa4fe6cf308ef5f02"; +pub const OPENAPI_BLAKE3: &str = "776683f1e7f0234e400bb71fb2604b3c477666bc77e7402f8942456ffc2209e6"; /// Error types. pub mod error { @@ -260,6 +260,13 @@ package, or other file published for a given type and platform.*/ /// ], /// "format": "uuid" /// }, +/// "digest": { +/// "description": "Subresource Integrity digest of the artifact's bytes, e.g.\n`sha256-LCTbqp…`, where one was recorded.", +/// "type": [ +/// "string", +/// "null" +/// ] +/// }, /// "download_url": { /// "description": "URL the artifact can be downloaded from.", /// "type": "string" @@ -302,6 +309,10 @@ pub struct Artifact { releaser device rather than created by an operator.*/ #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] pub device_id: ::std::option::Option<::uuid::Uuid>, + /**Subresource Integrity digest of the artifact's bytes, e.g. +`sha256-LCTbqp…`, where one was recorded.*/ + #[serde(default, skip_serializing_if = "::std::option::Option::is_none")] + pub digest: ::std::option::Option<::std::string::String>, ///URL the artifact can be downloaded from. pub download_url: ::std::string::String, ///Unique identifier of the artifact. @@ -1429,7 +1440,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. 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 until\noverdue), `url` (a replica's health report includes a link to it),\n`migrate` (Canopy names a target version and the replica applies that\nversion's migrations), `redact` (the replica de-identifies the restored\ndata before serving it), and `reporting-schema` (the replica builds a\nTamanu reporting schema and registers it for the group). Unrecognised\nvalues are stored but have no effect, so a consumer may advertise ahead\nof Canopy support.", /// "type": "array", /// "items": { /// "type": "string" @@ -1455,9 +1466,14 @@ parameter name.*/ 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.*/ +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, skip_serializing_if = "::std::vec::Vec::is_empty")] pub semantics: ::std::vec::Vec<::std::string::String>, } @@ -2425,6 +2441,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 @@ -3363,6 +3451,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": [ @@ -3471,6 +3570,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.*/ @@ -3972,6 +4073,25 @@ impl crate::CanopyClient { pub async fn applications_self(&self) -> crate::Result { self.call_json(::http::Method::GET, "/applications/self", None::<&()>).await } + /// 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. + /// + /// `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 diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index 6d769415d..bba9f04e2 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -66,6 +66,12 @@ pub enum AppError { #[error("no versions match given range")] NoMatchingVersions, + #[error("no such artifact for this version")] + ArtifactNotFound, + + #[error("artifact does not match its digest")] + ArtifactDigestMismatch, + #[error("version range is not usable")] UnusableRange, @@ -249,6 +255,12 @@ impl AppError { match self { Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, Self::NoMatchingVersions => StatusCode::NOT_FOUND, + // An artifact a caller is not offered is missing in exactly the + // way one that never existed is, so which groups hold an artifact + // is not enumerable through this endpoint. + // spec: ART#who-is-offered-a-group-scoped-artifact + Self::ArtifactNotFound => StatusCode::NOT_FOUND, + Self::ArtifactDigestMismatch => StatusCode::INTERNAL_SERVER_ERROR, Self::UnusableRange => StatusCode::BAD_REQUEST, // Both arise purely from what a client sent: a version segment in // a URL path, or the `X-Version` header. Nothing on the server is @@ -322,6 +334,8 @@ impl AppError { Self::Tera(_) => "render", Self::Io(_) => "io", Self::NoMatchingVersions => "no-matching-versions", + Self::ArtifactNotFound => "artifact-not-found", + Self::ArtifactDigestMismatch => "artifact-digest-mismatch", Self::UnusableRange => "unusable-range", Self::Timesync(_) => "timesync", Self::AuthMissingHeader(_) => "auth-missing-header", @@ -376,3 +390,33 @@ impl<'de> Deserialize<'de> for AppError { Ok(AppError::Problem(Box::new(value))) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Input an operator or a client controls answers as their mistake, not as + /// a fault, and carries the slug its documentation is written under. + #[test] + fn a_client_mistake_is_not_a_fault() { + for (error, status, slug) in [ + ( + AppError::BadRequest("no".into()), + StatusCode::BAD_REQUEST, + "/errors/bad-request", + ), + ( + AppError::Conflict("taken".into()), + StatusCode::CONFLICT, + "/errors/conflict", + ), + ] { + let problem = error.to_problem_details(); + assert_eq!(problem.status, Some(status)); + assert_eq!( + problem.r#type.as_ref().map(ToString::to_string).as_deref(), + Some(slug) + ); + } + } +} diff --git a/crates/commons-servers/Cargo.toml b/crates/commons-servers/Cargo.toml index 4e2bf0ca3..ce6657c70 100644 --- a/crates/commons-servers/Cargo.toml +++ b/crates/commons-servers/Cargo.toml @@ -15,6 +15,8 @@ age = { version = "0.12.1", default-features = false } aws-config.workspace = true aws-credential-types = "1.3.0" aws-sdk-route53 = "1.118.0" +aws-sdk-s3.workspace = true +aws-sdk-sts.workspace = true axum = { workspace = true, features = ["json", "macros"] } axum-client-ip = { version = "1.3.1", features = ["forwarded-header"] } axum-server-timing = "3.0.1" @@ -56,9 +58,12 @@ tower-http = { version = "0.7.0", features = [ "trace", ] } tracing.workspace = true +uuid = { version = "1.23.1", features = ["v4"] } # `verify` gives `verify_signature` on a parsed CSR — proof the sender holds the # key it asks Canopy to certify. Backed by ring, already a dependency here. x509-parser = { version = "0.18.1", features = ["verify"] } [dev-dependencies] +aws-sdk-s3 = { workspace = true, features = ["test-util"] } +aws-smithy-mocks.workspace = true axum-test = { workspace = true } diff --git a/crates/commons-servers/src/artifact_store.rs b/crates/commons-servers/src/artifact_store.rs new file mode 100644 index 000000000..e2116a706 --- /dev/null +++ b/crates/commons-servers/src/artifact_store.rs @@ -0,0 +1,425 @@ +//! Where Canopy keeps the bytes of the artifacts it holds, apart from any +//! group's backup repo. An object is addressed by its artifact's id, so a +//! re-registration replaces the one object and a deregistration removes it. +//! +//! Nothing here checks who is asking: a read resolves the artifact against the +//! caller's scope before it gets this far. +//! +//! `S3` is the real store; `Memory` is an in-process map for tests and the e2e +//! binary, mirroring [`crate::backup_secrets::BackupSecrets`]. +// spec: ART#where-an-artifact-rests + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use commons_errors::{AppError, Result}; +use jiff::Timestamp; +use uuid::Uuid; + +/// Bucket the artifacts Canopy holds are kept in. Unset ⇒ no store is +/// configured, and registering or serving held bytes reports that rather than +/// the binary failing to start. +pub const BUCKET_ENV: &str = "CANOPY_ARTIFACT_BUCKET"; +/// Region the bucket is in, where it is not the ambient one. +pub const REGION_ENV: &str = "CANOPY_ARTIFACT_REGION"; +/// Role to assume for the object calls, where the bucket lives in an account +/// the pod's own identity does not reach. +pub const ROLE_ARN_ENV: &str = "CANOPY_ARTIFACT_ROLE_ARN"; +/// Key prefix within the bucket, so artifacts can share a bucket with +/// something else. Defaults to [`DEFAULT_PREFIX`]. +pub const PREFIX_ENV: &str = "CANOPY_ARTIFACT_PREFIX"; +/// Env var that forces the in-memory store (no bucket needed). Set by the e2e +/// fixture; tests use [`ArtifactStore::memory`] directly. +const MEMORY_ENV: &str = "CANOPY_ARTIFACT_STORE_MEMORY"; + +pub const DEFAULT_PREFIX: &str = "artifacts/"; + +type MemoryStore = Arc, Timestamp)>>>; + +#[derive(Clone)] +pub enum ArtifactStore { + S3 { + client: aws_sdk_s3::Client, + bucket: String, + prefix: String, + }, + Memory(MemoryStore), +} + +impl std::fmt::Debug for ArtifactStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::S3 { bucket, prefix, .. } => f + .debug_struct("ArtifactStore::S3") + .field("bucket", bucket) + .field("prefix", prefix) + .finish_non_exhaustive(), + Self::Memory(_) => f.write_str("ArtifactStore::Memory"), + } + } +} + +impl ArtifactStore { + /// An in-process store for tests / the e2e binary. **Debug-only**: with no + /// constructor in a release build there is no way to reach the `Memory` + /// variant, so a real instance can never keep artifacts in a process-local + /// map that vanishes with the pod. + #[cfg(debug_assertions)] + pub fn memory() -> Self { + Self::Memory(Arc::new(Mutex::new(BTreeMap::new()))) + } + + /// Build the store from the environment. Returns `None` (logged) when no + /// bucket is configured, so the endpoints that need one report it rather + /// than the binary failing to start. + pub async fn try_default() -> Option { + if std::env::var_os(MEMORY_ENV).is_some() { + #[cfg(debug_assertions)] + { + tracing::warn!("{MEMORY_ENV} set; holding artifacts in process memory"); + return Some(Self::memory()); + } + #[cfg(not(debug_assertions))] + tracing::error!( + "{MEMORY_ENV} is set but IGNORED: the in-memory artifact store is debug-only" + ); + } + + let Ok(bucket) = std::env::var(BUCKET_ENV) else { + tracing::warn!("{BUCKET_ENV} is unset; Canopy can hold no artifact"); + return None; + }; + let prefix = std::env::var(PREFIX_ENV).unwrap_or_else(|_| DEFAULT_PREFIX.to_owned()); + + let sdk = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let mut builder = aws_sdk_s3::config::Builder::from(&sdk); + if let Ok(role_arn) = std::env::var(ROLE_ARN_ENV) { + match assumed_credentials(&sdk, &role_arn).await { + Ok(creds) => builder = builder.credentials_provider(creds), + Err(err) => { + tracing::error!("cannot assume {role_arn} for the artifact store: {err}"); + return None; + } + } + } + if let Ok(region) = std::env::var(REGION_ENV) { + builder = builder.region(aws_sdk_s3::config::Region::new(region)); + } + + Some(Self::S3 { + client: aws_sdk_s3::Client::from_conf(builder.build()), + bucket, + prefix, + }) + } + + /// What the store holds, by key. **Debug-only**: a test asserting nothing was + /// left behind has to be able to see what is there. [`Self::stored`] is the + /// same question asked of either variant. + #[cfg(debug_assertions)] + pub fn held(&self) -> Vec { + match self { + Self::S3 { .. } => panic!("held() is for the in-memory store"), + Self::Memory(store) => store + .lock() + .expect("artifact store") + .keys() + .cloned() + .collect(), + } + } + + /// Backdate an artifact, so a test reaches the sweep's age threshold without + /// waiting out a day. **Debug-only.** + #[cfg(debug_assertions)] + pub fn backdate(&self, artifact: Uuid, to: Timestamp) { + match self { + Self::S3 { .. } => panic!("backdate() is for the in-memory store"), + Self::Memory(store) => { + if let Some((_, at)) = store + .lock() + .expect("artifact store") + .get_mut(&self.key(artifact)) + { + *at = to; + } + } + } + } + + fn key(&self, artifact: Uuid) -> String { + match self { + Self::S3 { prefix, .. } => format!("{prefix}{artifact}"), + Self::Memory(_) => artifact.to_string(), + } + } + + /// Store an artifact's bytes, replacing whatever was under its id. + pub async fn put(&self, artifact: Uuid, bytes: Vec) -> Result<()> { + let key = self.key(artifact); + match self { + Self::S3 { client, bucket, .. } => { + client + .put_object() + .bucket(bucket) + .key(&key) + .body(bytes.into()) + .send() + .await + .map_err(|err| { + AppError::custom(format!("storing the artifact failed: {err}")) + })?; + } + Self::Memory(store) => { + store + .lock() + .expect("artifact store") + .insert(key, (bytes, Timestamp::now())); + } + } + Ok(()) + } + + /// An artifact's bytes, or `None` where the store holds none under that id. + pub async fn get(&self, artifact: Uuid) -> Result>> { + let key = self.key(artifact); + match self { + Self::S3 { client, bucket, .. } => { + let object = match client.get_object().bucket(bucket).key(&key).send().await { + Ok(object) => object, + Err(err) if is_missing(&err) => return Ok(None), + Err(err) => { + return Err(AppError::custom(format!( + "reading the artifact failed: {err}" + ))); + } + }; + let bytes = object.body.collect().await.map_err(|err| { + AppError::custom(format!("reading the artifact failed: {err}")) + })?; + Ok(Some(bytes.to_vec())) + } + Self::Memory(store) => Ok(store + .lock() + .expect("artifact store") + .get(&key) + .map(|(bytes, _)| bytes.clone())), + } + } + + /// Every artifact the store holds, with when each was last written. The time + /// is what keeps a sweep off a registration still in flight. + // spec: ART#where-an-artifact-rests + pub async fn stored(&self) -> Result> { + match self { + Self::S3 { + client, + bucket, + prefix, + } => { + let mut found = Vec::new(); + let mut pages = client + .list_objects_v2() + .bucket(bucket) + .prefix(prefix) + .into_paginator() + .send(); + + while let Some(page) = pages.next().await { + let page = page.map_err(|err| { + AppError::custom(format!("listing the artifacts failed: {err}")) + })?; + for object in page.contents() { + // Anything under the prefix that is not an artifact id + // was not put there by Canopy. + let Some(id) = object + .key() + .and_then(|key| key.strip_prefix(prefix.as_str())) + .and_then(|id| id.parse().ok()) + else { + continue; + }; + let Some(at) = object + .last_modified() + .and_then(|at| Timestamp::from_second(at.secs()).ok()) + else { + continue; + }; + found.push((id, at)); + } + } + + Ok(found) + } + Self::Memory(store) => Ok(store + .lock() + .expect("artifact store") + .iter() + .filter_map(|(key, (_, at))| Some((key.parse().ok()?, *at))) + .collect()), + } + } + + /// Drop an artifact's bytes. Dropping what is not there is not an error. + pub async fn delete(&self, artifact: Uuid) -> Result<()> { + let key = self.key(artifact); + match self { + Self::S3 { client, bucket, .. } => { + client + .delete_object() + .bucket(bucket) + .key(&key) + .send() + .await + .map_err(|err| { + AppError::custom(format!("dropping the artifact failed: {err}")) + })?; + } + Self::Memory(store) => { + store.lock().expect("artifact store").remove(&key); + } + } + Ok(()) + } +} + +/// What an endpoint reports when Canopy is asked to hold an artifact and has +/// nowhere to put it. +pub fn unconfigured() -> AppError { + AppError::custom(format!( + "Canopy holds no artifacts: {BUCKET_ENV} is not configured" + )) +} + +/// Whether a read found nothing there. A store that speaks S3 without modelling +/// `NoSuchKey` answers a plain `NotFound`, so both are an absent object. +fn is_missing( + err: &aws_sdk_s3::error::SdkError, +) -> bool { + use aws_sdk_s3::error::ProvideErrorMetadata as _; + + matches!( + err.as_service_error(), + Some(aws_sdk_s3::operation::get_object::GetObjectError::NoSuchKey(_)) + ) || matches!(err.code(), Some("NoSuchKey" | "NotFound")) +} + +async fn assumed_credentials( + sdk: &aws_config::SdkConfig, + role_arn: &str, +) -> std::result::Result { + let assumed = aws_sdk_sts::Client::new(sdk) + .assume_role() + .role_arn(role_arn) + .role_session_name("canopy-artifacts") + .send() + .await + .map_err(|err| format!("{err}"))?; + let creds = assumed + .credentials() + .ok_or_else(|| "AssumeRole returned no credentials".to_owned())?; + Ok(aws_sdk_s3::config::Credentials::new( + creds.access_key_id(), + creds.secret_access_key(), + Some(creds.session_token().to_string()), + None, + "canopy-artifacts", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_s3::operation::get_object::GetObjectError; + use aws_sdk_s3::operation::get_object::GetObjectOutput; + use aws_sdk_s3::operation::put_object::PutObjectOutput; + use aws_sdk_s3::types::error::NoSuchKey; + use aws_smithy_mocks::{RuleMode, mock, mock_client}; + + const ARTIFACT: Uuid = Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0); + + fn s3(client: aws_sdk_s3::Client) -> ArtifactStore { + ArtifactStore::S3 { + client, + bucket: "bes-canopy-artifacts".into(), + prefix: DEFAULT_PREFIX.into(), + } + } + + /// An artifact rests under its own id beneath the configured prefix. The key + /// is what a re-registration overwrites and what a deregistration removes, so + /// a store that derived it any other way would leave the old bytes behind. + #[tokio::test] + async fn an_artifact_is_stored_under_its_id_beneath_the_prefix() { + let put = mock!(aws_sdk_s3::Client::put_object) + .match_requests(|req| { + req.bucket() == Some("bes-canopy-artifacts") + && req.key() == Some("artifacts/12345678-9abc-def0-1234-56789abcdef0") + }) + .then_output(|| PutObjectOutput::builder().build()); + + s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&put])) + .put(ARTIFACT, b"kamaka schema".to_vec()) + .await + .expect("stored"); + assert_eq!(put.num_calls(), 1); + } + + /// Bytes come back whole, from the key the id names. + #[tokio::test] + async fn an_artifact_is_read_back_from_its_own_key() { + let get = mock!(aws_sdk_s3::Client::get_object) + .match_requests(|req| { + req.key() == Some("artifacts/12345678-9abc-def0-1234-56789abcdef0") + }) + .then_output(|| { + GetObjectOutput::builder() + .body(b"kamaka schema".to_vec().into()) + .build() + }); + + let held = s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&get])) + .get(ARTIFACT) + .await + .expect("read"); + assert_eq!(held.as_deref(), Some(&b"kamaka schema"[..])); + } + + /// An object that is not there is `None` rather than an error, which is what + /// lets the read answer as missing, identically to an artifact the caller is + /// not offered, instead of reporting a fault. + // spec: ART#where-an-artifact-rests + #[tokio::test] + async fn a_missing_object_is_absent_rather_than_a_fault() { + let get = mock!(aws_sdk_s3::Client::get_object) + .then_error(|| GetObjectError::NoSuchKey(NoSuchKey::builder().build())); + + let held = s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&get])) + .get(ARTIFACT) + .await + .expect("a missing object is not an error"); + assert!(held.is_none()); + } + + /// A store that refuses the read is a fault, not an absent artifact. Reading + /// a refusal as "not there" would have a permissions problem present itself + /// as an artifact nobody registered. + #[tokio::test] + async fn a_refused_read_is_a_fault() { + let get = mock!(aws_sdk_s3::Client::get_object).then_error(|| { + GetObjectError::generic( + aws_sdk_s3::error::ErrorMetadata::builder() + .code("AccessDenied") + .message("Access Denied") + .build(), + ) + }); + + s3(mock_client!(aws_sdk_s3, RuleMode::MatchAny, [&get])) + .get(ARTIFACT) + .await + .expect_err("a refusal is not an absent artifact"); + } +} diff --git a/crates/commons-servers/src/device_auth/mod.rs b/crates/commons-servers/src/device_auth/mod.rs index 6b789da59..01c20df9b 100644 --- a/crates/commons-servers/src/device_auth/mod.rs +++ b/crates/commons-servers/src/device_auth/mod.rs @@ -88,6 +88,48 @@ device_role_struct!(ServerDevice, DeviceRole::Machine); device_role_struct!(ReleaserDevice, DeviceRole::Releaser); device_role_struct!(BackupRestoreDevice, DeviceRole::BackupRestore); +/// Whether the caller presented no credential at all, as against presenting +/// one Canopy would not accept. +/// +/// Only an absent credential is anonymous. A credential that is presented and +/// rejected propagates, so a stale or unknown certificate is refused rather +/// than served the unscoped set as though it had identified itself. The +/// variants are named rather than tested by status, so which callers are served +/// does not follow from an unrelated mapping and a new variant is a decision +/// somebody makes. +fn no_credential(err: &AppError) -> bool { + matches!( + err, + AppError::AuthMissingHeader(_) + | AppError::AuthMissingCertificate + | AppError::AuthTailnetIdentityMissing + ) +} + +/// A read that is open to everyone but answers a device for its own group +/// takes `Option`: absent identity is not a refusal, it just +/// narrows what the caller is offered. +impl axum::extract::OptionalFromRequestParts for AuthDevice +where + Db: FromRef, + Option: FromRef, + mtls::ClientCertHeader: FromRef, + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result, Self::Rejection> { + match >::from_request_parts(parts, state).await { + Ok(device) => Ok(Some(device)), + Err(err) if no_credential(&err) => Ok(None), + Err(err) => Err(err), + } + } +} + impl axum::extract::FromRequestParts for AuthDevice where Db: FromRef, @@ -154,3 +196,45 @@ where Ok(Self(device, method)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A refusal is not an absent credential. Downgrading one to anonymous + /// hands a machine that has a group the unscoped set and presents it as + /// that machine's answer. + #[test] + fn a_refusal_is_not_an_absent_credential() { + assert!(!no_credential(&AppError::AuthInsufficientPermissions { + required: "releaser".into() + })); + assert!(!no_credential(&AppError::AuthTailnetNodeNotPermitted)); + assert!(!no_credential(&AppError::AuthTailnetDirectoryUnavailable)); + assert!(!no_credential(&AppError::DeviceHasNoServer)); + } + + /// A certificate that is presented and not accepted fails the read. Serving + /// it as anonymous would let an expired or revoked credential keep reading + /// by losing its identity. + #[test] + fn a_stale_certificate_fails_rather_than_going_anonymous() { + assert!(!no_credential(&AppError::AuthCertificateNotFound)); + assert!(!no_credential(&AppError::AuthInvalidCertificate( + "expired".into() + ))); + assert!(!no_credential(&AppError::AuthTokenNotValid)); + assert!(!no_credential(&AppError::AuthFailed { + reason: "mtls".into() + })); + } + + /// No credential at all is anonymous: these reads are open, and a caller + /// that never identified itself is offered the unscoped set. + #[test] + fn an_absent_credential_is_anonymous() { + assert!(no_credential(&AppError::AuthMissingCertificate)); + assert!(no_credential(&AppError::AuthMissingHeader("x"))); + assert!(no_credential(&AppError::AuthTailnetIdentityMissing)); + } +} diff --git a/crates/commons-servers/src/lib.rs b/crates/commons-servers/src/lib.rs index 777395340..cb7cc876c 100644 --- a/crates/commons-servers/src/lib.rs +++ b/crates/commons-servers/src/lib.rs @@ -13,6 +13,7 @@ use tower_http::{ use tracing::Span; pub mod acme; +pub mod artifact_store; pub mod backup_jobs; pub mod backup_secrets; pub mod csr; diff --git a/crates/commons-tests/src/server.rs b/crates/commons-tests/src/server.rs index 624ec54c5..700a5c6b0 100644 --- a/crates/commons-tests/src/server.rs +++ b/crates/commons-tests/src/server.rs @@ -1,6 +1,10 @@ use ::time::OffsetDateTime; use axum_client_ip::ClientIpSource; use axum_test::TestServer; +use std::collections::BTreeMap; +use std::sync::Mutex; + +use commons_servers::artifact_store::ArtifactStore; use commons_servers::device_auth::mtls::ClientCertHeader; use commons_servers::router; use diesel::{QueryableByName, sql_query, sql_types}; @@ -120,6 +124,38 @@ where run_on(DEFAULT_CERT_HEADER, test).await } +/// The artifact store the servers of this run were built on, for a test that +/// seeds or reads the bytes Canopy holds. +/// +/// Keyed by the throwaway database's name, so a test reaches its own store +/// through the connection it already has rather than every harness callback in +/// the workspace growing an argument for it. +pub async fn artifacts(conn: &mut AsyncPgConnection) -> ArtifactStore { + #[derive(QueryableByName)] + struct Name { + #[diesel(sql_type = sql_types::Text)] + current_database: String, + } + + let name: Name = sql_query("SELECT current_database()") + .get_result(conn) + .await + .expect("read database name"); + store_for(&name.current_database) +} + +/// The store for one throwaway database, created on first ask. +fn store_for(database: &str) -> ArtifactStore { + static STORES: Mutex> = Mutex::new(BTreeMap::new()); + + STORES + .lock() + .expect("artifact stores") + .entry(database.to_owned()) + .or_insert_with(ArtifactStore::memory) + .clone() +} + /// [`run`] against an explicitly chosen client-certificate header. pub async fn run_on(cert_header: ClientCertHeader, test: F) -> T where @@ -130,6 +166,11 @@ where // One pool per state, shared between the RW and RO handles — a second // pool would double connections against the throwaway test cluster, // and this mirrors production with RO_DATABASE_URL unset. + // One store per run, shared by both servers: an artifact uploaded through + // the private API is the one the public download endpoint serves, the way + // a single bucket serves both pods. + let artifacts = store_for(database_name(&url)); + let public_db = database::init_to(&url); let public_state = public_server::state::AppState { client_cert_header: cert_header, @@ -141,6 +182,7 @@ where rate_limiter: Default::default(), sts: None, kube: None, + artifacts: Some(artifacts.clone()), // From the environment, so a test can configure zones before building // the server the way the real edge does. dns_zones: commons_types::dns::ManagedZone::list_from_env().unwrap_or_default(), @@ -152,11 +194,12 @@ where ClientIpSource::RightmostForwarded, ); let private_router = router( - private_server::routes( - private_server::state::AppState::from_db_url(&url) + private_server::routes(private_server::state::AppState { + artifacts: Some(artifacts.clone()), + ..private_server::state::AppState::from_db_url(&url) .await - .unwrap(), - ) + .unwrap() + }) .unwrap(), ClientIpSource::RightmostForwarded, ); @@ -320,6 +363,7 @@ where rate_limiter: Default::default(), sts: None, kube: None, + artifacts: Some(store_for(database_name(&url))), // From the environment, so a test can configure zones before building // the server the way the real edge does. dns_zones: commons_types::dns::ManagedZone::list_from_env().unwrap_or_default(), @@ -343,6 +387,7 @@ where prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(store_for(database_name(&url))), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), // This harness is for the tailnet-auth paths; no test on it @@ -366,3 +411,8 @@ where }) .await } + +/// The throwaway database's name, which is the last path segment of its URL. +fn database_name(url: &str) -> &str { + url.rsplit('/').next().expect("a database in the url") +} diff --git a/crates/commons-types/src/backup.rs b/crates/commons-types/src/backup.rs index 3f2535291..1ccf2a6ce 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. @@ -498,9 +505,14 @@ pub struct IntentDescriptor { 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. + /// 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 diff --git a/crates/database/src/applications.rs b/crates/database/src/applications.rs index d57bec09a..de24ec10b 100644 --- a/crates/database/src/applications.rs +++ b/crates/database/src/applications.rs @@ -800,6 +800,15 @@ impl Application { .map_err(AppError::from) } + /// 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()) + } + /// 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( diff --git a/crates/database/src/artifacts.rs b/crates/database/src/artifacts.rs index 56ad863c6..c223af1af 100644 --- a/crates/database/src/artifacts.rs +++ b/crates/database/src/artifacts.rs @@ -1,16 +1,54 @@ +use std::collections::HashSet; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use commons_errors::{AppError, Result}; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::versions::Version; +/// Which artifacts a read may see. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// The unscoped artifacts alone: a read carrying no identity, or one whose + /// caller has no group of its own. + Unscoped, + /// The unscoped artifacts plus the named group's. + Group(Uuid), + /// Every artifact, whatever group it belongs to. Operator views only. + Fleet, +} + +impl Scope { + /// What a caller resolving to this group may see. + pub fn for_caller(group: Option) -> Self { + match group { + Some(group) => Self::Group(group), + None => Self::Unscoped, + } + } + + /// Whether an artifact of this group is in scope. + fn sees(self, group: Option) -> bool { + match self { + Self::Unscoped => group.is_none(), + Self::Group(caller) => group.is_none() || group == Some(caller), + Self::Fleet => true, + } + } +} + /// A downloadable artifact belonging to a release version: an installer, /// package, or other file published for a given type and platform. -#[derive( - Debug, Clone, Serialize, Deserialize, Queryable, Selectable, Associations, utoipa::ToSchema, -)] +/// +/// An artifact whose bytes Canopy holds carries their digest and media type +/// here; the bytes themselves rest in Canopy's own storage under the +/// artifact's id. +#[derive(Debug, Clone, Deserialize, Queryable, Selectable, Associations)] #[diesel(belongs_to(Version))] #[diesel(table_name = crate::schema::artifacts)] #[diesel(check_for_backend(diesel::pg::Pg))] @@ -25,8 +63,9 @@ pub struct Artifact { pub artifact_type: String, /// The platform the artifact targets (e.g. an OS or architecture name). pub platform: String, - /// URL the artifact can be downloaded from. - pub download_url: String, + /// URL the artifact can be downloaded from. `null` for an artifact whose + /// bytes Canopy holds. + pub download_url: Option, /// The device that registered this artifact, if it was registered by a /// releaser device rather than created by an operator. pub device_id: Option, @@ -34,6 +73,19 @@ pub struct Artifact { /// shared across a range of versions rather than pinned to one. `null` /// for exact-version artifacts. pub version_range_pattern: Option, + /// The group this artifact is for. `null` for an artifact that is for + /// every group. + pub group_id: Option, + /// Media type of the bytes Canopy holds, when known. + pub content_type: Option, + /// SHA-256 of the artifact's bytes. Always set for an artifact Canopy + /// holds. + pub digest: Option>, + /// The run that produced this artifact, where the registration named one. + pub run_id: Option, + /// 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)] @@ -41,116 +93,250 @@ pub struct Artifact { #[diesel(table_name = crate::schema::artifacts)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct NewArtifact { + /// The id to register under, where the caller settled one already. An + /// artifact Canopy holds is stored under its id, so the bytes are put + /// there before the row naming them exists. + pub id: Option, pub version_id: Option, pub artifact_type: String, pub platform: String, - pub download_url: String, + pub download_url: Option, pub device_id: Option, pub version_range_pattern: Option, + pub group_id: Option, + pub content_type: Option, + pub digest: Option>, + pub run_id: Option, +} + +/// Cap on the bytes Canopy will hold for one artifact. A reporting schema is a +/// SQL file, and anything approaching this is not one. +/// +/// Raising it takes streaming first, end to end: an upload buffers the body, +/// the store puts and gets whole `Vec`s, and a download reads and digests the +/// whole artifact before it answers. Until then a larger cap is what a handful +/// of concurrent fetches spend the server's memory on. +pub const MAX_HELD_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; + +/// The digest Canopy records and verifies bytes against. +pub fn digest_of(bytes: &[u8]) -> Vec { + Sha256::digest(bytes).to_vec() +} + +/// A digest in Subresource Integrity format. +// spec: ART#digests +pub fn sri(digest: &[u8]) -> String { + format!("sha256-{}", BASE64.encode(digest)) +} + +/// The digest an SRI string names, refusing anything that cannot be one. +// spec: ART#digests +pub fn parse_sri(value: &str) -> Result> { + let refuse = || AppError::BadRequest(format!("{value:?} is not a sha256 SRI digest")); + + let encoded = value.trim().strip_prefix("sha256-").ok_or_else(refuse)?; + let digest = BASE64.decode(encoded).map_err(|_| refuse())?; + if digest.len() != 32 { + return Err(refuse()); + } + + Ok(digest) +} + +/// A blank URL is no location at all. The constraint only tests for NULL, so an +/// empty string passes it and leaves an artifact nothing can be fetched from. +/// +/// The trimmed value is what is kept: a plain-text body picks up whatever +/// newline the shell that sent it added, and a URL nothing can parse is offered +/// to every device that asks. +// spec: ART#where-an-artifact-rests +pub fn location(url: Option) -> Option { + url.map(|url| url.trim().to_owned()) + .filter(|url| !url.is_empty()) +} + +/// The digest a registration names, where it names one at all. +/// +/// A blank digest is no digest: recorded, it says the bytes were checked +/// against something when nothing was. +// spec: ART#digests +pub fn parse_sri_opt(value: Option<&str>) -> Result>> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(parse_sri) + .transpose() +} + +impl NewArtifact { + /// Settle where this artifact rests, refusing a registration that names + /// neither place or both. + /// + /// The database constrains the same shape, so a write that skips this + /// answers a caller with a 500 rather than a refusal. + // spec: ART#where-an-artifact-rests + fn resting(mut self) -> Result { + self.download_url = location(self.download_url); + + match (self.group_id.is_some(), self.download_url.is_some()) { + (true, true) => Err(AppError::BadRequest( + "an artifact Canopy holds has no download URL".into(), + )), + (false, false) => Err(AppError::BadRequest( + "an artifact needs a download URL or a group".into(), + )), + (true, false) if self.digest.is_none() => Err(AppError::BadRequest( + "a group-scoped artifact must carry the digest of its bytes".into(), + )), + (false, true) if self.content_type.is_some() => Err(AppError::BadRequest( + "only a group-scoped artifact carries bytes".into(), + )), + _ => Ok(self), + } + } } impl Artifact { + /// The artifacts of a version that `scope` may see, one per type and + /// platform, most specific first. + // spec: ART#what-a-version-offers pub async fn get_for_version( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, ) -> Result> { - use crate::schema::artifacts::*; + let artifacts = Self::get_for_version_all_matches(db, target_version_id, scope).await?; + Ok(Self::offered(artifacts, scope)) + } - // First, get the version from the database to extract semver - let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let semver = version.as_semver(); + /// The artifacts of a sorted match set that `scope` is actually served: + /// the most specific of each type and platform it can see. + // spec: ART#what-a-version-offers + fn offered(artifacts: Vec, scope: Scope) -> Vec { + let offered = Self::offered_ids(&artifacts, scope); + artifacts + .into_iter() + .filter(|a| offered.contains(&a.id)) + .collect() + } + + /// The ids of the artifacts a sorted match set offers `scope`. + /// + /// The operator view answers the same question per resolved scope, so the + /// rule lives here rather than being written out again beside it: the two + /// disagreeing would have the listing mark an artifact as served that the + /// public path passes over. + // spec: ART#what-a-version-offers + fn offered_ids(artifacts: &[Self], scope: Scope) -> std::collections::HashSet { + let mut seen = std::collections::HashSet::new(); + artifacts + .iter() + .filter(|a| scope.sees(a.group_id)) + .filter(|a| seen.insert((a.artifact_type.as_str(), a.platform.as_str()))) + .map(|a| a.id) + .collect() + } - // Query all artifacts (both exact match and range-based) - let mut artifacts: Vec = table + /// One of a version's artifacts, as `scope` may see it. + /// + /// The version and the scope are both part of the read: an artifact of + /// another version, or of a group this caller is not in, is missing in + /// exactly the way one that never existed is. Read by id rather than by + /// taking the version's whole match set, which is every range artifact in + /// the table on the path every machine fetches from. + // spec: ART#who-is-offered-a-group-scoped-artifact + pub async fn of_version( + db: &mut AsyncPgConnection, + artifact_id: Uuid, + version: &Version, + scope: Scope, + ) -> Result> { + use crate::schema::artifacts::dsl::*; + + let mut query = artifacts.filter(id.eq(artifact_id)).into_boxed(); + query = match scope { + Scope::Unscoped => query.filter(group_id.is_null()), + Scope::Group(caller) => query.filter(group_id.is_null().or(group_id.eq(caller))), + Scope::Fleet => query, + }; + + let row: Option = query .select(Self::as_select()) - .filter( - version_id - .eq(Some(target_version_id)) - .or(version_range_pattern.is_not_null()), - ) - .order_by(artifact_type.asc()) - .then_order_by(platform.asc()) - .load(db) + .first(db) .await + .optional() .map_err(AppError::from)?; - // Filter out range artifacts that don't match the version - artifacts.retain(|artifact| { - if artifact.version_id == Some(target_version_id) { - // Exact match, always keep - true - } else if let Some(pattern) = &artifact.version_range_pattern { - // Range match, check if version satisfies the pattern - match node_semver::Range::parse(pattern) { - Ok(range) => range.satisfies(&semver), - Err(_) => false, // Invalid pattern, skip this artifact - } - } else { - // Should not happen due to DB constraint, but be safe - false - } - }); - - // Sort by specificity to handle conflicts - Self::sort_by_specificity(&mut artifacts); + Ok(row.filter(|row| row.belongs_to_version(version))) + } - // Keep the first (most specific) artifact per platform+artifact_type. - // Not `dedup_by_key`: that only drops *consecutive* duplicates, and the - // specificity sort has just destroyed the adjacency the SQL `ORDER BY` - // gave us — every exact artifact now precedes every range one, so two - // artifacts of the same type+platform are only neighbours when they - // happen to be equally specific. - let mut seen = std::collections::HashSet::new(); - artifacts.retain(|a| seen.insert((a.artifact_type.clone(), a.platform.clone()))); + /// Whether this artifact is one of `version`'s: named by it, or registered + /// for a range that covers it. + fn belongs_to_version(&self, version: &Version) -> bool { + if self.version_id == Some(version.id) { + return true; + } - Ok(artifacts) + // An unparseable pattern matches nothing rather than everything, so a + // malformed range withholds a file instead of offering it to the whole + // fleet. + self.version_range_pattern + .as_deref() + .and_then(|pattern| node_semver::Range::parse(pattern).ok()) + .is_some_and(|range| range.satisfies(&version.as_semver())) } - /// Get all artifacts for a version including duplicates (by platform+artifact_type). - /// This is for private/admin views where you want to see all matching artifacts. - /// Does not deduplicate - useful for understanding what's actually configured. + /// Every artifact of a version that `scope` may see, sorted most specific + /// first and not deduplicated. For operator views. + // spec: ART#what-a-version-offers pub async fn get_for_version_all_matches( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, ) -> Result> { use crate::schema::artifacts::*; - // First, get the version from the database to extract semver let version = crate::versions::Version::get_by_id(db, target_version_id).await?; let semver = version.as_semver(); - // Query all artifacts (both exact match and range-based) - let mut artifacts: Vec = table + let mut query = table .select(Self::as_select()) .filter( version_id .eq(Some(target_version_id)) .or(version_range_pattern.is_not_null()), ) + .into_boxed(); + + query = match scope { + Scope::Unscoped => query.filter(group_id.is_null()), + Scope::Group(caller) => query.filter(group_id.is_null().or(group_id.eq(caller))), + Scope::Fleet => query, + }; + + let mut artifacts: Vec = query .order_by(artifact_type.asc()) .then_order_by(platform.asc()) .load(db) .await .map_err(AppError::from)?; - // Filter out range artifacts that don't match the version artifacts.retain(|artifact| { if artifact.version_id == Some(target_version_id) { - // Exact match, always keep true } else if let Some(pattern) = &artifact.version_range_pattern { - // Range match, check if version satisfies the pattern + // An unparseable pattern matches nothing rather than + // everything, so a malformed range withholds a file instead of + // offering it to the whole fleet. match node_semver::Range::parse(pattern) { Ok(range) => range.satisfies(&semver), - Err(_) => false, // Invalid pattern, skip this artifact + Err(_) => false, } } else { - // Should not happen due to DB constraint, but be safe false } }); - // Sort by specificity but DON'T deduplicate - we want to see all of them Self::sort_by_specificity(&mut artifacts); Ok(artifacts) @@ -158,11 +344,27 @@ impl Artifact { /// Sort artifacts by specificity, with most specific first. /// Priority: - /// 1. Exact version matches (version_id set) - /// 2. More specific ranges (range that allows_all of other matching ranges) - /// 3. When ranges are incomparable, use pattern specificity: ^ > ~ > .x > others + /// 1. Group-scoped artifacts over unscoped ones + /// 2. Exact version matches (version_id set) + /// 3. More specific ranges (range that allows_all of other matching ranges) + /// 4. When ranges are incomparable, use pattern specificity: ^ > ~ > .x > others + // spec: ART#what-a-version-offers fn sort_by_specificity(artifacts: &mut [Self]) { artifacts.sort_by(|a, b| { + // An artifact scoped to the caller's group is more specific than one + // belonging to no group. A deduplicating read resolves one scope, so + // only one group's artifacts are ever in play here. + let a_is_scoped = a.group_id.is_some(); + let b_is_scoped = b.group_id.is_some(); + + if a_is_scoped != b_is_scoped { + return if a_is_scoped { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + // Exact match always wins let a_is_exact = a.version_id.is_some(); let b_is_exact = b.version_id.is_some(); @@ -190,12 +392,6 @@ impl Artifact { if range_b.allows_all(&range_a) && !range_a.allows_all(&range_b) { return std::cmp::Ordering::Less; // a is more specific } - // Ranges are equal or incomparable - use pattern specificity as tiebreaker - if range_a.allows_all(&range_b) && range_b.allows_all(&range_a) { - // Ranges are equivalent, check pattern specificity - return Self::compare_pattern_specificity(pattern_a, pattern_b); - } - // Ranges are incomparable - use pattern specificity as tiebreaker return Self::compare_pattern_specificity(pattern_a, pattern_b); } @@ -222,51 +418,193 @@ impl Artifact { pattern_rank(pattern_b).cmp(&pattern_rank(pattern_a)) } + /// When any artifact a build reads was last registered for each of these + /// 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 + /// 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. 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], + ranges: &RangeChanges, + ) -> Result> { + use crate::schema::artifacts::dsl; + + 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()) + .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(); + + 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); + } + } + + Ok(newest) + } + + /// The id an artifact of this identity is already registered under, where one + /// is. An artifact rests under its id, so a re-registration that reuses it + /// puts the new bytes where the old ones were. + // spec: ART#registration + pub async fn id_for_identity( + db: &mut AsyncPgConnection, + input: &NewArtifact, + ) -> Result> { + use crate::schema::artifacts::dsl::*; + + artifacts + .filter(artifact_type.eq(&input.artifact_type)) + .filter(platform.eq(&input.platform)) + .filter(version_id.is_not_distinct_from(input.version_id)) + .filter(version_range_pattern.is_not_distinct_from(&input.version_range_pattern)) + .filter(group_id.is_not_distinct_from(input.group_id)) + .select(id) + .first(db) + .await + .optional() + .map_err(AppError::from) + } + + /// The ids of every artifact whose bytes Canopy holds. An object under an id + /// not in here is one no artifact reaches. + // spec: ART#where-an-artifact-rests + pub async fn held_ids(db: &mut AsyncPgConnection) -> Result> { + use crate::schema::artifacts::dsl::*; + + let ids: Vec = artifacts + .filter(download_url.is_null()) + .select(id) + .load(db) + .await + .map_err(AppError::from)?; + + Ok(ids.into_iter().collect()) + } + + /// Register an artifact, replacing whatever is already registered for the + /// same version or range, type, platform, and group. + // spec: ART#registration + pub async fn register(db: &mut AsyncPgConnection, input: NewArtifact) -> Result { + use crate::schema::artifacts::dsl::*; + + let input = input.resting()?; + + diesel::insert_into(artifacts) + .values(&input) + .on_conflict(( + artifact_type, + platform, + version_id, + version_range_pattern, + group_id, + )) + .do_update() + .set(( + download_url.eq(&input.download_url), + device_id.eq(input.device_id), + content_type.eq(&input.content_type), + digest.eq(&input.digest), + run_id.eq(input.run_id), + )) + .returning(Self::as_select()) + .get_result(db) + .await + .map_err(|error| match error { + // A registration naming a group or version Canopy does not + // hold is the caller's own input, so it is refused rather than + // left to surface as a database fault. + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::ForeignKeyViolation, + _, + ) => AppError::BadRequest( + "the registration names a group or version Canopy does not hold".into(), + ), + error => AppError::from(error), + }) + } + pub async fn update( db: &mut AsyncPgConnection, artifact_id: Uuid, new_type: String, new_platform: String, - new_url: String, + new_url: Option, ) -> Result<()> { use crate::schema::artifacts::dsl::*; - diesel::update(artifacts.filter(id.eq(artifact_id))) + // An artifact Canopy holds has no location to change. Replacing its + // bytes is a registration, which is what carries the digest. + // spec: ART#where-an-artifact-rests + let (scoped, current_url): (Option, Option) = artifacts + .filter(id.eq(artifact_id)) + .select((group_id, download_url)) + .first(db) + .await + .map_err(AppError::from)?; + let new_url = location(new_url); + match (scoped.is_some(), new_url.is_some()) { + (true, true) => { + return Err(AppError::Conflict( + "an artifact Canopy holds has no download URL".into(), + )); + } + (false, false) => { + return Err(AppError::Conflict( + "an artifact Canopy does not hold needs a download URL".into(), + )); + } + _ => {} + } + + // The digest describes the bytes at the old URL, so a new URL clears + // it rather than carrying a checksum for a file that is no longer + // there. + // spec: ART#digests + let moved = new_url != current_url; + + match diesel::update(artifacts.filter(id.eq(artifact_id))) .set(( artifact_type.eq(new_type), platform.eq(new_platform), download_url.eq(new_url), + moved.then_some(digest.eq(None::>)), )) .execute(db) - .await?; - - Ok(()) - } - - pub async fn create( - db: &mut AsyncPgConnection, - ver_id: Uuid, - art_type: String, - plat: String, - url: String, - ) -> Result { - use crate::schema::artifacts::dsl::*; - - let new_artifact = NewArtifact { - version_id: Some(ver_id), - artifact_type: art_type, - platform: plat, - download_url: url, - device_id: None, - version_range_pattern: None, - }; - - diesel::insert_into(artifacts) - .values(new_artifact) - .returning(Self::as_select()) - .get_result(db) .await - .map_err(AppError::from) + { + Ok(_) => Ok(()), + // A rename onto an identity another artifact already holds is the + // operator's own input, so it is refused as a conflict rather than + // left to surface as a database fault. + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => Err(AppError::Conflict( + "an artifact of that type and platform is already registered".into(), + )), + Err(e) => Err(AppError::from(e)), + } } pub async fn delete(db: &mut AsyncPgConnection, artifact_id: Uuid) -> Result<()> { @@ -279,56 +617,6 @@ impl Artifact { Ok(()) } - /// Get artifacts enriched with metadata about whether they're exact or ranged, - /// and whether an exact artifact overrides a ranged one. - /// Note: this returns only deduplicated artifacts (as shown in public API) - pub async fn get_for_version_with_metadata( - db: &mut AsyncPgConnection, - target_version_id: Uuid, - ) -> Result> { - // First get the matching artifacts and the version details - let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let matching_artifacts = Self::get_for_version(db, target_version_id).await?; - - // Get all artifacts in DB to check for overrides - use crate::schema::artifacts::*; - let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; - - let semver = version.as_semver(); - - // For each matching artifact, determine if it's exact and if it has an override - // Since these are already deduplicated (from get_for_version), they're all used in public API - let result = matching_artifacts - .into_iter() - .map(|a| { - let is_exact = a.version_id == Some(target_version_id); - - // Check if there's a ranged artifact that also matches but is overridden by this exact one - let has_range_override = if is_exact { - all_artifacts.iter().any(|other| { - other.version_range_pattern.is_some() - && other.artifact_type == a.artifact_type - && other.platform == a.platform - && other.id != a.id && if let Some(pattern) = &other.version_range_pattern { - match node_semver::Range::parse(pattern) { - Ok(range) => range.satisfies(&semver), - Err(_) => false, - } - } else { - false - } - }) - } else { - false - }; - - (a, is_exact, has_range_override, true) // true = used in public API - }) - .collect(); - - Ok(result) - } - /// Get artifacts with metadata for a version, including all matches (not deduplicated). /// Also indicates which artifact is actually used in the public API. /// This is for private/admin views where you want to see all configured artifacts @@ -336,54 +624,122 @@ impl Artifact { pub async fn get_for_version_all_matches_with_metadata( db: &mut AsyncPgConnection, target_version_id: Uuid, + scope: Scope, ) -> Result> { - // Get all matching artifacts (no deduplication) and version details - let version = crate::versions::Version::get_by_id(db, target_version_id).await?; - let matching_artifacts = Self::get_for_version_all_matches(db, target_version_id).await?; - - // Get the public API version (deduplicated) to know which ones are actually used - let public_api_artifacts = Self::get_for_version(db, target_version_id).await?; - let public_api_ids: std::collections::HashSet = - public_api_artifacts.iter().map(|a| a.id).collect(); + let matching_artifacts = + Self::get_for_version_all_matches(db, target_version_id, scope).await?; + + // An artifact is offered where it wins inside a scope that is actually + // resolved, so the fleet-wide answer is the union over the unscoped read + // and each group present rather than one deduplication across them all: + // two groups' artifacts of one type and platform are both served. + // spec: ART#what-a-version-offers + let scopes: Vec = match scope { + Scope::Fleet => { + let mut groups: Vec = matching_artifacts + .iter() + .filter_map(|a| a.group_id) + .collect(); + groups.sort_unstable(); + groups.dedup(); + std::iter::once(Scope::Unscoped) + .chain(groups.into_iter().map(Scope::Group)) + .collect() + } + resolved => vec![resolved], + }; - // Get all artifacts in DB to check for overrides - use crate::schema::artifacts::*; - let all_artifacts: Vec = table.select(Self::as_select()).load(db).await?; + let public_api_ids: std::collections::HashSet = scopes + .into_iter() + .flat_map(|scope| Self::offered_ids(&matching_artifacts, scope)) + .collect(); - let semver = version.as_semver(); + // Only a range artifact can be the one an exact artifact displaces, and + // a range only reached this set by matching this version, so what + // displaces what is answerable from the set itself. + let ranges: Vec<&Self> = matching_artifacts + .iter() + .filter(|a| a.version_range_pattern.is_some()) + .collect(); - // For each matching artifact, determine if it's exact, if it has an override, and if it's used in public API let result = matching_artifacts - .into_iter() + .iter() .map(|a| { let is_exact = a.version_id == Some(target_version_id); - - // Check if there's a ranged artifact that also matches but is overridden by this exact one - let has_range_override = if is_exact { - all_artifacts.iter().any(|other| { - other.version_range_pattern.is_some() - && other.artifact_type == a.artifact_type - && other.platform == a.platform - && other.id != a.id && if let Some(pattern) = &other.version_range_pattern { - match node_semver::Range::parse(pattern) { - Ok(range) => range.satisfies(&semver), - Err(_) => false, - } - } else { - false - } - }) - } else { - false - }; - - // Check if this artifact is actually used in the public API + let has_range_override = Self::overridden_range(&ranges, a); let is_used_in_public_api = public_api_ids.contains(&a.id); - (a, is_exact, has_range_override, is_used_in_public_api) + ( + a.clone(), + is_exact, + has_range_override, + is_used_in_public_api, + ) }) .collect(); Ok(result) } + + /// Whether an exact artifact displaces a range artifact that also matches. + /// + /// `ranges` are the range artifacts of the same match set, which are there + /// only because they match this version already. + fn overridden_range(ranges: &[&Self], artifact: &Self) -> bool { + if artifact.version_id.is_none() { + return false; + } + + ranges.iter().any(|other| { + other.artifact_type == artifact.artifact_type + && other.platform == artifact.platform + // A range this artifact's own scope cannot see is not one it + // displaces, and a group's range outranks an unscoped exact, so + // what an exact artifact overrides is a range of its own group + // or an unscoped one. + // spec: ART#what-a-version-offers + && (other.group_id.is_none() || other.group_id == artifact.group_id) + }) + } +} + +/// 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/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/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/lib.rs b/crates/database/src/lib.rs index 2e2c0ad4f..6cc517536 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -31,6 +31,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..95d62bc58 --- /dev/null +++ b/crates/database/src/reporting_schemas.rs @@ -0,0 +1,674 @@ +//! 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, + /// 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)] +pub struct NewReportingSchemaBuild { + pub group_id: Uuid, + pub version_id: Uuid, + pub application_id: Option, + pub built: bool, + pub error: Option, + pub artifact_ids: Vec, +} + +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 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?; + + // 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), + crate::schema::reporting_schema_builds::artifact_ids.eq(build + .artifact_ids + .into_iter() + .map(Some) + .collect::>()), + )) + .execute(db) + .await?; + + // An operator's ask is answered once the build it asked for lands, + // 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) + } + + /// 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) + } + + /// 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)) + .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)?; + + 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. + // spec: RPT#pairs + pub async fn is_settled( + db: &mut AsyncPgConnection, + group: Uuid, + version: Uuid, + ) -> Result { + let row = Version::get_by_id(db, version).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)) + } +} + +/// 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], + 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, ranges) + .await?, + }) + } + + pub fn settled(&self, version: Uuid) -> bool { + if self.requested.contains(&version) { + return 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. + match self.changed.get(&version) { + Some(at) => *at <= build.built_at, + None => true, + } + } +} + +/// 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)] +#[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(()) + } + + /// 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()) + } + + /// 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::requested_at.lt(jiff_diesel::Timestamp::from(answered_at))), + ) + .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 { + /// 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, + /// 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 +/// 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> { + if !groups_building_schemas(db).await?.contains(&group) { + return Ok(Vec::new()); + } + + 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?; + + let mut pairs = Vec::with_capacity(versions.len()); + 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()), + }; + + pairs.push(Pair { + group_id: group, + version_id: version.id, + applications, + version: version.as_semver().to_string(), + state, + error, + requested: requests.contains(&version.id), + }); + } + + 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> { + let members = crate::applications::Application::list_live_in_group(db, group).await?; + versions_of_members(db, group, &members).await +} + +/// 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) + .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, + applications: &[crate::applications::Application], +) -> Result)>> { + use commons_types::version::VersionStatus; + + 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 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 { + 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. + 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 { + 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.clone(), vec![application.label()])), + } + } + + // A plan is opened against an environment, and the group's central is the + // application whose database a schema follows from. + let planned = match crate::server_groups::ServerGroup::canonical_central(applications) { + Some(central) => { + match crate::server_groups::ServerGroup::environment_of(db, central).await? { + Some(rank) => crate::upgrade_plans::planned_target(db, group, rank).await?, + None => None, + } + } + None => None, + }; + // 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) = planned + && !pairs.iter().any(|(v, _)| v.id == target.id) + { + pairs.push((target, Vec::new())); + } + + for (_, names) in &mut pairs { + names.sort(); + } + pairs.sort_by_key(|(v, _)| (v.major, v.minor, v.patch)); + + Ok(pairs) +} + +/// 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, staleness::applications_with_open_issue}, + issues::{CheckInstance, GradedInstance, Scope}, + restore::{RestoreCheck, file_restore_check}, + server_groups::ServerGroup, + }; + use commons_types::status::CheckResult; + + // 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?; + + // 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?; + // 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; + }; + + 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) + .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(); + + let name = group.name.clone(); + let total = pairs.len(); + file_restore_check( + db, + 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!( + "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(()) +} + +/// The groups an enabled declaration builds schemas for. +/// +/// 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(groups.into_iter().collect()) +} diff --git a/crates/database/src/restore.rs b/crates/database/src/restore.rs index 9c20c30c1..ebd36589a 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, } @@ -151,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})")) } @@ -261,6 +277,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()) @@ -311,6 +328,100 @@ impl RestoreReplica { .await } + /// Whether a consumer may register group-scoped artifacts for this 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 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, + 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)) + .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 + .map_err(AppError::from)?; + + 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. @@ -597,10 +708,14 @@ pub async fn redaction_gap_for( ))); }; - let published = crate::artifacts::Artifact::get_for_version(db, version.id) - .await? - .into_iter() - .any(|a| a.artifact_type == manifest.artifact_type); + let published = crate::artifacts::Artifact::get_for_version( + db, + version.id, + crate::artifacts::Scope::for_caller(server.group_id), + ) + .await? + .into_iter() + .any(|a| a.artifact_type == manifest.artifact_type); Ok((!published).then_some((RedactionGapReason::VersionHasNoManifest, Some(shown)))) } @@ -1722,11 +1837,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 @@ -1738,7 +1853,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/src/schema.rs b/crates/database/src/schema.rs index 181f561df..22da0c941 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -96,9 +96,13 @@ diesel::table! { version_id -> Nullable, artifact_type -> Text, platform -> Text, - download_url -> Text, + download_url -> Nullable, device_id -> Nullable, version_range_pattern -> Nullable, + group_id -> Nullable, + content_type -> Nullable, + digest -> Nullable, + run_id -> Nullable, } } @@ -635,6 +639,28 @@ 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, + artifact_ids -> Array>, + built_at -> Timestamptz, + } +} + +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, @@ -663,6 +689,7 @@ diesel::table! { updated_at -> Timestamptz, params -> Jsonb, redacts -> Bool, + publishes_schemas -> Bool, } } @@ -868,6 +895,7 @@ diesel::joinable!(application_names -> applications (application_id)); diesel::joinable!(application_reported_detail -> applications (application_id)); diesel::joinable!(applications -> machines (machine_id)); diesel::joinable!(artifacts -> devices (device_id)); +diesel::joinable!(artifacts -> server_groups (group_id)); diesel::joinable!(artifacts -> versions (version_id)); diesel::joinable!(backup_credential_issuances -> devices (device_id)); diesel::joinable!(backup_credential_issuances -> server_groups (group_id)); @@ -917,6 +945,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)); @@ -983,6 +1017,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 242fca265..a55474641 100644 --- a/crates/database/src/server_groups.rs +++ b/crates/database/src/server_groups.rs @@ -200,6 +200,44 @@ impl ServerGroup { .map_err(AppError::from) } + /// The named groups' names by id, including archived ones, so a reference + /// to a group can be shown by name whatever state the group is in. + pub async fn names_by_ids( + db: &mut AsyncPgConnection, + ids: &[Uuid], + ) -> Result> { + use crate::schema::server_groups::dsl; + + if ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + Ok(dsl::server_groups + .select((dsl::id, dsl::name)) + .filter(dsl::id.eq_any(ids)) + .load::<(Uuid, String)>(db) + .await + .map_err(AppError::from)? + .into_iter() + .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)) + } + /// Groups carrying this exact name, archived ones included, so a caller /// resolving a group by name can tell an archived group from one that does /// not exist. Names aren't unique, so this can return several. @@ -699,11 +737,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/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/src/versions.rs b/crates/database/src/versions.rs index 272299529..e7b3dfddc 100644 --- a/crates/database/src/versions.rs +++ b/crates/database/src/versions.rs @@ -200,6 +200,58 @@ 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::*; + + if wanted.is_empty() { + return Ok(Vec::new()); + } + + // 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, 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 + .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 { use crate::schema::versions::dsl::*; diff --git a/crates/database/tests/it/artifact_scopes.rs b/crates/database/tests/it/artifact_scopes.rs new file mode 100644 index 000000000..a70eac979 --- /dev/null +++ b/crates/database/tests/it/artifact_scopes.rs @@ -0,0 +1,923 @@ +//! DB-layer tests for group-scoped artifact resolution (`database::artifacts`). +//! +//! spec: ART + +use commons_tests::db::TestDb; +use database::{ + artifacts::{Artifact, NewArtifact, Scope, digest_of, parse_sri, sri}, + diesel_async::AsyncPgConnection, +}; +use diesel_async::{RunQueryDsl, SimpleAsyncConnection}; +use uuid::Uuid; + +async fn seed_version(conn: &mut AsyncPgConnection, major: i32, minor: i32, patch: i32) -> Uuid { + conn.batch_execute(&format!( + "INSERT INTO versions (major, minor, patch, changelog, status) \ + VALUES ({major}, {minor}, {patch}, '', 'published')" + )) + .await + .expect("seed version"); + + let version = database::versions::Version::get_by_version( + conn, + commons_types::version::VersionStr(node_semver::Version { + major: major as u64, + minor: minor as u64, + patch: patch as u64, + build: vec![], + pre_release: vec![], + }), + ) + .await + .expect("read back version"); + version.id +} + +async fn seed_group(conn: &mut AsyncPgConnection, name: &str) -> Uuid { + conn.batch_execute(&format!( + "INSERT INTO server_groups (name) VALUES ('{name}')" + )) + .await + .expect("seed group"); + + let groups = database::server_groups::ServerGroup::list_all(conn) + .await + .expect("list groups"); + groups + .into_iter() + .find(|g| g.name == name) + .expect("group is there") + .id +} + +fn unscoped(version_id: Uuid, artifact_type: &str, url: &str) -> NewArtifact { + NewArtifact { + id: None, + version_id: Some(version_id), + artifact_type: artifact_type.to_owned(), + platform: "any".to_owned(), + download_url: Some(url.to_owned()), + device_id: None, + version_range_pattern: None, + group_id: None, + content_type: None, + digest: None, + run_id: None, + } +} + +fn held(version_id: Uuid, artifact_type: &str, group: Uuid, bytes: &[u8]) -> NewArtifact { + NewArtifact { + id: None, + version_id: Some(version_id), + artifact_type: artifact_type.to_owned(), + platform: "any".to_owned(), + download_url: None, + device_id: None, + version_range_pattern: None, + group_id: Some(group), + content_type: Some("application/sql".to_owned()), + digest: Some(digest_of(bytes)), + run_id: None, + } +} + +/// A download reads one artifact by id, and what it may return is the same set +/// the version offers: its own, a range covering it, and nothing of another +/// version or another group. +#[tokio::test(flavor = "multi_thread")] +async fn one_artifact_is_read_as_the_version_offers_it() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let elsewhere = seed_version(&mut conn, 2, 59, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + let other = seed_group(&mut conn, "drifting").await; + let row = database::versions::Version::get_by_id(&mut conn, version) + .await + .expect("the version"); + + let exact = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/y")) + .await + .expect("register exact"); + let covering = Artifact::register(&mut conn, ranged("package", "2.60.x", "https://x/z")) + .await + .expect("register covering range"); + let missing = Artifact::register(&mut conn, ranged("docs", "2.58.x", "https://x/w")) + .await + .expect("register range that misses"); + let older = Artifact::register(&mut conn, unscoped(elsewhere, "installer", "https://x/o")) + .await + .expect("register another version's"); + let scoped = Artifact::register(&mut conn, held(version, "reporting-schema", theirs, b"s")) + .await + .expect("register held"); + + for (artifact, found, why) in [ + (exact.id, true, "the version's own"), + (covering.id, true, "a range covering the version"), + (missing.id, false, "a range that does not cover it"), + (older.id, false, "another version's"), + (scoped.id, false, "another group's"), + ] { + let read = Artifact::of_version(&mut conn, artifact, &row, Scope::Group(other)) + .await + .expect("read one"); + assert_eq!(read.is_some(), found, "{why}"); + } + + let read = Artifact::of_version(&mut conn, scoped.id, &row, Scope::Group(theirs)) + .await + .expect("read one"); + assert_eq!( + read.map(|a| a.id), + Some(scoped.id), + "the owning group reads its own" + ); + }) + .await; +} + +/// A group-scoped artifact and an unscoped one of the same type and platform +/// are both recorded, each group is offered the one for it, and no caller is +/// offered both. +#[tokio::test(flavor = "multi_thread")] +async fn each_group_is_offered_its_own_and_never_both() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + let other = seed_group(&mut conn, "drifting").await; + + Artifact::register( + &mut conn, + unscoped(version, "reporting-schema", "https://x/y"), + ) + .await + .expect("register unscoped"); + Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"theirs"), + ) + .await + .expect("register held"); + + let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(theirs)) + .await + .expect("resolve for the owning group"); + assert_eq!(offered.len(), 1, "never offered both"); + assert_eq!(offered[0].group_id, Some(theirs)); + + // Another group's read reaches the unscoped one, not the first group's. + let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(other)) + .await + .expect("resolve for another group"); + assert_eq!(offered.len(), 1); + assert_eq!(offered[0].group_id, None); + + // A read carrying no identity is answered with the unscoped set alone. + let offered = Artifact::get_for_version(&mut conn, version, Scope::Unscoped) + .await + .expect("resolve anonymously"); + assert_eq!(offered.len(), 1); + assert_eq!(offered[0].group_id, None); + + // An operator sees what resolution passed over. + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 2); + }) + .await; +} + +/// A group-scoped artifact is more specific than an unscoped one, so it wins +/// even where the unscoped one is exact and it is only a range match. +#[tokio::test(flavor = "multi_thread")] +async fn group_scope_outranks_an_exact_unscoped_artifact() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + Artifact::register(&mut conn, unscoped(version, "installer", "https://x/exact")) + .await + .expect("register exact unscoped"); + + let mut ranged = held(version, "installer", theirs, b"ranged"); + ranged.version_id = None; + ranged.version_range_pattern = Some("2.60.x".to_owned()); + Artifact::register(&mut conn, ranged) + .await + .expect("register ranged held"); + + let offered = Artifact::get_for_version(&mut conn, version, Scope::Group(theirs)) + .await + .expect("resolve"); + assert_eq!(offered.len(), 1); + assert_eq!( + offered[0].group_id, + Some(theirs), + "the group's range artifact beats an exact artifact for everyone" + ); + }) + .await; +} + +/// A registration replaces whatever is already registered for the same version, +/// type, platform and group, and what it replaces does not survive alongside it. +#[tokio::test(flavor = "multi_thread")] +async fn registering_again_replaces_what_it_held() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + let first = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"first build"), + ) + .await + .expect("first registration"); + + let second = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"second build"), + ) + .await + .expect("second registration"); + + assert_eq!(first.id, second.id, "replaced in place, not duplicated"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1, "a caller is never offered two of a kind"); + + assert_eq!(second.digest, Some(digest_of(b"second build"))); + }) + .await; +} + +/// The same type and platform can be registered for one group and for another +/// without colliding, which the old version-only unique constraint could not +/// express. +#[tokio::test(flavor = "multi_thread")] +async fn two_groups_hold_their_own_of_the_same_kind() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let one = seed_group(&mut conn, "kamaka").await; + let two = seed_group(&mut conn, "drifting").await; + + Artifact::register(&mut conn, held(version, "reporting-schema", one, b"one")) + .await + .expect("first group"); + Artifact::register(&mut conn, held(version, "reporting-schema", two, b"two")) + .await + .expect("second group"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 2); + }) + .await; +} + +/// Each group is served its own, so the operator view marks both as offered. +/// Deduplicating once across the fleet picks one and hides the other, which is +/// the opposite of what an operator has to be able to see. +#[tokio::test(flavor = "multi_thread")] +async fn the_operator_view_marks_every_group_s_own_as_offered() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let one = seed_group(&mut conn, "kamaka").await; + let two = seed_group(&mut conn, "drifting").await; + + Artifact::register(&mut conn, unscoped(version, "installer", "https://x/i.exe")) + .await + .expect("unscoped"); + Artifact::register(&mut conn, held(version, "reporting-schema", one, b"one")) + .await + .expect("first group"); + Artifact::register(&mut conn, held(version, "reporting-schema", two, b"two")) + .await + .expect("second group"); + + let all = + Artifact::get_for_version_all_matches_with_metadata(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + + assert_eq!(all.len(), 3); + for (artifact, _, _, offered) in all { + assert!( + offered, + "{} for {:?} is served to someone", + artifact.artifact_type, artifact.group_id + ); + } + }) + .await; +} + +/// The digest is a sha256 of the bytes, carried as Subresource Integrity writes +/// it. Pinned against a known answer rather than against `digest_of` of the +/// same input, which would hold just as well if the function returned a +/// constant. +// spec: ART#digests +#[test] +fn the_digest_is_a_sha256_in_sri_form() { + assert_eq!( + sri(&digest_of(b"")), + "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + ); + assert_eq!( + parse_sri("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap(), + digest_of(b"") + ); + + // A value nothing can check the bytes against is refused rather than + // recorded as though they had been. + for claimed in [ + "", + "sha256:abcd", + "notadigest", + "sha256-abcd", + "sha512-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", + ] { + assert!(parse_sri(claimed).is_err(), "{claimed:?} is not a digest"); + } +} + +/// A range artifact registered twice replaces itself. Before the identity +/// index this could not hold: `version_id` is NULL for every range artifact, +/// and the default treatment of NULL made each row distinct from the last. +#[tokio::test(flavor = "multi_thread")] +async fn a_range_artifact_replaces_itself() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + for url in ["https://x/first", "https://x/second"] { + let mut ranged = unscoped(version, "installer", url); + ranged.version_id = None; + ranged.version_range_pattern = Some("2.60.x".to_owned()); + Artifact::register(&mut conn, ranged) + .await + .expect("register range artifact"); + } + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1, "the second registration replaced the first"); + assert_eq!(all[0].download_url.as_deref(), Some("https://x/second")); + }) + .await; +} + +/// An artifact Canopy holds has no location, so an attempt to give it one is +/// refused rather than reaching the shape constraint as a database error. +#[tokio::test(flavor = "multi_thread")] +async fn a_held_artifact_cannot_be_given_a_url() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + let artifact = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"schema"), + ) + .await + .expect("register"); + + let refused = Artifact::update( + &mut conn, + artifact.id, + "reporting-schema".to_owned(), + "any".to_owned(), + Some("https://example.com/elsewhere.sql".to_owned()), + ) + .await; + assert!(refused.is_err(), "a held artifact takes no location"); + + // Renaming it without offering a location is still fine. + Artifact::update( + &mut conn, + artifact.id, + "reporting-assets".to_owned(), + "any".to_owned(), + None, + ) + .await + .expect("rename is allowed"); + }) + .await; +} + +/// A range artifact, for the specificity rules that need one. +fn ranged(artifact_type: &str, pattern: &str, url: &str) -> NewArtifact { + NewArtifact { + id: None, + version_id: None, + artifact_type: artifact_type.to_owned(), + platform: "any".to_owned(), + download_url: Some(url.to_owned()), + device_id: None, + version_range_pattern: Some(pattern.to_owned()), + group_id: None, + content_type: None, + digest: None, + run_id: None, + } +} + +/// Register a pair for one type both ways round and return which id won each +/// time. Resolution has to reorder rather than take what the query happened to +/// return first, so a rule only counts as pinned when it holds either way. +async fn winner_either_way( + conn: &mut AsyncPgConnection, + version: Uuid, + winner: impl Fn(&str) -> NewArtifact, + loser: impl Fn(&str) -> NewArtifact, +) -> (Uuid, Uuid, Uuid, Uuid) { + let first_winner = Artifact::register(conn, winner("winner-first")) + .await + .expect("winner registered first"); + Artifact::register(conn, loser("winner-first")) + .await + .expect("loser registered second"); + + Artifact::register(conn, loser("loser-first")) + .await + .expect("loser registered first"); + let second_winner = Artifact::register(conn, winner("loser-first")) + .await + .expect("winner registered second"); + + let offered = Artifact::get_for_version(conn, version, Scope::Unscoped) + .await + .expect("offered"); + + let pick = |artifact_type: &str| { + offered + .iter() + .find(|a| a.artifact_type == artifact_type) + .unwrap_or_else(|| panic!("something offered for {artifact_type}")) + .id + }; + + assert_eq!(offered.len(), 2, "one per type, whatever the order"); + ( + pick("winner-first"), + first_winner.id, + pick("loser-first"), + second_winner.id, + ) +} + +/// Within one scope an exact-version artifact is more specific than any range. +/// The group rule short-circuits ahead of this one, so a test that crosses +/// scopes never reaches it. +#[tokio::test(flavor = "multi_thread")] +async fn an_exact_artifact_displaces_a_range_of_the_same_scope() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let (a, expected_a, b, expected_b) = winner_either_way( + &mut conn, + version, + |t| unscoped(version, t, "https://x/exact"), + |t| ranged(t, "2.60.x", "https://x/range"), + ) + .await; + + assert_eq!(a, expected_a, "exact wins when it is registered first"); + assert_eq!(b, expected_b, "and when the range is"); + }) + .await; +} + +/// Between two ranges that both match, the narrower is more specific. +#[tokio::test(flavor = "multi_thread")] +async fn the_narrower_of_two_ranges_wins() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let (a, expected_a, b, expected_b) = winner_either_way( + &mut conn, + version, + |t| ranged(t, "~2.60.0", "https://x/narrow"), + |t| ranged(t, "^2.0.0", "https://x/wide"), + ) + .await; + + assert_eq!(a, expected_a, "narrow wins when it is registered first"); + assert_eq!(b, expected_b, "and when the wide one is"); + }) + .await; +} + +/// Where two ranges cover the same versions neither is narrower, so the +/// tiebreak is how explicitly each was written: `^` over `~` over `.x`. +#[tokio::test(flavor = "multi_thread")] +async fn equally_wide_ranges_fall_back_to_pattern_rank() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let (a, expected_a, b, expected_b) = winner_either_way( + &mut conn, + version, + |t| ranged(t, "~2.60.0", "https://x/tilde"), + |t| ranged(t, "2.60.x", "https://x/wildcard"), + ) + .await; + + assert_eq!(a, expected_a, "~ outranks .x when registered first"); + assert_eq!(b, expected_b, "and when .x is"); + }) + .await; +} + +/// A pattern Canopy cannot parse matches nothing rather than everything, so a +/// malformed range withholds a file instead of offering it to the whole fleet. +#[tokio::test(flavor = "multi_thread")] +async fn a_malformed_range_matches_nothing() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + Artifact::register( + &mut conn, + ranged("installer", "definitely not a range", "https://x/nope"), + ) + .await + .expect("register"); + + let offered = Artifact::get_for_version(&mut conn, version, Scope::Unscoped) + .await + .expect("offered"); + assert!(offered.is_empty(), "offered to nobody"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert!(all.is_empty(), "and matches the version for no one"); + }) + .await; +} + +/// Canopy records which device registered an artifact and the run that produced +/// it, so one that arrived by automation is distinguishable from one entered by +/// hand. A re-registration carries the new provenance rather than keeping the +/// old, since the row now describes a different build. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn provenance_is_recorded_and_replaced() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + let run = Uuid::new_v4(); + + let mut first = held(version, "reporting-schema", theirs, b"first"); + first.run_id = Some(run); + let registered = Artifact::register(&mut conn, first) + .await + .expect("register with a run"); + assert_eq!(registered.run_id, Some(run)); + + // Entered by hand this time: the run that is no longer named is cleared + // rather than left standing over bytes it did not produce. + let second = held(version, "reporting-schema", theirs, b"second"); + let replaced = Artifact::register(&mut conn, second) + .await + .expect("register without a run"); + assert_eq!(replaced.id, registered.id); + assert_eq!(replaced.run_id, None); + }) + .await; +} + +/// Canopy keeps none of what it has stopped serving, so deleting an artifact +/// takes the registration the bytes rest under with it. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn deleting_an_artifact_takes_its_registration() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + let artifact = Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"schema"), + ) + .await + .expect("register"); + + Artifact::delete(&mut conn, artifact.id) + .await + .expect("delete"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert!(all.is_empty()); + }) + .await; +} + +/// An artifact Canopy does not hold records a location and nothing else, which +/// is what makes the download fall through to that location instead. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_artifact_records_only_a_location() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let artifact = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/i")) + .await + .expect("register"); + + assert_eq!(artifact.download_url.as_deref(), Some("https://x/i")); + assert!(artifact.content_type.is_none()); + }) + .await; +} + +/// A group's artifacts go with the group. Bytes Canopy holds for a group that +/// no longer exists are bytes it has stopped serving. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn deleting_a_group_takes_its_artifacts() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let theirs = seed_group(&mut conn, "kamaka").await; + + Artifact::register(&mut conn, unscoped(version, "installer", "https://x/i")) + .await + .expect("unscoped"); + Artifact::register( + &mut conn, + held(version, "reporting-schema", theirs, b"schema"), + ) + .await + .expect("held"); + + conn.batch_execute(&format!("DELETE FROM server_groups WHERE id = '{theirs}'")) + .await + .expect("delete the group"); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1, "the group's went with it"); + assert_eq!(all[0].group_id, None); + }) + .await; +} + +/// An unscoped exact artifact replaces itself too. Its key carries a NULL range +/// pattern and a NULL group, which the default treatment of NULL would have +/// made distinct from the row already there. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_exact_artifact_replaces_itself() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let first = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/one")) + .await + .expect("first"); + let second = Artifact::register(&mut conn, unscoped(version, "installer", "https://x/two")) + .await + .expect("second"); + + assert_eq!(first.id, second.id); + assert_eq!(second.download_url.as_deref(), Some("https://x/two")); + + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("operator view"); + assert_eq!(all.len(), 1); + }) + .await; +} + +/// A group's name is shown whatever state the group is in, so a reference to an +/// archived group still reads as that group rather than as nothing. +#[tokio::test(flavor = "multi_thread")] +async fn an_archived_group_is_still_named() { + TestDb::run(|mut conn, _url| async move { + let theirs = seed_group(&mut conn, "kamaka").await; + + conn.batch_execute(&format!( + "UPDATE server_groups SET deleted_at = now() WHERE id = '{theirs}'" + )) + .await + .expect("archive the group"); + + let names = database::server_groups::ServerGroup::names_by_ids(&mut conn, &[theirs]) + .await + .expect("names"); + assert_eq!(names.get(&theirs).map(String::as_str), Some("kamaka")); + }) + .await; +} + +/// Two artifacts of one version differing only by platform. Renaming one onto +/// the other's identity is the operator's own input, so it is refused as a +/// conflict rather than reaching the unique index as a database fault. +#[tokio::test(flavor = "multi_thread")] +async fn a_rename_onto_an_existing_identity_is_refused() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + Artifact::register( + &mut conn, + unscoped(version, "installer", "https://example.com/win.exe"), + ) + .await + .expect("register"); + + let mut linux = unscoped(version, "installer", "https://example.com/lin.deb"); + linux.platform = "linux".to_owned(); + let linux = Artifact::register(&mut conn, linux) + .await + .expect("register"); + + let refused = Artifact::update( + &mut conn, + linux.id, + "installer".to_owned(), + "any".to_owned(), + Some("https://example.com/lin.deb".to_owned()), + ) + .await; + assert!( + matches!(refused, Err(commons_errors::AppError::Conflict(_))), + "a taken identity is a conflict, got {refused:?}" + ); + + // The rename is refused whole: the artifact keeps the platform it had. + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("list"); + let mut platforms: Vec<_> = all.iter().map(|a| a.platform.as_str()).collect(); + platforms.sort_unstable(); + assert_eq!(platforms, vec!["any", "linux"]); + }) + .await; +} + +const UP: &str = + include_str!("../../../../migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql"); +const DOWN: &str = + include_str!("../../../../migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql"); + +#[derive(diesel::QueryableByName)] +struct TextRow { + #[diesel(sql_type = diesel::sql_types::Text)] + value: String, +} + +/// Reverting drops the artifacts Canopy holds, since a schema with no group has +/// nowhere to record one, and leaves the unscoped ones as they were. The bytes +/// outlive the rows that named them, so a revert leaves the store to be swept by +/// hand. Nothing else runs these migrations backwards, so a `down.sql` that +/// cannot reverse would only be found on the box it was needed on. +#[tokio::test(flavor = "multi_thread")] +async fn the_group_scope_migration_reverses() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + let group = seed_group(&mut conn, "kamaka").await; + + Artifact::register( + &mut conn, + unscoped(version, "installer", "https://example.com/x.exe"), + ) + .await + .expect("register"); + Artifact::register( + &mut conn, + held(version, "reporting-schema", group, b"kamaka schema"), + ) + .await + .expect("register"); + + conn.batch_execute(DOWN).await.expect("revert"); + + let remaining: Vec = + diesel::sql_query("SELECT download_url AS value FROM artifacts") + .load(&mut conn) + .await + .expect("read back"); + assert_eq!( + remaining + .iter() + .map(|r| r.value.as_str()) + .collect::>(), + vec!["https://example.com/x.exe"], + "the held artifact goes with the group column that scoped it" + ); + + let columns: Vec = diesel::sql_query( + "SELECT column_name AS value FROM information_schema.columns \ + WHERE table_name = 'artifacts' \ + AND column_name IN ('group_id', 'content_type', 'digest', 'run_id')", + ) + .load(&mut conn) + .await + .expect("read columns"); + assert!(columns.is_empty(), "every added column is gone"); + + let nullable: Vec = diesel::sql_query( + "SELECT is_nullable AS value FROM information_schema.columns \ + WHERE table_name = 'artifacts' AND column_name = 'download_url'", + ) + .load(&mut conn) + .await + .expect("read nullability"); + assert_eq!( + nullable[0].value, "NO", + "an artifact rests at a location again" + ); + + conn.batch_execute(UP).await.expect("re-apply"); + + Artifact::register( + &mut conn, + held(version, "reporting-schema", group, b"kamaka schema"), + ) + .await + .expect("a group-scoped artifact registers again"); + }) + .await; +} + +/// A digest describes the bytes at a location, so moving the location drops it. +/// Kept, every device that honours the digest refuses a file that is the right +/// one, and one that ignores it verifies new bytes against an old hash. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn moving_an_artifact_drops_the_digest_of_where_it_was() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let mut first = unscoped(version, "installer", "https://x/first.exe"); + first.digest = Some(digest_of(b"the first build")); + let artifact = Artifact::register(&mut conn, first) + .await + .expect("register"); + assert!(artifact.digest.is_some()); + + // Renaming without touching the location keeps it: the bytes it + // describes have not moved. + Artifact::update( + &mut conn, + artifact.id, + "installer".into(), + "windows".into(), + Some("https://x/first.exe".into()), + ) + .await + .expect("rename"); + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("list"); + assert!(all[0].digest.is_some(), "the bytes did not move"); + + Artifact::update( + &mut conn, + artifact.id, + "installer".into(), + "windows".into(), + Some("https://x/second.exe".into()), + ) + .await + .expect("move"); + let all = Artifact::get_for_version_all_matches(&mut conn, version, Scope::Fleet) + .await + .expect("list"); + assert!( + all[0].digest.is_none(), + "a digest for the old location is worse than none" + ); + }) + .await; +} + +/// A URL is stored as it was tested. A plain-text body picks up whatever the +/// shell that sent it added, and a location nothing can parse is handed to +/// every device that asks. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_location_is_recorded_trimmed() { + TestDb::run(|mut conn, _url| async move { + let version = seed_version(&mut conn, 2, 60, 0).await; + + let artifact = Artifact::register( + &mut conn, + unscoped(version, "installer", " https://x/y.exe\n"), + ) + .await + .expect("register"); + + assert_eq!(artifact.download_url.as_deref(), Some("https://x/y.exe")); + }) + .await; +} diff --git a/crates/database/tests/it/main.rs b/crates/database/tests/it/main.rs index 1ca91ea81..7d8d4ee37 100644 --- a/crates/database/tests/it/main.rs +++ b/crates/database/tests/it/main.rs @@ -7,6 +7,7 @@ mod admins; mod application_certificates; mod application_reported_key; mod application_types; +mod artifact_scopes; mod backfill_registered_at_migration; mod backup_detection; mod backups; @@ -52,6 +53,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..c0e62e3dd --- /dev/null +++ b/crates/database/tests/it/reporting_schemas.rs @@ -0,0 +1,1008 @@ +//! 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() +} + +/// 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) { + 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, + 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, + 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()), + artifact_ids: vec![], + }, + ) + .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; + declare_builder(&mut conn, true).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; + declare_builder(&mut conn, true).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 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")] +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, + artifact_ids: vec![], + }, + ) + .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; +} + +/// 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; +} + +/// 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 +/// 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_type, digest) + VALUES ('{newer}', 'reporting-schema', 'any', '{other}', + 'application/sql', sha256('CREATE VIEW ...'::bytea))", + )) + .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")] +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; +} + +/// 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, publishes_schemas) + VALUES ('{CONSUMER}', '{GROUP}', 'tamanu-postgres', 'reporting-schema', + 'kamaka-schemas', {enabled}, '{{}}'::jsonb, true)", + )) + .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 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, false).await; + database::reporting_schemas::sweep(&mut conn) + .await + .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); + assert_eq!( + issues[0].effective_result, + Some(commons_types::status::CheckResult::Passed), + "a built pair is not a finding" + ); + }) + .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")] +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; +} + +/// 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. +#[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; +} + +/// 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, rank, target_version_id, created_by) + VALUES ('{GROUP}', 'production', '{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; +} + +/// 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; +} + +/// 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, rank, target_version_id, created_by) + VALUES ('{GROUP}', 'production', '{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/crates/database/tests/it/restore.rs b/crates/database/tests/it/restore.rs index 68f25e0a5..155edff02 100644 --- a/crates/database/tests/it/restore.rs +++ b/crates/database/tests/it/restore.rs @@ -165,6 +165,7 @@ fn new_replica( overdue_after: None, params: serde_json::json!({}), redacts: false, + publishes_schemas: false, created_by: Some("op@example.com".into()), } } @@ -182,6 +183,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/crates/jobs/src/artifact_sweep.rs b/crates/jobs/src/artifact_sweep.rs new file mode 100644 index 000000000..14eec4fe1 --- /dev/null +++ b/crates/jobs/src/artifact_sweep.rs @@ -0,0 +1,88 @@ +//! Sweeps the artifact store of bytes no artifact reaches: a registration whose +//! row write never landed after its bytes did, and what a revert of the group +//! scoping leaves behind. +//! +//! Not an age rule. A group can sit on one version for a year without a rebuild, +//! so expiring by age alone would take live bytes out from under a registered row. +// spec: ART#where-an-artifact-rests + +use std::time::Duration; + +use commons_servers::artifact_store::ArtifactStore; +use database::artifacts::Artifact; +use jiff::Timestamp; +use tokio::{ + task::{self, JoinHandle}, + time::sleep, +}; +use tracing::{debug, error, warn}; + +/// How old an object has to be before the sweep will consider it. A registration +/// stores the bytes before the row that names them, so anything younger may have +/// one still in flight behind it. +pub const GRACE: Duration = Duration::from_secs(24 * 3600); + +const TICK: Duration = Duration::from_secs(6 * 3600); + +/// One pass. Public so a test can drive it without waiting out [`TICK`]. +pub async fn tick( + db: &mut database::diesel_async::AsyncPgConnection, + store: &ArtifactStore, + now: Timestamp, +) { + let stored = match store.stored().await { + Ok(stored) => stored, + Err(e) => { + warn!("artifact-sweep: listing the store failed: {e}"); + return; + } + }; + + // After the listing, never before: an artifact registered while it ran is + // then in here, where the other order has it in neither and sweeps it. + let held = match Artifact::held_ids(db).await { + Ok(held) => held, + Err(e) => { + warn!("artifact-sweep: reading the registered artifacts failed: {e}"); + return; + } + }; + + let cutoff = now - GRACE; + let mut swept = 0usize; + for (artifact, stored_at) in stored { + if held.contains(&artifact) || stored_at > cutoff { + continue; + } + match store.delete(artifact).await { + Ok(()) => swept += 1, + Err(e) => warn!(%artifact, "artifact-sweep: delete failed: {e}"), + } + } + + match swept { + 0 => debug!("artifact-sweep: nothing to sweep"), + n => warn!("artifact-sweep: dropped {n} artifact(s) no registration reached"), + } +} + +/// With no store configured the pod carries on rather than refusing to start: +/// the servers are what report an artifact they cannot hold. +pub async fn spawn() -> JoinHandle<()> { + let Some(store) = ArtifactStore::try_default().await else { + warn!("artifact-sweep: no artifact store configured; not sweeping"); + return task::spawn(std::future::pending()); + }; + + let pool = database::init(); + task::spawn(async move { + loop { + sleep(TICK).await; + let Ok(mut db) = pool.get().await else { + error!("Failed to get database connection"); + continue; + }; + tick(&mut db, &store, Timestamp::now()).await; + } + }) +} diff --git a/crates/jobs/src/bin/backups.rs b/crates/jobs/src/bin/backups.rs index fc67f4f7d..f472fa358 100644 --- a/crates/jobs/src/bin/backups.rs +++ b/crates/jobs/src/bin/backups.rs @@ -75,6 +75,9 @@ async fn main() -> miette::Result<()> { let tag_reconcile = jobs::backup::tag_reconcile::spawn(); let progress_prune = jobs::backup::progress_prune::spawn(); let recovery_snapshot = jobs::backup::recovery_snapshot::spawn(worker, recovery_config); + + let artifact_sweep = jobs::artifact_sweep::spawn().await; + tokio::try_join!( preflight, maintenance, @@ -83,7 +86,8 @@ async fn main() -> miette::Result<()> { s3_metrics, tag_reconcile, progress_prune, - recovery_snapshot + recovery_snapshot, + artifact_sweep ) .into_diagnostic()?; Ok(()) diff --git a/crates/jobs/src/lib.rs b/crates/jobs/src/lib.rs index e07a3cbe7..96a3ded4a 100644 --- a/crates/jobs/src/lib.rs +++ b/crates/jobs/src/lib.rs @@ -4,5 +4,6 @@ //! read k8s Secrets; the pure scheduler helpers (jitter, due-ness, billing, //! retention floor) live in `commons_servers::backup_jobs`. +pub mod artifact_sweep; pub mod backup; pub mod domains; diff --git a/crates/jobs/tests/it/artifact_sweep.rs b/crates/jobs/tests/it/artifact_sweep.rs new file mode 100644 index 000000000..5466ffc01 --- /dev/null +++ b/crates/jobs/tests/it/artifact_sweep.rs @@ -0,0 +1,135 @@ +//! The sweep drops the bytes a failed registration left and nothing else. +//! +//! spec: ART + +use commons_servers::artifact_store::ArtifactStore; +use commons_tests::db::TestDb; +use commons_tests::diesel_async::SimpleAsyncConnection; +use database::artifacts::{Artifact, NewArtifact, digest_of}; +use jiff::Timestamp; +use uuid::Uuid; + +const VERSION: &str = "11111111-1111-1111-1111-111111111111"; +const GROUP: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{VERSION}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{GROUP}', 'kamaka')", + )) + .await + .expect("seed"); +} + +/// Register a held artifact and put its bytes where they rest, as an upload does. +async fn register( + conn: &mut database::diesel_async::AsyncPgConnection, + store: &ArtifactStore, + bytes: &[u8], +) -> Uuid { + let artifact = Artifact::register( + conn, + NewArtifact { + id: None, + version_id: Some(VERSION.parse().unwrap()), + artifact_type: "reporting-schema".into(), + platform: "any".into(), + download_url: None, + device_id: None, + version_range_pattern: None, + group_id: Some(GROUP.parse().unwrap()), + content_type: Some("application/sql".into()), + digest: Some(digest_of(bytes)), + run_id: None, + }, + ) + .await + .expect("register"); + + store.put(artifact.id, bytes.to_vec()).await.expect("store"); + artifact.id +} + +/// An artifact still registered keeps its bytes however old they are. A group +/// can sit on one version for a year without a rebuild, so age alone is never a +/// reason to drop what a row still names. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_registered_artifact_keeps_its_bytes_however_old() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + let store = ArtifactStore::memory(); + let artifact = register(&mut conn, &store, b"kamaka schema").await; + + let ancient = Timestamp::now() - std::time::Duration::from_secs(400 * 24 * 3600); + store.backdate(artifact, ancient); + + jobs::artifact_sweep::tick(&mut conn, &store, Timestamp::now()).await; + + assert_eq!( + store.get(artifact).await.unwrap().as_deref(), + Some(&b"kamaka schema"[..]), + "a registered artifact was swept" + ); + }) + .await; +} + +/// Bytes no artifact reaches are what the sweep is for: a registration whose row +/// write never landed leaves them, and nothing else can find them. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn bytes_no_registration_reaches_are_swept() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + let store = ArtifactStore::memory(); + let kept = register(&mut conn, &store, b"kamaka schema").await; + + let orphan = Uuid::new_v4(); + store + .put(orphan, b"nothing names these".to_vec()) + .await + .unwrap(); + store.backdate(orphan, Timestamp::now() - jobs::artifact_sweep::GRACE); + + jobs::artifact_sweep::tick(&mut conn, &store, Timestamp::now()).await; + + assert!( + store.get(orphan).await.unwrap().is_none(), + "the orphan survived" + ); + assert!( + store.get(kept).await.unwrap().is_some(), + "the registered one went" + ); + }) + .await; +} + +/// The bytes go in before the row that names them, so an object younger than the +/// grace has a registration possibly still in flight behind it. Sweeping on age +/// alone would race an upload and drop an artifact that registered successfully. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn bytes_still_within_the_grace_are_left_alone() { + TestDb::run(|mut conn, _url| async move { + seed(&mut conn).await; + let store = ArtifactStore::memory(); + + // Stored, with no row yet: exactly the window an upload passes through. + let in_flight = Uuid::new_v4(); + store + .put(in_flight, b"mid-registration".to_vec()) + .await + .unwrap(); + + jobs::artifact_sweep::tick(&mut conn, &store, Timestamp::now()).await; + + assert!( + store.get(in_flight).await.unwrap().is_some(), + "an upload still in flight was swept out from under itself" + ); + }) + .await; +} diff --git a/crates/jobs/tests/it/main.rs b/crates/jobs/tests/it/main.rs index 1dba210f9..7255ac242 100644 --- a/crates/jobs/tests/it/main.rs +++ b/crates/jobs/tests/it/main.rs @@ -3,4 +3,5 @@ // file, which keeps rebuilds from swamping the machine with I/O. // Nextest still runs every #[tokio::test] in parallel as usual. +mod artifact_sweep; mod domains; diff --git a/crates/private-server/src/fns.rs b/crates/private-server/src/fns.rs index 13cd7a789..fd47e7e08 100644 --- a/crates/private-server/src/fns.rs +++ b/crates/private-server/src/fns.rs @@ -18,6 +18,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; @@ -143,6 +144,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..ed7b338d6 --- /dev/null +++ b/crates/private-server/src/fns/reporting_schemas.rs @@ -0,0 +1,106 @@ +use axum::Json; +use axum::extract::State; +use canopy_utoipa_axum::{router::OpenApiRouter, routes}; +use commons_errors::{AppError, 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 { + /// The group whose schema to build. + pub group_id: Uuid, + /// The Tamanu version to build it for. + 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 = 400, body = ProblemDetailsSchema), + (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; + + // 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/src/fns/restore_replicas.rs b/crates/private-server/src/fns/restore_replicas.rs index 2bc35f7b5..124a8ddee 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,16 +279,36 @@ 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 { + // 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 { @@ -289,6 +326,16 @@ 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 && !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 .iter() @@ -393,6 +440,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 +789,8 @@ pub async fn create( &args.intent, &args.params, args.redacts, + args.publishes_schemas, + args.machine_id, ) .await?; let replica = RestoreReplica::create( @@ -751,6 +805,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 +858,8 @@ pub async fn update( &args.intent, &args.params, args.redacts, + args.publishes_schemas, + args.machine_id, ) .await?; let replica = RestoreReplica::update( @@ -818,6 +875,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/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index ac1a81749..20c0617ed 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -2,19 +2,37 @@ use std::collections::BTreeMap; use std::str::FromStr; use axum::Json; -use axum::extract::State; +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, Query, State}; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; +use commons_servers::artifact_store; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::version::{VersionStatus, VersionStr}; -use database::{artifacts::Artifact, version_known_issues::VersionKnownIssue, versions::Version}; +use database::{ + artifacts::{ + Artifact, MAX_HELD_ARTIFACT_BYTES, NewArtifact, Scope, digest_of, parse_sri, parse_sri_opt, + sri, + }, + server_groups::ServerGroup, + version_known_issues::VersionKnownIssue, + versions::Version, +}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::state::AppState; +/// Header the SPA sets on an upload, which no cross-origin page can send +/// without the browser preflighting the request first. +const FETCH_HEADER: &str = "x-canopy-upload"; + +/// Body budget for `upload_artifact`. Sizing above the cap keeps an over-limit +/// upload the handler's structured refusal rather than axum's plain-text 413. +const MAX_UPLOAD_ARTIFACT_BODY_BYTES: usize = MAX_HELD_ARTIFACT_BYTES + 64 * 1024; + /// A single released (or draft) software version. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct VersionData { @@ -177,8 +195,18 @@ pub struct ArtifactData { pub artifact_type: String, /// Target platform this artifact is built for. pub platform: String, - /// URL clients use to download this artifact. - pub download_url: String, + /// URL clients use to download this artifact. `null` when Canopy holds + /// the bytes itself. + pub download_url: Option, + /// The group this artifact is for, when it is for one alone. + pub group_id: Option, + /// Name of that group, for display. + pub group_name: Option, + /// Subresource Integrity digest recorded for the artifact, where there is + /// one. + pub digest: Option, + /// `true` when Canopy holds this artifact's bytes rather than a location. + pub canopy_holds_bytes: bool, /// `true` when this artifact is tied to the exact version being /// queried; `false` when it was matched via a version range pattern /// instead. @@ -205,6 +233,11 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(update_version_changelog)) .routes(routes!(update_artifact)) .routes(routes!(create_artifact)) + .merge( + OpenApiRouter::new() + .routes(routes!(upload_artifact)) + .layer(DefaultBodyLimit::max(MAX_UPLOAD_ARTIFACT_BODY_BYTES)), + ) .routes(routes!(delete_artifact)) .routes(routes!(list_known_issues)) .routes(routes!(add_known_issue)) @@ -415,6 +448,7 @@ pub async fn get_version_detail( post, path = "/get_version_artifacts", tag = "versions", + security(("tailscale-user" = [])), request_body = VersionStringArgs, responses( (status = 200, body = Vec), @@ -423,30 +457,53 @@ pub async fn get_version_detail( )] pub async fn get_version_artifacts( State(state): State, + // Every group's artifacts, digests and group names, which ART discloses to + // an operator and to nobody else. The tagged-device layer above only turns + // away a caller that both carries no identity and comes from the tailnet. + // spec: ART#who-is-offered-a-group-scoped-artifact, ADM + _user: TailscaleUser, Json(args): Json, ) -> Result>> { let mut conn = state.db_read.get().await?; let version = VersionStr::from_str(&args.version)?; let version_record = Version::get_by_version(&mut conn, version).await?; + Ok(Json(artifacts_of(&mut conn, version_record.id).await?)) +} + +/// Every artifact of a version as an operator sees it: the full set, including +/// what specificity passed over and every group's, because what resolution +/// hides is a fact about how a version was published. +// spec: ART#what-a-version-offers +async fn artifacts_of( + conn: &mut database::diesel_async::AsyncPgConnection, + version_id: Uuid, +) -> Result> { let artifacts_with_metadata = - Artifact::get_for_version_with_metadata(&mut conn, version_record.id).await?; - Ok(Json( - artifacts_with_metadata - .into_iter() - .map( - |(a, is_exact, has_range_override, is_used_in_public_api)| ArtifactData { - id: a.id, - artifact_type: a.artifact_type, - platform: a.platform, - download_url: a.download_url, - is_exact, - version_range_pattern: a.version_range_pattern, - has_range_override, - is_used_in_public_api, - }, - ) - .collect(), - )) + Artifact::get_for_version_all_matches_with_metadata(conn, version_id, Scope::Fleet).await?; + let groups: Vec = artifacts_with_metadata + .iter() + .filter_map(|(artifact, ..)| artifact.group_id) + .collect(); + let group_names = ServerGroup::names_by_ids(conn, &groups).await?; + Ok(artifacts_with_metadata + .into_iter() + .map( + |(a, is_exact, has_range_override, is_used_in_public_api)| ArtifactData { + id: a.id, + artifact_type: a.artifact_type, + platform: a.platform, + canopy_holds_bytes: a.download_url.is_none(), + download_url: a.download_url, + group_name: a.group_id.and_then(|g| group_names.get(&g).cloned()), + group_id: a.group_id, + digest: a.digest.as_deref().map(sri), + is_exact, + version_range_pattern: a.version_range_pattern, + has_range_override, + is_used_in_public_api, + }, + ) + .collect()) } /// Identifies a version and the publication status to set on it. @@ -545,8 +602,8 @@ pub struct UpdateArtifactArgs { pub artifact_type: String, /// New target platform. pub platform: String, - /// New download URL. - pub download_url: String, + /// New download URL. Leave unset for an artifact whose bytes Canopy holds. + pub download_url: Option, } /// Update an existing artifact's type, platform, and download URL. @@ -580,7 +637,11 @@ pub async fn update_artifact( Ok(Json(())) } -/// A new artifact to register against a version. +/// A new artifact to register against a version, at a location Canopy records. +/// +/// An artifact whose bytes Canopy holds is registered through +/// `upload_artifact` instead, since the bytes are the body there. +// spec: ART#where-an-artifact-rests #[derive(Deserialize, ToSchema)] pub struct CreateArtifactArgs { /// Id of the version to attach the new artifact to. @@ -589,8 +650,12 @@ pub struct CreateArtifactArgs { pub artifact_type: String, /// Target platform. pub platform: String, - /// Download URL for the artifact. + /// URL the artifact is downloaded from. pub download_url: String, + /// Subresource Integrity digest of the bytes at that URL, e.g. + /// `sha256-LCTbqp…`, where one is recorded. Whoever fetches the artifact + /// checks what it got against this. + pub digest: Option, } /// Create a new artifact tied to an exact version. @@ -605,6 +670,7 @@ pub struct CreateArtifactArgs { request_body = CreateArtifactArgs, responses( (status = 200, body = ArtifactData), + (status = 400, body = ProblemDetailsSchema), ), )] pub async fn create_artifact( @@ -613,24 +679,196 @@ pub async fn create_artifact( Json(args): Json, ) -> Result> { let mut conn = state.db.get().await?; - let artifact = Artifact::create( + + // A digest against a location is what whoever fetches the artifact checks + // the bytes it got against, so one that cannot be checked against is + // refused rather than published. + // spec: ART#digests + let digest = parse_sri_opt(args.digest.as_deref())?; + + // Where the artifact rests, and the refusal when it names neither place or + // both, is `Artifact::register`'s to settle. + let artifact = Artifact::register( &mut conn, - args.version_id, - args.artifact_type, - args.platform, - args.download_url, + NewArtifact { + id: None, + version_id: Some(args.version_id), + artifact_type: args.artifact_type, + platform: args.platform, + download_url: Some(args.download_url), + device_id: None, + version_range_pattern: None, + group_id: None, + content_type: None, + digest, + run_id: None, + }, ) .await?; - Ok(Json(ArtifactData { - id: artifact.id, - artifact_type: artifact.artifact_type, - platform: artifact.platform, - download_url: artifact.download_url, - is_exact: true, + + registered(&mut conn, args.version_id, artifact.id).await +} + +/// What an upload names beside its bytes. +// spec: ART#where-an-artifact-rests +#[derive(Deserialize, IntoParams)] +pub struct UploadArtifactQuery { + /// Id of the version to attach the new artifact to. + pub version_id: Uuid, + /// Artifact type. + pub artifact_type: String, + /// Target platform. + pub platform: String, + /// The group this artifact is for. + pub group_id: Uuid, + /// Subresource Integrity digest of the body, e.g. `sha256-LCTbqp…`. + /// Canopy checks the bytes against it as they arrive and refuses the + /// registration on a mismatch, so a corrupted upload is refused while + /// whoever sent it is still there to send it again. + pub digest: String, +} + +/// Register an artifact whose bytes Canopy holds, for one group. +/// +/// The body is the artifact itself and its `Content-Type` is what the bytes +/// are served back as. Returns the created artifact. +#[utoipa::path( + post, + path = "/upload_artifact", + tag = "versions", + security(("tailscale-admin" = [])), + params( + UploadArtifactQuery, + ("x-canopy-upload" = String, Header, description = "Any value. Required: it makes a browser preflight the request, so a cross-origin page cannot spend an operator's session on this endpoint."), + ), + request_body(content = Vec, content_type = "application/octet-stream", description = "The artifact's bytes."), + responses( + (status = 200, body = ArtifactData), + (status = 400, body = ProblemDetailsSchema), + ), +)] +pub async fn upload_artifact( + State(state): State, + _admin: TailscaleAdmin, + Query(named): Query, + headers: axum::http::HeaderMap, + body: Bytes, +) -> Result> { + // Every other write here carries a JSON body, which is not a content type a + // form can send, so the browser preflights it and a cross-origin page never + // reaches it. This one takes raw bytes, so it asks for a header of its own + // to the same end: the operator's tailnet identity is supplied by the proxy, + // and a page they merely visited must not be able to spend it. + if !headers.contains_key(FETCH_HEADER) { + return Err(AppError::BadRequest(format!( + "an upload must carry the {FETCH_HEADER} header" + ))); + } + + if body.len() > MAX_HELD_ARTIFACT_BYTES { + return Err(AppError::BadRequest(format!( + "artifact is larger than the {} MiB limit", + MAX_HELD_ARTIFACT_BYTES / (1024 * 1024) + ))); + } + + let claimed = parse_sri(&named.digest)?; + // Hashing the whole artifact is tens of milliseconds with no await in it, + // and the pool it would be holding while it ran is five connections wide. + let digest = { + let body = body.clone(); + tokio::task::spawn_blocking(move || digest_of(&body)) + .await + .map_err(|err| AppError::custom(format!("digesting the artifact failed: {err}")))? + }; + if claimed != digest { + return Err(AppError::BadRequest(format!( + "the bytes are {}, not the {} the registration names", + sri(&digest), + sri(&claimed) + ))); + } + + // The media type is served back as a header, and the browser sends none + // for a file it cannot type, so an absent one is the artifact's own + // default rather than a refusal. + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .filter(|media_type| media_type != "application/octet-stream"); + + let store = state + .artifacts + .as_ref() + .ok_or_else(artifact_store::unconfigured)?; + + let input = NewArtifact { + id: None, + version_id: Some(named.version_id), + artifact_type: named.artifact_type, + platform: named.platform, + download_url: None, + device_id: None, version_range_pattern: None, - has_range_override: false, - is_used_in_public_api: true, - })) + group_id: Some(named.group_id), + content_type, + digest: Some(digest), + run_id: None, + }; + + // Under the id the artifact already has where one is registered, so a + // replacement lands where the bytes it replaces were. + // spec: ART#where-an-artifact-rests + let mut conn = state.db.get().await?; + let existing = Artifact::id_for_identity(&mut conn, &input).await?; + let id = existing.unwrap_or_else(Uuid::new_v4); + drop(conn); + + store.put(id, Vec::from(body)).await?; + + let mut conn = state.db.get().await?; + let registered_row = Artifact::register( + &mut conn, + NewArtifact { + id: Some(id), + ..input + }, + ) + .await; + + // The row write is what refuses a group or version Canopy does not hold, by + // which point the bytes are stored. Only an id minted here is dropped: under + // one already registered the bytes are the live artifact's. + let artifact = match registered_row { + Ok(artifact) => artifact, + Err(refusal) => { + if existing.is_none() + && let Err(err) = store.delete(id).await + { + tracing::error!(artifact = %id, "refused registration left its bytes: {err}"); + } + return Err(refusal); + } + }; + + registered(&mut conn, named.version_id, artifact.id).await +} + +/// Read a just-registered artifact back through the listing rather than +/// describing the row a second time: whether it overrides a range and whether +/// it is the one served follow from the version's other artifacts. +async fn registered( + conn: &mut database::diesel_async::AsyncPgConnection, + version_id: Uuid, + artifact_id: Uuid, +) -> Result> { + artifacts_of(conn, version_id) + .await? + .into_iter() + .find(|a| a.id == artifact_id) + .map(Json) + .ok_or_else(|| AppError::custom("the artifact just registered is not listed")) } /// Identifies a single artifact by id. @@ -642,8 +880,8 @@ pub struct ArtifactIdArgs { /// Permanently delete an artifact. /// -/// The artifact record is removed outright; the file it pointed to is not -/// touched. There is no undo. +/// An artifact Canopy holds loses its bytes along with its record. One that +/// records a location keeps whatever is at that location. There is no undo. #[utoipa::path( post, path = "/delete_artifact", @@ -659,6 +897,14 @@ pub async fn delete_artifact( _admin: TailscaleAdmin, Json(args): Json, ) -> Result> { + // Before the row: a store that refuses the drop leaves the artifact + // registered and the operator retrying, rather than a row gone and bytes + // nothing reaches. + // spec: ART#where-an-artifact-rests + if let Some(store) = &state.artifacts { + store.delete(args.artifact_id).await?; + } + let mut conn = state.db.get().await?; Artifact::delete(&mut conn, args.artifact_id).await?; Ok(Json(())) diff --git a/crates/private-server/src/lib.rs b/crates/private-server/src/lib.rs index a77be2727..2599e45e2 100644 --- a/crates/private-server/src/lib.rs +++ b/crates/private-server/src/lib.rs @@ -50,6 +50,7 @@ pub fn routes(state: crate::state::AppState) -> commons_errors::Result, /// recovery vault recipient public keys (`CANOPY_RECOVERY_VAULT_KEYS`), for the /// verification ceremony. `None` ⇒ the ceremony endpoints 502 (the backups /// pod is what hard-requires them, not this admin server). @@ -181,6 +187,7 @@ impl AppState { let kube = BackupSecrets::try_default().await; let prober = BucketProber::try_default().await; + let artifacts = ArtifactStore::try_default().await; // For the nested `/public` mount's backup-credential issuance. Building // the client needs no creds (they resolve per-call from the pod's IRSA // identity), so this is always `Some` in a real run. @@ -202,6 +209,7 @@ impl AppState { kube, sts, prober, + artifacts, recovery_recipients: recovery_recipients_from_env(), recovery_challenge: Arc::new(Mutex::new(None)), dns_zones: dns_zones_from_env(), @@ -232,6 +240,9 @@ impl AppState { // drives each probe state by naming the bucket — `…existing…` → kopia // repo, `…other…` → other content, `…denied…` → inaccessible, else empty. prober: BucketProber::Fake(None), + // In-process artifact store so upload and download are exercised in + // tests and the e2e fixture without a bucket. + artifacts: Some(ArtifactStore::memory()), // Read from env so the e2e fixture can exercise the recovery ceremony. recovery_recipients: recovery_recipients_from_env(), recovery_challenge: Arc::new(Mutex::new(None)), diff --git a/crates/private-server/tests/it/artifacts.rs b/crates/private-server/tests/it/artifacts.rs index d1baabfbe..149ac0a84 100644 --- a/crates/private-server/tests/it/artifacts.rs +++ b/crates/private-server/tests/it/artifacts.rs @@ -1,7 +1,13 @@ use commons_tests::diesel_async::SimpleAsyncConnection; +use database::artifacts::{digest_of, sri}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// The digest an upload names for the bytes it carries. +fn sri_of(bytes: &[u8]) -> String { + sri(&digest_of(bytes)) +} + #[derive(Debug, Deserialize, Serialize)] pub struct ArtifactData { pub id: Uuid, @@ -32,9 +38,10 @@ async fn artifact_multiple_ranges_pattern_specificity_private_endpoint() { .await .unwrap(); - // The private detail page calls the same deduplicated view that the - // public API serves: among multiple ranges that match a version, only - // the most specific one wins. + // The operator view shows every artifact that matches, including the + // ones specificity passed over, and marks which one is actually served. + // What resolution hides is a fact about how a version was published. + // spec: ART#what-a-version-offers let response = private .post("/api/versions/get_version_artifacts") .json(&serde_json::json!({"version": "2.44.5"})) @@ -43,19 +50,660 @@ async fn artifact_multiple_ranges_pattern_specificity_private_endpoint() { response.assert_status_ok(); let artifacts: Vec = response.json(); - assert_eq!( - artifacts.len(), - 1, - "deduplicated view should keep only the more specific range" - ); - let chosen = &artifacts[0]; + assert_eq!(artifacts.len(), 2, "both matching ranges are shown"); + + let chosen = artifacts + .iter() + .find(|a| a.is_used_in_public_api) + .expect("one of them is the one served"); assert_eq!(chosen.id.to_string(), narrower_range_id.to_lowercase()); assert_eq!( chosen.version_range_pattern, Some("^2.44.2".to_string()), - "the more specific range should be the one returned" + "the more specific range is the one served" + ); + + let passed_over = artifacts + .iter() + .find(|a| !a.is_used_in_public_api) + .expect("the broader range is shown but not served"); + assert_eq!(passed_over.id.to_string(), broader_range_id.to_lowercase()); + }) + .await +} + +/// A registration that names neither a location nor a group, names both a group +/// and a location, or carries bytes without a group, is a client mistake and is +/// refused as one. Writing the row and letting the check constraint catch it +/// answers 500 for input the operator controls. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_that_rests_nowhere_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "77777777-7777-7777-7777-777777777777"; + let group = "88888888-8888-8888-8888-888888888888"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + // A location that is no location. + let refused = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, "artifact_type": "installer", "platform": "any", + "download_url": " ", + })) + .await; + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + + // Bytes that are not the digest the upload names. + let mismatched = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "installer") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"something else")) + .content_type("application/sql") + .bytes("hello".into()) + .await; + assert_eq!( + mismatched.status_code(), + axum::http::StatusCode::BAD_REQUEST + ); + + // A digest nothing can check the bytes against. + for claimed in ["", " ", "sha256:abcd", "notadigest", "sha256-abcd"] { + let refused = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "installer") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", claimed) + .bytes("hello".into()) + .await; + assert_eq!( + refused.status_code(), + axum::http::StatusCode::BAD_REQUEST, + "refused as a client mistake: {claimed:?}" + ); + } + }) + .await +} + +/// Editing only an artifact's type or platform must not take its location away. +/// The field is optional on the wire, so an omitted URL used to null the column +/// and fail the constraint, losing the artifact and answering 500. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_unscoped_artifact_cannot_lose_its_location() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "99999999-9999-9999-9999-999999999999"; + let artifact = "aaaaaaaa-0000-0000-0000-aaaaaaaaaaaa"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{artifact}', '{version}', 'any', 'installer', 'https://example.com/x.exe')", + )) + .await + .unwrap(); + + let response = private + .post("/api/versions/update_artifact") + .json(&serde_json::json!({ + "artifact_id": artifact, + "artifact_type": "installer", + "platform": "windows", + })) + .await; + // Refused as a conflict, not left to the check constraint, which would + // answer 500 for something the operator asked for. + assert_eq!(response.status_code(), axum::http::StatusCode::CONFLICT); + + let listed = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + listed.assert_status_ok(); + let artifacts: Vec = listed.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!( + artifacts[0]["download_url"], "https://example.com/x.exe", + "the location it had is still the location it has" + ); + }) + .await +} + +/// An operator registers a group-scoped artifact by carrying its bytes. Canopy +/// holds them, takes the digest of what it received, and offers the group's +/// name back so the operator can see whose it is. +// spec: ART#where-an-artifact-rests, ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn an_operator_registers_a_group_scoped_artifact() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "bbbbbbbb-0000-0000-0000-bbbbbbbbbbbb"; + let group = "cccccccc-0000-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let response = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"kamaka schema")) + .content_type("application/sql") + .bytes("kamaka schema".into()) + .await; + response.assert_status_ok(); + + let artifact: serde_json::Value = response.json(); + assert_eq!(artifact["canopy_holds_bytes"], true); + assert!(artifact["download_url"].is_null(), "it rests in Canopy"); + assert_eq!(artifact["group_id"], group); + assert_eq!(artifact["group_name"], "kamaka"); + assert_eq!(artifact["digest"], sri_of(b"kamaka schema")); + }) + .await +} + +/// The group an artifact names is a foreign key, so an id that names no group +/// answers the operator's own input with a database fault instead of a refusal. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_naming_no_group_that_exists_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-5555-0000-0000-111111111111"; + let gone = "cccccccc-5555-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", gone) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await; + assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + }) + .await +} + +/// A digest against a location is what whoever fetches the artifact checks the +/// bytes it got against. Dropped, a caller that supplied one gets no error and +/// no digest, and the fetch is unchecked. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn a_digest_against_a_location_is_recorded() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-3333-0000-0000-111111111111"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "windows", + "download_url": "https://example.com/x.exe", + "digest": sri_of(b"kamaka installer"), + })) + .await; + created.assert_status_ok(); + + let artifact: serde_json::Value = created.json(); + assert_eq!(artifact["digest"], sri_of(b"kamaka installer")); + }) + .await +} + +/// A blank location is no location. The check constraint only tests for NULL, +/// so an empty string would pass it and leave an artifact nothing can be +/// fetched from. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_blank_download_url_is_not_a_location() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "dddddddd-0000-0000-0000-dddddddddddd"; + let artifact = "eeeeeeee-0000-0000-0000-eeeeeeeeeeee"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{artifact}', '{version}', 'any', 'installer', 'https://example.com/x.exe')", + )) + .await + .unwrap(); + + let created = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "linux", + "download_url": " ", + })) + .await; + assert_eq!(created.status_code(), axum::http::StatusCode::BAD_REQUEST); + + let updated = private + .post("/api/versions/update_artifact") + .json(&serde_json::json!({ + "artifact_id": artifact, + "artifact_type": "installer", + "platform": "any", + "download_url": "", + })) + .await; + assert_eq!(updated.status_code(), axum::http::StatusCode::CONFLICT); + + let listed = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + let artifacts: Vec = listed.json(); + assert_eq!(artifacts.len(), 1, "nothing blank was written"); + assert_eq!(artifacts[0]["download_url"], "https://example.com/x.exe"); + }) + .await +} + +/// The upload route carries a body limit sized from the held-bytes cap, so an +/// upload well past axum's 2 MB default is accepted, and one past the cap is +/// refused by the handler naming the limit rather than by axum with a +/// plain-text 413 the SPA has nothing structured to render. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_upload_over_the_limit_is_told_what_it_is() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "ffffffff-0000-0000-0000-ffffffffffff"; + let group = "ffffffff-1111-1111-1111-ffffffffffff"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let four_mib = vec![0u8; 4 * 1024 * 1024]; + let accepted = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(&four_mib)) + .bytes(four_mib.into()) + .await; + accepted.assert_status_ok(); + + let over_limit = vec![0u8; 32 * 1024 * 1024 + 1]; + let refused = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "linux") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(&over_limit)) + .bytes(over_limit.into()) + .await; + + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + let problem: serde_json::Value = refused.json(); + assert!( + problem["title"] + .as_str() + .expect("a problem-details title") + .contains("32 MiB"), + "the refusal names the limit, but got: {problem}" + ); + }) + .await +} + +/// The listing's offered flag, over the wire. An artifact is offered where it +/// wins inside a scope that is actually resolved, so an unscoped artifact and +/// the group's own that displaces it are both served, to different callers, and +/// both say so. A range the exact displaces inside one scope is served to +/// nobody. +// spec: ART#what-a-version-offers +#[tokio::test(flavor = "multi_thread")] +async fn the_listing_says_which_artifacts_are_offered() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "11111111-2222-0000-0000-111111111111"; + let group = "11111111-3333-0000-0000-111111111111"; + let unscoped_schema = "11111111-4444-0000-0000-111111111111"; + let group_schema = "11111111-5555-0000-0000-111111111111"; + let exact_installer = "11111111-6666-0000-0000-111111111111"; + let range_installer = "11111111-7777-0000-0000-111111111111"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{unscoped_schema}', '{version}', 'any', 'reporting-schema', 'https://example.com/all.sql'), + ('{exact_installer}', '{version}', 'windows', 'installer', 'https://example.com/exact.exe'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, version_range_pattern, download_url) + VALUES ('{range_installer}', NULL, 'windows', 'installer', '2.60.x', 'https://example.com/range.exe'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content_type, digest) + VALUES ('{group_schema}', '{version}', 'any', 'reporting-schema', '{group}', 'application/sql', sha256('kamaka schema'::bytea))", + )) + .await + .unwrap(); + + let response = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + let offered = |id: &str| -> bool { + artifacts + .iter() + .find(|a| a["id"] == id) + .unwrap_or_else(|| panic!("{id} is listed"))["is_used_in_public_api"] + .as_bool() + .expect("a flag") + }; + + assert!( + offered(group_schema), + "the group is offered the one held for it" + ); + assert!( + offered(unscoped_schema), + "every other group is still offered the unscoped one" + ); + assert!(offered(exact_installer), "the exact wins its own scope"); + assert!( + !offered(range_installer), + "the range it displaces is served to nobody" + ); + }) + .await +} + +/// What an exact artifact overrides follows the resolution rules rather than a +/// group match: it displaces a range its own scope can see, so a group's exact +/// artifact overrides an unscoped range, and an unscoped exact overrides +/// nothing in a group whose own range outranks it. The registration answers +/// with what the listing would say rather than describing the row a second +/// time. +// spec: ART#what-a-version-offers +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_answers_what_it_overrides() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "22222222-1111-0000-0000-222222222222"; + let ours = "22222222-2222-0000-0000-222222222222"; + let theirs = "22222222-3333-0000-0000-222222222222"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES + ('{ours}', 'kamaka'), ('{theirs}', 'drifting'); + + INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, download_url) + VALUES (NULL, 'any', 'reporting-schema', '2.60.x', 'https://example.com/range.sql'); + + INSERT INTO artifacts (version_id, platform, artifact_type, version_range_pattern, group_id, content_type, digest) + VALUES (NULL, 'windows', 'installer', '2.60.x', '{theirs}', 'application/octet-stream', sha256('theirs'::bytea))", + )) + .await + .unwrap(); + + let held = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", ours) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await; + held.assert_status_ok(); + let held: serde_json::Value = held.json(); + assert_eq!(held["is_exact"], true); + assert_eq!( + held["has_range_override"], true, + "the group's own displaces the unscoped range for that group" + ); + assert_eq!(held["is_used_in_public_api"], true); + + let unscoped = private + .post("/api/versions/create_artifact") + .json(&serde_json::json!({ + "version_id": version, + "artifact_type": "installer", + "platform": "windows", + "download_url": "https://example.com/x.exe", + })) + .await; + unscoped.assert_status_ok(); + let unscoped: serde_json::Value = unscoped.json(); + assert_eq!( + unscoped["has_range_override"], false, + "a group's range outranks an unscoped exact, so nothing is displaced" + ); + }) + .await +} + +/// Every other write here carries a JSON body, which a form cannot send, so the +/// browser preflights it and a cross-origin page never reaches it. This one +/// takes raw bytes, so it asks for a header of its own to the same end: the +/// operator's identity comes from the proxy, and a page they merely visited +/// must not be able to spend it. +#[tokio::test(flavor = "multi_thread")] +async fn an_upload_without_the_fetch_header_is_refused() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "aaaaaaaa-9999-0000-0000-aaaaaaaaaaaa"; + let group = "cccccccc-9999-0000-0000-cccccccccccc"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let refused = private + .post("/api/versions/upload_artifact") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await; + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + + // Nothing was written by it. + let listed = private + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({ "version": "2.60.0" })) + .await; + let artifacts: Vec = listed.json(); + assert!(artifacts.is_empty(), "the refusal wrote nothing"); + }) + .await +} + +/// A registration replaces whatever is already registered for the same version, +/// type, platform and group, and the bytes it replaces go with it: the artifact +/// rests under its id, so a rebuild is put where the old build was rather than +/// beside it. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_rebuild_replaces_the_bytes_where_they_rest() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "dddddddd-0000-0000-0000-dddddddddddd"; + let group = "eeeeeeee-0000-0000-0000-eeeeeeeeeeee"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let upload = async |bytes: &'static [u8]| { + private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(bytes)) + .bytes(bytes.into()) + .await + }; + + let first: serde_json::Value = upload(b"first build").await.json(); + let second: serde_json::Value = upload(b"second build").await.json(); + assert_eq!(first["id"], second["id"], "replaced in place"); + + let id: Uuid = serde_json::from_value(second["id"].clone()).unwrap(); + let store = commons_tests::server::artifacts(&mut conn).await; + assert_eq!( + store.get(id).await.unwrap().as_deref(), + Some(&b"second build"[..]) + ); + }) + .await +} + +/// Canopy keeps none of what it has stopped serving, so deregistering an +/// artifact takes its bytes out of the store as well as its row. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn deleting_an_artifact_takes_its_bytes() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "ffffffff-0000-0000-0000-ffffffffffff"; + let group = "ffffffff-1111-0000-0000-ffffffffffff"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published'); + INSERT INTO server_groups (id, name) VALUES ('{group}', 'kamaka')", + )) + .await + .unwrap(); + + let registered: serde_json::Value = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + .add_query_param("group_id", group) + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await + .json(); + let id: Uuid = serde_json::from_value(registered["id"].clone()).unwrap(); + + let store = commons_tests::server::artifacts(&mut conn).await; + assert!(store.get(id).await.unwrap().is_some()); + + private + .post("/api/versions/delete_artifact") + .json(&serde_json::json!({ "artifact_id": id })) + .await + .assert_status_ok(); + + assert!(store.get(id).await.unwrap().is_none()); + }) + .await +} + +/// A registration that is refused leaves nothing in the store. The bytes go in +/// before the row that names them, so a refusal the row write raises, meaning a +/// group or version that does not exist, is the one case where an object can +/// outlive the registration that put it there. Reachable by typing an id wrong +/// rather than by a crash. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_refused_registration_leaves_no_bytes() { + commons_tests::server::run(async |mut conn, _public, private| { + let version = "aaaaaaaa-9999-0000-0000-aaaaaaaaaaaa"; + + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{version}', 2, 60, 0, '', 'published')", + )) + .await + .unwrap(); + + let refused = private + .post("/api/versions/upload_artifact") + .add_header("x-canopy-upload", "1") + .add_query_param("version_id", version) + .add_query_param("artifact_type", "reporting-schema") + .add_query_param("platform", "any") + // No such group. The row write is what refuses it, by which point + // the bytes have been stored. + .add_query_param("group_id", "dddddddd-9999-0000-0000-dddddddddddd") + .add_query_param("digest", sri_of(b"kamaka schema")) + .bytes("kamaka schema".into()) + .await; + assert_eq!(refused.status_code(), axum::http::StatusCode::BAD_REQUEST); + + let store = commons_tests::server::artifacts(&mut conn).await; + assert!( + store.held().is_empty(), + "a refused registration left {:?} behind", + store.held() ); - assert!(chosen.is_used_in_public_api); }) .await } diff --git a/crates/private-server/tests/it/device_admin_endpoints.rs b/crates/private-server/tests/it/device_admin_endpoints.rs index 8c74694f3..aff564c21 100644 --- a/crates/private-server/tests/it/device_admin_endpoints.rs +++ b/crates/private-server/tests/it/device_admin_endpoints.rs @@ -44,6 +44,7 @@ async fn private_with_directory(url: &str, directory: TailnetDirectory) -> TestS prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(commons_servers::artifact_store::ArtifactStore::memory()), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), dns_zones: Vec::new(), diff --git a/crates/private-server/tests/it/main.rs b/crates/private-server/tests/it/main.rs index 695507630..60059bc35 100644 --- a/crates/private-server/tests/it/main.rs +++ b/crates/private-server/tests/it/main.rs @@ -31,6 +31,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; +} 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. diff --git a/crates/private-server/tests/it/sql.rs b/crates/private-server/tests/it/sql.rs index da984e2eb..8618f94d9 100644 --- a/crates/private-server/tests/it/sql.rs +++ b/crates/private-server/tests/it/sql.rs @@ -24,6 +24,7 @@ async fn private_with_ro_pool(url: &str) -> TestServer { prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(commons_servers::artifact_store::ArtifactStore::memory()), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), dns_zones: Vec::new(), diff --git a/crates/private-server/tests/it/tailnet_device_auth.rs b/crates/private-server/tests/it/tailnet_device_auth.rs index 63c6a4565..602999511 100644 --- a/crates/private-server/tests/it/tailnet_device_auth.rs +++ b/crates/private-server/tests/it/tailnet_device_auth.rs @@ -92,6 +92,7 @@ async fn unknown_tailnet_node_is_rejected_without_creating_a_row() { prober: private_server::backup_probe::BucketProber::fake( private_server::backup_probe::ProbeState::Empty, ), + artifacts: Some(commons_servers::artifact_store::ArtifactStore::memory()), recovery_recipients: None, recovery_challenge: std::sync::Arc::new(std::sync::Mutex::new(None)), dns_zones: Vec::new(), diff --git a/crates/public-server/Cargo.toml b/crates/public-server/Cargo.toml index c4e2973ca..8df557487 100644 --- a/crates/public-server/Cargo.toml +++ b/crates/public-server/Cargo.toml @@ -66,6 +66,7 @@ aws-sdk-sts = { workspace = true, features = ["test-util"] } aws-smithy-mocks.workspace = true axum-test.workspace = true commons-tests = { path = "../commons-tests" } +hex = "0.4.3" http.workspace = true percent-encoding = "2.3.2" rcgen = "0.14.8" diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index 8672e29cd..b479e92c0 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -105,6 +105,127 @@ ] } }, + "/artifacts/groups/{group}/{version}/{artifact_type}/{platform}": { + "post": { + "tags": [ + "artifacts" + ], + "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`) the schema was built against.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "artifact_type", + "in": "path", + "description": "Must be `reporting-schema`: the authorisation is defined with that artifact.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "platform", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "run", + "in": "query", + "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": [ + { + "backup-restore-device": [] + } + ] + } + }, "/artifacts/{version}/{artifact_type}/{platform}": { "post": { "tags": [ @@ -138,6 +259,25 @@ "schema": { "type": "string" } + }, + { + "name": "group", + "in": "query", + "description": "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused.", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "digest", + "in": "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.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -1687,6 +1827,13 @@ "format": "uuid", "description": "The device that registered this artifact, if it was registered by a\nreleaser device rather than created by an operator." }, + "digest": { + "type": [ + "string", + "null" + ], + "description": "Subresource Integrity digest of the artifact's bytes, e.g.\n`sha256-LCTbqp…`, where one was recorded." + }, "download_url": { "type": "string", "description": "URL the artifact can be downloaded from." @@ -2200,7 +2347,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. 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 until\noverdue), `url` (a replica's health report includes a link to it),\n`migrate` (Canopy names a target version and the replica applies that\nversion's migrations), `redact` (the replica de-identifies the restored\ndata before serving it), and `reporting-schema` (the replica builds a\nTamanu reporting schema and registers it for the group). Unrecognised\nvalues are stored but have no effect, so a consumer may advertise ahead\nof Canopy support." } } }, @@ -2738,6 +2885,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.", @@ -3144,6 +3334,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 aee31bab2..a5676c2a4 100644 --- a/crates/public-server/src/artifacts.rs +++ b/crates/public-server/src/artifacts.rs @@ -1,23 +1,121 @@ use axum::{ Json, - extract::{Path, State}, + extract::{DefaultBodyLimit, Path, Query, State}, }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; -use commons_errors::{ProblemDetailsSchema, Result}; -use commons_servers::device_auth::ReleaserDevice; -use commons_types::version::{VersionStatus, VersionStr}; +use commons_errors::{AppError, ProblemDetailsSchema, Result}; +use commons_servers::device_auth::{AuthDevice, ReleaserDevice}; +use commons_types::{ + device::DeviceRole, + version::{VersionStatus, VersionStr}, +}; use database::{ Db, - artifacts::{Artifact, NewArtifact}, + artifacts::{ + Artifact as ArtifactRow, MAX_HELD_ARTIFACT_BYTES, NewArtifact, Scope, digest_of, location, + parse_sri_opt, sri, + }, + machines::Machine, + restore::RestoreReplica, versions::{NewVersion, Version}, }; use diesel::SelectableHelper as _; use diesel_async::RunQueryDsl as _; +use serde::Serialize; +use uuid::Uuid; use crate::state::AppState; +/// A downloadable artifact belonging to a release version: an installer, +/// package, or other file published for a given type and platform. +#[derive(Debug, Clone, Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct Artifact { + /// Unique identifier of the artifact. + pub id: Uuid, + /// The exact version this artifact belongs to. `null` for range + /// artifacts, which apply to every version matching + /// `version_range_pattern` instead. + pub version_id: Option, + /// What kind of artifact this is (e.g. an installer or package name). + pub artifact_type: String, + /// The platform the artifact targets (e.g. an OS or architecture name). + pub platform: String, + /// URL the artifact can be downloaded from. + pub download_url: String, + /// The device that registered this artifact, if it was registered by a + /// releaser device rather than created by an operator. + pub device_id: Option, + /// Semver range this artifact applies to (e.g. `^2.10.0`), for artifacts + /// shared across a range of versions rather than pinned to one. `null` + /// for exact-version artifacts. + pub version_range_pattern: Option, + /// Subresource Integrity digest of the artifact's bytes, e.g. + /// `sha256-LCTbqp…`, where one was recorded. + pub digest: Option, +} + +impl Artifact { + /// Present a stored row to a caller it is offered to. + /// + /// An artifact Canopy holds has no location of its own, so it is offered + /// Canopy's download endpoint: whoever is offered an artifact is given one + /// URL to fetch it from, whichever of the two it turned out to be. + // spec: ART#where-an-artifact-rests + pub(crate) fn offered(row: ArtifactRow, base: &str, version: &str) -> Self { + let download_url = row + .download_url + .clone() + .unwrap_or_else(|| format!("{base}/versions/{version}/artifacts/{}/download", row.id)); + + Self { + id: row.id, + version_id: row.version_id, + artifact_type: row.artifact_type, + platform: row.platform, + download_url, + device_id: row.device_id, + version_range_pattern: row.version_range_pattern, + digest: row.digest.as_deref().map(sri), + } + } +} + +/// What the authenticated caller may see. +/// +/// A caller's group is derived from its identity and never taken from the +/// request, and a caller with no identity, no machine, or no group is offered +/// the unscoped artifacts alone rather than refused. +// spec: ART#who-is-offered-a-group-scoped-artifact +pub(crate) async fn caller_scope( + conn: &mut database::diesel_async::AsyncPgConnection, + device: Option, +) -> Result { + let Some(device) = device else { + return Ok(Scope::Unscoped); + }; + + let machine = Machine::get_by_device_id(conn, device.0.id).await?; + Ok(Scope::for_caller(machine.and_then(|m| m.group_id))) +} + +/// 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_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)) + OpenApiRouter::new().routes(routes!(create)).merge( + OpenApiRouter::new() + .routes(routes!(register_for_group)) + .layer(DefaultBodyLimit::max(MAX_UPLOAD_BODY_BYTES)), + ) } /// Register a downloadable artifact for a version or version range. @@ -48,6 +146,8 @@ pub fn routes() -> OpenApiRouter { ("version" = String, Path, description = "Exact semver (e.g. `2.10.5`) or range pattern (e.g. `2.10.x`, `^2.10.0`)."), ("artifact_type" = String, Path), ("platform" = String, Path), + ("group" = Option, Query, description = "Group the artifact is for. A releaser credential carries no authorisation for any group, so naming one here is refused."), + ("digest" = Option, Query, description = "Subresource Integrity digest of the bytes at the URL, e.g. `sha256-LCTbqp…`. Whoever fetches the artifact checks what it got against this; an artifact registered without one is fetched unchecked."), ), request_body(content = String, description = "Download URL for the artifact, as a plain-text body."), responses( @@ -62,23 +162,42 @@ async fn create( device: ReleaserDevice, State(db): State, Path((version, artifact_type, platform)): Path<(String, String, String)>, + Query(named): Query, + headers: axum::http::HeaderMap, url: String, ) -> Result> { use node_semver::{Range, Version as SemverVersion}; + // A releaser registers unscoped artifacts and carries no authorisation for + // any group, so the group-scoped path is not reachable from this endpoint + // at all rather than being refused per group. + // spec: ART#registration + if named.group.is_some() { + return Err(AppError::AuthInsufficientPermissions { + required: "authorisation for the named group".into(), + }); + } + + // Settled before the draft version below is written: a body that is no + // location is refused inside `Artifact::register`, by which point the + // version named by a registration that came to nothing exists. + let digest = parse_sri_opt(named.digest.as_deref())?; + let url = location(Some(url)).ok_or_else(|| { + AppError::BadRequest("an artifact needs a download URL or a group".into()) + })?; + let mut db = db.get().await?; let device_id = device.0.0.id; - // Try to parse as a specific version first - if let Ok(semver) = SemverVersion::parse(&version) { - // It's a specific version (e.g., "1.0.5") + let (version_id, version_range_pattern) = if let Ok(semver) = SemverVersion::parse(&version) { let version_str = VersionStr(semver); - // Try to get the version, or create it as a draft if it doesn't exist + // The version an artifact names may not exist yet: it is created as a + // draft so the artifact has something to attach to, and publishing it + // stays a separate step. let version_id = match Version::get_by_version(&mut db, version_str.clone()).await { Ok(version) => version.id, Err(_) => { - // Version doesn't exist, create it as a draft let new_version = NewVersion { major: version_str.0.major as _, minor: version_str.0.minor as _, @@ -88,52 +207,236 @@ async fn create( device_id: Some(device_id), }; - let version = diesel::insert_into(database::schema::versions::table) + diesel::insert_into(database::schema::versions::table) .values(new_version) .returning(Version::as_select()) - .get_result(&mut db) - .await?; - - version.id + .get_result::(&mut db) + .await? + .id } }; - let input = NewArtifact { - version_id: Some(version_id), + (Some(version_id), None) + } else { + Range::parse(&version).map_err(|_| AppError::custom("Invalid version or version range"))?; + + (None, Some(version.clone())) + }; + + let row = ArtifactRow::register( + &mut db, + NewArtifact { + id: None, + version_id, platform, artifact_type, - download_url: url, + download_url: Some(url), device_id: Some(device_id), - version_range_pattern: None, - }; + version_range_pattern, + group_id: None, + content_type: None, + digest, + run_id: None, + }, + ) + .await?; - let artifact = diesel::insert_into(database::schema::artifacts::table) - .values(input) - .returning(Artifact::as_select()) - .get_result(&mut db) - .await?; + let base = crate::versions::public_base_url(&headers); + Ok(Json(Artifact::offered(row, &base, &version))) +} - Ok(Json(artifact)) - } else { - // Try to parse as a range (e.g., "1.0.x", "^1.0.0") - Range::parse(&version) - .map_err(|_| commons_errors::AppError::custom("Invalid version or version range"))?; +/// 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(state): 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 input = NewArtifact { - version_id: None, - platform, - artifact_type, - download_url: url, - device_id: Some(device_id), - version_range_pattern: Some(version), - }; + let mut db = state.db.get().await?; + let device_id = device.0.id; - let artifact = diesel::insert_into(database::schema::artifacts::table) - .values(input) - .returning(Artifact::as_select()) - .get_result(&mut db) - .await?; + // What a schema builder is authorised for is the artifact its declaration + // 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 + || 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(), + }); + } - Ok(Json(artifact)) + 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); + + // 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 digest = digest_of(&body); + + let store = state + .artifacts + .as_ref() + .ok_or_else(commons_servers::artifact_store::unconfigured)?; + + let input = NewArtifact { + id: None, + version_id: Some(version_row.id), + platform, + artifact_type, + download_url: None, + device_id: Some(device_id), + version_range_pattern: None, + group_id: Some(group), + content_type, + digest: Some(digest), + run_id: named.run, + }; + + // Under the id the artifact already has where one is registered, so a rebuild + // lands where the build it replaces was. + // spec: ART#where-an-artifact-rests + let existing = ArtifactRow::id_for_identity(&mut db, &input).await?; + let artifact_id = existing.unwrap_or_else(Uuid::new_v4); + store.put(artifact_id, Vec::from(body)).await?; + + let row = match ArtifactRow::register( + &mut db, + NewArtifact { + id: Some(artifact_id), + ..input + }, + ) + .await + { + Ok(row) => row, + Err(refusal) => { + // Only bytes put under an id minted here are dropped: under one + // already registered they are the live artifact's. + if existing.is_none() + && let Err(err) = store.delete(artifact_id).await + { + tracing::error!(artifact = %artifact_id, "refused registration left its bytes: {err}"); + } + return Err(refusal); + } + }; + + let base = crate::versions::public_base_url(&headers); + Ok(Json(Artifact::offered(row, &base, &version))) +} + +/// 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 Subresource Integrity digest whoever registers it records, where + /// they record one. An unscoped artifact is fetched from its location by + /// the caller rather than by Canopy, so this is what that caller checks + /// against. + // spec: ART#digests + digest: Option, } diff --git a/crates/public-server/src/restore.rs b/crates/public-server/src/restore.rs index 9836157a9..15fdb5478 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, }, @@ -182,6 +183,47 @@ 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, + 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 { + 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_of_members(conn, group_id, &members).await?; + let settlement = + database::reporting_schemas::Settlement::for_group(conn, group_id, &versions, ranges) + .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: @@ -238,6 +280,16 @@ 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(); + // 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(); + 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. @@ -290,11 +342,108 @@ 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 { + // 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. + if d.redacts { + 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. + // spec: RST#the-masking-manifest + let params = if owns_masking { + masked_params(¶ms, None) + } else { + params.clone() + }; + + if !schema_groups.contains_key(&d.group_id) { + // 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 { + continue; + }; + + let latest = snapshots.get(&(group.machine_id, d.r#type.clone())); + + for version in &group.versions { + if !pairs.insert((d.group_id, version.id)) { + continue; + } + + if once && group.settlement.settled(version.id) { + 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: 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(), + 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 +808,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, @@ -752,6 +905,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 { @@ -869,8 +1042,52 @@ 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)) => { + // 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 + // 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, + artifact_ids: build.artifacts, + }, + ) + .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) @@ -882,7 +1099,7 @@ async fn verification( ) .await?; } - None => { + (None, None) => { BackupRestoreCheck::record_report(&mut conn, report).await?; } } @@ -913,6 +1130,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 diff --git a/crates/public-server/src/state.rs b/crates/public-server/src/state.rs index 26a388a48..bf223c642 100644 --- a/crates/public-server/src/state.rs +++ b/crates/public-server/src/state.rs @@ -48,6 +48,11 @@ pub struct AppState { /// Kube client for reading repo-password Secrets in canopy's namespace. /// `None` in tests / non-cluster runs ⇒ `GET /backup-target` returns 502. pub kube: Option, + /// Where the bytes of the artifacts Canopy holds rest. `None` where no + /// bucket is configured, in which case serving one reports that rather than + /// the edge failing to start. + // spec: ART#where-an-artifact-rests + pub artifacts: Option, /// The DNS zones Canopy may write records in, from its instance /// configuration. Empty when none are configured, in which case no name can /// be acted on — read once at startup, so a change takes effect on restart. @@ -102,6 +107,7 @@ impl AppState { let mut state = Self::from_db(database::init())?; state.sts = Some(Self::init_sts().await); state.kube = Self::init_kube().await; + state.artifacts = commons_servers::artifact_store::ArtifactStore::try_default().await; Ok(state) } @@ -142,6 +148,7 @@ impl AppState { rate_limiter: crate::ratelimit::RateLimiter::default(), sts: None, kube: None, + artifacts: None, dns_zones: dns_zones_from_env(), }) } @@ -157,11 +164,13 @@ impl AppState { tailnet_directory: Option, sts: Option, kube: Option, + artifacts: Option, ) -> Result { Ok(Self { client_cert_header: commons_servers::device_auth::mtls::ClientCertHeader::from_env(), sts, kube, + artifacts, ..Self::from_db_with_directory(db, tailnet_directory)? }) } diff --git a/crates/public-server/src/versions.rs b/crates/public-server/src/versions.rs index 3dbc9961c..50fc5c1e8 100644 --- a/crates/public-server/src/versions.rs +++ b/crates/public-server/src/versions.rs @@ -8,17 +8,17 @@ use axum::{ Json, body::{Body, Bytes}, extract::{Path, State}, - http::header, + http::{StatusCode, header}, response::IntoResponse, routing::{Router, get}, }; use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; -use commons_servers::device_auth::{AdminDevice, ReleaserDevice}; +use commons_servers::device_auth::{AdminDevice, AuthDevice, ReleaserDevice}; use commons_types::version::{VersionRange, VersionStr}; use database::{ Db, - artifacts::Artifact, + artifacts::{Artifact as ArtifactRow, Scope}, version_known_issues::VersionKnownIssue, versions::{NewVersion, Version, ViewVersion}, }; @@ -36,7 +36,10 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "ui")] use tera::{Context, Tera}; -use crate::state::AppState; +use crate::{ + artifacts::{Artifact, caller_scope}, + state::AppState, +}; /// Drop versions that any known issue's range still covers. The public /// site never serves these — the admin UI shows them, but clients only @@ -85,6 +88,26 @@ async fn latest_matching_ready( .ok_or(AppError::NoMatchingVersions) } +/// The version a read names. +/// +/// An exact version is answered for itself: a known issue says a version is not +/// one to move to, which is a fact about where a fleet should go rather than +/// about what is published for where it already is. A range is a question about +/// where to go, so it resolves to the latest ready version it covers. +// spec: ART#what-a-version-offers +async fn version_named(conn: &mut AsyncPgConnection, named: &str) -> Result { + if let Ok(exact) = node_semver::Version::parse(named) { + let version = + Version::get_by_version(conn, commons_types::version::VersionStr(exact)).await?; + if version.status != commons_types::version::VersionStatus::Published { + return Err(AppError::NoMatchingVersions); + } + return Ok(version); + } + + latest_matching_ready(conn, VersionRange::from_str(named)?.0).await +} + pub fn routes() -> OpenApiRouter { let api = OpenApiRouter::new() .routes(routes!(list)) @@ -170,12 +193,11 @@ async fn list(State(db): State) -> Result>> { Ok(Json(versions)) } -/// Base URL for absolute links in the feed. Prefers the configured -/// `PUBLIC_URL`; otherwise reconstructs the origin from the request's -/// forwarded scheme and `Host` header so local and test runs still emit -/// well-formed links. -#[cfg(feature = "ui")] -fn feed_base_url(headers: &axum::http::HeaderMap) -> String { +/// Base URL for absolute links Canopy emits about itself. Prefers the +/// configured `PUBLIC_URL`; otherwise reconstructs the origin from the +/// request's forwarded scheme and `Host` header so local and test runs still +/// emit well-formed links. +pub(crate) fn public_base_url(headers: &axum::http::HeaderMap) -> String { if let Ok(url) = std::env::var("PUBLIC_URL") { let trimmed = url.trim_end_matches('/'); if !trimmed.is_empty() { @@ -211,7 +233,7 @@ async fn releases_rss( let versions = Version::get_all(&mut db).await?; let versions = filter_ready(&mut db, versions).await?; - let base = feed_base_url(&headers); + let base = public_base_url(&headers); let items: Vec = versions .into_iter() @@ -379,6 +401,7 @@ async fn view_artifacts( Path(version): Path, State(db): State, State(tera): State>, + headers: axum::http::HeaderMap, ) -> Result> { use commons_types::version::VersionStatus; use diesel::QueryDsl; @@ -405,7 +428,7 @@ async fn view_artifacts( let version = VersionRange::from_str(&version)?; let mut version = latest_matching_ready(&mut db, version.0).await?; version.changelog = parse_markdown(&version.changelog); - let artifacts = Artifact::get_for_version(&mut db, version.id).await?; + let artifacts = offered_artifacts(&mut db, &version, Scope::Unscoped, &headers).await?; // The latest *ready* version in this minor. The page's own version came // through `latest_matching_ready`, so the banner has to use the same set: @@ -504,15 +527,35 @@ async fn view_artifacts( ), )] async fn list_artifacts( + device: Option, Path(version): Path, State(db): State, + headers: axum::http::HeaderMap, ) -> Result>> { let mut db = db.get().await?; - let version = VersionRange::from_str(&version)?; - let version = latest_matching_ready(&mut db, version.0).await?; - let artifacts = Artifact::get_for_version(&mut db, version.id).await?; + let version = version_named(&mut db, &version).await?; + let scope = caller_scope(&mut db, device).await?; - Ok(Json(artifacts)) + Ok(Json( + offered_artifacts(&mut db, &version, scope, &headers).await?, + )) +} + +/// The artifacts of a version as `scope` is offered them. +async fn offered_artifacts( + db: &mut AsyncPgConnection, + version: &Version, + scope: Scope, + headers: &axum::http::HeaderMap, +) -> Result> { + let base = public_base_url(headers); + let shown = version.as_semver().to_string(); + + Ok(ArtifactRow::get_for_version(db, version.id, scope) + .await? + .into_iter() + .map(|row| Artifact::offered(row, &base, &shown)) + .collect()) } #[cfg(feature = "ui")] @@ -520,11 +563,12 @@ async fn view_mobile_install( Path(version): Path, State(db): State, State(tera): State>, + headers: axum::http::HeaderMap, ) -> Result> { let mut db = db.get().await?; let version = VersionRange::from_str(&version)?; let version = latest_matching_ready(&mut db, version.0).await?; - let artifacts = Artifact::get_for_version(&mut db, version.id) + let artifacts = offered_artifacts(&mut db, &version, Scope::Unscoped, &headers) .await? .into_iter() .filter(|a| a.artifact_type == "mobile") @@ -614,29 +658,93 @@ async fn update_for( } async fn download_artifact( - State(db): State, + device: Option, + State(state): State, Path((version, artifact_id)): Path<(String, String)>, ) -> Result { use uuid::Uuid; - let mut db = db.get().await?; - let version = VersionRange::from_str(&version)?; - let version = latest_matching_ready(&mut db, version.0).await?; + let mut db = state.db.get().await?; + let version = version_named(&mut db, &version).await?; + let scope = caller_scope(&mut db, device).await?; - let artifact_uuid = - Uuid::parse_str(&artifact_id).map_err(|_| AppError::custom("Invalid artifact ID"))?; + let artifact_uuid = Uuid::parse_str(&artifact_id) + .map_err(|_| AppError::BadRequest("Invalid artifact ID".into()))?; - let artifacts = Artifact::get_for_version(&mut db, version.id).await?; - let artifact = artifacts - .into_iter() - .find(|a| a.id == artifact_uuid) - .ok_or_else(|| AppError::custom("Artifact not found for this version"))?; + // The boundary is what the caller may see, not what it is currently offered: + // a URL Canopy handed out keeps working after a more specific artifact of + // the same type and platform is registered. An artifact this caller may not + // see is missing in exactly the way one that never existed is. + // spec: ART#who-is-offered-a-group-scoped-artifact + let artifact = ArtifactRow::of_version(&mut db, artifact_uuid, &version, scope) + .await? + .ok_or(AppError::ArtifactNotFound)?; + + // An artifact with no location of its own is one Canopy holds. + // spec: ART#where-an-artifact-rests + if artifact.download_url.is_none() { + let store = state + .artifacts + .as_ref() + .ok_or_else(commons_servers::artifact_store::unconfigured)?; + let (Some(bytes), Some(recorded)) = + (store.get(artifact.id).await?, artifact.digest.clone()) + else { + return Err(AppError::ArtifactNotFound); + }; + + // The artifact is here in memory whole, which registration caps, and + // hashing it is tens of milliseconds with no await in it: a fleet + // fetching one schema at once would spend that on the runtime's own + // threads. + let bytes = tokio::task::spawn_blocking(move || { + (database::artifacts::digest_of(&bytes) == recorded).then_some(bytes) + }) + .await + .map_err(|err| AppError::custom(format!("verifying the artifact failed: {err}")))?; + + let Some(bytes) = bytes else { + tracing::error!( + artifact = %artifact.id, + "held artifact does not match its digest; refusing to serve" + ); + return Err(AppError::ArtifactDigestMismatch); + }; + + let content_type = artifact + .content_type + .unwrap_or_else(|| "application/octet-stream".to_owned()); + + // Bytes are served from the same origin as this server's own HTML pages, + // and the media type is whatever was registered or fetched, so nothing + // this endpoint answers may be rendered by a browser. + return Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, content_type), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", artifact.id), + ), + ( + header::HeaderName::from_static("x-content-type-options"), + "nosniff".to_owned(), + ), + ], + Body::from(bytes), + ) + .into_response()); + } + + let Some(download_url) = artifact.download_url else { + return Err(AppError::ArtifactNotFound); + }; let client = reqwest::Client::builder() .build() .map_err(|err| AppError::custom(format!("failed to build HTTP client: {err}")))?; let response = client - .get(&artifact.download_url) + .get(&download_url) .send() .await .map_err(|err| AppError::custom(format!("Failed to download artifact: {err}")))?; @@ -651,5 +759,17 @@ async fn download_artifact( let body = Body::from_stream(response.bytes_stream()); - Ok((status, [(header::CONTENT_TYPE, content_type)], body).into_response()) + Ok(( + status, + [ + (header::CONTENT_TYPE, content_type), + (header::CONTENT_DISPOSITION, "attachment".to_owned()), + ( + header::HeaderName::from_static("x-content-type-options"), + "nosniff".to_owned(), + ), + ], + body, + ) + .into_response()) } diff --git a/crates/public-server/tests/it/artifact_scopes.rs b/crates/public-server/tests/it/artifact_scopes.rs new file mode 100644 index 000000000..958c8f3fe --- /dev/null +++ b/crates/public-server/tests/it/artifact_scopes.rs @@ -0,0 +1,992 @@ +//! A group-scoped artifact reaches its own group's machines and nobody else. +//! +//! spec: ART + +use axum::http::StatusCode; +use database::artifacts::{digest_of, sri}; +use diesel_async::SimpleAsyncConnection; + +const VERSION: &str = "11111111-1111-1111-1111-111111111111"; +const UNSCOPED: &str = "22222222-2222-2222-2222-222222222222"; +const THEIRS: &str = "33333333-3333-3333-3333-333333333333"; +const GROUP_A: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const GROUP_B: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + +/// One published version, two groups, and a `reporting-schema` artifact for +/// each of the unscoped and group-A cases. +async fn seed(conn: &mut database::diesel_async::AsyncPgConnection) { + let digest = hex::encode(digest_of(b"group a schema")); + conn.batch_execute(&format!( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('{VERSION}', 2, 60, 0, '', 'published'); + + INSERT INTO server_groups (id, name) VALUES + ('{GROUP_A}', 'kamaka'), ('{GROUP_B}', 'drifting'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('{UNSCOPED}', '{VERSION}', 'any', 'reporting-schema', 'https://example.com/all.sql'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, group_id, content_type, digest) + VALUES ('{THEIRS}', '{VERSION}', 'any', 'reporting-schema', '{GROUP_A}', + 'application/sql', '\\x{digest}'::bytea)", + )) + .await + .expect("seed"); + + hold(conn, THEIRS, b"group a schema").await; +} + +/// Put an artifact's bytes where Canopy holds them, as a registration would. +async fn hold(conn: &mut database::diesel_async::AsyncPgConnection, artifact: &str, bytes: &[u8]) { + commons_tests::server::artifacts(conn) + .await + .put(artifact.parse().expect("an artifact id"), bytes.to_vec()) + .await + .expect("hold the bytes"); +} + +/// Put the authenticated device on a machine in the given group. +async fn enrol( + conn: &mut database::diesel_async::AsyncPgConnection, + device_id: uuid::Uuid, + group: &str, +) { + conn.batch_execute(&format!( + "INSERT INTO machines (name, group_id, device_id) + VALUES ('box', '{group}', '{device_id}')" + )) + .await + .expect("enrol machine"); +} + +/// A read carrying no identity is answered with the unscoped artifacts alone, +/// so giving an artifact a group narrows who is offered it rather than +/// widening what an open path serves. +#[tokio::test(flavor = "multi_thread")] +async fn an_anonymous_read_sees_only_unscoped_artifacts() { + commons_tests::server::run(async |mut conn, public, _| { + seed(&mut conn).await; + + let response = public.get("/versions/2.60.0/artifacts").await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + }) + .await +} + +/// A caller whose credential is bound to a machine has that machine's group, +/// and the artifact scoped to it displaces the unscoped one of the same type +/// and platform. +#[tokio::test(flavor = "multi_thread")] +async fn a_machine_is_offered_its_own_group_s_artifact() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + assert_eq!(artifacts.len(), 1, "never offered both"); + assert_eq!(artifacts[0]["id"], THEIRS); + + // A caller is offered one artifact per type and platform. Which + // group it belongs to is how Canopy chose it, not part of what + // the caller is told. + // spec: ART#what-a-version-offers + assert!( + artifacts[0].get("group_id").is_none(), + "the scope is not disclosed, but got {}", + artifacts[0] + ); + }, + ) + .await +} + +/// A machine in another group reaches the unscoped artifact, and the one it is +/// not offered is answered as though it did not exist. The refusal is the same +/// one an artifact id that was never registered gets, so which groups hold an +/// artifact is not enumerable here. +#[tokio::test(flavor = "multi_thread")] +async fn another_group_cannot_tell_the_artifact_apart_from_a_missing_one() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_B).await; + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED, "not group A's"); + + // The one it is not offered, by its real id. + let refused = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + // An id that was never registered at all. + let absent = public + .get("/versions/2.60.0/artifacts/99999999-9999-9999-9999-999999999999/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!( + refused.status_code(), + absent.status_code(), + "an artifact held for another group must answer exactly as a missing one does" + ); + assert_eq!(refused.status_code(), StatusCode::NOT_FOUND); + assert_eq!(refused.text(), absent.text(), "and say the same thing"); + }, + ) + .await +} + +/// Canopy serves the bytes it holds to a caller the artifact is offered to. +#[tokio::test(flavor = "multi_thread")] +async fn the_owning_group_is_served_the_held_bytes() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + response.assert_status_ok(); + assert_eq!(response.text(), "group a schema"); + }, + ) + .await +} + +/// Canopy verifies the bytes it holds against the recorded digest as it serves +/// them, so a corrupted artifact fails the read rather than reaching a server +/// as the artifact it is not. +#[tokio::test(flavor = "multi_thread")] +async fn corrupted_bytes_fail_the_read() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + hold(&mut conn, THEIRS, b"tampered").await; + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!response.text().contains("tampered")); + }, + ) + .await +} + +/// An artifact whose bytes are not where Canopy holds them is missing in +/// exactly the way one that never existed is: the read is refused rather than +/// answered with an empty file, and the caller learns nothing from which of the +/// two it was. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn an_artifact_whose_bytes_are_gone_is_missing() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + commons_tests::server::artifacts(&mut conn) + .await + .delete(THEIRS.parse().expect("an artifact id")) + .await + .expect("drop the bytes"); + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let absent = public + .get("/versions/2.60.0/artifacts/99999999-9999-9999-9999-999999999999/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); + assert_eq!(response.text(), absent.text()); + }, + ) + .await +} + +/// A releaser credential carries no authorisation for any group, so a +/// registration naming one is refused. +#[tokio::test(flavor = "multi_thread")] +async fn a_releaser_cannot_register_for_a_group() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let response = public + .post(&format!( + "/artifacts/groups/{GROUP_A}/2.60.0/reporting-schema/any" + )) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.sql") + .await; + + assert_eq!(response.status_code(), StatusCode::FORBIDDEN); + + // The same registration without a group is accepted. + let response = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + response.assert_status_ok(); + }, + ) + .await +} + +/// The same read over the client-certificate header the live ingress sets. +/// Every other test here runs the Envoy path the harness selects by default, +/// which is not what is deployed. +#[tokio::test(flavor = "multi_thread")] +async fn the_owning_group_is_served_over_the_nginx_header() { + commons_tests::server::run_with_device_auth_on( + commons_servers::device_auth::mtls::ClientCertHeader::Mtls, + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("mtls-certificate", &cert) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], THEIRS); + }, + ) + .await +} + +/// The listing hands out a URL that fetches the bytes, including where the +/// caller named a range rather than the resolved version. Building the path by +/// hand instead leaves the synthesis untested. +#[tokio::test(flavor = "multi_thread")] +async fn the_offered_download_url_fetches_the_bytes() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let response = public + .get("/versions/2.60.x/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + let url = artifacts[0]["download_url"] + .as_str() + .expect("a download url") + .to_owned(); + + assert!( + url.ends_with(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")), + "names the resolved version, not the range asked for: {url}" + ); + + let path = url.split_once("://").map_or(url.as_str(), |(_, rest)| { + rest.split_once('/').map_or("", |(_, path)| path) + }); + let fetched = public + .get(&format!("/{path}")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + fetched.assert_status_ok(); + assert_eq!(fetched.text(), "group a schema"); + }, + ) + .await +} + +/// An artifact of another version is missing in the same way one held for +/// another group is, so the three refusals a caller can provoke are not +/// distinguishable from each other. +#[tokio::test(flavor = "multi_thread")] +async fn an_artifact_of_another_version_is_refused_identically() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute( + "INSERT INTO versions (id, major, minor, patch, changelog, status) + VALUES ('44444444-4444-4444-4444-444444444444', 2, 59, 0, '', 'published'); + + INSERT INTO artifacts (id, version_id, platform, artifact_type, download_url) + VALUES ('55555555-5555-5555-5555-555555555555', + '44444444-4444-4444-4444-444444444444', 'any', 'installer', + 'https://example.com/old.exe')", + ) + .await + .expect("seed another version"); + + let elsewhere = public + .get("/versions/2.60.0/artifacts/55555555-5555-5555-5555-555555555555/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let absent = public + .get("/versions/2.60.0/artifacts/99999999-9999-9999-9999-999999999999/download") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(elsewhere.status_code(), StatusCode::NOT_FOUND); + assert_eq!(elsewhere.status_code(), absent.status_code()); + assert_eq!(elsewhere.text(), absent.text()); + }, + ) + .await +} + +/// A device Canopy can place but which sits on no machine, and one on a machine +/// with no group, are both answered as an anonymous caller is rather than +/// refused. +#[tokio::test(flavor = "multi_thread")] +async fn a_device_with_no_group_is_answered_anonymously() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + + // No machine at all. + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + + // On a machine, but the machine belongs to no group. + conn.batch_execute(&format!( + "INSERT INTO machines (name, device_id) VALUES ('ungrouped', '{device_id}')" + )) + .await + .expect("enrol without a group"); + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + response.assert_status_ok(); + let artifacts: Vec = response.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["id"], UNSCOPED); + }, + ) + .await +} + +/// The public pages and the release feed are read by anyone, so they resolve +/// unscoped whoever asks. A caller presenting the owning group's credential +/// still sees no trace of the artifact Canopy holds for it. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn the_public_pages_never_carry_a_group_s_artifact() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + for path in [ + "/versions/2.60.0", + "/versions/2.60.0/mobile", + "/versions/rss", + ] { + let response = public + .get(path) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + // A page that did not render carries no artifact either, which + // would pass the assertions below for the wrong reason. + response.assert_status_ok(); + let body = response.text(); + assert!( + !body.contains(THEIRS), + "{path} names the artifact held for group A" + ); + assert!( + !body.contains("group a schema"), + "{path} carries the bytes held for group A" + ); + } + }, + ) + .await +} + +/// A corrupted artifact fails the read as itself, so an operator reading the +/// problem type is told the bytes no longer match rather than being left with +/// an unclassified fault. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn a_digest_mismatch_says_what_it_is() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + hold(&mut conn, THEIRS, b"tampered").await; + + let response = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + let problem: serde_json::Value = response.json(); + assert_eq!(problem["type"], "/errors/artifact-digest-mismatch"); + }, + ) + .await +} + +/// A registration through the endpoint replaces what stood for the same version, +/// type and platform, so a caller is never offered two of a kind. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn registering_again_over_the_wire_replaces() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let first = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/first.exe") + .await; + first.assert_status_ok(); + let first: serde_json::Value = first.json(); + + let second = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/second.exe") + .await; + second.assert_status_ok(); + let second: serde_json::Value = second.json(); + + assert_eq!(first["id"], second["id"], "replaced in place"); + assert_eq!(second["download_url"], "https://example.com/second.exe"); + + let listed = public.get("/versions/2.60.0/artifacts").await; + let artifacts: Vec = listed.json(); + assert_eq!( + artifacts + .iter() + .filter(|a| a["artifact_type"] == "installer") + .count(), + 1, + ); + }, + ) + .await +} + +/// Deactivating a key stops it reading. A credential that is presented and +/// rejected fails rather than losing its identity and being served the unscoped +/// set, which would leave a revoked key reading for as long as it is presented. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn a_deactivated_key_is_refused_rather_than_read_as_anonymous() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute(&format!( + "UPDATE device_keys SET is_active = false WHERE device_id = '{device_id}'" + )) + .await + .expect("deactivate the key"); + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + + // Registering with the same credential is refused too. + let refused = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + assert_eq!(refused.status_code(), StatusCode::UNAUTHORIZED); + }, + ) + .await +} + +/// The media type a registration recorded is what the bytes are served as, and +/// an artifact registered without one is served as opaque bytes rather than +/// guessed at. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn held_bytes_are_served_as_the_type_they_were_registered_with() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + let typed = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + typed.header("content-type").to_str().unwrap(), + "application/sql" + ); + + conn.batch_execute(&format!( + "UPDATE artifacts SET content_type = NULL WHERE id = '{THEIRS}'" + )) + .await + .expect("drop the media type"); + + let untyped = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + untyped.header("content-type").to_str().unwrap(), + "application/octet-stream" + ); + }, + ) + .await +} + +/// Held bytes come off the same origin as this server's own HTML pages, and +/// the media type is whatever the registration named, so a schema registered +/// as `text/html` would otherwise render as same-origin content. +// spec: ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn held_bytes_are_never_rendered_by_a_browser() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + conn.batch_execute(&format!( + "UPDATE artifacts SET content_type = 'text/html' WHERE id = '{THEIRS}'" + )) + .await + .expect("set the media type"); + + let served = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!( + served.header("x-content-type-options").to_str().unwrap(), + "nosniff" + ); + assert!( + served + .header("content-disposition") + .to_str() + .unwrap() + .starts_with("attachment"), + "held bytes are downloaded, not displayed" + ); + }, + ) + .await +} + +/// An operator device registers either kind, and needs no declaration to name a +/// group: the authorisation a builder holds is what a component has instead of +/// being an operator, not a narrower form of it. +// spec: ART#registration +#[tokio::test(flavor = "multi_thread")] +async fn an_admin_device_registers_for_any_group() { + commons_tests::server::run_with_device_auth( + "admin", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let scoped = public + .post(&format!( + "/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") + .text("CREATE VIEW ...") + .await; + scoped.assert_status_ok(); + let scoped: serde_json::Value = scoped.json(); + assert!( + scoped["digest"].is_string(), + "the bytes are held, so Canopy digests them" + ); + + let accepted = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + accepted.assert_status_ok(); + }, + ) + .await +} + +/// A registration carrying no location, and one naming a group that is not a +/// group at all, are both the registrant's own mistake and are refused as one. +/// A blank body would otherwise pass the constraint, which only tests for NULL, +/// and leave an artifact nothing can be fetched from. +// spec: ART#registration, ART#where-an-artifact-rests +#[tokio::test(flavor = "multi_thread")] +async fn a_registration_with_nothing_in_it_is_refused() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + for body in ["", " "] { + let response = public + .post("/artifacts/2.60.0/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text(body) + .await; + assert_eq!( + response.status_code(), + StatusCode::BAD_REQUEST, + "a body of {body:?} is no location" + ); + } + + let malformed = public + .post("/artifacts/groups/not-a-uuid/2.60.0/reporting-schema/any") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("CREATE VIEW ...") + .await; + assert_eq!( + malformed.status_code(), + StatusCode::BAD_REQUEST, + "a group that is not a uuid is a client mistake, not a 500" + ); + + // The version a registration names is drafted so the artifact has + // something to attach to, so a refusal after that point leaves one + // standing for an artifact that was never registered. + let unknown = public + .post("/artifacts/9.9.9/installer/windows") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text(" ") + .await; + assert_eq!(unknown.status_code(), StatusCode::BAD_REQUEST); + assert!( + database::versions::Version::get_by_version( + &mut conn, + "9.9.9".parse().expect("a version"), + ) + .await + .is_err(), + "a refused registration drafts no version" + ); + + // Nothing was written by any of them. + let listed = public.get("/versions/2.60.0/artifacts").await; + let artifacts: Vec = listed.json(); + assert!( + !artifacts.iter().any(|a| a["artifact_type"] == "installer"), + "nothing blank or malformed was registered" + ); + }, + ) + .await +} + +/// Archiving a machine takes its group with it. The device is unbound and its +/// keys deactivated in one transaction, so the credential that was offered the +/// group's artifact stops reading rather than keeping the group the box used to +/// be in or falling back to the unscoped set. +// spec: ART#who-is-offered-a-group-scoped-artifact, FLT#archival +#[tokio::test(flavor = "multi_thread")] +async fn an_archived_machine_s_credential_stops_reading() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + const MACHINE: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + seed(&mut conn).await; + conn.batch_execute(&format!( + "INSERT INTO machines (id, name, group_id, device_id) + VALUES ('{MACHINE}', 'box', '{GROUP_A}', '{device_id}')" + )) + .await + .expect("enrol machine"); + + let before = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + let artifacts: Vec = before.json(); + assert_eq!(artifacts[0]["id"], THEIRS, "its own group's, to start with"); + + database::machines::Machine::archive(&mut conn, MACHINE.parse().unwrap()) + .await + .expect("archive"); + + let after = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!(after.status_code(), StatusCode::UNAUTHORIZED); + + let refused = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + refused.status_code(), + StatusCode::UNAUTHORIZED, + "and the bytes it used to be served are out of reach" + ); + }, + ) + .await +} + +/// A read that serves everyone downgrades a credential it cannot place to +/// anonymous. A fault reaching that decision is not a credential it cannot +/// place: answered anonymously it hands a machine that has a group the +/// unscoped set and presents it as that machine's answer. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn a_fault_placing_a_credential_is_not_anonymity() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + // Resolving an identity reads device_keys. Taking the table away is + // the only way from here to make that read fail rather than miss. + conn.batch_execute("DROP TABLE device_keys CASCADE") + .await + .expect("drop device_keys"); + + let response = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + + assert_eq!( + response.status_code(), + StatusCode::INTERNAL_SERVER_ERROR, + "a caller that cannot be placed is told so, not answered as nobody" + ); + }, + ) + .await +} + +/// An unscoped artifact is read from its location by the caller rather than by +/// Canopy, so its digest is what that caller checks what it fetched against. +/// A releaser records one where it has one, and an artifact registered without +/// one is fetched unchecked rather than fetched against a blank. +// spec: ART#digests +#[tokio::test(flavor = "multi_thread")] +async fn a_releaser_records_the_digest_it_publishes() { + commons_tests::server::run_with_device_auth( + "releaser", + async |mut conn, cert, _device_id, public, _| { + seed(&mut conn).await; + + let claimed = sri(&digest_of(b"the installer")); + let recorded = public + .post("/artifacts/2.60.0/installer/windows") + .add_query_param("digest", &claimed) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.exe") + .await; + recorded.assert_status_ok(); + let recorded: serde_json::Value = recorded.json(); + assert_eq!(recorded["digest"], claimed); + + for query in ["", "?digest=", "?digest=%20%20"] { + let response = public + .post(&format!("/artifacts/2.60.0/installer/linux{query}")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.deb") + .await; + response.assert_status_ok(); + let artifact: serde_json::Value = response.json(); + assert!( + artifact["digest"].is_null(), + "a blank digest is no digest, but got {artifact}" + ); + } + + // A digest nothing can check the bytes against is refused rather + // than published to every device that fetches the artifact. + for claimed in ["sha256:abcd", "notadigest", "sha256-abcd"] { + let response = public + .post("/artifacts/2.60.0/installer/macos") + .add_query_param("digest", claimed) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .text("https://example.com/x.dmg") + .await; + assert_eq!( + response.status_code(), + StatusCode::BAD_REQUEST, + "{claimed:?} is not a digest" + ); + } + }, + ) + .await +} + +/// A device is offered the artifacts of the version it reports running, and a +/// known issue on that version does not withhold them. A known issue says a +/// version is not one to move to, and the versions carrying one are exactly the +/// ones a fleet is still sitting on and still needs a schema for. A range is a +/// question about where to go, so it still resolves past the version. +// spec: ART#what-a-version-offers +#[tokio::test(flavor = "multi_thread")] +async fn a_known_issue_does_not_withhold_a_version_s_own_artifacts() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + // An open known issue has no upper bound, so it covers 2.60.0 and + // every later patch of that line. + conn.batch_execute( + "INSERT INTO version_known_issues + (author, description, min_major, min_minor, min_patch) + VALUES ('admin', 'broken', 2, 60, 0)", + ) + .await + .expect("seed a known issue over 2.60.0"); + + let offered = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + offered.assert_status_ok(); + let artifacts: Vec = offered.json(); + assert_eq!(artifacts.len(), 1); + assert_eq!( + artifacts[0]["id"], THEIRS, + "the group's own schema for the version it is on" + ); + + let bytes = public + .get(&format!("/versions/2.60.0/artifacts/{THEIRS}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + bytes.assert_status_ok(); + assert_eq!(bytes.text(), "group a schema"); + + // The same known issue still keeps a range off that version. + let ranged = public + .get("/versions/2.60.x/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + assert_eq!( + ranged.status_code(), + StatusCode::NOT_FOUND, + "a range has nothing ready to land on" + ); + }, + ) + .await +} + +/// A URL Canopy handed out keeps working after a more specific artifact of the +/// same type and platform is registered. Fetchability is what a caller may see, +/// not which artifact currently wins the offer. +// spec: ART#who-is-offered-a-group-scoped-artifact +#[tokio::test(flavor = "multi_thread")] +async fn a_displaced_artifact_is_still_fetchable() { + commons_tests::server::run_with_device_auth( + "machine", + async |mut conn, cert, device_id, public, _| { + seed(&mut conn).await; + enrol(&mut conn, device_id, GROUP_A).await; + + // A range artifact of group A's, which group A's exact one displaces. + let ranged = "44444444-4444-4444-4444-444444444444"; + let digest = hex::encode(digest_of(b"the range schema")); + conn.batch_execute(&format!( + "INSERT INTO artifacts + (id, version_id, platform, artifact_type, version_range_pattern, group_id, content_type, digest) + VALUES ('{ranged}', NULL, 'any', 'reporting-schema', '2.60.x', '{GROUP_A}', + 'application/sql', '\\x{digest}'::bytea)" + )) + .await + .expect("seed the range artifact"); + hold(&mut conn, ranged, b"the range schema").await; + + let listed = public + .get("/versions/2.60.0/artifacts") + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + listed.assert_status_ok(); + let artifacts: Vec = listed.json(); + assert_eq!(artifacts.len(), 1, "one artifact per type and platform"); + assert_eq!(artifacts[0]["id"], THEIRS, "the exact one is offered"); + + // The one specificity passed over is still fetchable by the URL it + // was offered under before. + let download = public + .get(&format!("/versions/2.60.0/artifacts/{ranged}/download")) + .add_header("x-forwarded-client-cert", &format!("Cert={cert}")) + .await; + download.assert_status_ok(); + assert_eq!(download.text(), "the range schema"); + }, + ) + .await +} diff --git a/crates/public-server/tests/it/backup.rs b/crates/public-server/tests/it/backup.rs index 7e107bce9..5acac63fe 100644 --- a/crates/public-server/tests/it/backup.rs +++ b/crates/public-server/tests/it/backup.rs @@ -575,6 +575,7 @@ fn public_server_with_sts(url: &str, sts: aws_sdk_sts::Client) -> TestServer { rate_limiter: Default::default(), sts: Some(sts), kube: None, + artifacts: None, dns_zones: Vec::new(), }; let app = router( diff --git a/crates/public-server/tests/it/main.rs b/crates/public-server/tests/it/main.rs index 91cfbc814..959851dc5 100644 --- a/crates/public-server/tests/it/main.rs +++ b/crates/public-server/tests/it/main.rs @@ -4,6 +4,7 @@ // Nextest still runs every #[tokio::test] in parallel as usual. mod application_aliases; +mod artifact_scopes; mod auth_requirements; mod backup; mod backup_secrets; @@ -18,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..59b5fbbf7 --- /dev/null +++ b/crates/public-server/tests/it/reporting_schemas.rs @@ -0,0 +1,1126 @@ +//! 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, publishes_schemas) + VALUES ('{consumer}', '{GROUP}', 'tamanu-postgres', 'schema-build', 'schemas', true, 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 +} + +/// 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; + + // 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, false)" + )) + .await + .expect("a non-publishing 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")] +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 +} + +/// 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 +/// 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. +#[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/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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/groups/{OTHER_GROUP}/2.60.0/reporting-schema/any" + )) + .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/groups/99999999-9999-9999-9999-999999999999/2.60.0/reporting-schema/any", + ) + .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 +} + +/// 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/groups/{GROUP}/2.60.x/reporting-schema/any" + )) + .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); + }, + ) + .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/groups/{GROUP}/9999.0.0/reporting-schema/any" + )) + .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. +#[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/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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 +} + +/// 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/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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 +/// 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/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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/groups/{GROUP}/2.60.0/installer/windows" + )) + .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::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!( + "/artifacts/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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/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") + .text("CREATE VIEW ...") + .await; + assert_eq!(claimed.status_code(), StatusCode::BAD_REQUEST); + + let own = public + .post(&format!( + "/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") + .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}; + 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 +} + +/// 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/groups/{GROUP}/2.60.0/reporting-schema/any" + )) + .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["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::sri(&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 = hex::encode(database::artifacts::digest_of(b"the group's schema")); + conn.batch_execute(&format!( + "INSERT INTO artifacts + (version_id, platform, artifact_type, group_id, content_type, digest) + VALUES ('{VERSION}', 'any', 'reporting-schema', '{GROUP}', + 'application/sql', '\\x{digest}'::bytea); + + 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["version_id"], VERSION); + }, + ) + .await +} diff --git a/crates/public-server/tests/it/versions.rs b/crates/public-server/tests/it/versions.rs index 14d999c6f..a1fbae682 100644 --- a/crates/public-server/tests/it/versions.rs +++ b/crates/public-server/tests/it/versions.rs @@ -509,17 +509,22 @@ async fn artifact_download_proxy() { .await .unwrap(); - // Invalid artifact ID format + // An unreadable artifact id is a client mistake, the same as an + // unreadable version in the path, so it answers 400 rather than + // polluting 5xx monitoring with input a caller controls. let response = public .get("/versions/1.2.3/artifacts/not-a-uuid/download") .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + response.assert_status(StatusCode::BAD_REQUEST); - // Nonexistent artifact + // Nonexistent artifact. 404 rather than an error, because an artifact + // scoped to a group the caller is not offered has to be answered the + // same way, and that answer must not be distinguishable. + // spec: ART#who-is-offered-a-group-scoped-artifact let response = public .get("/versions/1.2.3/artifacts/44444444-4444-4444-4444-444444444444/download") .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + response.assert_status(StatusCode::NOT_FOUND); // Nonexistent version let response = public @@ -570,6 +575,22 @@ async fn artifact_download_proxy_with_mock_server() { .await; response.assert_status_ok(); + // Proxied bytes come off the same origin as this server's own HTML + // pages, carrying an upstream media type, so they are downloaded rather + // than displayed. + // spec: ART#where-an-artifact-rests + assert_eq!( + response.header("x-content-type-options").to_str().unwrap(), + "nosniff" + ); + assert!( + response + .header("content-disposition") + .to_str() + .unwrap() + .starts_with("attachment") + ); + // Verify we got the content let text = response.text(); assert_eq!(text.as_bytes(), test_content); diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql new file mode 100644 index 000000000..916b54cd1 --- /dev/null +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/down.sql @@ -0,0 +1,20 @@ +DROP INDEX artifacts_identity; + +DELETE FROM artifacts WHERE group_id IS NOT NULL; + +ALTER TABLE artifacts ADD CONSTRAINT artifacts_type_platform_version_id UNIQUE ( + artifact_type, platform, version_id +); + +DROP INDEX artifacts_group_id; + +ALTER TABLE artifacts DROP CONSTRAINT artifact_rests_by_scope; +ALTER TABLE artifacts DROP CONSTRAINT artifact_download_url_not_blank; + +ALTER TABLE artifacts + DROP COLUMN group_id, + DROP COLUMN content_type, + DROP COLUMN digest, + DROP COLUMN run_id; + +ALTER TABLE artifacts ALTER COLUMN download_url SET NOT NULL; diff --git a/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql new file mode 100644 index 000000000..5c1b3c9e4 --- /dev/null +++ b/migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql @@ -0,0 +1,69 @@ +-- ── An artifact may belong to a group ─────────────────────────────────────── +-- +-- An artifact belonging to no group is for every group. One that belongs to a +-- group is for that group alone, and Canopy holds its bytes rather than a +-- location, so the boundary is enforced on the read instead of resting on a +-- location being hard to guess. + +ALTER TABLE artifacts + ADD COLUMN group_id UUID REFERENCES server_groups(id) ON DELETE CASCADE, + ADD COLUMN content_type TEXT, + ADD COLUMN digest BYTEA, + ADD COLUMN run_id UUID; + +ALTER TABLE artifacts ALTER COLUMN download_url DROP NOT NULL; + +-- An unscoped artifact rests at a location Canopy records and does not hold; a +-- group-scoped one rests in Canopy's own storage under its id and always +-- carries a digest, which the read verifies the bytes against. +ALTER TABLE artifacts ADD CONSTRAINT artifact_rests_by_scope CHECK ( + (group_id IS NULL + AND download_url IS NOT NULL + AND content_type IS NULL) + OR + (group_id IS NOT NULL + AND download_url IS NULL + AND digest IS NOT NULL) +); + +-- A blank location is no location, and a digest that is not a SHA-256 is +-- nothing the bytes can be checked against. Both are enforced here so a writer +-- that skips the application cannot record one. +ALTER TABLE artifacts ADD CONSTRAINT artifact_download_url_not_blank CHECK ( + download_url IS NULL OR btrim(download_url) <> '' +); + +ALTER TABLE artifacts ADD CONSTRAINT artifact_digest_is_sha256 CHECK ( + digest IS NULL OR octet_length(digest) = 32 +); + +CREATE INDEX artifacts_group_id ON artifacts (group_id); + +-- ── Identity ──────────────────────────────────────────────────────────────── +-- +-- A registration replaces whatever is already registered for the same version +-- or range, type, platform, and group, so that tuple has to be a key to upsert +-- on. The old constraint keyed on version_id alone, which left range artifacts +-- with no uniqueness at all: version_id is NULL for every one of them, and the +-- default treatment of NULL makes those rows all distinct from each other. +-- NULLS NOT DISTINCT is what lets one index cover the exact and range shapes +-- and the grouped and ungrouped ones together. + +ALTER TABLE artifacts DROP CONSTRAINT artifacts_type_platform_version_id; + +-- Range rows had no uniqueness to conflict with, so a repeat registration of +-- one is a second row and the index below cannot be created over the pair. The +-- newest is the registration that would have replaced the others had this key +-- been in force, so that is the one kept. +DELETE FROM artifacts a +USING artifacts b +WHERE a.artifact_type = b.artifact_type + AND a.platform = b.platform + AND a.version_id IS NOT DISTINCT FROM b.version_id + AND a.version_range_pattern IS NOT DISTINCT FROM b.version_range_pattern + AND a.group_id IS NOT DISTINCT FROM b.group_id + AND (a.created_at, a.id) < (b.created_at, b.id); + +CREATE UNIQUE INDEX artifacts_identity + ON artifacts (artifact_type, platform, version_id, version_range_pattern, group_id) + NULLS NOT DISTINCT; 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) +); 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(); 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; 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; 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; 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; diff --git a/private-web/e2e/artifact-scopes.spec.ts b/private-web/e2e/artifact-scopes.spec.ts new file mode 100644 index 000000000..67ae89eeb --- /dev/null +++ b/private-web/e2e/artifact-scopes.spec.ts @@ -0,0 +1,397 @@ +import { + resetSeededTables, + seedArtifact, + seedServerGroup, + seedVersion, +} from "./seed"; +import { expect, test } from "./test-fixtures"; + +/// How a version's artifacts are presented to an operator: every artifact that +/// matches, whose group each is for, and which one is actually served. +/// +/// spec: ART +test.describe("group-scoped artifacts", () => { + test.beforeEach(async ({ sql }) => { + await resetSeededTables(sql); + }); + + /// The full set, including the artifacts specificity passed over, is + /// available to operators: what resolution hides is a fact about how a + /// version was published and an operator has to be able to see it. + /// + /// spec: ART#what-a-version-offers + test("an operator sees a group's artifact alongside the one it displaces", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + downloadUrl: "https://example.com/all.sql", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + + // Both are listed, so the operator can see what the group's own + // artifact displaced. + const rows = page.locator("table tbody tr"); + await expect(rows).toHaveCount(2); + + // The group's artifact says whose it is and shows its digest rather + // than a location, because Canopy holds the bytes. + await expect(page.getByText("Held by Canopy for kamaka")).toBeVisible(); + await expect(page.getByText(/^sha256-/)).toBeVisible(); + + // The unscoped one still shows where it rests. + await expect( + page.getByRole("link", { name: "https://example.com/all.sql" }), + ).toBeVisible(); + }); + + /// A range artifact a more specific one displaces is shown, and marked as + /// not being the one served. + /// + /// spec: ART#what-a-version-offers + test("an artifact resolution passed over is marked rather than hidden", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + + // `2.60.x` is >=2.60.0 <2.61.0 and `^2.60.0` is >=2.60.0 <3.0.0, so the + // wildcard is the narrower of the two and is the one served. + await seedArtifact(sql, { + versionId: null, + rangePattern: "2.60.x", + artifactType: "installer", + platform: "windows", + downloadUrl: "https://example.com/narrow.exe", + }); + await seedArtifact(sql, { + versionId: null, + rangePattern: "^2.60.0", + artifactType: "installer", + platform: "windows", + downloadUrl: "https://example.com/wide.exe", + }); + + await page.goto(`/versions/2.60.0`); + + await expect(page.locator("table tbody tr")).toHaveCount(2); + await expect(page.getByText("[Hidden]")).toHaveCount(1); + + // Counting the markers alone would pass just as well with the marker on + // the artifact that is actually served. + const wide = page + .locator("table tbody tr") + .filter({ hasText: "https://example.com/wide.exe" }); + await expect(wide.getByText("[Hidden]")).toBeVisible(); + + const narrow = page + .locator("table tbody tr") + .filter({ hasText: "https://example.com/narrow.exe" }); + await expect(narrow.getByText("[Hidden]")).toHaveCount(0); + }); + + /// A group's artifact displaces the unscoped one for that group alone, so + /// the unscoped one is still what every other caller is served and is not + /// marked as passed over. Resolving once across the fleet marks it hidden, + /// which tells the operator the opposite of the truth. + /// + /// spec: ART#what-a-version-offers + test("an artifact a group's own displaces is not marked hidden", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: null, + rangePattern: "2.60.x", + artifactType: "reporting-schema", + platform: "any", + downloadUrl: "https://example.com/fleet.sql", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + + await expect(page.locator("table tbody tr")).toHaveCount(2); + await expect(page.getByText("[Hidden]")).toHaveCount(0); + }); + + + /// An operator publishes into a group by carrying the bytes: there is no + /// store to be credentialled for, so being able to register for the group is + /// the whole of what publishing into it takes. + /// + /// spec: ART#where-an-artifact-rests + test("an operator registers a group's artifact by uploading it", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedServerGroup(sql, { name: "kamaka" }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("textbox", { name: "Type" }).fill("reporting-schema"); + await page.getByRole("textbox", { name: "Platform" }).fill("any"); + await page.getByRole("combobox", { name: "Group" }).click(); + await page.getByRole("option", { name: "kamaka" }).click(); + await page.getByLabel("Choose file…").setInputFiles({ + name: "kamaka.sql", + mimeType: "application/sql", + buffer: Buffer.from("kamaka schema"), + }); + // The submit shares its label with the button that revealed the form, + // so it has to be picked out of the form itself. + await page + .locator("form") + .getByRole("button", { name: "Create" }) + .click(); + + await expect(page.getByText("Held by Canopy for kamaka")).toBeVisible(); + + // Canopy holds the bytes elsewhere and records the digest of what it took + // in, so the row carries no location and the digest of the file. + const rows = await sql.query<{ + download_url: string | null; + digest: string | null; + }>( + `SELECT download_url, encode(digest, 'base64') AS digest + FROM artifacts WHERE version_id = $1`, + [version.id], + ); + expect(rows).toHaveLength(1); + expect(rows[0].download_url).toBeNull(); + expect(rows[0].digest).toBe("IUs61BxmDig34DQY/ofHCx6CzHw1MdeO/v+aNAnqkdk="); + }); + + /// An artifact Canopy holds has no location to edit. Replacing its bytes is + /// a registration, which is what carries the digest. + /// + /// spec: ART#where-an-artifact-rests + test("a held artifact offers no location to edit", async ({ page, sql }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page + .getByRole("button", { name: "edit reporting-schema any for kamaka" }) + .click(); + + await expect( + page.getByText("Register it again to replace the bytes"), + ).toBeVisible(); + }); + + /// Canopy keeps none of what it has stopped serving, so removing an artifact + /// takes the bytes it held with it. + /// + /// spec: ART#where-an-artifact-rests + test("deleting a group's artifact takes its bytes", async ({ page, sql }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + const group = await seedServerGroup(sql, { name: "kamaka" }); + + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + downloadUrl: "https://example.com/all.sql", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "reporting-schema", + platform: "any", + groupId: group.id, + content: "kamaka schema", + }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + + // The two rows share a type and platform, so the label has to name the + // group to pick one out. + await page + .getByRole("button", { name: "delete reporting-schema any for kamaka" }) + .click(); + await page.getByRole("button", { name: "Really delete" }).click(); + + await expect(page.getByText("Held by Canopy for kamaka")).toHaveCount(0); + + const [held] = await sql.query<{ n: string }>( + "SELECT count(*) AS n FROM artifacts WHERE download_url IS NULL", + ); + expect(Number(held.n)).toBe(0); + + // The unscoped one is untouched. + const [left] = await sql.query<{ n: string }>( + "SELECT count(*) AS n FROM artifacts", + ); + expect(Number(left.n)).toBe(1); + }); + + /// An unscoped artifact rests at a location, so editing its type or platform + /// must not be a way to take that location away. The field's `required` never + /// fires: the row is not a form and Save is not a submit. + /// + /// spec: ART#where-an-artifact-rests + test("an unscoped artifact cannot be saved without a location", async ({ + page, + sql, + }) => { + const version = await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedArtifact(sql, { + versionId: version.id, + artifactType: "installer", + platform: "windows", + downloadUrl: "https://example.com/i.exe", + }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page + .getByRole("button", { name: "edit installer windows for every group" }) + .click(); + + await page.getByRole("textbox").last().fill(""); + await expect(page.getByRole("button", { name: "Save" })).toBeDisabled(); + + // And the location it had is still the location it has. + const [row] = await sql.query<{ download_url: string | null }>( + "SELECT download_url FROM artifacts", + ); + expect(row.download_url).toBe("https://example.com/i.exe"); + }); + + /// A group list that failed to load offers only "Every group", which reads as + /// a fleet with no groups. Publishing into a group becomes impossible, so the + /// operator has to be told rather than left to infer it. + /// + /// spec: ART#registration + test("a group list that fails to load says so", async ({ page, sql }) => { + await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedServerGroup(sql, { name: "kamaka" }); + + await page.route("**/api/fleet/groups/list", (route) => route.abort()); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByText("Could not load groups")).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Group" })).toBeDisabled(); + }); + + /// A file past what Canopy will hold is refused with the size named, before + /// it is read and encoded: base64 of an oversized file locks the tab up for + /// a request the server was always going to refuse. + /// + /// spec: ART#where-an-artifact-rests + test("a file over the limit is refused with the limit named", async ({ + page, + sql, + }) => { + await seedVersion(sql, { + major: 2, + minor: 60, + patch: 0, + status: "published", + }); + await seedServerGroup(sql, { name: "kamaka" }); + + await page.goto(`/versions/2.60.0`); + await page.getByRole("button", { name: "Unlock" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("textbox", { name: "Type" }).fill("reporting-schema"); + await page.getByRole("textbox", { name: "Platform" }).fill("any"); + await page.getByRole("combobox", { name: "Group" }).click(); + await page.getByRole("option", { name: "kamaka" }).click(); + await page.getByLabel("Choose file…").setInputFiles({ + name: "kamaka.sql", + mimeType: "application/sql", + buffer: Buffer.alloc(32 * 1024 * 1024 + 1), + }); + + await expect( + page.getByText("Artifact is larger than the 32 MiB limit"), + ).toBeVisible(); + await expect( + page.locator("form").getByRole("button", { name: "Create" }), + ).toBeDisabled(); + + const rows = await sql.query("SELECT id FROM artifacts"); + expect(rows).toHaveLength(0); + }); +}); diff --git a/private-web/e2e/fixture.ts b/private-web/e2e/fixture.ts index 88be1a963..daaa2e5b9 100644 --- a/private-web/e2e/fixture.ts +++ b/private-web/e2e/fixture.ts @@ -198,6 +198,9 @@ export async function startStack(opts: StartOptions = {}): Promise // the bucket name (…existing… → kopia repo, …other… → other content, // …denied… → inaccessible, else empty). CANOPY_BACKUP_PROBER_FAKE: "1", + // No bucket in e2e: hold uploaded artifacts in the server's own + // process so registering and serving one is exercised end to end. + CANOPY_ARTIFACT_STORE_MEMORY: "1", // A throwaway age recipient (bestool-generated) so the recovery vault // ceremony page reports as configured. The matching private key isn't // needed: the e2e exercises status + challenge + wrong-answer, and the diff --git a/private-web/e2e/reporting-schemas.spec.ts b/private-web/e2e/reporting-schemas.spec.ts new file mode 100644 index 000000000..f9bd4ef9b --- /dev/null +++ b/private-web/e2e/reporting-schemas.spec.ts @@ -0,0 +1,288 @@ +import type { Sql } from "./seed"; +import { + resetSeededTables, + seedApplicationReport, + seedDevice, + seedReportingSchemaBuild, + seedRestoreConsumerCapability, + seedRestoreReplica, + seedServer, + seedServerGroup, + seedUpgradePlan, + seedVersion, +} from "./seed"; +import { expect, test } from "./test-fixtures"; + +/// A consumer that advertises a schema-building intent, declared against the +/// 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 { + 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", + publishesSchemas: true, + }); + return consumer.id; +} + +/// 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" }); + 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 page.goto(`/groups/${group.id}`); + + 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(); + + // 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 + /// 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" }); + 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.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 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(); + }); + + /// 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. + /// + /// 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/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 f4259e216..05f4827e8 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -11,7 +11,7 @@ // often don't want in setup (e.g. submitting a status fires events // and opens incidents). -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { Client } from "pg"; export interface Sql { @@ -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, inventory_variables, inventory_leases, 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, inventory_variables, inventory_leases, 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. @@ -1044,6 +1044,48 @@ export async function seedVersion( return { id, major, minor, patch }; } +/** Seed an artifact for a version. Naming a group makes Canopy hold the bytes + * rather than record a location; `content` is what its digest is taken of, the + * bytes themselves resting in the store rather than the row. */ +export async function seedArtifact( + sql: Sql, + opts: { + versionId?: string | null; + artifactType?: string; + platform?: string; + downloadUrl?: string; + rangePattern?: string | null; + groupId?: string | null; + content?: string; + }, +): Promise { + const id = randomUUID(); + const scoped = opts.groupId != null; + const content = opts.content ?? "held bytes"; + const digest = scoped + ? createHash("sha256").update(content).digest() + : null; + + await sql.query( + `INSERT INTO artifacts + (id, version_id, artifact_type, platform, download_url, version_range_pattern, + group_id, content_type, digest) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + id, + opts.versionId ?? null, + opts.artifactType ?? "installer", + opts.platform ?? "windows", + scoped ? null : (opts.downloadUrl ?? "https://example.com/installer.exe"), + opts.rangePattern ?? null, + opts.groupId ?? null, + scoped ? "application/sql" : null, + digest, + ], + ); + return id; +} + // ── Backup-credentials seeding ────────────────────────────────────────────── export type BackupConfigStatus = "provisioning" | "ready"; @@ -1441,6 +1483,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(); @@ -1449,8 +1493,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, @@ -1462,13 +1506,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, @@ -1481,6 +1526,7 @@ export async function seedRestoreReplica( params, opts.enabled ?? true, opts.redacts ?? false, + opts.publishesSchemas ?? false, ], ); } @@ -1606,6 +1652,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 { diff --git a/private-web/openapi.json b/private-web/openapi.json index afeb9e512..dee1059f1 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -6667,6 +6667,126 @@ ] } }, + "/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": "" + }, + "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": [ + { + "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": [ @@ -8345,6 +8465,16 @@ } } } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } } }, "security": [ @@ -8360,7 +8490,7 @@ "versions" ], "summary": "Permanently delete an artifact.", - "description": "The artifact record is removed outright; the file it pointed to is not\ntouched. There is no undo.", + "description": "An artifact Canopy holds loses its bytes along with its record. One that\nrecords a location keeps whatever is at that location. There is no undo.", "operationId": "delete_artifact", "requestBody": { "content": { @@ -8461,7 +8591,12 @@ } } } - } + }, + "security": [ + { + "tailscale-user": [] + } + ] } }, "/api/versions/get_version_detail": { @@ -8712,6 +8847,117 @@ } ] } + }, + "/api/versions/upload_artifact": { + "post": { + "tags": [ + "versions" + ], + "summary": "Register an artifact whose bytes Canopy holds, for one group.", + "description": "The body is the artifact itself and its `Content-Type` is what the bytes\nare served back as. Returns the created artifact.", + "operationId": "upload_artifact", + "parameters": [ + { + "name": "version_id", + "in": "query", + "description": "Id of the version to attach the new artifact to.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "artifact_type", + "in": "query", + "description": "Artifact type.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "platform", + "in": "query", + "description": "Target platform.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "group_id", + "in": "query", + "description": "The group this artifact is for.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "digest", + "in": "query", + "description": "Subresource Integrity digest of the body, e.g. `sha256-LCTbqp…`.\nCanopy checks the bytes against it as they arrive and refuses the\nregistration on a mismatch, so a corrupted upload is refused while\nwhoever sent it is still there to send it again.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-canopy-upload", + "in": "header", + "description": "Any value. Required: it makes a browser preflight the request, so a cross-origin page cannot spend an operator's session on this endpoint.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The artifact's bytes.", + "content": { + "application/octet-stream": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactData" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + } + }, + "security": [ + { + "tailscale-admin": [] + } + ] + } } }, "components": { @@ -9120,7 +9366,7 @@ "id", "artifact_type", "platform", - "download_url", + "canopy_holds_bytes", "is_exact", "has_range_override", "is_used_in_public_api" @@ -9130,9 +9376,38 @@ "type": "string", "description": "Kind of artifact (for example, an installer or update package)." }, + "canopy_holds_bytes": { + "type": "boolean", + "description": "`true` when Canopy holds this artifact's bytes rather than a location." + }, + "digest": { + "type": [ + "string", + "null" + ], + "description": "Subresource Integrity digest recorded for the artifact, where there is\none." + }, "download_url": { - "type": "string", - "description": "URL clients use to download this artifact." + "type": [ + "string", + "null" + ], + "description": "URL clients use to download this artifact. `null` when Canopy holds\nthe bytes itself." + }, + "group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The group this artifact is for, when it is for one alone." + }, + "group_name": { + "type": [ + "string", + "null" + ], + "description": "Name of that group, for display." }, "has_range_override": { "type": "boolean", @@ -9737,6 +10012,26 @@ } } }, + "BuildPairArgs": { + "type": "object", + "description": "Which pair to build.", + "required": [ + "group_id", + "version_id" + ], + "properties": { + "group_id": { + "type": "string", + "format": "uuid", + "description": "The group whose schema to build." + }, + "version_id": { + "type": "string", + "format": "uuid", + "description": "The Tamanu version to build it for." + } + } + }, "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.", @@ -10428,7 +10723,7 @@ }, "CreateArtifactArgs": { "type": "object", - "description": "A new artifact to register against a version.", + "description": "A new artifact to register against a version, at a location Canopy records.\n\nAn artifact whose bytes Canopy holds is registered through\n`upload_artifact` instead, since the bytes are the body there.", "required": [ "version_id", "artifact_type", @@ -10440,9 +10735,16 @@ "type": "string", "description": "Artifact type." }, + "digest": { + "type": [ + "string", + "null" + ], + "description": "Subresource Integrity digest of the bytes at that URL, e.g.\n`sha256-LCTbqp…`, where one is recorded. Whoever fetches the artifact\nchecks what it got against this." + }, "download_url": { "type": "string", - "description": "Download URL for the artifact." + "description": "URL the artifact is downloaded from." }, "platform": { "type": "string", @@ -12335,7 +12637,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. 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 until\noverdue), `url` (a replica's health report includes a link to it),\n`migrate` (Canopy names a target version and the replica applies that\nversion's migrations), `redact` (the replica de-identifies the restored\ndata before serving it), and `reporting-schema` (the replica builds a\nTamanu reporting schema and registers it for the group). Unrecognised\nvalues are stored but have no effect, so a consumer may advertise ahead\nof Canopy support." } } }, @@ -14928,6 +15230,79 @@ } } }, + "Pair": { + "type": "object", + "description": "One pair of group and Tamanu version, and where it stands.", + "required": [ + "group_id", + "version_id", + "version", + "state", + "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", + "null" + ], + "description": "What went wrong, where a build failed." + }, + "group_id": { + "type": "string", + "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", + "description": "Whether the pair has a schema, failed to build one, or is awaiting one." + }, + "version": { + "type": "string", + "description": "That version as semver, for display." + }, + "version_id": { + "type": "string", + "format": "uuid", + "description": "The Tamanu version this pair is for." + } + } + }, + "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.", @@ -16259,6 +16634,8 @@ "params", "redacts", "can_redact", + "publishes_schemas", + "can_publish_schemas", "redaction_gaps", "enabled", "gap", @@ -16266,6 +16643,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." @@ -16338,6 +16719,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": { @@ -16407,6 +16792,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." @@ -16490,6 +16879,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." @@ -18918,8 +19311,7 @@ "required": [ "artifact_id", "artifact_type", - "platform", - "download_url" + "platform" ], "properties": { "artifact_id": { @@ -18932,8 +19324,11 @@ "description": "New artifact type." }, "download_url": { - "type": "string", - "description": "New download URL." + "type": [ + "string", + "null" + ], + "description": "New download URL. Leave unset for an artifact whose bytes Canopy holds." }, "platform": { "type": "string", @@ -19543,6 +19938,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 cb1855f2f..e7f698d5b 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -3274,6 +3274,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; @@ -4193,8 +4239,8 @@ export interface paths { put?: never; /** * Permanently delete an artifact. - * @description The artifact record is removed outright; the file it pointed to is not - * touched. There is no undo. + * @description An artifact Canopy holds loses its bytes along with its record. One that + * records a location keeps whatever is at that location. There is no undo. */ post: operations["delete_artifact"]; delete?: never; @@ -4381,6 +4427,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/versions/upload_artifact": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register an artifact whose bytes Canopy holds, for one group. + * @description The body is the artifact itself and its `Content-Type` is what the bytes + * are served back as. Returns the created artifact. + */ + post: operations["upload_artifact"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -4648,8 +4715,25 @@ export interface components { ArtifactData: { /** @description Kind of artifact (for example, an installer or update package). */ artifact_type: string; - /** @description URL clients use to download this artifact. */ - download_url: string; + /** @description `true` when Canopy holds this artifact's bytes rather than a location. */ + canopy_holds_bytes: boolean; + /** + * @description Subresource Integrity digest recorded for the artifact, where there is + * one. + */ + digest?: string | null; + /** + * @description URL clients use to download this artifact. `null` when Canopy holds + * the bytes itself. + */ + download_url?: string | null; + /** + * Format: uuid + * @description The group this artifact is for, when it is for one alone. + */ + group_id?: string | null; + /** @description Name of that group, for display. */ + group_name?: string | null; /** * @description Only meaningful when `is_exact` is `true`: `true` when a * range-matched artifact of the same type and platform also matches @@ -5071,6 +5155,19 @@ export interface components { /** @description Label value. */ value: string; }; + /** @description Which pair to build. */ + BuildPairArgs: { + /** + * Format: uuid + * @description The group whose schema to build. + */ + group_id: string; + /** + * Format: uuid + * @description The Tamanu version to build it for. + */ + version_id: string; + }; /** * @description What Canopy does for an application of a given type. * @@ -5537,11 +5634,22 @@ export interface components { /** @description The rolled-up health over these checks, by the one classifier. */ health_state: components["schemas"]["HealthState"]; }; - /** @description A new artifact to register against a version. */ + /** + * @description A new artifact to register against a version, at a location Canopy records. + * + * An artifact whose bytes Canopy holds is registered through + * `upload_artifact` instead, since the bytes are the body there. + */ CreateArtifactArgs: { /** @description Artifact type. */ artifact_type: string; - /** @description Download URL for the artifact. */ + /** + * @description Subresource Integrity digest of the bytes at that URL, e.g. + * `sha256-LCTbqp…`, where one is recorded. Whoever fetches the artifact + * checks what it got against this. + */ + digest?: string | null; + /** @description URL the artifact is downloaded from. */ download_url: string; /** @description Target platform. */ platform: string; @@ -6760,9 +6868,14 @@ export interface components { /** * @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. + * 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. */ semantics?: string[]; }; @@ -8363,6 +8476,46 @@ export interface components { */ offset: number; }; + /** @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; + /** + * 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 + * @description The Tamanu version this pair is for. + */ + 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 @@ -9172,6 +9325,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. @@ -9236,6 +9394,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 @@ -9295,6 +9458,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 @@ -9369,6 +9539,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. @@ -10875,8 +11051,8 @@ export interface components { artifact_id: string; /** @description New artifact type. */ artifact_type: string; - /** @description New download URL. */ - download_url: string; + /** @description New download URL. Leave unset for an artifact whose bytes Canopy holds. */ + download_url?: string | null; /** @description New target platform. */ platform: string; }; @@ -15772,6 +15948,91 @@ 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; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + 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; @@ -16932,6 +17193,14 @@ export interface operations { "application/json": components["schemas"]["ArtifactData"]; }; }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; }; }; delete_artifact: { @@ -17185,4 +17454,55 @@ export interface operations { }; }; }; + upload_artifact: { + parameters: { + query: { + /** @description Id of the version to attach the new artifact to. */ + version_id: string; + /** @description Artifact type. */ + artifact_type: string; + /** @description Target platform. */ + platform: string; + /** @description The group this artifact is for. */ + group_id: string; + /** + * @description Subresource Integrity digest of the body, e.g. `sha256-LCTbqp…`. + * Canopy checks the bytes against it as they arrive and refuses the + * registration on a mismatch, so a corrupted upload is refused while + * whoever sent it is still there to send it again. + */ + digest: string; + }; + header: { + /** @description Any value. Required: it makes a browser preflight the request, so a cross-origin page cannot spend an operator's session on this endpoint. */ + "x-canopy-upload": string; + }; + path?: never; + cookie?: never; + }; + /** @description The artifact's bytes. */ + requestBody: { + content: { + "application/octet-stream": number[]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactData"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + }; + }; } diff --git a/private-web/src/api.ts b/private-web/src/api.ts index 4cb4ce92a..9a4b91a2b 100644 --- a/private-web/src/api.ts +++ b/private-web/src/api.ts @@ -35,33 +35,65 @@ export async function callApi< signal, }); - if (!response.ok) { - let detail: unknown = null; - try { - detail = await response.json(); - } catch { - detail = await response.text().catch(() => null); - } - // Surface the problem-details title (and detail line, if present) - // in the thrown error's message so action.error?.message in the UI - // shows the actual server-side cause, not just the HTTP status. - let extra = ""; - if ( - detail && - typeof detail === "object" && - "title" in detail && - typeof (detail as { title?: unknown }).title === "string" - ) { - extra = `: ${(detail as { title: string }).title}`; - } - throw new ApiError( - response.status, - `server fn ${module}.${fn} failed: ${response.status}${extra}`, - detail, - ); + return (await answered(response, module, fn)) as T; +} + +async function answered( + response: Response, + module: string, + fn: string, +): Promise { + if (response.ok) return await response.json(); + + let detail: unknown = null; + try { + detail = await response.json(); + } catch { + detail = await response.text().catch(() => null); } + // Surface the problem-details title (and detail line, if present) + // in the thrown error's message so action.error?.message in the UI + // shows the actual server-side cause, not just the HTTP status. + let extra = ""; + if ( + detail && + typeof detail === "object" && + "title" in detail && + typeof (detail as { title?: unknown }).title === "string" + ) { + extra = `: ${(detail as { title: string }).title}`; + } + throw new ApiError( + response.status, + `server fn ${module}.${fn} failed: ${response.status}${extra}`, + detail, + ); +} + +// An endpoint whose body is the bytes themselves: everything it is told about +// them travels in the query string. +export async function uploadApi( + module: string, + fn: string, + query: Record, + body: Blob, +): Promise { + const response = await fetch( + `/api/${module}/${fn}?${new URLSearchParams(query)}`, + { + method: "POST", + headers: { + "content-type": body.type || "application/octet-stream", + // A raw body is a content type a form can send, so this is what + // makes the browser preflight the request and keeps a + // cross-origin page off the endpoint. + "x-canopy-upload": "1", + }, + body, + }, + ); - return (await response.json()) as T; + return (await answered(response, module, fn)) as T; } export type ApiState = @@ -174,16 +206,57 @@ export function useApiAction< pending: boolean; error: Error | null; reset: () => void; +} { + return useApiCall<[Record?], T>( + useCallback( + (params = {}) => callApi(module, fn, params), + [module, fn], + ), + ); +} + +/** + * `useApiAction` for an endpoint whose body is the bytes themselves. + */ +export function useApiUpload( + module: string, + fn: string, +): { + call: (query: Record, body: Blob) => Promise; + pending: boolean; + error: Error | null; + reset: () => void; +} { + return useApiCall<[Record, Blob], T>( + useCallback( + (query, body) => uploadApi(module, fn, query, body), + [module, fn], + ), + ); +} + +/** + * The pending/error bookkeeping both write hooks share, so the two cannot + * differ on what they report or on telling the rest of the page that + * something changed. + */ +function useApiCall( + request: (...args: A) => Promise, +): { + call: (...args: A) => Promise; + pending: boolean; + error: Error | null; + reset: () => void; } { const [pending, setPending] = useState(false); const [error, setError] = useState(null); const call = useCallback( - async (params: Record = {}): Promise => { + async (...args: A): Promise => { setPending(true); setError(null); try { - const result = await callApi(module, fn, params); + const result = await request(...args); // Broadcast so global, page-agnostic queries (e.g. the open- // incidents nav badge) can refetch without the caller having // to know they exist. Listeners hook via useReloadInterval. @@ -197,7 +270,7 @@ export function useApiAction< setPending(false); } }, - [module, fn], + [request], ); const reset = useCallback(() => setError(null), []); diff --git a/private-web/src/components/ReportingSchemasSection.test.tsx b/private-web/src/components/ReportingSchemasSection.test.tsx new file mode 100644 index 000000000..1e6075058 --- /dev/null +++ b/private-web/src/components/ReportingSchemasSection.test.tsx @@ -0,0 +1,193 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +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; + version: string; + state: "awaiting" | "built" | "failed"; + error?: string | null; + requested: boolean; + applications: string[]; +}; + +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, + applications: ["kamaka-central"], + ...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; +} + +beforeEach(() => { + admin.is = true; +}); + +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("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([ + 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("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(); + + 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", () => { + // 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(); + + 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 new file mode 100644 index 000000000..d095f5e36 --- /dev/null +++ b/private-web/src/components/ReportingSchemasSection.tsx @@ -0,0 +1,190 @@ +import { + Alert, + Box, + Button, + Chip, + LinearProgress, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import { useApi, useApiAction } from "../api"; +import { useIsAdmin } from "../hooks/useIsAdmin"; + +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"); + const isAdmin = useIsAdmin() === true; + + if (pairs.status === "loading" || pairs.status === "idle") { + return ( + + + + + ); + } + if (pairs.status === "error") { + return ( + + + {pairs.error.message} + + ); + } + + if (pairs.data.length === 0) { + return ( + + + + 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. + + + ); + } + + 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 + On + + + + + {pairs.data.map((pair) => ( + + + {pair.version} + + + + + + + + + {pair.requested ? ( + + Build asked for + + ) : ( + isAdmin && ( + + ) + )} + + + ))} + +
+
+ ); +} + +/// 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, +}: { + 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/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 && ( + + )} + + - {artifact.download_url.startsWith("https://") ? ( + {artifact.canopy_holds_bytes ? ( + + + Held by Canopy for {artifact.group_name ?? "a group"} + + {artifact.digest && ( + + {artifact.digest} + + )} + + ) : artifact.download_url?.startsWith("https://") ? (
setEditing(true)} > setConfirmDelete(true)} @@ -553,7 +575,7 @@ function EditArtifactRow({ artifact_id: artifact.id, artifact_type: type, platform, - download_url: url, + download_url: artifact.canopy_holds_bytes ? null : url, }); onClose(true); } catch { @@ -582,14 +604,21 @@ function EditArtifactRow({ /> - setUrl(e.target.value)} - disabled={action.pending} - required - /> + {artifact.canopy_holds_bytes ? ( + + Held by Canopy for {artifact.group_name ?? "a group"}. Register + it again to replace the bytes. + + ) : ( + setUrl(e.target.value)} + disabled={action.pending} + required + /> + )} @@ -597,7 +626,12 @@ function EditArtifactRow({ size="small" variant="contained" onClick={save} - disabled={action.pending} + // `required` on the field never fires: the row is not a + // form and Save is not a submit, so nothing validates it. + disabled={ + action.pending || + (!artifact.canopy_holds_bytes && !url?.trim()) + } > {action.pending ? "Saving…" : "Save"} @@ -615,6 +649,18 @@ function EditArtifactRow({ ); } +/// The digest travels with the bytes: Canopy checks what it received against +/// it and refuses the registration on a mismatch. +async function digestOf(file: File): Promise { + const hash = new Uint8Array( + await crypto.subtle.digest("SHA-256", await file.arrayBuffer()), + ); + return `sha256-${btoa(String.fromCharCode(...hash))}`; +} + +const MAX_HELD_ARTIFACT_BYTES = 32 * 1024 * 1024; +const OVER_LIMIT_MESSAGE = `Artifact is larger than the ${MAX_HELD_ARTIFACT_BYTES / (1024 * 1024)} MiB limit`; + function CreateArtifactForm({ versionId, onCreated, @@ -625,20 +671,57 @@ function CreateArtifactForm({ const [type, setType] = useState(""); const [platform, setPlatform] = useState(""); const [url, setUrl] = useState(""); - const action = useApiAction("versions", "create_artifact"); + const [groupId, setGroupId] = useState(""); + const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); + const create = useApiAction("versions", "create_artifact"); + const upload = useApiUpload("versions", "upload_artifact"); + const groups = useApi("fleet/groups", "list", {}, []); + + const scoped = groupId !== ""; + const action = scoped ? upload : create; const submit = async (e: React.FormEvent) => { e.preventDefault(); try { - await action.call({ - version_id: versionId, - artifact_type: type, - platform, - download_url: url, - }); + if (scoped && file) { + // Digesting the file is the browser's own work rather than the + // upload's, so a failure here reaches no hook and would leave + // the form sitting there having done nothing. + let digest: string; + try { + digest = await digestOf(file); + } catch (err) { + setFileError( + err instanceof Error ? err.message : "could not read the file", + ); + return; + } + + await upload.call( + { + version_id: versionId, + artifact_type: type, + platform, + group_id: groupId, + digest, + }, + file, + ); + } else { + await create.call({ + version_id: versionId, + artifact_type: type, + platform, + download_url: url, + }); + } setType(""); setPlatform(""); setUrl(""); + setGroupId(""); + setFile(null); + setFileError(null); onCreated(); } catch { /* surfaced via action.error */ @@ -671,24 +754,78 @@ function CreateArtifactForm({ /> setUrl(e.target.value)} - disabled={action.pending} - fullWidth - required - /> + select + label="Group" + value={groupId} + onChange={(e) => { + setGroupId(e.target.value); + // The file input is only rendered for a group, so + // an over-limit file left behind disables Create + // with nothing on screen to clear. + setFile(null); + setFileError(null); + }} + disabled={action.pending || groups.status === "error"} + // Falling back to an empty list silently offers only + // "Every group", which reads as a fleet with no groups + // rather than as a list that failed to load. + error={groups.status === "error"} + helperText={ + groups.status === "error" ? "Could not load groups" : undefined + } + sx={{ minWidth: 160 }} + > + Every group + {(groups.status === "ok" ? groups.data : []).map((g) => ( + + {g.name} + + ))} + + {scoped ? ( + + ) : ( + setUrl(e.target.value)} + disabled={action.pending} + fullWidth + required + /> + )} - {action.error && ( + {(fileError ?? action.error?.message) && ( - {action.error.message} + {fileError ?? action.error?.message} )}