dbinit.rs (10338B)
1 //! KyCH database initialisation and migration. 2 //! 3 //! Creates the `oauth2gw` schema in a fresh database and brings an existing one 4 //! up to the patch level of the installed SQL files. Both are the same 5 //! operation: every schema change is a numbered patch file 6 //! (`oauth2gw-000N.sql`) that registers itself with the depesz `Versioning` 7 //! bookkeeping in the `_v` schema, and this tool applies those that the 8 //! database does not have yet, in order. 9 //! 10 //! It is idempotent and says nothing but which patches it applied, so that it 11 //! can run unattended from the Debian package's postinst on every upgrade. 12 //! 13 //! Usage: 14 //! kych-dbinit [-c kych.conf] [--sql-dir DIR] [--dry-run] 15 //! kych-dbinit [-c kych.conf] --print-connection 16 17 use std::collections::HashSet; 18 use std::path::{Path, PathBuf}; 19 20 use anyhow::{Context, Result, anyhow, bail}; 21 use clap::Parser; 22 use kych_oauth2_gateway_lib::config::{CONFIG_SOURCE, Config}; 23 use sqlx::{Connection, Executor, PgConnection, Row}; 24 use taler_config::{CommonArgs, taler_main}; 25 26 /// Bookkeeping, loaded before the first patch. Idempotent, so it is applied 27 /// unconditionally rather than guessed at. 28 const VERSIONING_SQL: &str = "versioning.sql"; 29 30 /// Patch files are `<PATCH_PREFIX><number>.sql`, and the file name without the 31 /// extension is the patch name they register. 32 const PATCH_PREFIX: &str = "oauth2gw-"; 33 34 #[derive(Parser, Debug)] 35 #[command(name = "kych-dbinit")] 36 #[command(version)] 37 #[command(about = "initialise and upgrade the KyCH database schema")] 38 struct Args { 39 #[command(flatten)] 40 common: CommonArgs, 41 42 /// Directory holding versioning.sql and the oauth2gw-*.sql patches 43 /// (default: SQL_DIR from the configuration) 44 #[arg(long)] 45 sql_dir: Option<PathBuf>, 46 47 /// Report what would be applied, without changing the database 48 #[arg(long, short = 'n')] 49 dry_run: bool, 50 51 /// Print the connection the configuration names as PG* environment 52 /// assignments and exit, without connecting. This is how kych-dbconfig 53 /// learns where the database is without having to parse kych.conf itself. 54 #[arg(long)] 55 print_connection: bool, 56 } 57 58 /// Exit status for "there is no database here (yet)": either it does not 59 /// exist or the server cannot be reached. The Debian package's postinst 60 /// tells this apart from a real failure, because on a first install it is the 61 /// expected outcome and must not make the installation fail. 62 const EXIT_NO_DATABASE: i32 = 2; 63 64 fn main() { 65 let args = Args::parse(); 66 67 taler_main(CONFIG_SOURCE, args.common, async |cfg| { 68 let config = Config::parse(cfg)?; 69 if args.print_connection { 70 print_connection(&config)?; 71 return Ok(()); 72 } 73 let sql_dir = match args.sql_dir { 74 Some(dir) => dir, 75 None => PathBuf::from(&config.sql_dir), 76 }; 77 run(&config, &sql_dir, args.dry_run).await 78 }) 79 } 80 81 async fn run(config: &Config, sql_dir: &Path, dry_run: bool) -> Result<()> { 82 let patches = available_patches(sql_dir)?; 83 if patches.is_empty() { 84 bail!( 85 "no {}*.sql patch file in {}", 86 PATCH_PREFIX, 87 sql_dir.display() 88 ); 89 } 90 91 let mut conn = match PgConnection::connect_with(&config.database).await { 92 Ok(conn) => conn, 93 Err(err) if is_absent_database(&err) => { 94 // Not an error worth a stack of context: on a first install this 95 // is simply the state before kych-dbconfig has run. 96 eprintln!( 97 "No database at {}: {}", 98 describe_target(config), 99 err 100 ); 101 eprintln!("Run kych-dbconfig to create it."); 102 std::process::exit(EXIT_NO_DATABASE); 103 } 104 Err(err) => return Err(err).context("Failed to connect to database"), 105 }; 106 107 if dry_run { 108 // Without the bookkeeping there is nothing to compare against, and 109 // creating it would be a change: everything is pending in that case. 110 let applied = if versioning_present(&mut conn).await? { 111 applied_patches(&mut conn).await? 112 } else { 113 println!("{VERSIONING_SQL} would be loaded"); 114 HashSet::new() 115 }; 116 let mut pending = 0; 117 for (name, path) in &patches { 118 if !applied.contains(name) { 119 println!("{} would be applied", path.display()); 120 pending += 1; 121 } 122 } 123 if pending == 0 { 124 println!("database is at the latest patch level"); 125 } 126 return Ok(()); 127 } 128 129 // Idempotent: CREATE SCHEMA / TABLE IF NOT EXISTS and CREATE OR REPLACE 130 // FUNCTION throughout, so an existing installation is left as it is. 131 execute_sql_file(&mut conn, &sql_dir.join(VERSIONING_SQL)).await?; 132 133 let applied = applied_patches(&mut conn).await?; 134 let mut count = 0; 135 for (name, path) in &patches { 136 if applied.contains(name) { 137 continue; 138 } 139 tracing::info!(target: "dbinit", "applying patch {}", name); 140 execute_sql_file(&mut conn, path).await?; 141 println!("applied {name}"); 142 count += 1; 143 } 144 145 if count == 0 { 146 tracing::info!(target: "dbinit", "database is at the latest patch level"); 147 } 148 149 conn.close().await.context("Failed to close connection")?; 150 Ok(()) 151 } 152 153 /// Whether the connection failed because there is nothing to connect to: the 154 /// database does not exist (PostgreSQL `invalid_catalog_name`), or the server 155 /// is not listening where the configuration says it is. Anything else -- a 156 /// rejected password, a TLS failure, a protocol error -- is a real problem 157 /// and is reported as one. 158 fn is_absent_database(err: &sqlx::Error) -> bool { 159 match err { 160 sqlx::Error::Database(db_err) => db_err.code().as_deref() == Some("3D000"), 161 sqlx::Error::Io(_) => true, 162 _ => false, 163 } 164 } 165 166 /// Where the configured connection goes. `?host=/some/dir` in the DATABASE 167 /// URI makes sqlx connect over that Unix socket directory and leaves the TCP 168 /// host at its default, so the socket has to be asked for first or the answer 169 /// is a plausible-looking lie about localhost. 170 fn connection_host(config: &Config) -> String { 171 match config.database.get_socket() { 172 Some(socket) => socket.to_string_lossy().into_owned(), 173 None => config.database.get_host().to_owned(), 174 } 175 } 176 177 /// Where we tried to connect, for a message that is useful without having to 178 /// go and read the configuration file. 179 fn describe_target(config: &Config) -> String { 180 format!( 181 "{}@{}:{}", 182 config.database.get_database().unwrap_or("?"), 183 connection_host(config), 184 config.database.get_port() 185 ) 186 } 187 188 /// `PGDATABASE`/`PGHOST`/`PGPORT` assignments for a shell to eval. Only 189 /// these three, and deliberately not PGUSER: the caller connects as the 190 /// PostgreSQL administrator, not as the daemon. 191 fn print_connection(config: &Config) -> Result<()> { 192 let name = config 193 .database 194 .get_database() 195 .ok_or_else(|| anyhow!("DATABASE in the configuration names no database"))?; 196 let host = connection_host(config); 197 // A shell would mangle anything exotic, and a database or socket path 198 // with a quote in it is a configuration mistake rather than a use case. 199 for (name, value) in [("PGDATABASE", name), ("PGHOST", host.as_str())] { 200 if value.contains(['\'', '"', '$', '`', '\\', '\n']) { 201 bail!("{name} value '{value}' contains a character a shell would mangle"); 202 } 203 } 204 println!("PGDATABASE='{name}'"); 205 println!("PGHOST='{host}'"); 206 println!("PGPORT='{}'", config.database.get_port()); 207 Ok(()) 208 } 209 210 /// The patch files in `sql_dir`, as (patch name, path), ordered by the number 211 /// in the file name -- `oauth2gw-0002.sql` must not be applied before 212 /// `oauth2gw-0001.sql`, and `register_patch` would refuse it anyway if it 213 /// declared the earlier one as a requirement. 214 fn available_patches(sql_dir: &Path) -> Result<Vec<(String, PathBuf)>> { 215 let entries = std::fs::read_dir(sql_dir) 216 .with_context(|| format!("Failed to read SQL directory {}", sql_dir.display()))?; 217 218 let mut patches = Vec::new(); 219 for entry in entries { 220 let path = entry 221 .with_context(|| format!("Failed to read SQL directory {}", sql_dir.display()))? 222 .path(); 223 let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { 224 continue; 225 }; 226 if path.extension().and_then(|e| e.to_str()) != Some("sql") { 227 continue; 228 } 229 let Some(number) = name.strip_prefix(PATCH_PREFIX) else { 230 continue; 231 }; 232 let number: u32 = number.parse().map_err(|_| { 233 anyhow!( 234 "patch file {} does not end in a number", 235 path.display() 236 ) 237 })?; 238 patches.push((number, name.to_owned(), path)); 239 } 240 241 patches.sort_by_key(|(number, _, _)| *number); 242 Ok(patches 243 .into_iter() 244 .map(|(_, name, path)| (name, path)) 245 .collect()) 246 } 247 248 async fn versioning_present(conn: &mut PgConnection) -> Result<bool> { 249 let present: Option<String> = 250 sqlx::query_scalar("SELECT to_regclass('_v.patches')::text") 251 .fetch_one(&mut *conn) 252 .await 253 .context("Failed to look for the _v.patches table")?; 254 Ok(present.is_some()) 255 } 256 257 async fn applied_patches(conn: &mut PgConnection) -> Result<HashSet<String>> { 258 let rows = sqlx::query("SELECT patch_name FROM _v.patches") 259 .fetch_all(&mut *conn) 260 .await 261 .context("Failed to read the applied patches from _v.patches")?; 262 Ok(rows 263 .into_iter() 264 .map(|row| row.get::<String, _>("patch_name")) 265 .collect()) 266 } 267 268 /// Run a whole SQL file. The files carry their own `BEGIN`/`COMMIT`, which is 269 /// what makes each patch all-or-nothing, so they must not be wrapped in 270 /// another transaction here. 271 async fn execute_sql_file(conn: &mut PgConnection, path: &Path) -> Result<()> { 272 let sql = std::fs::read_to_string(path) 273 .with_context(|| format!("Failed to read {}", path.display()))?; 274 conn.execute(sqlx::raw_sql(&sql)) 275 .await 276 .with_context(|| format!("Failed to apply {}", path.display()))?; 277 Ok(()) 278 }