Skip to content

Commit d68506c

Browse files
committed
Add 'spin doctor' subcommand
Signed-off-by: Lann Martin <lann.martin@fermyon.com>
1 parent 2669251 commit d68506c

22 files changed

+1073
-25
lines changed

Cargo.lock

Lines changed: 69 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ spin-app = { path = "crates/app" }
5050
spin-bindle = { path = "crates/bindle" }
5151
spin-build = { path = "crates/build" }
5252
spin-config = { path = "crates/config" }
53+
spin-doctor = { path = "crates/doctor" }
5354
spin-trigger-http = { path = "crates/trigger-http" }
5455
spin-loader = { path = "crates/loader" }
5556
spin-manifest = { path = "crates/manifest" }
@@ -98,29 +99,9 @@ fermyon-platform = []
9899

99100
[workspace]
100101
members = [
101-
"crates/app",
102-
"crates/bindle",
103-
"crates/build",
104-
"crates/config",
105-
"crates/core",
106-
"crates/http",
107-
"crates/loader",
108-
"crates/manifest",
109-
"crates/oci",
110-
"crates/outbound-http",
111-
"crates/outbound-redis",
112-
"crates/key-value",
113-
"crates/key-value-sqlite",
114-
"crates/key-value-redis",
115-
"crates/plugins",
116-
"crates/redis",
117-
"crates/templates",
118-
"crates/testing",
119-
"crates/trigger",
120-
"crates/trigger-http",
102+
"crates/*",
121103
"sdk/rust",
122104
"sdk/rust/macro",
123-
"crates/e2e-testing"
124105
]
125106

126107
[workspace.dependencies]

crates/doctor/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "spin-doctor"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
anyhow = "1"
8+
async-trait = "0.1"
9+
serde = { version = "1", features = ["derive"] }
10+
similar = "2"
11+
spin-loader = { path = "../loader" }
12+
tokio = "1"
13+
toml = "0.7"
14+
toml_edit = "0.19"
15+
tracing = { workspace = true }
16+
17+
[dev-dependencies]
18+
tempfile = "3"

