Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ k8s-openapi = { version = "0.28.0", features = ["latest", "schemars"] }
kube = { version = "4.0.0", default-features = false, features = ["runtime", "derive", "client", "rustls-tls", "ws"] }
kube_quantity = "0.9.0"
mimalloc = { version = "0.1.52", default-features = false }
percent-encoding = "2.3.2"
prometheus = "0.14.0"
rand = "0.10.1"
reqwest = { version = "0.13.4", features = ["json", "socks"] }
Expand All @@ -29,6 +30,7 @@ serde_json = "1.0.150"
serde_yaml = "0.9.34"
sha2 = "0.11.0"
socket2 = "0.6.2"
strip-ansi-escapes = "0.2.1"
thiserror = "2.0.18"
tokio = { version = "1.50.0", features = ["full"] }
tokio-postgres = { version = "0.7.18", features = ["with-serde_json-1"] }
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ Defines a continuously-refreshed replica of a PostgreSQL database restored from
| `postgresExtraConfig` | `string` | No | — | Extra lines appended to `postgresql.conf` (e.g. `shared_preload_libraries`). |
| `notifications` | `[]NotificationConfig` | No | `[]` | Notification targets called on restore events. |
| `persistentSchemas` | `[]string` | No | — | List of schema names to migrate from the previous restore to the new restore on each switchover. See [Persistent schemas](#persistent-schemas) below for the migration time budget and what happens on timeout. |
| `preMigrateDropSchemas` | `[]string` | No | — | Schemas dropped from the restore before its migration Job runs. Views over a table a migration alters block the DDL, so a deployment that drops and regenerates such schemas around a real upgrade names them here and the test starts from the same place. `public`, `information_schema` and `pg_*` are refused. Set from the intent's `pre_migrate_drop_schemas` param, which the `upgrade` intent defaults to `reporting`. |
| `preMigrateSql` | `string` | No | — | SQL run against the restore after the schema drops and before its migration Job, for starting-state changes a drop can't express. Set from the intent's `pre_migrate_sql` param, which the `upgrade` intent defaults to calling Tamanu's `forget_server_identity()` where the snapshot has it and soft-deleting the `S1:` encrypted values in `local_system_secrets` and `local_system_facts`, so the copy forgets the source server and the upgrade's config key check passes without the source deployment's key. |
| `preMigrateDropSchemas` | `[]string` | No | — | Schemas dropped from the restore before its migration Job runs. Views over a table a migration alters block the DDL, so a deployment that drops and regenerates such schemas around a real upgrade names them here and the test starts from the same place. `public`, `information_schema` and `pg_*` are refused. Set from the intent's `pre_migrate_drop_schemas` param, which the `upgrade` and `reporting-schema` intents default to `reporting`. |
| `preMigrateSql` | `string` | No | — | SQL run against the restore after the schema drops and before its migration Job, for starting-state changes a drop can't express. Set from the intent's `pre_migrate_sql` param, which the `upgrade` and `reporting-schema` intents default to calling Tamanu's `forget_server_identity()` where the snapshot has it and soft-deleting the `S1:` encrypted values in `local_system_secrets` and `local_system_facts`, so the copy forgets the source server and the upgrade's config key check passes without the source deployment's key. |
| `redaction` | `RedactionSpec` | No | — | If set, apply a Tamanu/dbt-shaped masking manifest to the restored data via the `postgresql_anonymizer` extension before switchover. See [RedactionSpec](#redactionspec) below. |
| `migrateTo` | `MigrationTarget` | No | — | Apply a Tamanu version's schema migrations to each restore of this replica. `{ version, versionId }`. Set by the canopy worklist syncer by two routes: for the `upgrade` intent from the target version canopy names on the worklist entry, whose outcome is reported back as a version-readiness signal; and for an `analytics` replica from its operator-chosen `migrate_to` param, keeping a persistent upgraded query replica, whose outcome is not reported. A restore carrying this is built read-write, since migrations are DDL. |
| `builderImage` | `string` | No | — | Image that builds a Tamanu reporting schema against each migrated restore. Set by the canopy worklist syncer from the `reporting-schema` intent's `builder_image` param. pgro hands the image a database, a version and a group and takes back the SQL it emits over a callback; how a schema is made is the image's business. Its presence is what sends the restore through a build before switchover. |
Expand Down
48 changes: 32 additions & 16 deletions src/controllers/canopy/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,17 @@ const DEFAULT_ANALYTICS_MINIMUM_TTL_SECS: i64 = 7200;
const DEFAULT_ANALYTICS_SWITCHOVER_GRACE_SECS: i64 = 120;
/// Every tamanu deployment carries the reporting schema, and a real upgrade
/// drops and regenerates it because its views over altered tables block the
/// DDL. An `upgrade` replica that left this unset would fail on something the
/// DDL. A migrating replica that left this unset would fail on something the
/// deployment it models never meets.
const DEFAULT_UPGRADE_DROP_SCHEMAS: &str = "reporting";
const DEFAULT_PRE_MIGRATE_DROP_SCHEMAS: &str = "reporting";

/// Tamanu's upgrade refuses to start when a live `S1:` encrypted value in these
/// tables doesn't decrypt with the configured key, and the migration Job only
/// has the default key, so those are soft-deleted. Tamanu's
/// `forget_server_identity()`, where the snapshot has it, also clears the
/// plaintext facts that would point the copy at the source's central; it runs
/// only once the transaction declares the database a copy.
pub const DEFAULT_UPGRADE_PRE_MIGRATE_SQL: &str = r#"DO $$
pub const DEFAULT_PRE_MIGRATE_SQL: &str = r#"DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('public.forget_server_identity()') IS NOT NULL THEN
Expand Down Expand Up @@ -190,15 +190,15 @@ fn upgrade_param_schema() -> ParamSchema {
ParamSchema(HashMap::from([
(
params::PRE_MIGRATE_DROP_SCHEMAS.to_string(),
param(ParamType::Text, Some(json!(DEFAULT_UPGRADE_DROP_SCHEMAS))),
),
(
params::PRE_MIGRATE_SQL.to_string(),
param(
ParamType::Text,
Some(json!(DEFAULT_UPGRADE_PRE_MIGRATE_SQL)),
Some(json!(DEFAULT_PRE_MIGRATE_DROP_SCHEMAS)),
),
),
(
params::PRE_MIGRATE_SQL.to_string(),
param(ParamType::Text, Some(json!(DEFAULT_PRE_MIGRATE_SQL))),
),
]))
}

Expand All @@ -213,11 +213,14 @@ fn reporting_schema_param_schema() -> ParamSchema {
),
(
params::PRE_MIGRATE_DROP_SCHEMAS.to_string(),
param(ParamType::Text, None),
param(
ParamType::Text,
Some(json!(DEFAULT_PRE_MIGRATE_DROP_SCHEMAS)),
),
),
(
params::PRE_MIGRATE_SQL.to_string(),
param(ParamType::Text, None),
param(ParamType::Text, Some(json!(DEFAULT_PRE_MIGRATE_SQL))),
),
]))
}
Expand Down Expand Up @@ -839,7 +842,7 @@ mod tests {

#[test]
fn pre_migrate_drop_schemas_parses_on_every_migrating_intent() {
for intent in ["analytics", "upgrade"] {
for intent in ["analytics", "upgrade", "reporting-schema"] {
let spec = config_for(intent).unwrap().to_replica_spec(
&entry(
intent,
Expand Down Expand Up @@ -889,20 +892,20 @@ mod tests {
#[test]
fn upgrade_default_forgets_identity_and_hides_secrets() {
assert!(
DEFAULT_UPGRADE_PRE_MIGRATE_SQL.contains("PERFORM public.forget_server_identity();"),
DEFAULT_PRE_MIGRATE_SQL.contains("PERFORM public.forget_server_identity();"),
"the default calls Tamanu's function when the snapshot has it"
);
assert!(
DEFAULT_UPGRADE_PRE_MIGRATE_SQL.contains(
DEFAULT_PRE_MIGRATE_SQL.contains(
"PERFORM set_config('tamanu.this_is_a_copy', 'true', true);\n PERFORM public.forget_server_identity();"
),
"the function refuses unless the same transaction declares a copy"
);
assert!(
DEFAULT_UPGRADE_PRE_MIGRATE_SQL.contains("SET deleted_at = now()"),
DEFAULT_PRE_MIGRATE_SQL.contains("SET deleted_at = now()"),
"the encrypted values the job's key can't read are always hidden"
);
assert!(!DEFAULT_UPGRADE_PRE_MIGRATE_SQL.contains("RETURN;"));
assert!(!DEFAULT_PRE_MIGRATE_SQL.contains("RETURN;"));
}

#[test]
Expand Down Expand Up @@ -1016,7 +1019,7 @@ mod tests {
// read, so the default hides them.
assert_eq!(
upgrade_params.get(params::PRE_MIGRATE_SQL).unwrap().default,
Some(json!(DEFAULT_UPGRADE_PRE_MIGRATE_SQL))
Some(json!(DEFAULT_PRE_MIGRATE_SQL))
);
let drop_schemas = upgrade_params
.get(params::PRE_MIGRATE_DROP_SCHEMAS)
Expand Down Expand Up @@ -1048,6 +1051,19 @@ mod tests {
build_params.get(params::BUILDER_IMAGE).unwrap().type_,
ParamType::Text
);
// A build restores a real site, carrying its reporting views and its
// encrypted secrets, and migrates it just as an upgrade test does.
assert_eq!(
build_params.get(params::PRE_MIGRATE_SQL).unwrap().default,
upgrade_params.get(params::PRE_MIGRATE_SQL).unwrap().default,
);
assert_eq!(
build_params
.get(params::PRE_MIGRATE_DROP_SCHEMAS)
.unwrap()
.default,
Some(json!("reporting"))
);
assert!(build.description.is_some());

let analytics = &ds[2];
Expand Down
Loading
Loading