kasl_server/backup.rs
1//! `kasl-server backup` and `restore`: the installation's data as one file.
2//!
3//! An operator running this on their own hardware owns the consequences of
4//! losing it, so taking a copy has to be something they can do without knowing
5//! PostgreSQL. `pg_dump` is the better tool for someone who already has a
6//! backup regime; this is for everyone else, and it does two things `pg_dump`
7//! cannot:
8//!
9//! * **It knows the schema version.** A dump carries the migration it was
10//! taken at, and a restore refuses a file from a newer server rather than
11//! loading rows into tables whose columns have since changed. The failure
12//! mode being avoided is not an error - it is a restore that appears to work
13//! and quietly drops what the older schema has no place for.
14//! * **It is the same binary.** No `postgres-client` package on the host, no
15//! version skew between a client and the server it dumps.
16//!
17//! JSON Lines rather than SQL: each line is a table's worth of rows, so a file
18//! can be read by anything, checked by eye, and streamed without holding the
19//! installation in memory.
20
21use anyhow::{Context, Result, bail};
22use serde::{Deserialize, Serialize};
23use sqlx::{PgPool, Row, postgres::PgRow};
24use std::io::{BufRead, Write};
25
26/// Tables in the order they must be written and loaded.
27///
28/// Parents first: every foreign key points at a table earlier in this list, so
29/// a restore that walks it in order never inserts a row whose owner is missing.
30/// `_sqlx_migrations` is deliberately absent - the schema is applied by the
31/// server, not carried in the file.
32///
33/// `users` and `departments` are the exception no ordering can solve: a person
34/// belongs to a department and a department names its manager, so each points
35/// at the other. See [`DEFERRED_COLUMNS`].
36const TABLES: [&str; 11] = [
37 "users",
38 "departments",
39 "agents",
40 "sessions",
41 "workdays",
42 "pauses",
43 "tasks",
44 "tags",
45 "task_tags",
46 "reports",
47 "audit_log",
48];
49
50/// Columns held back on insert and written once every table is loaded.
51///
52/// The one cycle in this schema: `users.department_id` points at a department,
53/// and `departments.manager_id` points back at a user. Whichever goes in first
54/// has a column referring to a row that does not exist yet, so that column
55/// waits - the row is inserted without it and updated at the end.
56///
57/// The alternative, making the constraints `DEFERRABLE`, would loosen them for
58/// every ordinary write in the product to serve one command that runs rarely.
59const DEFERRED_COLUMNS: [(&str, &str); 2] = [("users", "department_id"), ("departments", "manager_id")];
60
61/// Tables the restore replaces wholesale but never empties from the file.
62///
63/// `settings` is one row created by a migration. Deleting it and inserting the
64/// backup's copy would work, but leaving the row alone and updating it keeps
65/// the `CHECK (singleton)` constraint honest at every moment.
66const SETTINGS: &str = "settings";
67
68/// What the file says about itself, on its first line.
69#[derive(Debug, Serialize, Deserialize)]
70pub struct Header {
71 /// Always `kasl-server-backup`, so a file fed to the wrong tool says so.
72 pub format: String,
73 /// The migration the source database was at. The restore's gate.
74 pub schema_version: i64,
75 /// The version that wrote it - for a human reading the file, not a check.
76 pub server_version: String,
77 pub taken_at: chrono::DateTime<chrono::Utc>,
78}
79
80/// The marker every backup starts with.
81const FORMAT: &str = "kasl-server-backup";
82
83/// One table's rows.
84#[derive(Debug, Serialize, Deserialize)]
85struct Chunk {
86 table: String,
87 rows: Vec<serde_json::Value>,
88}
89
90/// Writes the whole installation to `out`.
91///
92/// Rows are read a table at a time and written as they come, so the file is
93/// produced without the installation ever being resident in memory.
94pub async fn dump(pool: &PgPool, schema_version: i64, out: &mut impl Write) -> Result<Summary> {
95 let header = Header {
96 format: FORMAT.to_string(),
97 schema_version,
98 server_version: env!("CARGO_PKG_VERSION").to_string(),
99 taken_at: chrono::Utc::now(),
100 };
101 writeln!(out, "{}", serde_json::to_string(&header)?)?;
102
103 let mut summary = Summary::default();
104
105 for table in TABLES.into_iter().chain([SETTINGS]) {
106 // `row_to_json` hands the whole row over as JSON, so this module does
107 // not need a struct per table - and a column added by a later
108 // migration travels without anything here being edited.
109 // `AssertSqlSafe` on a name from `TABLES`, a constant in this file.
110 let rows: Vec<serde_json::Value> = sqlx::query(sqlx::AssertSqlSafe(format!("SELECT row_to_json(t) AS row FROM {table} AS t")))
111 .fetch_all(pool)
112 .await
113 .with_context(|| format!("failed to read {table}"))?
114 .into_iter()
115 .map(|row: PgRow| row.get::<serde_json::Value, _>("row"))
116 .collect();
117
118 summary.rows += rows.len();
119 summary.tables += 1;
120 writeln!(
121 out,
122 "{}",
123 serde_json::to_string(&Chunk {
124 table: table.to_string(),
125 rows,
126 })?
127 )?;
128 }
129
130 out.flush()?;
131 Ok(summary)
132}
133
134/// What a backup or restore moved.
135#[derive(Debug, Default, PartialEq, Eq)]
136pub struct Summary {
137 pub tables: usize,
138 pub rows: usize,
139}
140
141/// Reads a backup into an empty installation.
142///
143/// Refuses rather than merges: a restore into a database with data in it would
144/// have to decide what wins, and every answer to that is wrong for somebody.
145/// The operator empties the database - or points at a fresh one - and the
146/// intent is theirs rather than inferred.
147pub async fn load(pool: &PgPool, schema_version: i64, input: impl BufRead) -> Result<Summary> {
148 let mut lines = input.lines();
149
150 let header: Header = {
151 let first = lines.next().context("the backup is empty")??;
152 serde_json::from_str(&first).context("the first line is not a kasl-server backup header")?
153 };
154
155 if header.format != FORMAT {
156 bail!("this is not a kasl-server backup (format: {})", header.format);
157 }
158
159 // The gate this module exists for. A dump from a newer server may contain
160 // columns this schema has no home for; loading it would drop them silently
161 // and look like a success.
162 if header.schema_version > schema_version {
163 bail!(
164 "the backup is from a newer server (schema {} against this server's {}); upgrade kasl-server before restoring",
165 header.schema_version,
166 schema_version
167 );
168 }
169
170 ensure_empty(pool).await?;
171
172 let mut summary = Summary::default();
173 // One transaction: a restore that stopped halfway would leave an
174 // installation whose rows reference owners that were never loaded.
175 let mut tx = pool.begin().await?;
176
177 // What the cycle forced us to leave out, to be written once every row it
178 // could point at exists: (table, column, row id, value).
179 let mut deferred: Vec<(&'static str, &'static str, serde_json::Value, serde_json::Value)> = Vec::new();
180
181 for line in lines {
182 let line = line?;
183 if line.trim().is_empty() {
184 continue;
185 }
186 let chunk: Chunk = serde_json::from_str(&line).context("a line of the backup could not be read")?;
187
188 summary.tables += 1;
189 for row in &chunk.rows {
190 let held = insert(&mut tx, &chunk.table, row).await?;
191 deferred.extend(held);
192 summary.rows += 1;
193 }
194 }
195
196 for (table, column, id, value) in deferred {
197 // Both names come from `DEFERRED_COLUMNS`, constants in this file.
198 sqlx::query(sqlx::AssertSqlSafe(format!(
199 "UPDATE {table} SET {column} = ($1::text)::uuid WHERE id = ($2::text)::uuid"
200 )))
201 .bind(value.as_str().unwrap_or_default())
202 .bind(id.as_str().unwrap_or_default())
203 .execute(&mut *tx)
204 .await
205 .with_context(|| format!("failed to restore {table}.{column}"))?;
206 }
207
208 tx.commit().await?;
209 Ok(summary)
210}
211
212/// The one table name in this module that does not come from its own source.
213///
214/// A restore reads names out of a file, and a file can say anything. Matching
215/// against the known list turns that name back into a constant before it
216/// reaches a statement - the check is that the name *is* one of ours, not that
217/// it looks harmless.
218fn known_table(name: &str) -> Result<&'static str> {
219 TABLES
220 .into_iter()
221 .chain([SETTINGS])
222 .find(|table| *table == name)
223 .with_context(|| format!("the backup names a table this server does not have: {name}"))
224}
225
226/// Inserts one row given as a JSON object.
227///
228/// `json_populate_record` turns the object back into a row of the table's own
229/// type, so the column list lives in the database rather than being repeated
230/// here for every table.
231/// One value held back until the rest of the restore has caught up.
232type Deferred = (&'static str, &'static str, serde_json::Value, serde_json::Value);
233
234async fn insert(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, table: &str, row: &serde_json::Value) -> Result<Vec<Deferred>> {
235 let table = known_table(table)?;
236
237 // Take out the column that points into the cycle, if this table has one
238 // and this row actually uses it. A null needs nothing held back.
239 let mut held = Vec::new();
240 let mut row = row.clone();
241 for (owner, column) in DEFERRED_COLUMNS {
242 if owner != table {
243 continue;
244 }
245 let Some(object) = row.as_object_mut() else { continue };
246 match object.get(column) {
247 Some(serde_json::Value::Null) | None => {}
248 Some(value) => {
249 let value = value.clone();
250 let id = object.get("id").cloned().unwrap_or(serde_json::Value::Null);
251 object.insert(column.to_string(), serde_json::Value::Null);
252 held.push((owner, column, id, value));
253 }
254 }
255 }
256 let row = &row;
257
258 if table == SETTINGS {
259 // The singleton row already exists, put there by the migration.
260 // `coalesce` on `demo`: a backup taken before the column existed has
261 // no key for it, `json_populate_record` reads that as NULL, and a
262 // restore of a perfectly good older file must not fail on the
263 // column it could not have known about.
264 sqlx::query(sqlx::AssertSqlSafe(format!(
265 "UPDATE {SETTINGS} SET privacy_level = (r).privacy_level,
266 demo = coalesce((r).demo, false),
267 updated_at = (r).updated_at
268 FROM (SELECT json_populate_record(NULL::{SETTINGS}, $1::json) AS r) AS s
269 WHERE singleton"
270 )))
271 .bind(row)
272 .execute(&mut **tx)
273 .await
274 .with_context(|| format!("failed to restore {table}"))?;
275 return Ok(held);
276 }
277
278 sqlx::query(sqlx::AssertSqlSafe(format!(
279 "INSERT INTO {table} SELECT * FROM json_populate_record(NULL::{table}, $1::json)"
280 )))
281 .bind(row)
282 .execute(&mut **tx)
283 .await
284 .with_context(|| format!("failed to restore a row of {table}"))?;
285 Ok(held)
286}
287
288/// Refuses to restore over an installation that already holds people.
289///
290/// `users` alone: everything else hangs off it, and an operator who has
291/// started a server but never signed anyone in should not have to empty a
292/// database to restore into it.
293async fn ensure_empty(pool: &PgPool) -> Result<()> {
294 let users: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(pool).await?;
295 if users > 0 {
296 bail!("this database already holds {users} accounts; restore into an empty one");
297 }
298 Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn a_backup_names_itself_and_its_schema() {
307 // The header is the whole contract with a future restore: a file that
308 // did not say which schema it came from could only be loaded on faith.
309 let header = Header {
310 format: FORMAT.to_string(),
311 schema_version: 20260826000001,
312 server_version: "0.14.0".to_string(),
313 taken_at: chrono::Utc::now(),
314 };
315 let json = serde_json::to_string(&header).unwrap();
316 let read: Header = serde_json::from_str(&json).unwrap();
317
318 assert_eq!(read.format, FORMAT);
319 assert_eq!(read.schema_version, 20260826000001);
320 }
321
322 #[test]
323 fn parents_come_before_their_children() {
324 // The restore inserts in this order, so a table must never appear
325 // before one it points at. Checked as a property rather than by eye:
326 // a table added in the wrong place fails here instead of failing a
327 // customer's restore with a foreign key violation.
328 let position = |table: &str| TABLES.iter().position(|t| *t == table).unwrap_or_else(|| panic!("{table} is not in TABLES"));
329
330 for (child, parent) in [
331 ("agents", "users"),
332 ("sessions", "users"),
333 ("workdays", "users"),
334 ("pauses", "workdays"),
335 ("tasks", "users"),
336 ("tags", "users"),
337 ("task_tags", "tasks"),
338 ("task_tags", "tags"),
339 ("reports", "users"),
340 ("departments", "users"),
341 ] {
342 assert!(position(parent) < position(child), "{parent} must be restored before {child}");
343 }
344 }
345
346 #[test]
347 fn a_table_name_from_a_file_has_to_be_one_of_ours() {
348 // The only name in this module that does not come from its own source
349 // arrives inside a backup, and a file can say anything. Every table
350 // reaches a statement only after being matched back to a constant.
351 assert_eq!(known_table("users").unwrap(), "users");
352 assert_eq!(known_table(SETTINGS).unwrap(), SETTINGS);
353
354 for hostile in ["users; DROP TABLE users", "pg_shadow", "_sqlx_migrations", "users --", ""] {
355 let error = known_table(hostile).unwrap_err();
356 assert!(error.to_string().contains("does not have"), "`{hostile}` must be refused by name, got: {error}");
357 }
358 }
359
360 #[test]
361 fn every_table_is_listed_once() {
362 let mut seen = TABLES.to_vec();
363 seen.sort_unstable();
364 let count = seen.len();
365 seen.dedup();
366 assert_eq!(seen.len(), count, "a table listed twice would be restored twice");
367 assert!(!TABLES.contains(&SETTINGS), "settings is updated, not inserted, so it must not be in TABLES");
368 assert!(
369 !TABLES.contains(&"_sqlx_migrations"),
370 "the schema is applied by the server, not carried in a backup"
371 );
372 }
373}