1use std::collections::BTreeMap;
6use std::process::Stdio;
7
8use stackless_core::def::{Namespace, Service};
9use stackless_core::fault::FAILURE_LOG_TAIL_LINES;
10
11#[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
21pub 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#[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
101pub 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
144pub 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}