1use std::collections::HashSet;
35use std::path::Path;
36use std::sync::OnceLock;
37use std::time::Duration;
38
39use sha2::{Digest, Sha256};
40
41pub const SCOPE_FILE: &str = "data-volume-scope";
43pub const SCOPE_LABEL: &str = "fakecloud-data-scope";
45pub const DATA_PATH_LABEL: &str = "fakecloud-data-path";
47pub const INSTANCE_LABEL: &str = "fakecloud-instance";
49
50const CLI_TIMEOUT: Duration = Duration::from_secs(30);
52
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum DataVolumeBinding {
57 Scoped,
59 Legacy(String),
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum VolumeScope {
66 DataDir { tag: String, path: String },
68 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
87fn 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 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 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 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 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
164pub 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
179fn 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
191pub fn current_scope() -> &'static VolumeScope {
194 SCOPE.get_or_init(VolumeScope::for_process)
195}
196
197pub 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
210pub 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
220pub 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
231pub fn incarnation_id(parts: &[&str]) -> String {
236 short_hash(parts.join("\n").as_bytes())
237}
238
239pub 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
267pub 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
284pub 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
291pub 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
315pub async fn remove_volume(cli: &str, name: &str) {
317 let _ = run(cli, &["volume", "rm", "-f", name]).await;
318}
319
320pub async fn remove_process_volumes(cli: &str) {
326 let scope = current_scope();
327 if !matches!(scope, VolumeScope::Process { .. }) {
328 return;
329 }
330 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
344pub 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 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 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 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 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 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}