fabrica

hanna/fabrica

5237 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `fabrica migrate` and `fabrica doctor` — schema application and a startup
6//! health check.
7
8use std::path::Path;
9use std::process::{Command, ExitCode};
10
11use config::Config;
12
13use crate::context::block_on;
14
15/// The minimum git version fabrica supports (pack transport relies on it).
16const MIN_GIT: (u32, u32) = (2, 41);
17
18/// Run `fabrica migrate`: connect and apply all pending migrations.
19pub(crate) fn migrate(config_path: Option<&Path>) -> anyhow::Result<ExitCode> {
20 block_on(async {
21 let (_config, _store) = crate::context::open_store(config_path).await?;
22 // `open_store` already migrated on connect; reaching here means success.
23 println!("migrations applied");
24 anyhow::Ok(ExitCode::SUCCESS)
25 })
26}
27
28/// Run `fabrica doctor`: verify the environment fabrica needs to serve.
29///
30/// Checks the `git` binary and version, that the configuration loads, and that the
31/// database is reachable and migratable. `--quiet` suppresses the per-check report
32/// and only sets the exit code, for use as a container health check.
33pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result<ExitCode> {
34 let mut ok = true;
35 let mut check = |label: &str, result: Result<String, String>| {
36 match &result {
37 Ok(detail) => {
38 if !quiet {
39 println!("ok {label}: {detail}");
40 }
41 }
42 Err(detail) => {
43 ok = false;
44 // Failures are always reported, even in quiet mode, on stderr.
45 eprintln!("FAIL {label}: {detail}");
46 }
47 }
48 };
49
50 // Configuration must load before anything else can be checked.
51 let loaded = match config::load(config_path) {
52 Ok(loaded) => loaded,
53 Err(err) => {
54 eprintln!("FAIL config: {err}");
55 return Ok(ExitCode::FAILURE);
56 }
57 };
58 check("config", Ok("loaded".to_string()));
59
60 check("git", check_git(&loaded.config));
61 check("storage", Ok(describe_storage(&loaded.config)));
62 let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?;
63 check("database", database);
64
65 if ok {
66 if !quiet {
67 println!("all checks passed");
68 }
69 Ok(ExitCode::SUCCESS)
70 } else {
71 Ok(ExitCode::FAILURE)
72 }
73}
74
75/// Describe the upload storage backend (config is already validated by load).
76fn describe_storage(config: &Config) -> String {
77 match config.storage.backend {
78 config::StorageBackend::Local => {
79 format!("local ({})", config.storage.data_dir.display())
80 }
81 config::StorageBackend::S3 => match &config.storage.s3 {
82 Some(s3) => {
83 let endpoint = s3.endpoint.as_deref().unwrap_or("aws");
84 format!(
85 "s3 (bucket {}, region {}, {endpoint})",
86 s3.bucket, s3.region
87 )
88 }
89 None => "s3 (unconfigured)".to_string(),
90 },
91 }
92}
93
94/// Verify the configured `git` binary is present and recent enough.
95fn check_git(config: &Config) -> Result<String, String> {
96 let output = Command::new(&config.git.binary)
97 .arg("--version")
98 .output()
99 .map_err(|err| format!("cannot run {:?}: {err}", config.git.binary))?;
100 if !output.status.success() {
101 return Err(format!("{:?} --version failed", config.git.binary));
102 }
103 let text = String::from_utf8_lossy(&output.stdout);
104 let version = parse_git_version(&text)
105 .ok_or_else(|| format!("could not parse git version from {text:?}"))?;
106 if version < MIN_GIT {
107 return Err(format!(
108 "git {}.{} is too old; need >= {}.{}",
109 version.0, version.1, MIN_GIT.0, MIN_GIT.1
110 ));
111 }
112 Ok(format!("{}.{} ({})", version.0, version.1, text.trim()))
113}
114
115/// Extract `(major, minor)` from a `git version 2.43.0` string.
116fn parse_git_version(text: &str) -> Option<(u32, u32)> {
117 let version = text.split_whitespace().nth(2)?;
118 let mut parts = version.split('.');
119 let major = parts.next()?.parse().ok()?;
120 let minor = parts.next()?.parse().ok()?;
121 Some((major, minor))
122}
123
124/// Verify the database opens and migrations apply.
125async fn check_database(config: &Config) -> Result<String, String> {
126 let store = store::Store::connect(&config.database.url, config.database.max_connections)
127 .await
128 .map_err(|err| format!("connect failed: {err}"))?;
129 store
130 .migrate()
131 .await
132 .map_err(|err| format!("migrate failed: {err}"))?;
133 Ok(format!("{:?} reachable", store.backend()))
134}
135
136#[cfg(test)]
137mod tests {
138 #![allow(clippy::unwrap_used)]
139
140 use super::*;
141
142 #[test]
143 fn parses_git_version() {
144 assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
145 assert_eq!(
146 parse_git_version("git version 2.41.0.windows.1"),
147 Some((2, 41))
148 );
149 assert_eq!(parse_git_version("nonsense"), None);
150 }
151}