Skip to main content

stackless_cloud/
prepare.rs

1//! Operator-side cloud prepare (§4): shallow git checkout + run a command on
2//! the operator's machine. Cloud substrates call this and map the neutral
3//! [`PrepareFailure`] to their own `PrepareFailed`-style fault.
4
5use std::collections::BTreeMap;
6use std::process::Stdio;
7
8use stackless_core::def::{Namespace, Service};
9use stackless_core::fault::FAILURE_LOG_TAIL_LINES;
10
11/// A prepare step that failed, as neutral data the provider maps to its own
12/// fault (preserving per-provider error codes and remediation).
13#[derive(Debug, Clone)]
14pub struct PrepareFailure {
15    pub service: String,
16    pub command: Option<String>,
17    pub message: String,
18    pub log_tail: Option<String>,
19}
20
21/// Shallow-clone `repo@reference` into a temp dir, run `command` there with
22/// `env`, and clean up. Any failure is returned as a [`PrepareFailure`].
23pub fn run_prepare_command(
24    service: &str,
25    repo: &str,
26    reference: &str,
27    command: &str,
28    env: &[(String, String)],
29) -> Result<(), PrepareFailure> {
30    let tmp = tempdir().map_err(|message| PrepareFailure {
31        service: service.to_owned(),
32        command: Some(command.to_owned()),
33        message,
34        log_tail: None,
35    })?;
36    let result = (|| {
37        stackless_git::clone_checkout(
38            repo,
39            reference,
40            &tmp,
41            &stackless_git::Credentials::default(),
42        )
43        .map_err(|err| PrepareFailure {
44            service: service.to_owned(),
45            command: Some(format!("clone --depth 1 --branch {reference} {repo}")),
46            message: format!("clone {repo}@{reference} failed: {err}"),
47            log_tail: None,
48        })?;
49        let mut cmd = std::process::Command::new("sh");
50        cmd.arg("-c")
51            .arg(command)
52            .current_dir(&tmp)
53            .stdin(Stdio::null());
54        for (key, value) in env {
55            cmd.env(key, value);
56        }
57        let output = cmd.output().map_err(|err| PrepareFailure {
58            service: service.to_owned(),
59            command: Some(command.to_owned()),
60            message: format!("could not run prepare command: {err}"),
61            log_tail: None,
62        })?;
63        if !output.status.success() {
64            return Err(PrepareFailure {
65                service: service.to_owned(),
66                command: Some(command.to_owned()),
67                message: format!("`{command}` exited {}", output.status),
68                log_tail: Some(tail_bytes(&output.stderr)),
69            });
70        }
71        Ok(())
72    })();
73    let _ = std::fs::remove_dir_all(&tmp);
74    result
75}
76
77fn tail_bytes(bytes: &[u8]) -> String {
78    let text = String::from_utf8_lossy(bytes);
79    let lines: Vec<&str> = text.lines().collect();
80    let start = lines.len().saturating_sub(FAILURE_LOG_TAIL_LINES);
81    lines[start..].join("\n")
82}
83
84fn tempdir() -> Result<std::path::PathBuf, String> {
85    tempfile::tempdir()
86        .map(|dir| dir.keep())
87        .map_err(|err| err.to_string())
88}
89
90/// A resolved prepare step: the command, the pinned source, and the fully
91/// interpolated env it runs with. [`resolve_prepare_env`] returns `None` when
92/// the service declares no `prepare` hook.
93#[derive(Debug, Clone)]
94pub struct PreparePlan {
95    pub command: String,
96    pub repo: String,
97    pub reference: String,
98    pub env: Vec<(String, String)>,
99}
100
101/// Resolve a service's prepare env against `namespace` and inject same-named
102/// secrets. The caller builds the namespace — substrates differ in what it
103/// carries (only Render exports the external-DB url) — so this stays pure and
104/// substrate-agnostic. Any failure is the neutral [`PrepareFailure`].
105pub fn resolve_prepare_env(
106    namespace: &Namespace,
107    secrets: &BTreeMap<String, String>,
108    service: &str,
109    substrate: &str,
110    spec: &Service,
111) -> Result<Option<PreparePlan>, PrepareFailure> {
112    let Some(command) = spec.prepare.clone() else {
113        return Ok(None);
114    };
115    let fail = |message: String| PrepareFailure {
116        service: service.to_owned(),
117        command: Some(command.clone()),
118        message,
119        log_tail: None,
120    };
121    let raw = spec
122        .effective_env(service, substrate)
123        .map_err(|err| fail(err.to_string()))?;
124    let mut env: Vec<(String, String)> = Vec::new();
125    for (key, value) in &raw {
126        let location = format!("services.{service}.env.{key}");
127        let resolved = stackless_core::def::interp::resolve(value, namespace, &location)
128            .map_err(|err| fail(err.to_string()))?;
129        env.push((key.clone(), resolved));
130    }
131    for key in &spec.secrets {
132        if let Some(value) = secrets.get(key) {
133            env.push((key.clone(), value.clone()));
134        }
135    }
136    Ok(Some(PreparePlan {
137        command,
138        repo: spec.source.repo.clone(),
139        reference: spec.source.reference.clone(),
140        env,
141    }))
142}
143
144/// Resolve and run a service's prepare hook on the operator's machine. A no-op
145/// when the service declares no `prepare`. The caller maps the neutral
146/// [`PrepareFailure`] to its own fault.
147pub async fn run_service_prepare(
148    namespace: &Namespace,
149    secrets: &BTreeMap<String, String>,
150    service: &str,
151    substrate: &str,
152    spec: &Service,
153) -> Result<(), PrepareFailure> {
154    let Some(plan) = resolve_prepare_env(namespace, secrets, service, substrate, spec)? else {
155        return Ok(());
156    };
157    let PreparePlan {
158        command,
159        repo,
160        reference,
161        env,
162    } = plan;
163    let service_owned = service.to_owned();
164    let command_for_panic = command.clone();
165    tokio::task::spawn_blocking(move || {
166        run_prepare_command(&service_owned, &repo, &reference, &command, &env)
167    })
168    .await
169    .map_err(|err| PrepareFailure {
170        service: service.to_owned(),
171        command: Some(command_for_panic),
172        message: format!("prepare task panicked: {err}"),
173        log_tail: None,
174    })?
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use stackless_core::def::StackDef;
181    use stackless_core::types::DnsName;
182
183    fn service_def(extra: &str) -> StackDef {
184        StackDef::parse(&format!(
185            "[stack]\nname=\"demo\"\n[services.api]\nsource={{repo=\"r\",ref=\"main\"}}\n{extra}health={{path=\"/h\"}}\n"
186        ))
187        .unwrap()
188    }
189
190    #[test]
191    fn resolves_env_interpolation_and_injects_secrets() {
192        let def = service_def(
193            "prepare=\"migrate\"\nsecrets=[\"DB_PASSWORD\"]\nenv={ORIGIN=\"${services.api.origin}\"}\n",
194        );
195        let spec = def.services.get("api").unwrap();
196        let mut namespace = Namespace {
197            instance_name: DnsName::from_stored("demo1"),
198            ..Namespace::default()
199        };
200        namespace
201            .service_origins
202            .insert("api".to_owned(), "https://api.example".to_owned());
203        let mut secrets = BTreeMap::new();
204        secrets.insert("DB_PASSWORD".to_owned(), "hunter2".to_owned());
205
206        let plan = resolve_prepare_env(&namespace, &secrets, "api", "render", spec)
207            .unwrap()
208            .unwrap();
209        assert_eq!(plan.command, "migrate");
210        assert_eq!(plan.repo, "r");
211        assert_eq!(plan.reference, "main");
212        assert!(
213            plan.env
214                .contains(&("ORIGIN".to_owned(), "https://api.example".to_owned()))
215        );
216        assert!(
217            plan.env
218                .contains(&("DB_PASSWORD".to_owned(), "hunter2".to_owned()))
219        );
220    }
221
222    #[test]
223    fn no_prepare_hook_is_none() {
224        let def = service_def("env={}\n");
225        let spec = def.services.get("api").unwrap();
226        let plan = resolve_prepare_env(
227            &Namespace::default(),
228            &BTreeMap::new(),
229            "api",
230            "render",
231            spec,
232        )
233        .unwrap();
234        assert!(plan.is_none());
235    }
236}