steid

@jamesgill /

steid/src/cli/backup.rs
Rust6.6 KBCode·Blame·History·Raw
1//! `steid backup <destination>`.
2
3use std::{path::Path, time::SystemTime};
4
5use crate::{
6 application::{AppConfig, archive_name, back_up},
7 infrastructure::{
8 self,
9 backup::{SqliteDatabaseSnapshot, TarGzBackupArchive, database_file},
10 repository::{SqliteOrgRepo, SqliteRepoRepo, SqliteUserRepo},
11 },
12};
13
14use super::BACKUP_WORKSPACE;
15
16/// Writes one archive of the database and the data directory, and says where.
17///
18/// Deliberately usable while the server is running — that is the whole point of a
19/// snapshot — and deliberately does **not** migrate: a control plane taking a backup
20/// must not change the schema of the database an older binary is serving from.
21///
22/// # Errors
23///
24/// Returns the reason as a sentence: the database URL names no file, the file does not
25/// exist yet, or the use case refused.
26pub async fn run_backup(
27 config: &AppConfig,
28 destination: &Path,
29) -> Result<(), Box<dyn std::error::Error>> {
30 let now = SystemTime::now();
31
32 // A directory means "put one in here", which is what a cron job passes. Resolved out
33 // here rather than in the use case because it is the one question that needs the
34 // filesystem to answer it.
35 let destination = if destination.is_dir() {
36 destination.join(archive_name(now))
37 } else {
38 destination.to_path_buf()
39 };
40
41 let database = database_file(&config.database_url)
42 .ok_or("STEID_DATABASE_URL names no file, so there is nothing to back up")?;
43
44 // Checked before opening, because `mode=rwc` would create the file and the backup
45 // would then fail deep inside a count with "no such table: users". An instance that
46 // has never started has nothing to back up, and saying so here is the difference
47 // between a usable message and a SQLite error code.
48 if !database.exists() {
49 return Err(format!(
50 "{} does not exist yet — start Steid once before backing it up",
51 database.display()
52 )
53 .into());
54 }
55
56 let pool = infrastructure::database::open(&config.database_url).await?;
57 let workspace = config.data_dir.join(BACKUP_WORKSPACE);
58 tokio::fs::create_dir_all(&workspace).await?;
59
60 let outcome = back_up(
61 now,
62 &destination,
63 &config.data_dir,
64 &workspace,
65 &SqliteUserRepo::new(pool.clone()),
66 &SqliteOrgRepo::new(pool.clone()),
67 &SqliteRepoRepo::new(pool.clone()),
68 &SqliteDatabaseSnapshot::new(pool.clone(), database),
69 &TarGzBackupArchive::new(),
70 )
71 .await;
72
73 // Before the `?`: a failure leaves a full copy of the database in the workspace, and
74 // that is exactly the case where nobody is watching to clean it up.
75 let _ = tokio::fs::remove_dir_all(&workspace).await;
76 pool.close().await;
77
78 let written = outcome?;
79 let counts = written.manifest.counts;
80
81 println!("steid: wrote {}", written.path.display());
82 println!(
83 " {} · taken at {} · {} user(s), {} organisation(s), {} repository/repositories",
84 human_bytes(written.bytes),
85 written.manifest.taken_at,
86 counts.users,
87 counts.organisations,
88 counts.repositories
89 );
90
91 Ok(())
92}
93
94/// A size somebody can read at a glance. Powers of two, one decimal place.
95fn human_bytes(bytes: u64) -> String {
96 const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
97
98 let mut size = bytes as f64;
99 let mut unit = 0;
100
101 while size >= 1024.0 && unit + 1 < UNITS.len() {
102 size /= 1024.0;
103 unit += 1;
104 }
105
106 if unit == 0 {
107 format!("{bytes} {}", UNITS[0])
108 } else {
109 format!("{size:.1} {}", UNITS[unit])
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::cli::fixture;
117
118 #[tokio::test]
119 async fn a_backup_of_a_claimed_instance_lands_in_a_directory_it_is_given() {
120 let instance = fixture::claimed("ada").await;
121 let out = instance.dir.path().join("out");
122 std::fs::create_dir_all(&out).expect("mkdir");
123
124 run_backup(&instance.config, &out)
125 .await
126 .expect("the backup is written");
127
128 // A directory means "put one in here", so the name is the use case's and the
129 // only thing this can assert is that exactly one archive appeared.
130 let written: Vec<_> = std::fs::read_dir(&out)
131 .expect("readable")
132 .map(|entry| entry.expect("an entry").path())
133 .collect();
134
135 assert_eq!(written.len(), 1, "expected one archive in {written:?}");
136 assert!(
137 written[0]
138 .file_name()
139 .expect("a name")
140 .to_string_lossy()
141 .ends_with(".tar.gz"),
142 "{written:?} is not an archive"
143 );
144 assert!(
145 std::fs::metadata(&written[0]).expect("stat").len() > 0,
146 "the archive is empty"
147 );
148 }
149
150 #[tokio::test]
151 async fn the_workspace_does_not_survive_the_backup() {
152 // A full copy of the database is written into `.backup` on the way; leaving it
153 // behind would double the disk a scheduled backup costs, silently.
154 let instance = fixture::claimed("ada").await;
155
156 run_backup(&instance.config, &instance.dir.path().join("one.tar.gz"))
157 .await
158 .expect("the backup is written");
159
160 assert!(!instance.config.data_dir.join(BACKUP_WORKSPACE).exists());
161 }
162
163 #[tokio::test]
164 async fn an_instance_that_has_never_started_is_refused_by_name() {
165 // `mode=rwc` would happily create the file and the backup would then fail deep
166 // inside a count with "no such table: users".
167 let dir = tempfile::tempdir().expect("a temp dir");
168 let config = AppConfig {
169 database_url: format!("sqlite:{}?mode=rwc", dir.path().join("absent.db").display()),
170 data_dir: dir.path().to_path_buf(),
171 insecure_cookies: false,
172 allow_private_webhooks: false,
173 trusted_proxy: false,
174 operator_token: None,
175 setup_token: None,
176 backup_marker: None,
177 smtp_url: None,
178 mail_from: None,
179 public_url: None,
180 };
181
182 let error = run_backup(&config, &dir.path().join("out.tar.gz"))
183 .await
184 .expect_err("there is nothing to back up");
185
186 assert!(
187 error.to_string().contains("start Steid once"),
188 "{error} does not say what to do"
189 );
190 }
191
192 #[test]
193 fn a_size_is_readable_at_a_glance() {
194 assert_eq!(human_bytes(0), "0 B");
195 assert_eq!(human_bytes(1023), "1023 B");
196 assert_eq!(human_bytes(1024), "1.0 KiB");
197 assert_eq!(human_bytes(1024 * 1024 * 3 / 2), "1.5 MiB");
198 }
199}