Skip to main content

acme_proxy/cli/
schema.rs

1//! Who owns the database schema for this invocation.
2//!
3//! Opening the database used to apply the migrations as a side effect, which
4//! made every subcommand an upgrade step — `acme-proxy audit list` against a
5//! newer binary silently rewrote the schema — and let two processes starting
6//! together race `MIGRATOR::run` on `SQLite`, where `sqlx` takes no migration
7//! lock. (`PostgreSQL` has one, an advisory lock, which serializes the race but
8//! does nothing for the first problem.)
9//!
10//! So the act is named now, and this is the decision: one owner per
11//! invocation, everybody else checks and refuses. It lives here rather than in
12//! `src/main.rs` for [`plan_logging`](super::logging::plan_logging)'s reason —
13//! that file is excluded from the coverage floor, and each rule below is a row
14//! of a table test.
15
16use super::Command;
17
18/// What an invocation is entitled to do about the database schema.
19///
20/// Pure, and here rather than in `src/main.rs` for [`plan_logging`]'s reason:
21/// that file is excluded from the coverage floor, and every rule below is a row
22/// of a table test.
23///
24/// [`plan_logging`]: super::logging::plan_logging
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SchemaPlan {
27    /// This command applies the migrations itself: `migrate`, `init`, and
28    /// `serve` when the process runs the `worker` role.
29    Migrate,
30    /// This command needs the schema present but must not write it. An
31    /// unapplied migration stops it by name.
32    Require,
33}
34
35/// Decides whether `command` may migrate, or must find the schema already
36/// current.
37///
38/// **There is no third answer.** Every command here reads or writes rows, so
39/// every one needs the schema; `completions` and `man` never reach this, being
40/// answered in `main.rs` before the configuration or the database.
41///
42/// The split exists because migrating used to be a side effect of opening the
43/// database, which made `acme-proxy audit list` an upgrade step. It also raced
44/// on `SQLite`: two processes starting together both ran `MIGRATOR::run`, and
45/// `sqlx` takes no migration lock there. One owner, named on the command line,
46/// removes both.
47#[must_use]
48pub fn plan_schema(command: Option<&Command>) -> SchemaPlan {
49    match command {
50        // `serve` with no `--role` runs the worker, which owns the schema; a
51        // `--role` naming it does too. `clap` has already parsed the roles,
52        // refusing an unknown one before anything reached this.
53        // `None` is `serve` — the default subcommand — so it takes the same
54        // arm as an explicit one, `plan_logging`'s own shape.
55        None => SchemaPlan::Migrate,
56        Some(Command::Serve { role }) => {
57            match role
58                .unwrap_or_default()
59                .has(acme_proxy_server::ProcessRole::Worker)
60            {
61                true => SchemaPlan::Migrate,
62                false => SchemaPlan::Require,
63            }
64        }
65        Some(Command::Migrate | Command::Init) => SchemaPlan::Migrate,
66        _ => SchemaPlan::Require,
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::cli::Cli;
74    use clap::Parser;
75
76    /// Parsed from a real command line rather than a hand-built variant: what
77    /// has to be right is what an operator's argv resolves to, which is the
78    /// same argument `plan_logging`'s table makes.
79    fn plan(argv: &[&str]) -> SchemaPlan {
80        let cli = Cli::try_parse_from(argv).expect("the command line must parse");
81        plan_schema(cli.command.as_ref())
82    }
83
84    /// All-in-one is the default and must be unaffected: a bare `serve`
85    /// against a fresh database migrates it, exactly as it did when opening
86    /// the database was what applied them.
87    #[test]
88    fn a_default_serve_owns_the_schema() {
89        assert_eq!(plan(&["acme-proxy"]), SchemaPlan::Migrate);
90        assert_eq!(plan(&["acme-proxy", "serve"]), SchemaPlan::Migrate);
91    }
92
93    #[test]
94    fn serve_owns_the_schema_exactly_when_it_runs_the_worker() {
95        for roles in ["worker", "acme,worker", "admin,worker", "acme,admin,worker"] {
96            assert_eq!(
97                plan(&["acme-proxy", "serve", "--role", roles]),
98                SchemaPlan::Migrate,
99                "`--role {roles}` runs the worker, which owns the schema"
100            );
101        }
102        for roles in ["acme", "admin", "acme,admin"] {
103            assert_eq!(
104                plan(&["acme-proxy", "serve", "--role", roles]),
105                SchemaPlan::Require,
106                "`--role {roles}` runs no worker and must not migrate"
107            );
108        }
109    }
110
111    #[test]
112    fn migrate_and_init_own_the_schema() {
113        assert_eq!(plan(&["acme-proxy", "migrate"]), SchemaPlan::Migrate);
114        assert_eq!(plan(&["acme-proxy", "init"]), SchemaPlan::Migrate);
115    }
116
117    /// Every other command reads or writes rows, so every one needs the schema
118    /// and none of them may write it. A sample across the subtrees rather than
119    /// all of them: the rule is the `_` arm, and a new command joins it.
120    #[test]
121    fn every_other_command_requires_a_current_schema() {
122        for argv in [
123            vec!["acme-proxy", "account", "list"],
124            vec!["acme-proxy", "order", "list"],
125            vec!["acme-proxy", "audit", "list"],
126            vec!["acme-proxy", "jobs", "list"],
127            vec!["acme-proxy", "eab", "list"],
128            vec!["acme-proxy", "profile", "list"],
129            vec!["acme-proxy", "admin", "user", "list"],
130            // Reads every row of the source and writes none of the schema:
131            // the target is migrated by `acme-proxy migrate` against it, not
132            // by this.
133            vec!["acme-proxy", "transfer", "--to", "postgres://h/db"],
134        ] {
135            assert_eq!(
136                plan(&argv),
137                SchemaPlan::Require,
138                "`{}` must not migrate",
139                argv.join(" ")
140            );
141        }
142    }
143
144    /// An unknown role never reaches this decision: `clap` refuses it at argv
145    /// time, before the configuration is read or the database file created.
146    #[test]
147    fn an_unknown_role_is_refused_before_anything_is_planned() {
148        let Err(error) = Cli::try_parse_from(["acme-proxy", "serve", "--role", "wroker"]) else {
149            panic!("an unknown role must not parse");
150        };
151        assert!(error.to_string().contains("wroker"), "{error}");
152    }
153}