kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | Submodules | README | LICENSE

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 }