crates/doctor/src/lib.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! Spin doctor: check and automatically fix problems with Spin apps.
2+
#![deny(missing_docs)]
3+
4+
use std::{fmt::Debug, fs, future::Future, path::PathBuf, pin::Pin, process::Command, sync::Arc};
5+
6+
use anyhow::{ensure, Context, Result};
7+
use async_trait::async_trait;
8+
use tokio::sync::Mutex;
9+
use toml_edit::Document;
10+
11+
/// Diagnoses for app manifest format problems.
12+
pub mod manifest;
13+
/// Test helpers.
14+
pub mod test;
15+
/// Diagnoses for Wasm source problems.
16+
pub mod wasm;
17+
18+
/// Configuration for an app to be checked for problems.
19+
pub struct Checkup {
20+
manifest_path: PathBuf,
21+
diagnose_fns: Vec<DiagnoseFn>,
22+
}
23+
24+
type DiagnoseFut<'a> =
25+
Pin<Box<dyn Future<Output = Result<Vec<Box<dyn Diagnosis + 'static>>>> + 'a>>;
26+
type DiagnoseFn = for<'a> fn(&'a PatientApp) -> DiagnoseFut<'a>;
27+
28+
impl Checkup {
29+
/// Return a new checkup for the app manifest at the given path.
30+
pub fn new(manifest_path: impl Into<PathBuf>) -> Self {
31+
let mut config = Self {
32+
manifest_path: manifest_path.into(),
33+
diagnose_fns: vec![],
34+
};
35+
config.add_diagnose::<manifest::version::VersionDiagnosis>();
36+
config.add_diagnose::<manifest::trigger::TriggerDiagnosis>();
37+
config.add_diagnose::<wasm::missing::WasmMissing>();
38+
config
39+
}
40+
41+
/// Add a detectable problem to this checkup.
42+
pub fn add_diagnose<D: Diagnose + 'static>(&mut self) -> &mut Self {
43+
self.diagnose_fns.push(diagnose_boxed::<D>);
44+
self
45+
}
46+
47+
fn patient(&self) -> Result<PatientApp> {
48+
let path = &self.manifest_path;
49+
ensure!(
50+
path.is_file(),
51+
"No Spin app manifest file found at {path:?}"
52+
);
53+
54+
let contents = fs::read_to_string(path)
55+
.with_context(|| format!("Couldn't read Spin app manifest file at {path:?}"))?;
56+
57+
let manifest_doc: Document = contents
58+
.parse()
59+
.with_context(|| format!("Couldn't parse manifest file at {path:?} as valid TOML"))?;
60+
61+
Ok(PatientApp {
62+
manifest_path: path.into(),
63+
manifest_doc,
64+
})
65+
}
66+
67+
/// Find problems with the configured app, calling the given closure with
68+
/// each problem found.
69+
pub async fn for_each_diagnosis<F>(&self, mut f: F) -> Result<usize>
70+
where
71+
F: for<'a> FnMut(
72+
Box<dyn Diagnosis + 'static>,
73+
&'a mut PatientApp,
74+
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>,
75+
{
76+
let patient = Arc::new(Mutex::new(self.patient()?));
77+
let mut count = 0;
78+
for diagnose in &self.diagnose_fns {
79+
let patient = patient.clone();
80+
let diags = diagnose(&*patient.lock().await)
81+
.await
82+
.unwrap_or_else(|err| {
83+
tracing::debug!("Diagnose failed: {err:?}");
84+
vec![]
85+
});
86+
count += diags.len();
87+
for diag in diags {
88+
let mut patient = patient.lock().await;
89+
f(diag, &mut patient).await?;
90+
}
91+
}
92+
Ok(count)
93+
}
94+
}
95+
96+
/// An app "patient" to be checked for problems.
97+
#[derive(Clone)]
98+
pub struct PatientApp {
99+
/// Path to an app manifest file.
100+
pub manifest_path: PathBuf,
101+
/// Parsed app manifest TOML document.
102+
pub manifest_doc: Document,
103+
}
104+
105+
/// The Diagnose trait implements the detection of a particular Spin app problem.
106+
#[async_trait]
107+
pub trait Diagnose: Diagnosis + Send + Sized + 'static {
108+
/// Check the given [`Patient`], returning any problem(s) found.
109+
async fn diagnose(patient: &PatientApp) -> Result<Vec<Self>>;
110+
}
111+
112+
fn diagnose_boxed<D: Diagnose>(patient: &PatientApp) -> DiagnoseFut {
113+
Box::pin(async {
114+
let diags = D::diagnose(patient).await?;
115+
Ok(diags.into_iter().map(|diag| Box::new(diag) as _).collect())
116+
})
117+
}
118+
119+
/// The Diagnosis trait represents a detected problem with a Spin app.
120+
pub trait Diagnosis: Debug + Send + Sync {
121+
/// Return a human-friendly description of this problem.
122+
fn description(&self) -> String;
123+
124+
/// Return true if this problem is "critical", i.e. if the app's
125+
/// configuration or environment is invalid. Return false for
126+
/// "non-critical" problems like deprecations.
127+
fn is_critical(&self) -> bool {
128+
true
129+
}
130+
131+
/// Return a [`Treatment`] that can (potentially) fix this problem, or
132+
/// None if there is no automatic fix.
133+
fn treatment(&self) -> Option<&dyn Treatment> {
134+
None
135+
}
136+
}
137+
138+
/// The Treatment trait represents a (potential) fix for a detected problem.
139+
#[async_trait]
140+
pub trait Treatment: Sync {
141+
/// Return a human-readable description of what this treatment will do to
142+
/// fix the problem, such as a file diff.
143+
async fn description(&self, patient: &PatientApp) -> Result<String>;
144+
145+
/// Attempt to fix this problem. Return Ok only if the problem is
146+
/// successfully fixed.
147+
async fn treat(&self, patient: &mut PatientApp) -> Result<()>;
148+
}
149+
150+
const SPIN_BIN_PATH: &str = "SPIN_BIN_PATH";
151+
152+
/// Return a [`Command`] targeting the `spin` binary. The `spin` path is
153+
/// resolved to the first of these that is available:
154+
/// - the `SPIN_BIN_PATH` environment variable
155+
/// - the current executable ([`std::env::current_exe`])
156+
/// - the constant `"spin"` (resolved by e.g. `$PATH`)
157+
pub fn spin_command() -> Command {
158+
let spin_path = std::env::var_os(SPIN_BIN_PATH)
159+
.map(PathBuf::from)
160+
.or_else(|| std::env::current_exe().ok())
161+
.unwrap_or("spin".into());
162+
Command::new(spin_path)
163+
}

crates/doctor/src/manifest.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use std::fs;
2+
3+
use anyhow::{Context, Result};
4+
use async_trait::async_trait;
5+
use toml_edit::Document;
6+
7+
use crate::Treatment;
8+
9+
/// Diagnose app manifest trigger config problems.
10+
pub mod trigger;
11+
/// Diagnose app manifest version problems.
12+
pub mod version;
13+
14+
/// ManifestTreatment helps implement [`Treatment`]s for app manifest problems.
15+
#[async_trait]
16+
pub trait ManifestTreatment {
17+
/// Attempt to fix this problem. See [`Treatment::treat`].
18+
async fn treat_manifest(&self, doc: &mut Document) -> Result<()>;
19+
}
20+
21+
#[async_trait]
22+
impl<T: ManifestTreatment + Sync> Treatment for T {
23+
async fn description(&self, patient: &crate::PatientApp) -> Result<String> {
24+
let mut after_doc = patient.manifest_doc.clone();
25+
self.treat_manifest(&mut after_doc).await?;
26+
let before = patient.manifest_doc.to_string();
27+
let after = after_doc.to_string();
28+
let diff = similar::udiff::unified_diff(Default::default(), &before, &after, 1, None);
29+
Ok(format!(
30+
"Apply the following diff to {:?}:\n{}",
31+
patient.manifest_path, diff
32+
))
33+
}
34+
35+
async fn treat(&self, patient: &mut crate::PatientApp) -> Result<()> {
36+
let doc = &mut patient.manifest_doc;
37+
self.treat_manifest(doc).await?;
38+
let path = &patient.manifest_path;
39+
fs::write(path, doc.to_string())
40+
.with_context(|| format!("failed to write fixed manifest to {path:?}"))
41+
}
42+
}

0 commit comments

Comments
 (0)