diff --git a/.workhorse/specs/canopy/backup.md b/.workhorse/specs/canopy/backup.md index 2d557b04..1394ae44 100644 --- a/.workhorse/specs/canopy/backup.md +++ b/.workhorse/specs/canopy/backup.md @@ -50,7 +50,8 @@ There must be exactly one method table. ```toml [simple] # snapshot a path as-is -path = "/var/lib/example" +path = "/var/lib/example" # exactly one of path / path_command +# path_command = ["bestool", "tamanu", "blob-root"] # resolve the path by command instead ``` #### PostgreSQL @@ -68,6 +69,9 @@ socket = "/var/run/postgresql" # optional — override the unix socket direct ## Methods The `simple` method hands kopia a path verbatim; it contributes no extra tags and needs no preparation or cleanup. +The path is either fixed (`path`) or resolved on every run by an argv-style command (`path_command`) whose output must be a single line naming an absolute path; a failed or malformed resolution fails the run. +Run-time resolution serves a source whose location lives outside the definition and can move under it (the Tamanu blob store root is a database-backed setting an administrator can change, printed by `bestool tamanu blob-root`), so the capture follows the live location instead of a hardcoded path silently going stale. +At restore, the same resolution names the destination, so a store restored after its database lands where the freshly restored database expects it. The `postgresql` method takes a crash-consistent physical copy of a postgres cluster, described under "The postgresql method" below. @@ -117,7 +121,7 @@ A run that failed, was skipped because its type was already running, or exited d A follower is otherwise an ordinary definition: it registers as a capability, may be scheduled by Canopy or run manually on its own, and reports its runs like any other type. Following is for a capture that must be a superset of what another capture references. -A definition that captures data the leader's rows point at follows the leader, so whatever the leader references is already stored when the follower's capture begins. +The Tamanu blob store definition follows the database definition, so every blob the database capture references is already stored when the store capture begins; blobs are immutable and never removed while referenced, so the store capture can only hold more, never less, than the database capture needs. A follower run triggered on its own (by schedule or by hand) is still safe on these terms, being a superset for every earlier capture of the followed type; what only the chain provides is a store capture promptly after each database capture. ### Sharing a capture @@ -250,10 +254,10 @@ A restore can equally take its source from a capture held on the device, describ Selection is by id across the whole repository — not scoped to the server issuing the restore — so a replacement host can restore a backup taken by the server it succeeds. It restores the snapshot into a staging area on the same filesystem as the target so the final move is atomic, then hands off to the method. -Restoring a type also restores its followers, so a leader and the data it references come back as a consistent pair. +Restoring a type also restores its followers, so a cycle like database-and-blob-store comes back as a consistent pair. A follower's snapshot is selected rather than named: the earliest snapshot of the follower's type, from the same source host as the chosen snapshot, taken at or after it. At-or-after is the safety rule: a later follower snapshot is a superset of what the restored data references, an earlier one may not be, and is never selected; when none exists at or after, the restore refuses. -The whole cycle is planned up front, before any data is touched, and each follower restore is then a full restore of its own, with its own credentials, run id, and report, in chain order, so a follower lands against data its leader has just restored. +The whole cycle is planned up front, before any data is touched, and each follower restore is then a full restore of its own, with its own credentials, run id, and report, in chain order, so a follower whose target is resolved by `path_command` resolves it against data its leader has just restored. `--no-followers` restores the named type alone; restoring a follower's type explicitly by snapshot id remains the operator's manual path around a refusal. `--target` redirects only the named type's destination while followers would still restore over their live paths, so combining it with planned followers is refused; pass `--no-followers` alongside it. Follower snapshots are recognised by the backup type they carry, as a tag or as their description. diff --git a/crates/bestool/Cargo.toml b/crates/bestool/Cargo.toml index 951e3991..68ce4143 100644 --- a/crates/bestool/Cargo.toml +++ b/crates/bestool/Cargo.toml @@ -122,6 +122,7 @@ tamanu = [ # enable all tamanu subcommands "tamanu-artifacts", "tamanu-backup", "tamanu-backup-configs", + "tamanu-blob-root", "tamanu-config", "tamanu-url", "tamanu-doctor", @@ -142,6 +143,9 @@ alertd-tamanu = ["alertd", "tamanu-config", "dep:bestool-postgres", "bestool-tam tamanu-artifacts = ["__tamanu", "dep:comfy-table", "dep:detect-targets", "dep:target-tuples"] tamanu-backup = ["__tamanu", "file", "tamanu-config", "dep:bestool-psql", "dep:algae-cli", "dep:duct"] tamanu-backup-configs = ["__tamanu", "tamanu-backup", "dep:walkdir", "dep:zip"] +# Print the blob store root, resolved from Tamanu's `blobStorage.root` setting +# in the database, for blob store backup defs to use as their `path_command`. +tamanu-blob-root = ["__tamanu", "tamanu-config", "dep:bestool-postgres"] tamanu-config = ["__tamanu"] tamanu-doctor = [ "__tamanu", diff --git a/crates/bestool/USAGE.md b/crates/bestool/USAGE.md index a9d38dd0..3f9c56e6 100644 --- a/crates/bestool/USAGE.md +++ b/crates/bestool/USAGE.md @@ -72,6 +72,7 @@ This document contains the help content for the `bestool` command-line program. * [`bestool tamanu artifacts`↴](#bestool-tamanu-artifacts) * [`bestool tamanu backup`↴](#bestool-tamanu-backup) * [`bestool tamanu backup-configs`↴](#bestool-tamanu-backup-configs) +* [`bestool tamanu blob-root`↴](#bestool-tamanu-blob-root) * [`bestool tamanu config`↴](#bestool-tamanu-config) * [`bestool tamanu db-url`↴](#bestool-tamanu-db-url) * [`bestool tamanu doctor`↴](#bestool-tamanu-doctor) @@ -1531,6 +1532,7 @@ Alias: t * `artifacts` — List available artifacts for a Tamanu version * `backup` — Backup a local Tamanu database to a single file * `backup-configs` — Backup local Tamanu-related config files to a zip archive +* `blob-root` — Print the Tamanu blob store root * `config` — Find and print the current Tamanu config * `db-url` — Generate a DATABASE_URL connection string * `doctor` — Gather server info + healthchecks for a Tamanu install @@ -1907,6 +1909,22 @@ If `--key` or `--key-file` is provided, the backup file will be encrypted. Note +## `bestool tamanu blob-root` + +Print the Tamanu blob store root. + +The root is Tamanu's `blobStorage.root` setting (database-backed and editable in the admin panel, so no config file carries it), resolved against the server package directory when not absolute. A blob store backup def names this command as its `path_command`, so every capture and restore follows the live setting instead of a hardcoded path going stale. + +**Usage:** `bestool tamanu blob-root [OPTIONS]` + +###### **Options:** + +* `-p`, `--package ` — Package to read the setting for (central-server or facility-server). + + Detected from the config and database when not given. + + + ## `bestool tamanu config` Find and print the current Tamanu config. diff --git a/crates/bestool/src/actions/canopy/backup/config.rs b/crates/bestool/src/actions/canopy/backup/config.rs index dcd24c65..f4e994a5 100644 --- a/crates/bestool/src/actions/canopy/backup/config.rs +++ b/crates/bestool/src/actions/canopy/backup/config.rs @@ -57,7 +57,7 @@ pub struct BackupDef { /// The Canopy backup-type name (label only). pub r#type: String, /// A type this def follows: after a successful run of that type, this def - /// runs too, so a pair like database-then-secrets stays ordered. + /// runs too, so a pair like database-then-blob-store stays ordered. pub after: Option, /// Extra kopia tags merged with the canopy-* tags. pub tags: BTreeMap, @@ -101,7 +101,10 @@ struct RawDef { impl RawDef { fn into_def(self) -> Result { let method = match (self.simple, self.postgresql) { - (Some(simple), None) => Method::Simple(simple), + (Some(simple), None) => { + simple.validate(&self.r#type)?; + Method::Simple(simple) + } (None, Some(postgresql)) => Method::Postgresql(postgresql), (None, None) => bail!( "backup def '{}' has no method table; add exactly one of [simple] or [postgresql]", @@ -269,17 +272,17 @@ mod tests { } #[test] - fn parses_after() { + fn parses_after_and_path_command() { let def = parse_def( r#" - type = "tamanu-secrets" + type = "tamanu-blobs" after = "tamanu-postgres" [simple] - path = "/var/lib/containers/storage/secrets" + path_command = ["bestool", "tamanu", "blob-root"] "#, ) .unwrap(); - assert_eq!(def.r#type, "tamanu-secrets"); + assert_eq!(def.r#type, "tamanu-blobs"); assert_eq!(def.after.as_deref(), Some("tamanu-postgres")); assert_eq!(def.method.name(), "simple"); } @@ -355,6 +358,32 @@ mod tests { assert!(format!("{err}").contains("its own")); } + #[test] + fn rejects_simple_with_both_path_forms() { + let err = parse_def( + r#" + type = "bad" + [simple] + path = "/a" + path_command = ["resolve-path"] + "#, + ) + .unwrap_err(); + assert!(format!("{err}").contains("exactly one")); + } + + #[test] + fn rejects_simple_with_no_path_form() { + let err = parse_def( + r#" + type = "bad" + [simple] + "#, + ) + .unwrap_err(); + assert!(format!("{err}").contains("neither")); + } + #[tokio::test] async fn followers_of_selects_by_after_in_type_order() { let dir = std::env::temp_dir().join(format!("bestool-followers-{}", std::process::id())); @@ -366,8 +395,8 @@ mod tests { .await .unwrap(); tokio::fs::write( - dir.join("secrets.toml"), - "type = \"tamanu-secrets\"\nafter = \"tamanu-postgres\"\n[simple]\npath = \"/srv/secrets\"\n", + dir.join("blobs.toml"), + "type = \"tamanu-blobs\"\nafter = \"tamanu-postgres\"\n[simple]\npath = \"/srv/blobs\"\n", ) .await .unwrap(); @@ -380,8 +409,8 @@ mod tests { let followers = followers_of(&dir, "tamanu-postgres").await.unwrap(); let types: Vec<&str> = followers.iter().map(|d| d.r#type.as_str()).collect(); - assert_eq!(types, vec!["assets", "tamanu-secrets"]); - assert!(followers_of(&dir, "tamanu-secrets").await.unwrap().is_empty()); + assert_eq!(types, vec!["assets", "tamanu-blobs"]); + assert!(followers_of(&dir, "tamanu-blobs").await.unwrap().is_empty()); tokio::fs::remove_dir_all(&dir).await.ok(); } diff --git a/crates/bestool/src/actions/canopy/backup/method.rs b/crates/bestool/src/actions/canopy/backup/method.rs index d10fd571..2eb4540f 100644 --- a/crates/bestool/src/actions/canopy/backup/method.rs +++ b/crates/bestool/src/actions/canopy/backup/method.rs @@ -103,10 +103,78 @@ pub(super) enum Teardown { } /// `[simple]` method: snapshot a path verbatim. +/// +/// The path is either fixed (`path`) or resolved on every run by a command +/// (`path_command`), for sources whose location lives outside the def, e.g. +/// the Tamanu blob store root, a database-backed setting an administrator can +/// move (`bestool tamanu blob-root` prints it). Exactly one of the two. #[derive(Debug, Clone, Deserialize)] pub struct SimpleConfig { /// The path kopia snapshots. - pub path: PathBuf, + #[serde(default)] + pub path: Option, + /// A command (argv-style, no shell) whose output is the absolute path to + /// snapshot and restore. + #[serde(default)] + pub path_command: Option>, +} + +impl SimpleConfig { + /// Enforce exactly one of `path` / `path_command` at def load. + pub fn validate(&self, backup_type: &str) -> Result<()> { + match (&self.path, &self.path_command) { + (Some(_), None) => Ok(()), + (None, Some(command)) if !command.is_empty() => Ok(()), + (None, Some(_)) => bail!("backup def '{backup_type}' has an empty [simple] path_command"), + (None, None) => bail!( + "backup def '{backup_type}' has a [simple] table with neither path nor path_command" + ), + (Some(_), Some(_)) => bail!( + "backup def '{backup_type}' has both [simple] path and path_command; exactly one is allowed" + ), + } + } + + /// The path to snapshot or restore: the fixed one, or the command's output. + /// + /// spec: BAK#methods + pub async fn resolve_path(&self) -> Result { + use miette::{Context as _, IntoDiagnostic as _}; + + if let Some(path) = &self.path { + return Ok(path.clone()); + } + let command = self + .path_command + .as_ref() + .expect("validated: path or path_command is set"); + let (program, args) = command + .split_first() + .expect("validated: path_command is not empty"); + let output = tokio::process::Command::new(program) + .args(args) + .output() + .await + .into_diagnostic() + .wrap_err_with(|| format!("running path_command {program}"))?; + if !output.status.success() { + bail!( + "path_command {program} exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let path = stdout.trim(); + if path.is_empty() || path.lines().count() != 1 { + bail!("path_command {program} must output exactly one line, got: {path:?}"); + } + let path = PathBuf::from(path); + if !path.is_absolute() { + bail!("path_command {program} must output an absolute path, got {}", path.display()); + } + Ok(path) + } } /// `[postgresql]` method: physical, crash-consistent cluster snapshot. @@ -183,7 +251,7 @@ impl Method { pub async fn prepare(&self, backup_type: &str, within: Option<&VolumeCapture>) -> Result { match self { Method::Simple(config) => { - let live = config.path.clone(); + let live = config.resolve_path().await?; let frozen = within.and_then(|capture| capture.contains(&live)); if let (Some(source), Some(capture)) = (&frozen, within) { info!( @@ -289,7 +357,7 @@ impl Method { Method::Simple(config) => { let target = match target_override { Some(target) => target.to_path_buf(), - None => config.path.clone(), + None => config.resolve_path().await?, }; target .parent() @@ -309,7 +377,7 @@ impl Method { Method::Simple(config) => { let target = match &opts.target { Some(target) => target.clone(), - None => config.path.clone(), + None => config.resolve_path().await?, }; ensure_not_clobbering(&target, opts.clobber)?; replace_dir(staging, &target).await @@ -465,7 +533,8 @@ mod tests { #[tokio::test] async fn simple_prepare_returns_its_path_and_no_tags() { let method = Method::Simple(SimpleConfig { - path: PathBuf::from("/data/custom"), + path: Some(PathBuf::from("/data/custom")), + path_command: None, }); let prepared = method.prepare("custom", None).await.unwrap(); assert_eq!(prepared.path, PathBuf::from("/data/custom")); @@ -516,6 +585,83 @@ mod tests { assert_eq!(c_volume().contains(Path::new(r"C:\")), Some(c_volume().root)); } + #[test] + fn simple_config_requires_exactly_one_path_form() { + let fixed = SimpleConfig { + path: Some(PathBuf::from("/data")), + path_command: None, + }; + assert!(fixed.validate("t").is_ok()); + + let resolved = SimpleConfig { + path: None, + path_command: Some(vec!["/bin/echo".into(), "/data".into()]), + }; + assert!(resolved.validate("t").is_ok()); + + let neither = SimpleConfig { + path: None, + path_command: None, + }; + assert!(format!("{}", neither.validate("t").unwrap_err()).contains("neither")); + + let both = SimpleConfig { + path: Some(PathBuf::from("/data")), + path_command: Some(vec!["/bin/echo".into()]), + }; + assert!(format!("{}", both.validate("t").unwrap_err()).contains("exactly one")); + + let empty = SimpleConfig { + path: None, + path_command: Some(vec![]), + }; + assert!(format!("{}", empty.validate("t").unwrap_err()).contains("empty")); + } + + #[cfg(unix)] + #[tokio::test] + async fn path_command_resolves_trimmed_absolute_output() { + let config = SimpleConfig { + path: None, + path_command: Some(vec!["/bin/echo".into(), "/var/lib/tamanu/blobs".into()]), + }; + assert_eq!( + config.resolve_path().await.unwrap(), + PathBuf::from("/var/lib/tamanu/blobs") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn path_command_rejects_relative_and_multiline_output() { + let relative = SimpleConfig { + path: None, + path_command: Some(vec!["/bin/echo".into(), "data/blobs".into()]), + }; + assert!( + format!("{}", relative.resolve_path().await.unwrap_err()).contains("absolute") + ); + + let multiline = SimpleConfig { + path: None, + path_command: Some(vec!["/bin/echo".into(), "/a\n/b".into()]), + }; + assert!( + format!("{}", multiline.resolve_path().await.unwrap_err()) + .contains("exactly one line") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn path_command_failure_is_an_error() { + let failing = SimpleConfig { + path: None, + path_command: Some(vec!["/bin/sh".into(), "-c".into(), "exit 3".into()]), + }; + assert!(format!("{}", failing.resolve_path().await.unwrap_err()).contains("exited")); + } + #[test] fn clobber_guard_blocks_occupied_dir_unless_forced() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/bestool/src/actions/canopy/restore.rs b/crates/bestool/src/actions/canopy/restore.rs index 8f143ec9..cf7ecd3f 100644 --- a/crates/bestool/src/actions/canopy/restore.rs +++ b/crates/bestool/src/actions/canopy/restore.rs @@ -228,7 +228,8 @@ pub async fn run(args: RestoreArgs, _ctx: Context) -> Result<()> { outcome?; // The followers, each a full restore session of its own, after the type they - // follow, so a follower's data lands against the leader restored just above. + // follow: a store def resolving its target through `path_command` reads the + // database restored just above. for (follower_def, follower_snapshot) in &followed { restore_follower( &client, @@ -798,12 +799,12 @@ mod tests { fn pairs_earliest_follower_at_or_after_leader() { let leader = typed_snap("db1", "srv", "tamanu-postgres", Some("2026-08-01T03:00:00Z")); let snaps = vec![ - typed_snap("blob-early", "srv", "tamanu-secrets", Some("2026-08-01T02:00:00Z")), - typed_snap("blob-next", "srv", "tamanu-secrets", Some("2026-08-01T03:10:00Z")), - typed_snap("blob-later", "srv", "tamanu-secrets", Some("2026-08-02T03:10:00Z")), + typed_snap("blob-early", "srv", "tamanu-blobs", Some("2026-08-01T02:00:00Z")), + typed_snap("blob-next", "srv", "tamanu-blobs", Some("2026-08-01T03:10:00Z")), + typed_snap("blob-later", "srv", "tamanu-blobs", Some("2026-08-02T03:10:00Z")), leader.clone(), ]; - let paired = select_paired(&snaps, &leader, "tamanu-secrets").unwrap(); + let paired = select_paired(&snaps, &leader, "tamanu-blobs").unwrap(); assert_eq!(paired.id, "blob-next"); } @@ -811,11 +812,11 @@ mod tests { fn pairs_at_identical_start_time() { let leader = typed_snap("db1", "srv", "tamanu-postgres", Some("2026-08-01T03:00:00Z")); let snaps = vec![ - typed_snap("blob-same", "srv", "tamanu-secrets", Some("2026-08-01T03:00:00Z")), + typed_snap("blob-same", "srv", "tamanu-blobs", Some("2026-08-01T03:00:00Z")), leader.clone(), ]; assert_eq!( - select_paired(&snaps, &leader, "tamanu-secrets").unwrap().id, + select_paired(&snaps, &leader, "tamanu-blobs").unwrap().id, "blob-same" ); } @@ -826,10 +827,10 @@ mod tests { // database references, so it is never selected. let leader = typed_snap("db1", "srv", "tamanu-postgres", Some("2026-08-01T03:00:00Z")); let snaps = vec![ - typed_snap("blob-early", "srv", "tamanu-secrets", Some("2026-08-01T02:00:00Z")), + typed_snap("blob-early", "srv", "tamanu-blobs", Some("2026-08-01T02:00:00Z")), leader.clone(), ]; - let err = select_paired(&snaps, &leader, "tamanu-secrets") + let err = select_paired(&snaps, &leader, "tamanu-blobs") .unwrap_err() .to_string(); assert!(err.contains("at or after")); @@ -842,21 +843,21 @@ mod tests { // snapshots and other types on this server must not pair. let leader = typed_snap("db1", "srv", "tamanu-postgres", Some("2026-08-01T03:00:00Z")); let snaps = vec![ - typed_snap("blob-elsewhere", "other-srv", "tamanu-secrets", Some("2026-08-01T03:10:00Z")), + typed_snap("blob-elsewhere", "other-srv", "tamanu-blobs", Some("2026-08-01T03:10:00Z")), typed_snap("db2", "srv", "tamanu-postgres", Some("2026-08-01T03:10:00Z")), leader.clone(), ]; - assert!(select_paired(&snaps, &leader, "tamanu-secrets").is_err()); + assert!(select_paired(&snaps, &leader, "tamanu-blobs").is_err()); } #[test] fn refuses_pairing_without_a_leader_start_time() { let leader = typed_snap("db1", "srv", "tamanu-postgres", None); let snaps = vec![ - typed_snap("blob", "srv", "tamanu-secrets", Some("2026-08-01T03:10:00Z")), + typed_snap("blob", "srv", "tamanu-blobs", Some("2026-08-01T03:10:00Z")), leader.clone(), ]; - assert!(select_paired(&snaps, &leader, "tamanu-secrets").is_err()); + assert!(select_paired(&snaps, &leader, "tamanu-blobs").is_err()); } #[test] @@ -864,11 +865,11 @@ mod tests { let mut tagged = typed_snap("blob", "srv", "", Some("2026-08-01T03:10:00Z")); tagged .tags - .insert("tag:canopy-type".into(), "tamanu-secrets".into()); - assert!(is_of_type(&tagged, "tamanu-secrets")); + .insert("tag:canopy-type".into(), "tamanu-blobs".into()); + assert!(is_of_type(&tagged, "tamanu-blobs")); assert!(!is_of_type(&tagged, "tamanu-postgres")); let untyped = typed_snap("blob", "srv", "", Some("2026-08-01T03:10:00Z")); - assert!(!is_of_type(&untyped, "tamanu-secrets")); + assert!(!is_of_type(&untyped, "tamanu-blobs")); } } diff --git a/crates/bestool/src/actions/tamanu.rs b/crates/bestool/src/actions/tamanu.rs index 102170bc..3260cab9 100644 --- a/crates/bestool/src/actions/tamanu.rs +++ b/crates/bestool/src/actions/tamanu.rs @@ -59,6 +59,8 @@ super::subcommands! { backup => Backup(BackupArgs), #[cfg(feature = "tamanu-backup-configs")] backup_configs => BackupConfigs(BackupConfigsArgs), + #[cfg(feature = "tamanu-blob-root")] + blob_root => BlobRoot(BlobRootArgs), #[cfg(feature = "tamanu-config")] #[clap(alias = "c")] config => Config(ConfigArgs), diff --git a/crates/bestool/src/actions/tamanu/blob_root.rs b/crates/bestool/src/actions/tamanu/blob_root.rs new file mode 100644 index 00000000..7e843fe6 --- /dev/null +++ b/crates/bestool/src/actions/tamanu/blob_root.rs @@ -0,0 +1,192 @@ +use std::path::{Path, PathBuf}; + +use clap::Parser; +use miette::{Context as _, IntoDiagnostic as _, Result, bail}; + +use bestool_tamanu::{ApiServerKind, config::TamanuConfig, config::load_config, detect_kind}; + +use crate::actions::{ + Context, + tamanu::{TamanuArgs, find_tamanu}, +}; + +/// The schema default when no settings row exists, from +/// `packages/settings/src/schema/{central,facility}.ts` in Tamanu. +const DEFAULT_ROOT: &str = "data/blobs"; + +/// Print the Tamanu blob store root. +/// +/// The root is Tamanu's `blobStorage.root` setting (database-backed and +/// editable in the admin panel, so no config file carries it), resolved +/// against the server package directory when not absolute. A blob store +/// backup def names this command as its `path_command`, so every capture and +/// restore follows the live setting instead of a hardcoded path going stale. +#[derive(Debug, Clone, Parser)] +pub struct BlobRootArgs { + /// Package to read the setting for (central-server or facility-server). + /// + /// Detected from the config and database when not given. + #[arg(short, long)] + pub package: Option, +} + +pub async fn run(args: BlobRootArgs, ctx: Context) -> Result<()> { + let (_, root) = find_tamanu(ctx.require::()).await?; + let config = load_config(&root, args.package.as_deref())?; + let client = + bestool_postgres::pool::connect_one(&config.database_url(), "bestool-tamanu-blob-root") + .await?; + let kind = match args.package.as_deref().and_then(ApiServerKind::from_str_ci) { + Some(kind) => kind, + None => detect_kind(&config, Some(&client)).await, + }; + + let rows = client + .query( + "SELECT value, scope, facility_id FROM settings \ + WHERE key = 'blobStorage.root' AND deleted_at IS NULL \ + ORDER BY facility_id NULLS LAST", + &[], + ) + .await + .into_diagnostic() + .wrap_err("querying the blobStorage.root setting")?; + let rows: Vec = rows + .into_iter() + .map(|row| SettingRow { + value: row.get(0), + scope: row.get(1), + facility_id: row.get(2), + }) + .collect(); + + let setting = pick_root(&rows, kind, first_facility_id(&config)) + .unwrap_or_else(|| DEFAULT_ROOT.to_owned()); + println!("{}", resolve_root(&setting, &root, kind)?.display()); + Ok(()) +} + +/// One live `settings` row for the key: a JSONB value, its scope, and the +/// facility it applies to (facility-scoped rows only). +struct SettingRow { + value: serde_json::Value, + scope: String, + facility_id: Option, +} + +/// The facility whose settings the server uses: Tamanu's tasks convention is +/// the first configured facility on a multi-facility server. +fn first_facility_id(config: &TamanuConfig) -> Option<&str> { + config + .server_facility_ids + .as_ref() + .and_then(|ids| ids.first()) + .or(config.server_facility_id.as_ref()) + .map(String::as_str) + .filter(|id| !id.is_empty()) +} + +/// Pick the stored value the server would use, or `None` when the schema +/// default applies. On a facility server that's the first configured +/// facility's row (any facility row when the config doesn't say which); on +/// central, the central-scoped row. +fn pick_root(rows: &[SettingRow], kind: ApiServerKind, facility_id: Option<&str>) -> Option { + let row = match kind { + ApiServerKind::Facility => match facility_id { + Some(id) => rows.iter().find(|r| r.facility_id.as_deref() == Some(id)), + None => rows.iter().find(|r| r.facility_id.is_some()), + }, + ApiServerKind::Central => rows + .iter() + .find(|r| r.scope == "central" && r.facility_id.is_none()), + }; + row.and_then(|r| r.value.as_str().map(str::to_owned)) +} + +/// Resolve a relative root the way the server does: against its working +/// directory, the server package directory under the install root. An +/// absolute root passes through. A relative root with no package directory to +/// resolve against (e.g. a containerised install, whose in-container path +/// means nothing on the host) is an error: such a deployment should set an +/// absolute root. +fn resolve_root(setting: &str, root: &Path, kind: ApiServerKind) -> Result { + let path = Path::new(setting); + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + let base = root.join("packages").join(kind.package_name()); + if !base.is_dir() { + bail!( + "blobStorage.root is relative ({setting}) and there is no {} to resolve it against; \ + set the setting to an absolute path", + base.display() + ); + } + Ok(base.join(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(value: &str, scope: &str, facility_id: Option<&str>) -> SettingRow { + SettingRow { + value: serde_json::Value::String(value.to_owned()), + scope: scope.to_owned(), + facility_id: facility_id.map(str::to_owned), + } + } + + #[test] + fn picks_the_configured_facility_row() { + let rows = vec![ + row("/a", "facility", Some("facility-a")), + row("/b", "facility", Some("facility-b")), + ]; + assert_eq!( + pick_root(&rows, ApiServerKind::Facility, Some("facility-b")), + Some("/b".to_owned()) + ); + // The configured facility has no row: the schema default applies, even + // though another facility's row exists. + assert_eq!(pick_root(&rows, ApiServerKind::Facility, Some("facility-c")), None); + // Unknown facility: any facility row (rows arrive sorted by facility id). + assert_eq!( + pick_root(&rows, ApiServerKind::Facility, None), + Some("/a".to_owned()) + ); + } + + #[test] + fn picks_the_central_row_only_on_central() { + let rows = vec![row("/c", "central", None)]; + assert_eq!( + pick_root(&rows, ApiServerKind::Central, None), + Some("/c".to_owned()) + ); + assert_eq!(pick_root(&rows, ApiServerKind::Facility, None), None); + assert_eq!(pick_root(&[], ApiServerKind::Central, None), None); + } + + #[test] + fn absolute_root_passes_through() { + let root = if cfg!(windows) { r"C:\Tamanu\blobs" } else { "/var/lib/tamanu/blobs" }; + assert_eq!( + resolve_root(root, Path::new("/nonexistent"), ApiServerKind::Central).unwrap(), + PathBuf::from(root) + ); + } + + #[test] + fn relative_root_resolves_against_the_package_dir() { + let tmp = tempfile::tempdir().unwrap(); + let package_dir = tmp.path().join("packages").join("facility-server"); + std::fs::create_dir_all(&package_dir).unwrap(); + assert_eq!( + resolve_root("data/blobs", tmp.path(), ApiServerKind::Facility).unwrap(), + package_dir.join("data/blobs") + ); + // No package dir (e.g. containerised install): refuse rather than guess. + assert!(resolve_root("data/blobs", tmp.path(), ApiServerKind::Central).is_err()); + } +} diff --git a/crates/kopia/src/lib.rs b/crates/kopia/src/lib.rs index 75b1e53d..9f5a95c3 100644 --- a/crates/kopia/src/lib.rs +++ b/crates/kopia/src/lib.rs @@ -1589,13 +1589,13 @@ mod tests { let mut listed = snapshot("a", "host-1", "/data", now); listed .tags - .insert("tag:canopy-type".into(), "tamanu-secrets".into()); - assert_eq!(listed.tag("canopy-type"), Some("tamanu-secrets")); + .insert("tag:canopy-type".into(), "tamanu-blobs".into()); + assert_eq!(listed.tag("canopy-type"), Some("tamanu-blobs")); let mut bare = snapshot("b", "host-1", "/data", now); bare.tags - .insert("canopy-type".into(), "tamanu-secrets".into()); - assert_eq!(bare.tag("canopy-type"), Some("tamanu-secrets")); + .insert("canopy-type".into(), "tamanu-blobs".into()); + assert_eq!(bare.tag("canopy-type"), Some("tamanu-blobs")); assert_eq!( snapshot("c", "host-1", "/data", now).tag("canopy-type"), diff --git a/crates/kopia/tests/proxy_e2e.rs b/crates/kopia/tests/proxy_e2e.rs index ea86ff13..6c54bbe8 100644 --- a/crates/kopia/tests/proxy_e2e.rs +++ b/crates/kopia/tests/proxy_e2e.rs @@ -107,9 +107,9 @@ async fn full_lifecycle_through_proxy() { "snapshot".into(), "create".into(), "--description".into(), - "tamanu-secrets".into(), + "tamanu-blobs".into(), "--tags".into(), - "canopy-type:tamanu-secrets".into(), + "canopy-type:tamanu-blobs".into(), data.to_string_lossy().into_owned(), ]) .await; @@ -122,8 +122,8 @@ async fn full_lifecycle_through_proxy() { let [snapshot] = &listed[..] else { panic!("expected exactly one snapshot, got {}", listed.len()) }; - assert_eq!(snapshot.description, "tamanu-secrets"); - assert_eq!(snapshot.tag("canopy-type"), Some("tamanu-secrets")); + assert_eq!(snapshot.description, "tamanu-blobs"); + assert_eq!(snapshot.tag("canopy-type"), Some("tamanu-blobs")); let snapshot_id = snapshot.id.clone(); run(vec![