Skip to main content

isothrum_runner/lab/
up.rs

1//! Starting a run: the occupied-slot rules, the start steps with their recovery retry, the ready
2//! conditions, then outputs, the message and the embedder's hooks.
3
4use 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
24/// What `up` finds where it would start the run.
25enum Slot {
26    /// Nothing of the run exists.
27    Free,
28    /// Something is left (a failed start, stopped machines): removed before the start.
29    Leftovers,
30    /// The run is parked, and the embedder resumes parked runs.
31    Parked,
32}
33
34impl Lab<'_> {
35    /// Starts the run: refused while it runs or runs elsewhere; a parked run is resumed (with
36    /// [`Occupied::ResumeParked`]); leftovers are removed first.
37    ///
38    /// # Errors
39    ///
40    /// Fails, after its `error` event, when the run is up already (`already_running`) or recorded
41    /// elsewhere (`running_elsewhere`); when a param, secret or tool is missing or refused; when a
42    /// host port is taken (`port_in_use`); when a step fails or runs out of time (`tool_failed`,
43    /// `tool_missing`, `timeout`); when a ready condition doesn't hold (`not_ready`); or when the
44    /// run's state can't be locked or written (`locked`, `io`).
45    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        // Leftovers are removed with the names they were made with.
62        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        // What the guests wait for once they exist: their secrets, where no store holds them.
84        crate::guest::deliver(&mut cx, None).await?;
85        // The ready conditions may name an output (`${outputs.ip}`, the lab host).
86        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    /// Fails when the run's record puts it on another place than `asked`.
95    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            // A start that failed with its machines up: what it left goes, then a fresh start.
115            _ 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    /// Removes what is left of the run: its `down` steps, with the params it was started with
124    /// (the region it was applied in, its seal), never the ones of the start to come.
125    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    /// Where the run goes on its place, kept in `record`: its slot (when the manifest names
139    /// `${run.slot}`), its SSH jump, and its expiry (when the manifest has one).
140    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    /// The start steps; a failure whose message holds one of `recovery.stale_markers` runs the
156    /// recovery steps and starts again, up to `recovery.retries` times. A genuine failure never
157    /// retries.
158    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                // A module that converges on a later apply (a cloud's eventual consistency); any
179                // other step's failure is the start's.
180                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    /// Reads the run's published ports and outputs, fills its message, and runs the hooks at
190    /// `edge`.
191    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
200/// Whether `e` is the failure of a step of `up` that runs `terraform apply`.
201fn 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
209/// Whether `argv` (on any host) is `terraform apply …` (or `tofu`), global options such as
210/// `-chdir=<module>` before the subcommand.
211fn 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
228/// How long a run with an `expiry` lives when neither the embedder nor the manifest says.
229const DEFAULT_LIFETIME_MINUTES: u32 = 240;
230
231/// A run that came up, as the operation returns it and its `result` event says.
232pub(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}