Skip to main content

fakecloud_core/
data_volume.rs

1//! Naming and lifecycle of the named container volumes that keep a
2//! container-backed resource's data (an RDS database, an ElastiCache RDB, an
3//! EC2 instance's data dir) across a container being recreated.
4//!
5//! A volume belongs to exactly one *scope*, and its name carries the scope's
6//! tag:
7//!
8//! * **Data directory** (`--storage-mode persistent --data-path <dir>`): the
9//!   tag is a short hash of the canonical `--data-path` together with a
10//!   random id minted the first time fakecloud uses the directory and stored
11//!   in it ([`SCOPE_FILE`]). The same data dir reattaches the same volumes
12//!   across restarts. A different data dir (a copy included, since its path
13//!   differs), the same path after the directory was wiped (a new id), or a
14//!   second fakecloud on the same daemon (two containers mounting different
15//!   host dirs at the same in-container path differ by id) never sees them,
16//!   so a fresh data dir can't inherit another one's database. The volumes
17//!   are labelled with the scope tag and the data path, so `docker volume ls
18//!   --filter label=fakecloud-data-path=<dir>` finds the ones a data dir
19//!   owns.
20//! * **Process** (memory mode): nothing outlives the process's state, so the
21//!   tag is unique to the process and the volume carries the
22//!   `fakecloud-instance=fakecloud-<pid>` ownership label the startup reaper
23//!   uses to remove it once that process is gone.
24//!
25//! Volumes created before scoping existed used an unscoped *legacy* name. A
26//! resource restored from a data dir written by such a build keeps using its
27//! legacy volume (docker has no volume rename, and copying a database between
28//! volumes needs an extra container and can fail half way). Each service
29//! records the choice on the resource as a [`DataVolumeBinding`]: resources
30//! created by this build are bound to their scoped volume from the start, and
31//! a resource persisted without a binding is bound once by [`resolve_binding`]
32//! against the daemon's volume list.
33
34use std::collections::HashSet;
35use std::path::Path;
36use std::sync::OnceLock;
37use std::time::Duration;
38
39use sha2::{Digest, Sha256};
40
41/// File in the data dir holding the random half of its volume scope.
42pub const SCOPE_FILE: &str = "data-volume-scope";
43/// Label carrying the scope tag a volume belongs to.
44pub const SCOPE_LABEL: &str = "fakecloud-data-scope";
45/// Label carrying the canonical `--data-path` of a data-dir scoped volume.
46pub const DATA_PATH_LABEL: &str = "fakecloud-data-path";
47/// Ownership label shared with containers and networks (see the reaper).
48pub const INSTANCE_LABEL: &str = "fakecloud-instance";
49
50/// Bound on each volume CLI call, so a wedged daemon can't hang a create.
51const CLI_TIMEOUT: Duration = Duration::from_secs(30);
52
53/// Which data volume a persisted resource mounts.
54#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum DataVolumeBinding {
57    /// The volume named for the current scope.
58    Scoped,
59    /// An unscoped volume a pre-scoping build created for the resource.
60    Legacy(String),
61}
62
63/// Which lifetime a fakecloud process's container volumes are tied to.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum VolumeScope {
66    /// Persistent mode: tied to the data directory.
67    DataDir { tag: String, path: String },
68    /// Memory mode: tied to this process.
69    Process { tag: String, pid: u32 },
70}
71
72fn random_id() -> String {
73    uuid::Uuid::new_v4().simple().to_string()
74}
75
76fn short_hash(bytes: &[u8]) -> String {
77    Sha256::digest(bytes)[..6]
78        .iter()
79        .map(|b| format!("{b:02x}"))
80        .collect()
81}
82
83fn valid_id(id: &str) -> bool {
84    id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
85}
86
87/// The data dir's stored id, minting it on first use. Written to a temp file
88/// and renamed into place (works on any filesystem), so a crash never leaves
89/// a torn id.
90fn data_dir_id(dir: &Path) -> std::io::Result<String> {
91    let file = dir.join(SCOPE_FILE);
92    let read = |file: &Path| -> std::io::Result<String> {
93        let id = std::fs::read_to_string(file)?.trim().to_string();
94        if valid_id(&id) {
95            Ok(id)
96        } else {
97            Err(std::io::Error::new(
98                std::io::ErrorKind::InvalidData,
99                format!("{} does not hold a volume scope id", file.display()),
100            ))
101        }
102    };
103    match read(&file) {
104        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
105        other => return other,
106    }
107    // A per-writer temp name, so two writers never trip over one temp file.
108    // (Two servers sharing one data dir isn't supported; the rename keeps the
109    // file whole either way.)
110    let tmp = dir.join(format!("{SCOPE_FILE}.{}.tmp", random_id()));
111    std::fs::write(&tmp, format!("{}\n", random_id()))?;
112    std::fs::rename(&tmp, &file)?;
113    read(&file)
114}
115
116impl VolumeScope {
117    /// Scope for a persistent data directory: a hash of its canonical path
118    /// and the id stored in it (minted on first use). Fails if the directory
119    /// can't be read or written, since naming volumes for a scope the next
120    /// start can't recover would orphan them.
121    pub fn for_data_dir(path: &Path) -> std::io::Result<Self> {
122        let canonical = std::fs::canonicalize(path)?;
123        let id = data_dir_id(&canonical)?;
124        let path = canonical.to_string_lossy().into_owned();
125        Ok(Self::DataDir {
126            tag: format!("d{}", short_hash(format!("{path}\n{id}").as_bytes())),
127            path,
128        })
129    }
130
131    /// Scope for a memory-mode process. The tag is random, so a later process
132    /// that happens to reuse this PID never reattaches a volume the reaper
133    /// hasn't removed yet.
134    pub fn for_process() -> Self {
135        Self::Process {
136            tag: format!("p{}", &random_id()[..12]),
137            pid: std::process::id(),
138        }
139    }
140
141    pub fn tag(&self) -> &str {
142        match self {
143            Self::DataDir { tag, .. } | Self::Process { tag, .. } => tag,
144        }
145    }
146
147    /// `key=value` labels a volume in this scope is created with.
148    pub fn labels(&self) -> Vec<String> {
149        match self {
150            Self::DataDir { tag, path } => vec![
151                format!("{SCOPE_LABEL}={tag}"),
152                format!("{DATA_PATH_LABEL}={path}"),
153            ],
154            Self::Process { tag, pid } => vec![
155                format!("{SCOPE_LABEL}={tag}"),
156                format!("{INSTANCE_LABEL}=fakecloud-{pid}"),
157            ],
158        }
159    }
160}
161
162static SCOPE: OnceLock<VolumeScope> = OnceLock::new();
163
164/// Tie this process's volumes to `data_path`. Called once by the server in
165/// persistent mode, before any container runtime is built. A later call
166/// returns the scope already in place (it can't change under volumes already
167/// named for it).
168pub fn init_data_dir_scope(data_path: &Path) -> std::io::Result<&'static VolumeScope> {
169    let scope = match SCOPE.get() {
170        Some(scope) => scope,
171        None => {
172            let scope = VolumeScope::for_data_dir(data_path)?;
173            SCOPE.get_or_init(|| scope)
174        }
175    };
176    require_data_dir_scope(scope)
177}
178
179/// A process scope already in place means something named volumes before
180/// the data dir was known; going on would give durable data a
181/// process-lifetime name (removed on shutdown, unreachable next start).
182fn require_data_dir_scope(scope: &VolumeScope) -> std::io::Result<&VolumeScope> {
183    match scope {
184        VolumeScope::DataDir { .. } => Ok(scope),
185        VolumeScope::Process { .. } => Err(std::io::Error::other(
186            "container data volumes were named before the data directory scope was set",
187        )),
188    }
189}
190
191/// The scope this process names its volumes in: the data dir once
192/// [`init_data_dir_scope`] ran, otherwise the process.
193pub fn current_scope() -> &'static VolumeScope {
194    SCOPE.get_or_init(VolumeScope::for_process)
195}
196
197/// Replace characters outside Docker's `[a-zA-Z0-9_.-]` volume-name set.
198pub fn sanitize(s: &str) -> String {
199    s.chars()
200        .map(|c| {
201            if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
202                c
203            } else {
204                '-'
205            }
206        })
207        .collect()
208}
209
210/// `fakecloud-<service>-data-<scope tag>-<parts...>`.
211pub fn scoped_volume_name(service: &str, scope_tag: &str, parts: &[&str]) -> String {
212    let mut name = format!("fakecloud-{service}-data-{scope_tag}");
213    for part in parts {
214        name.push('-');
215        name.push_str(&sanitize(part));
216    }
217    name
218}
219
220/// The unscoped name a build before data-dir scoping gave the same volume:
221/// `fakecloud-<service>-data-<parts...>`.
222pub fn legacy_volume_name(service: &str, parts: &[&str]) -> String {
223    let mut name = format!("fakecloud-{service}-data");
224    for part in parts {
225        name.push('-');
226        name.push_str(&sanitize(part));
227    }
228    name
229}
230
231/// A stable incarnation id derived from immutable facts of one resource
232/// incarnation (e.g. its ARN and creation timestamp): a delete and a
233/// recreate under the same identifier get different ids, so runtime records,
234/// container names and data volumes keyed by it never collide.
235pub fn incarnation_id(parts: &[&str]) -> String {
236    short_hash(parts.join("\n").as_bytes())
237}
238
239/// Bind a resource persisted without a binding (written by a pre-scoping
240/// build, or never bound because the daemon couldn't be listed) against the
241/// daemon's volumes: its scoped volume once that exists (it has been used
242/// since), else the legacy volume a pre-scoping build left for it, else a new
243/// scoped one.
244pub fn resolve_binding(
245    scoped: &str,
246    legacy: &str,
247    existing: &HashSet<String>,
248) -> DataVolumeBinding {
249    if !existing.contains(scoped) && existing.contains(legacy) {
250        DataVolumeBinding::Legacy(legacy.to_string())
251    } else {
252        DataVolumeBinding::Scoped
253    }
254}
255
256async fn run(cli: &str, args: &[&str]) -> Option<std::process::Output> {
257    let fut = tokio::process::Command::new(cli)
258        .args(args)
259        .kill_on_drop(true)
260        .output();
261    match tokio::time::timeout(CLI_TIMEOUT, fut).await {
262        Ok(Ok(out)) => Some(out),
263        _ => None,
264    }
265}
266
267/// Names of every volume on the daemon, or `None` when the CLI can't answer
268/// (so a caller doesn't mistake an unreachable daemon for "no volumes").
269pub async fn list_volumes(cli: &str) -> Option<HashSet<String>> {
270    let out = run(cli, &["volume", "ls", "--format", "{{.Name}}"]).await?;
271    if !out.status.success() {
272        return None;
273    }
274    Some(
275        String::from_utf8_lossy(&out.stdout)
276            .lines()
277            .map(str::trim)
278            .filter(|l| !l.is_empty())
279            .map(str::to_string)
280            .collect(),
281    )
282}
283
284/// Whether the daemon has a volume named `name` (false if it can't answer).
285pub async fn volume_exists(cli: &str, name: &str) -> bool {
286    run(cli, &["volume", "inspect", name])
287        .await
288        .is_some_and(|o| o.status.success())
289}
290
291/// Create `name` labelled for `scope` unless it already exists (a volume from
292/// an earlier run of the same scope, or an adopted legacy volume, is reused
293/// as is). Best effort: if the create fails, the container's `-v` still
294/// creates the volume, just without labels.
295pub async fn ensure_volume(cli: &str, name: &str, scope: &VolumeScope, extra_labels: &[String]) {
296    if volume_exists(cli, name).await {
297        return;
298    }
299    let mut args: Vec<String> = vec!["volume".into(), "create".into()];
300    for label in scope.labels().iter().chain(extra_labels) {
301        args.push("--label".into());
302        args.push(label.clone());
303    }
304    args.push(name.to_string());
305    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
306    let created = run(cli, &argv).await;
307    if !created.as_ref().is_some_and(|o| o.status.success()) {
308        tracing::warn!(
309            volume = name,
310            "could not pre-create labelled data volume; the container will create it unlabelled"
311        );
312    }
313}
314
315/// Remove a volume, ignoring a missing one.
316pub async fn remove_volume(cli: &str, name: &str) {
317    let _ = run(cli, &["volume", "rm", "-f", name]).await;
318}
319
320/// On a clean shutdown in memory mode, remove every volume this process
321/// created: its state is gone, so nothing can reattach them. Run after the
322/// runtimes stopped their containers (a mounted volume can't be removed).
323/// A no-op for a data-dir scope, whose volumes must outlive the process. A
324/// killed process's volumes are left to the startup reaper instead.
325pub async fn remove_process_volumes(cli: &str) {
326    let scope = current_scope();
327    if !matches!(scope, VolumeScope::Process { .. }) {
328        return;
329    }
330    // Match by name rather than label: a volume whose labelled create failed
331    // was auto-created unlabelled by the container's `-v`, and the scope tag
332    // is part of every name this process gave.
333    let Some(names) = list_volumes(cli).await else {
334        return;
335    };
336    for name in names
337        .iter()
338        .filter(|n| is_scoped_volume_name(n, scope.tag()))
339    {
340        remove_volume(cli, name).await;
341    }
342}
343
344/// Whether `name` is a volume [`scoped_volume_name`] produced for `scope_tag`.
345pub fn is_scoped_volume_name(name: &str, scope_tag: &str) -> bool {
346    name.strip_prefix("fakecloud-")
347        .and_then(|rest| rest.split_once("-data-"))
348        .is_some_and(|(_, rest)| {
349            rest.strip_prefix(scope_tag)
350                .is_some_and(|tail| tail.starts_with('-'))
351        })
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn data_dir_scope_is_stable_per_dir_and_distinct_across_dirs() {
360        let a = tempfile::tempdir().unwrap();
361        let b = tempfile::tempdir().unwrap();
362        let a1 = VolumeScope::for_data_dir(a.path()).unwrap();
363        let a2 = VolumeScope::for_data_dir(a.path()).unwrap();
364        let b1 = VolumeScope::for_data_dir(b.path()).unwrap();
365        assert_eq!(a1, a2, "same data dir must map to the same scope");
366        assert_ne!(a1.tag(), b1.tag(), "different data dirs must never share");
367        assert!(a1.tag().starts_with('d'));
368        assert_eq!(a1.tag().len(), 13);
369        // The id lives in the data dir.
370        let stored = std::fs::read_to_string(a.path().join(SCOPE_FILE)).unwrap();
371        assert!(valid_id(stored.trim()), "{stored:?}");
372    }
373
374    #[test]
375    fn wiped_data_dir_at_the_same_path_gets_a_new_scope() {
376        let parent = tempfile::tempdir().unwrap();
377        let dir = parent.path().join("data");
378        std::fs::create_dir(&dir).unwrap();
379        let before = VolumeScope::for_data_dir(&dir).unwrap();
380        std::fs::remove_dir_all(&dir).unwrap();
381        std::fs::create_dir(&dir).unwrap();
382        let after = VolumeScope::for_data_dir(&dir).unwrap();
383        assert_ne!(before.tag(), after.tag());
384    }
385
386    #[test]
387    fn copied_data_dir_gets_its_own_scope() {
388        let parent = tempfile::tempdir().unwrap();
389        let from = parent.path().join("from");
390        let to = parent.path().join("to");
391        std::fs::create_dir(&from).unwrap();
392        std::fs::create_dir(&to).unwrap();
393        let original = VolumeScope::for_data_dir(&from).unwrap();
394        std::fs::copy(from.join(SCOPE_FILE), to.join(SCOPE_FILE)).unwrap();
395        let copy = VolumeScope::for_data_dir(&to).unwrap();
396        assert_ne!(original.tag(), copy.tag());
397    }
398
399    #[test]
400    fn same_path_with_a_different_id_gets_its_own_scope() {
401        // Two fakecloud containers mounting different host dirs at the same
402        // in-container --data-path.
403        let dir = tempfile::tempdir().unwrap();
404        let first = VolumeScope::for_data_dir(dir.path()).unwrap();
405        std::fs::write(dir.path().join(SCOPE_FILE), format!("{}\n", random_id())).unwrap();
406        let second = VolumeScope::for_data_dir(dir.path()).unwrap();
407        assert_ne!(first.tag(), second.tag());
408    }
409
410    #[test]
411    fn data_dir_scope_canonicalizes_the_path() {
412        let dir = tempfile::tempdir().unwrap();
413        let sub = dir.path().join("data");
414        std::fs::create_dir(&sub).unwrap();
415        let dotted = sub.join("..").join("data");
416        assert_eq!(
417            VolumeScope::for_data_dir(&sub).unwrap().tag(),
418            VolumeScope::for_data_dir(&dotted).unwrap().tag()
419        );
420    }
421
422    #[test]
423    fn data_dir_scope_rejects_a_corrupt_tag_file() {
424        let dir = tempfile::tempdir().unwrap();
425        std::fs::write(dir.path().join(SCOPE_FILE), "../../etc").unwrap();
426        assert!(VolumeScope::for_data_dir(dir.path()).is_err());
427    }
428
429    #[test]
430    fn data_dir_labels_name_the_scope_and_path() {
431        let dir = tempfile::tempdir().unwrap();
432        let scope = VolumeScope::for_data_dir(dir.path()).unwrap();
433        let VolumeScope::DataDir { tag, path } = &scope else {
434            panic!("expected a data-dir scope");
435        };
436        assert_eq!(
437            path,
438            &std::fs::canonicalize(dir.path())
439                .unwrap()
440                .to_string_lossy()
441                .into_owned()
442        );
443        assert_eq!(
444            scope.labels(),
445            vec![
446                format!("fakecloud-data-scope={tag}"),
447                format!("fakecloud-data-path={path}"),
448            ]
449        );
450        // No ownership label: the reaper must never remove durable volumes.
451        assert!(!scope.labels().iter().any(|l| l.starts_with(INSTANCE_LABEL)));
452    }
453
454    #[test]
455    fn process_scope_is_unique_and_reapable() {
456        let a = VolumeScope::for_process();
457        let b = VolumeScope::for_process();
458        assert_ne!(a.tag(), b.tag(), "a reused pid must not reuse a scope");
459        assert!(a.tag().starts_with('p'));
460        let me = std::process::id();
461        assert!(a
462            .labels()
463            .contains(&format!("fakecloud-instance=fakecloud-{me}")));
464    }
465
466    #[test]
467    fn scoped_and_legacy_names() {
468        assert_eq!(
469            scoped_volume_name("rds", "dabc123def456", &["123456789012", "my-db"]),
470            "fakecloud-rds-data-dabc123def456-123456789012-my-db"
471        );
472        assert_eq!(
473            scoped_volume_name("elasticache", "dabc", &["weird/id:1"]),
474            "fakecloud-elasticache-data-dabc-weird-id-1"
475        );
476        // The legacy names match what builds before scoping created.
477        assert_eq!(
478            legacy_volume_name("rds", &["123456789012", "my-db"]),
479            "fakecloud-rds-data-123456789012-my-db"
480        );
481        assert_eq!(
482            legacy_volume_name("elasticache", &["my-cache"]),
483            "fakecloud-elasticache-data-my-cache"
484        );
485    }
486
487    #[test]
488    fn persistent_mode_refuses_a_process_scope_already_in_place() {
489        assert!(require_data_dir_scope(&VolumeScope::for_process()).is_err());
490        let dir = tempfile::tempdir().unwrap();
491        let scope = VolumeScope::for_data_dir(dir.path()).unwrap();
492        assert!(require_data_dir_scope(&scope).is_ok());
493    }
494
495    #[test]
496    fn scoped_volume_names_are_recognised_by_tag() {
497        let name = scoped_volume_name("elasticache", "pabc123", &["123456789012", "c"]);
498        assert!(is_scoped_volume_name(&name, "pabc123"));
499        assert!(!is_scoped_volume_name(&name, "pabc12"));
500        assert!(!is_scoped_volume_name(&name, "pother"));
501        assert!(!is_scoped_volume_name(
502            "fakecloud-elasticache-data-c",
503            "pabc123"
504        ));
505        assert!(!is_scoped_volume_name(
506            "someone-else-data-pabc123-x",
507            "pabc123"
508        ));
509    }
510
511    #[test]
512    fn incarnation_ids_differ_per_incarnation() {
513        let a = incarnation_id(&[
514            "arn:aws:elasticache:us-east-1:1:cluster:c",
515            "2026-01-01T00:00:00.1Z",
516        ]);
517        let b = incarnation_id(&[
518            "arn:aws:elasticache:us-east-1:1:cluster:c",
519            "2026-01-01T00:00:00.2Z",
520        ]);
521        assert_ne!(a, b);
522        assert_eq!(
523            a,
524            incarnation_id(&[
525                "arn:aws:elasticache:us-east-1:1:cluster:c",
526                "2026-01-01T00:00:00.1Z"
527            ])
528        );
529        assert_eq!(a.len(), 12);
530    }
531
532    #[test]
533    fn resolve_binding_prefers_scoped_then_legacy() {
534        let set = |names: &[&str]| names.iter().map(|n| n.to_string()).collect();
535        assert_eq!(
536            resolve_binding("s", "l", &set(&[])),
537            DataVolumeBinding::Scoped
538        );
539        assert_eq!(
540            resolve_binding("s", "l", &set(&["l"])),
541            DataVolumeBinding::Legacy("l".into())
542        );
543        // A scoped volume in use wins over a legacy one lying around.
544        assert_eq!(
545            resolve_binding("s", "l", &set(&["s", "l"])),
546            DataVolumeBinding::Scoped
547        );
548        assert_eq!(
549            resolve_binding("s", "l", &set(&["s"])),
550            DataVolumeBinding::Scoped
551        );
552    }
553
554    #[test]
555    fn binding_serializes_stably() {
556        assert_eq!(
557            serde_json::to_string(&DataVolumeBinding::Scoped).unwrap(),
558            "\"scoped\""
559        );
560        assert_eq!(
561            serde_json::to_string(&DataVolumeBinding::Legacy("v".into())).unwrap(),
562            "{\"legacy\":\"v\"}"
563        );
564    }
565}