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