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}