1use isoloom_manifest::Recovery;
5use serde_json::json;
6
7use super::Lab;
8use super::operation::Done;
9use crate::Result;
10use crate::driver::Observed;
11use crate::error::Error;
12use crate::events::WarningCode;
13use crate::events::{Emitter, Events};
14use crate::executor::{Cx, Ran, run_steps};
15use crate::hooks::Edge;
16use crate::options::{Occupied, RunOptions};
17use crate::params::{params_for_existing, params_for_start};
18use crate::secrets::{check_secrets, sensitive_values};
19use crate::state::{Op, RunState};
20use crate::status::{MachineState, OutputValue, Run, any_target_running};
21use crate::store::RunRecord;
22use crate::{clock, outputs, ready, tools};
23
24enum Slot {
26 Free,
28 Leftovers,
30 Parked,
32}
33
34impl Lab<'_> {
35 pub async fn up(&self, opts: &RunOptions, events: &dyn Events) -> Result<Run> {
46 let opts = &self.connected(opts);
47 let mask = sensitive_values(self.manifest(), &opts.secrets);
48 self.operate(Op::Up, events, mask, async |e: &Emitter<'_>| self.up_work(opts, e).await).await
49 }
50
51 async fn up_work(&self, opts: &RunOptions, events: &Emitter<'_>) -> Result<Done<Run>> {
52 let scope = self.scope(opts.provider.clone());
53 check_secrets(self.manifest(), &opts.secrets, &scope)?;
54 let mut record = self.record_or_fresh()?;
55 self.refuse_elsewhere(&record, opts.host_label.as_deref())?;
56 let params = params_for_start(self.manifest(), &opts.params, &scope)?;
57 if crate::ssh::needed(self.manifest()) {
58 crate::ssh::ensure_key(self.runner.config.processes.as_ref(), &self.runner.config.home).await?;
59 }
60 let mut cx = self.acting(self.cx(params, &opts.secrets, events, opts.provider.clone())?)?;
61 Self::bind_record(&mut cx, &record);
63 self.relay(&mut cx).await?;
64 self.driver.preflight(&cx).await?;
65 self.driver.prepare(&cx)?;
66 tools::probe(&cx).await?;
67 let observed = self.observe(&mut cx).await?;
68 match self.slot(&observed, &record, opts.occupied)? {
69 Slot::Parked => return self.resume_work(opts, events, false).await,
70 Slot::Leftovers => self.clear(&mut cx, &record, opts).await?,
71 Slot::Free => {}
72 }
73 record.params = cx.vars.params().clone();
74 record.cloud = self.manifest().cloud;
75 record.host_label.clone_from(&opts.host_label);
76 record.provider.clone_from(&opts.provider);
77 record.started_at = Some(clock::rfc3339(self.runner.config.clock.now()));
78 self.place(&mut record, opts)?;
79 Self::bind_record(&mut cx, &record);
80 self.begin(&mut record, Op::Up)?;
81 crate::executor::state_files::write(&cx, true)?;
82 let ran = self.start(&mut cx).await?;
83 crate::guest::deliver(&mut cx, None).await?;
85 self.read_outputs(&mut cx).await?;
87 ready::check(&mut cx, Some(&ran)).await?;
88 self.seal(&mut cx, &mut record).await?;
89 let (outputs, message) = self.finish(&mut cx, Edge::AfterUp).await?;
90 record.state = RunState::Ready;
91 Ok(ready_run(record, outputs, message))
92 }
93
94 pub(crate) fn refuse_elsewhere(&self, record: &RunRecord, asked: Option<&str>) -> Result<()> {
96 match (record.host_label.as_deref(), asked) {
97 (Some(recorded), Some(asked)) if recorded != asked && record.state != RunState::Absent => Err(Error::RunningElsewhere {
98 run: self.name().to_string(),
99 recorded: recorded.into(),
100 asked: asked.into(),
101 }),
102 _ => Ok(()),
103 }
104 }
105
106 fn slot(&self, observed: &Observed, record: &RunRecord, occupied: Occupied) -> Result<Slot> {
107 let exists = observed.machines.iter().any(|m| m.state != MachineState::Absent);
108 let parked = record.state == RunState::Parked;
109 let running = any_target_running(&observed.machines);
110 let already = || Err(Error::AlreadyRunning { run: self.name().to_string() });
111 match occupied {
112 _ if !exists => Ok(Slot::Free),
113 Occupied::Replace => Ok(Slot::Leftovers),
114 _ if record.state == RunState::Failed => Ok(Slot::Leftovers),
116 _ if running && !parked => already(),
117 Occupied::ResumeParked if parked => Ok(Slot::Parked),
118 Occupied::Refuse if parked => already(),
119 _ => Ok(Slot::Leftovers),
120 }
121 }
122
123 async fn clear(&self, cx: &mut Cx<'_>, record: &RunRecord, opts: &RunOptions) -> Result<()> {
126 cx.events
127 .warning(WarningCode::LeftoversRemoved, "removing what an earlier run left before starting");
128 let fresh = cx.vars.params().clone();
129 if record.started_at.is_some() {
130 cx.vars
131 .set_params(params_for_existing(self.manifest(), Some(&record.params), &opts.params, &cx.scope)?);
132 }
133 let cleared = run_steps(cx, "lifecycle.down", self.manifest().lifecycle.down.as_slice()).await.map(drop);
134 cx.vars.set_params(fresh);
135 cleared
136 }
137
138 fn place(&self, record: &mut RunRecord, opts: &RunOptions) -> Result<()> {
141 let place = opts.host_label.as_deref();
142 if crate::slot::needed(self.manifest()) {
143 let taken = crate::slot::taken(&self.runner.store.records()?, self.name(), place);
144 record.slot = Some(crate::slot::pick(record.slot, &taken, place)?);
145 }
146 record.ssh_jump.clone_from(&opts.ssh_jump);
147 record.expires_at = self.manifest().expiry.as_ref().map(|e| {
148 let minutes = u64::from(e.default_minutes.unwrap_or(DEFAULT_LIFETIME_MINUTES));
149 let life = opts.lifetime.map_or(minutes * 60, |l| l.as_secs());
150 clock::unix_seconds(self.runner.config.clock.now()).saturating_add(life)
151 });
152 Ok(())
153 }
154
155 async fn start(&self, cx: &mut Cx<'_>) -> Result<Ran> {
159 let up = self.manifest().lifecycle.up.as_slice();
160 let none = Recovery {
161 stale_markers: Vec::new(),
162 steps: Vec::new(),
163 retries: isoloom_manifest::Retries::default(),
164 apply_retries: None,
165 };
166 let recovery = self.manifest().recovery.as_ref().unwrap_or(&none);
167 let mut left = recovery.retries.get();
168 let mut applies = recovery.apply_retries.map_or(0, isoloom_manifest::Retries::get);
169 loop {
170 cx.stale.take();
171 match run_steps(cx, crate::executor::UP, up).await {
172 Err(e) if left > 0 && cx.stale.take().is_some() => {
173 left -= 1;
174 cx.events
175 .warning(WarningCode::Recovering, &format!("clearing stale state, then starting again: {e}"));
176 run_steps(cx, "recovery.steps", &recovery.steps).await?;
177 }
178 Err(e @ Error::ToolFailed { .. }) if applies > 0 && applied(up, &e) => {
181 applies -= 1;
182 cx.events.warning(WarningCode::Retrying, &format!("applying again: {e}"));
183 }
184 other => return other,
185 }
186 }
187 }
188
189 pub(crate) async fn finish(&self, cx: &mut Cx<'_>, edge: Edge) -> Result<(Vec<OutputValue>, Option<String>)> {
192 let outputs = self.read_outputs(cx).await?;
193 outputs::emit(cx.events, &outputs);
194 let message = outputs::message(self.manifest(), &cx.vars, &outputs);
195 self.hooks(cx, edge, &outputs).await;
196 Ok((outputs, message))
197 }
198}
199
200fn applied(up: &[isoloom_manifest::Step], e: &Error) -> bool {
202 let Error::ToolFailed { step, .. } = e else { return false };
203 up.iter().filter(|s| s.id.as_str() == step).any(|s| match &s.run {
204 isoloom_manifest::Run::Argv(argv) => argv_applies(argv),
205 isoloom_manifest::Run::Action { .. } => false,
206 })
207}
208
209fn argv_applies(argv: &isoloom_manifest::HostArgv) -> bool {
212 let applies = |a: &isoloom_manifest::Argv| {
213 let subcommand = a.args().iter().map(ToString::to_string).find(|w| !w.starts_with('-'));
214 ["terraform", "tofu"].contains(&a.program().as_str()) && subcommand.as_deref() == Some("apply")
215 };
216 match argv {
217 isoloom_manifest::HostArgv::Every(a) => applies(a),
218 isoloom_manifest::HostArgv::PerHost(p) => p
219 .default
220 .iter()
221 .chain(p.linux.iter())
222 .chain(p.macos.iter())
223 .chain(p.windows.iter())
224 .any(applies),
225 }
226}
227
228const DEFAULT_LIFETIME_MINUTES: u32 = 240;
230
231pub(crate) fn ready_run(record: RunRecord, outputs: Vec<OutputValue>, message: Option<String>) -> Done<Run> {
233 let shown: Vec<serde_json::Value> = outputs
234 .iter()
235 .map(|o| json!({ "name": o.name, "role": o.role, "value": if o.sensitive { o.value.as_ref().map(|_| "***".to_owned()) } else { o.value.clone() } }))
236 .collect();
237 let data = json!({ "state": RunState::Ready, "outputs": shown, "message": message });
238 Done {
239 value: Run { record, outputs, message },
240 ok: true,
241 data,
242 state: Some(RunState::Ready),
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn argv(json: &str) -> isoloom_manifest::HostArgv {
251 serde_json::from_str(json).unwrap()
252 }
253
254 #[test]
255 fn an_apply_is_terraforms_apply_subcommand_wherever_its_module() {
256 assert!(argv_applies(&argv(r#"["terraform", "apply", "-auto-approve"]"#)));
257 assert!(argv_applies(&argv(
258 r#"["terraform", "-chdir=${run.state_dir}/module", "apply", "-auto-approve"]"#
259 )));
260 assert!(argv_applies(&argv(r#"["tofu", "apply"]"#)));
261 assert!(!argv_applies(&argv(r#"["terraform", "destroy", "-auto-approve"]"#)));
262 assert!(
263 !argv_applies(&argv(r#"["terraform", "-chdir=apply", "init"]"#)),
264 "a folder named apply isn't the subcommand"
265 );
266 assert!(!argv_applies(&argv(r#"["ssh", "apply"]"#)));
267 assert!(argv_applies(&argv(r#"{"default": ["terraform", "apply"], "windows": ["terraform", "apply"]}"#)));
268 }
269}