Skip to main content

isothrum_runner/
adopt.rs

1//! Taking over a Terraform deployment made before the runner: an embedder whose old runtime kept
2//! its own state (the launcher's `deployments/<run>/<target>/`) hands it to the runner once, so a
3//! player's running cloud run stays destroyable after the switch, by one code path (`down`, the
4//! reaper).
5//!
6//! The deployment's state moves into the run's state folder (`terraform.tfstate`, and its
7//! `.backup` when there is one), and the run's record says it is up, with the params it was
8//! applied with: a later `down` replays them (the region it was created in, never the
9//! embedder's current one). Terraform's data folder stays behind: it names the old state's path
10//! as its backend, which a run's `init` would refuse to change without a prompt; `init` makes a
11//! fresh one.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use isoloom_manifest::{Driver, SetBy};
17
18use crate::Result;
19use crate::error::Error;
20use crate::manifest::{LoadedManifest, load_manifest};
21use crate::name::RunName;
22use crate::runner::Runner;
23use crate::state::RunState;
24use crate::store::RunRecord;
25
26/// The state file a deployment must hold to be taken over.
27const STATE: &str = "terraform.tfstate";
28
29/// The files that move, the state first (the only one required).
30const MOVED: [&str; 2] = [STATE, "terraform.tfstate.backup"];
31
32/// A deployment the runner takes over ([`Runner::adopt`]).
33#[derive(Debug, Clone, PartialEq, Eq)]
34#[non_exhaustive]
35pub struct Adoption {
36    /// The run it becomes.
37    pub name: RunName,
38    /// The folder holding its Terraform state.
39    pub state_from: PathBuf,
40    /// The params it was applied with (the region, the account's profile or project): only the
41    /// manifest's params the embedder sets are kept.
42    pub params: BTreeMap<String, String>,
43    /// What the embedder calls the place it is on (the account's id).
44    pub host_label: Option<String>,
45    /// When it stops itself, in seconds since the Unix epoch.
46    pub expires_at: Option<u64>,
47    /// When it was started (RFC 3339), when known (else the time it is taken over).
48    pub started_at: Option<String>,
49    /// Its slot on its place (`${run.slot}`), for a manifest that names one: the one it was
50    /// applied with (the launcher's Proxmox labs had 1). Unset, the place's lowest free slot.
51    pub slot: Option<u8>,
52}
53
54impl Adoption {
55    /// The deployment whose state is in `state_from`, as run `name`: no param, place or expiry.
56    #[must_use]
57    pub fn new(name: RunName, state_from: impl Into<PathBuf>) -> Adoption {
58        Adoption {
59            name,
60            state_from: state_from.into(),
61            params: BTreeMap::new(),
62            host_label: None,
63            expires_at: None,
64            started_at: None,
65            slot: None,
66        }
67    }
68
69    /// With the params it was applied with.
70    #[must_use]
71    pub fn with_params(mut self, params: impl IntoIterator<Item = (String, String)>) -> Adoption {
72        self.params.extend(params);
73        self
74    }
75
76    /// On the place the embedder calls `label`.
77    #[must_use]
78    pub fn with_host_label(mut self, label: impl Into<String>) -> Adoption {
79        self.host_label = Some(label.into());
80        self
81    }
82
83    /// Stopping itself at `unix_seconds`.
84    #[must_use]
85    pub fn with_expires_at(mut self, unix_seconds: u64) -> Adoption {
86        self.expires_at = Some(unix_seconds);
87        self
88    }
89
90    /// Started at `rfc3339`.
91    #[must_use]
92    pub fn with_started_at(mut self, rfc3339: impl Into<String>) -> Adoption {
93        self.started_at = Some(rfc3339.into());
94        self
95    }
96
97    /// Applied in slot `slot` of its place.
98    #[must_use]
99    pub fn with_slot(mut self, slot: u8) -> Adoption {
100        self.slot = Some(slot);
101        self
102    }
103}
104
105impl Runner {
106    /// Takes over the Terraform deployment `adoption` describes as a run of the manifest at
107    /// `manifest` (`pub async fn adopt(&self, manifest: &Path, adoption: Adoption) ->
108    /// Result<RunRecord>`): its state moves into the run's state folder, and the run is recorded
109    /// as up, with the manifest's params among `adoption.params`. A run whose record an earlier
110    /// `down` left absent (its state gone with it) is taken over too, its record replaced. Once
111    /// taken over, the old folder holds no state: taking it over again is refused, never a second
112    /// copy.
113    ///
114    /// # Errors
115    ///
116    /// Fails with `adopt_refused` when the manifest isn't a Terraform one, when the run already
117    /// has a record that isn't absent or a state, or when the old folder holds no state; with `locked` while
118    /// another operation holds the run; with `manifest_invalid` or `io` when the manifest can't
119    /// be read; and with `io` when a file can't be moved or the record written (the state is
120    /// put back then).
121    pub async fn adopt(&self, manifest: &Path, adoption: Adoption) -> Result<RunRecord> {
122        let loaded = load_manifest(manifest)?;
123        let name = adoption.name.clone();
124        let refuse = |reason: String| Error::Adopt { run: name.to_string(), reason };
125        if loaded.manifest.driver != Driver::Terraform {
126            return Err(refuse(format!("its manifest's driver is {}, not terraform", loaded.manifest.driver.id())));
127        }
128        let to = self.store.run_dir(&name)?;
129        let _lock = self.locks.acquire(&name, &self.store.lock_path(&name)).await?;
130        // A record an earlier `down` left absent holds nothing to lose: the run is taken over.
131        if self.store.record(&name)?.is_some_and(|r| r.state != RunState::Absent) {
132            return Err(refuse("the run has a record already".into()));
133        }
134        if to.join(STATE).exists() {
135            return Err(refuse("the run has a Terraform state already".into()));
136        }
137        if !adoption.state_from.join(STATE).is_file() {
138            return Err(refuse(format!("{} holds no {STATE}", adoption.state_from.display())));
139        }
140        let from = adoption.state_from.clone();
141        let record = self.adopted(loaded, adoption)?;
142        let moved = move_files(&from, &to)?;
143        if let Err(e) = self.store.save(&record) {
144            put_back(&moved, &from, &to);
145            return Err(e);
146        }
147        Ok(record)
148    }
149}
150
151impl Runner {
152    /// The record of `adoption`, a run of `loaded`: up, with the manifest's params among its own,
153    /// and its slot when the manifest names one.
154    fn adopted(&self, loaded: LoadedManifest, adoption: Adoption) -> Result<RunRecord> {
155        let manifest = &loaded.manifest;
156        let mut record = RunRecord::fresh(adoption.name, loaded.path, loaded.sha256, manifest.target, manifest.driver);
157        record.params = adoption
158            .params
159            .into_iter()
160            .filter(|(k, _)| manifest.params.iter().any(|p| p.name.as_str() == k && p.set_by == SetBy::Embedder))
161            .collect();
162        if crate::slot::needed(manifest) {
163            let place = adoption.host_label.as_deref();
164            let taken = crate::slot::taken(&self.store.records()?, &record.name, place);
165            record.slot = Some(crate::slot::pick(adoption.slot, &taken, place)?);
166        }
167        record.cloud = manifest.cloud;
168        record.host_label = adoption.host_label;
169        record.expires_at = adoption.expires_at;
170        // A recorded start is what makes a later operation replay the params.
171        record.started_at = Some(adoption.started_at.unwrap_or_else(|| crate::clock::rfc3339(self.config.clock.now())));
172        record.state = RunState::Ready;
173        Ok(record)
174    }
175}
176
177/// Moves each of [`MOVED`] that `from` holds into `to`; the names moved. A failure puts back
178/// what moved before it.
179fn move_files(from: &Path, to: &Path) -> Result<Vec<&'static str>> {
180    let mut moved = Vec::new();
181    for name in MOVED {
182        let source = from.join(name);
183        if !source.is_file() {
184            continue;
185        }
186        if let Err(e) = move_file(&source, &to.join(name)) {
187            put_back(&moved, from, to);
188            return Err(Error::io(&source, "moved")(e));
189        }
190        moved.push(name);
191    }
192    Ok(moved)
193}
194
195/// Moves `from` to `to`: a rename, else (another file system) a copy then a removal.
196fn move_file(from: &Path, to: &Path) -> std::io::Result<()> {
197    if std::fs::rename(from, to).is_ok() {
198        return Ok(());
199    }
200    std::fs::copy(from, to)?;
201    std::fs::remove_file(from)
202}
203
204/// Moves `names` back from `to` to `from`, as best it can (an error is already on its way).
205fn put_back(names: &[&str], from: &Path, to: &Path) {
206    for name in names {
207        if let Err(e) = move_file(&to.join(name), &from.join(name)) {
208            tracing::warn!(error = %e, file = name, "a taken-over state couldn't be put back");
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests;