steid

@jamesgill /

steid/src/cli/restore.rs
Rust4.5 KBCode·Blame·History·Raw
1//! `steid restore <archive>`.
2
3use std::path::Path;
4
5use crate::{
6 application::{AppConfig, restore},
7 infrastructure::{
8 self,
9 backup::{SqliteDatabaseSnapshot, TarGzBackupArchive, database_file},
10 git::DiskGitStorage,
11 repository::SqliteUserRepo,
12 },
13};
14
15use super::BACKUP_WORKSPACE;
16
17/// Restores an archive into an empty instance.
18///
19/// Only ever into an instance with nothing in it. The refusals are the use case's, and
20/// they all happen before a byte is written — see
21/// [`restore`](crate::application::restore).
22///
23/// # Errors
24///
25/// Returns the reason as a sentence: the database URL names no file, or the use case
26/// refused — most often because the instance already holds a user.
27pub async fn run_restore(
28 config: &AppConfig,
29 archive: &Path,
30) -> Result<(), Box<dyn std::error::Error>> {
31 let database = database_file(&config.database_url)
32 .ok_or("STEID_DATABASE_URL names no file, so there is nothing to restore into")?;
33
34 // `connect`, not `open`: a fresh install has no database file at all, and a restore
35 // has to be able to ask "does this hold a user" before it can answer. Migrating an
36 // empty file costs nothing and the file is replaced wholesale a moment later anyway.
37 let pool = infrastructure::database::connect(&config.database_url).await?;
38
39 let workspace = config.data_dir.join(BACKUP_WORKSPACE);
40 tokio::fs::create_dir_all(&workspace).await?;
41
42 let outcome = restore(
43 archive,
44 &config.data_dir,
45 &workspace,
46 &SqliteUserRepo::new(pool.clone()),
47 &DiskGitStorage::new(config.data_dir.clone()),
48 &SqliteDatabaseSnapshot::new(pool.clone(), database),
49 &TarGzBackupArchive::new(),
50 )
51 .await;
52
53 let _ = tokio::fs::remove_dir_all(&workspace).await;
54
55 let restored = outcome?;
56 let counts = restored.manifest.counts;
57
58 println!(
59 "steid: restored a backup taken at {} by Steid {}",
60 restored.manifest.taken_at, restored.manifest.version
61 );
62 println!(
63 " {} user(s), {} organisation(s), {} repository/repositories into {}",
64 counts.users,
65 counts.organisations,
66 counts.repositories,
67 config.data_dir.display()
68 );
69 println!(" Start Steid to use it.");
70
71 Ok(())
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use crate::cli::fixture;
78
79 #[tokio::test]
80 async fn an_instance_that_already_has_a_user_is_refused() {
81 let instance = fixture::claimed("ada").await;
82
83 // The archive need not exist: the refusal happens before anything is read, which
84 // is the property worth pinning — a restore that got as far as unpacking would
85 // already have written into somebody's live data directory.
86 let error = run_restore(&instance.config, &instance.dir.path().join("absent.tar.gz"))
87 .await
88 .expect_err("a claimed instance is not restored into");
89
90 assert!(
91 error.to_string().contains("unclaimed instance"),
92 "{error} does not say why"
93 );
94 }
95
96 #[tokio::test]
97 async fn a_backup_of_a_claimed_instance_restores_into_an_empty_one() {
98 let source = fixture::claimed("ada").await;
99 let archive = source.dir.path().join("one.tar.gz");
100 crate::cli::run_backup(&source.config, &archive)
101 .await
102 .expect("the backup is written");
103
104 let target = fixture::empty().await;
105 // The pool the fixture opened holds the file this is about to replace; SQLite on
106 // macOS is content to overwrite underneath it, but the assertion below has to
107 // read the restored file rather than a cached page.
108 target.pool.close().await;
109
110 run_restore(&target.config, &archive)
111 .await
112 .expect("the restore succeeds");
113
114 let pool = crate::infrastructure::database::connect(&target.config.database_url)
115 .await
116 .expect("the restored database opens");
117 let users = crate::infrastructure::repository::SqliteUserRepo::new(pool);
118
119 assert!(
120 crate::application::is_claimed(&users)
121 .await
122 .expect("the query runs"),
123 "the restored instance has no owner"
124 );
125 }
126
127 #[tokio::test]
128 async fn the_workspace_does_not_survive_the_restore() {
129 let instance = fixture::claimed("ada").await;
130
131 let _ = run_restore(&instance.config, &instance.dir.path().join("absent.tar.gz")).await;
132
133 assert!(!instance.config.data_dir.join(BACKUP_WORKSPACE).exists());
134 }
135}