use std::path::Path;
use crate::config::StorageError;
pub(crate) const FILE: &str = "storage.claim";
pub(crate) const OBJECT: &str = "vcon-server.claim";
pub(crate) fn new_id() -> String {
let mut bytes = [0; 16];
aws_lc_rs::rand::fill(&mut bytes).expect("AWS-LC aborts instead of failing");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub(crate) fn parse_id(text: &str) -> Option<&str> {
let id = text.trim_end_matches(['\r', '\n']);
(id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))).then_some(id)
}
pub(crate) fn read_file(data_dir: &Path) -> Result<Option<String>, StorageError> {
let path = data_dir.join(FILE);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(StorageError(format!("{}: {e}", path.display()))),
};
parse_id(&text)
.map(|id| Some(id.to_owned()))
.ok_or_else(|| {
StorageError(format!(
"{} does not hold a claim id (32 hexadecimal digits)",
path.display()
))
})
}
pub(crate) fn write_file(data_dir: &Path, id: &str) -> Result<(), StorageError> {
use crate::fs::{Fs, OsFs};
let path = data_dir.join(FILE);
let tmp = data_dir.join(format!("{FILE}.tmp"));
let failed = |e: std::io::Error| StorageError(format!("{}: {e}", path.display()));
match OsFs.remove(&tmp) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(failed(e)),
_ => {}
}
OsFs.write_new(&tmp, format!("{id}\n").as_bytes())
.map_err(failed)?;
OsFs.sync_file(&tmp).map_err(failed)?;
OsFs.rename(&tmp, &path).map_err(failed)?;
OsFs.sync_dir(data_dir).map_err(failed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Step {
Use,
Claim,
Finish,
}
pub(crate) fn decide(
local: Option<&str>,
bucket: Option<&str>,
holds_tenants: bool,
place: &str,
) -> Result<Step, StorageError> {
match (local, bucket) {
(Some(local), Some(bucket)) if local == bucket => Ok(Step::Use),
(_, Some(bucket)) => Err(StorageError(format!(
"{place} belongs to another data directory (its claim is {bucket}); start-up \
recovery here would remove that server's vCons. Give this server a prefix of \
its own, or point it at the data directory that claimed this one"
))),
(local, None) if holds_tenants => Err(StorageError(format!(
"{place} holds tenant objects but no claim{}; start-up recovery would remove \
every object no catalog here names. Give this server an empty prefix",
if local.is_some() {
", although this data directory claimed it"
} else {
""
}
))),
(Some(_), None) => Ok(Step::Finish),
(None, None) => Ok(Step::Claim),
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLACE: &str = "bucket \"vcon\", prefix \"server-a/\"";
const A: &str = "0123456789abcdef0123456789abcdef";
const B: &str = "fedcba9876543210fedcba9876543210";
#[test]
fn a_prefix_is_used_only_by_the_data_directory_that_claimed_it() {
for holds in [false, true] {
assert_eq!(
decide(Some(A), Some(A), holds, PLACE),
Ok(Step::Use),
"the claims agree, holding tenants: {holds}"
);
}
for (local, bucket, holds) in [
(Some(A), Some(B), false),
(Some(A), Some(B), true),
(None, Some(B), false),
(None, Some(B), true),
] {
let refused = decide(local, bucket, holds, PLACE).expect_err("another's prefix");
assert!(
refused.0.contains(PLACE) && refused.0.contains("another data directory"),
"{local:?} {bucket:?} {holds}: {refused}"
);
}
}
#[test]
fn a_claim_id_is_32_lowercase_hex_digits() {
let id = new_id();
assert_eq!(parse_id(&id), Some(id.as_str()), "a new id parses");
assert_ne!(new_id(), id, "each id is new");
assert_eq!(
parse_id(&format!("{A}\n")),
Some(A),
"its line ending is left out"
);
for text in [
"",
"0123",
&A.to_uppercase(),
&format!("{A}0"),
&format!("{A}\nmore"),
] {
assert_eq!(parse_id(text), None, "{text:?}");
}
}
#[test]
fn a_claim_file_reads_back_and_a_damaged_one_is_named() {
let dir = tempfile::tempdir().expect("dir");
assert_eq!(read_file(dir.path()), Ok(None), "no claim yet");
write_file(dir.path(), A).expect("writes");
assert_eq!(
read_file(dir.path()),
Ok(Some(A.to_owned())),
"the claim written"
);
write_file(dir.path(), B).expect("writes over a cut-off write's temporary file");
assert_eq!(
read_file(dir.path()),
Ok(Some(B.to_owned())),
"the claim written last"
);
std::fs::write(dir.path().join(FILE), "not a claim\n").expect("damages it");
let refused = read_file(dir.path()).expect_err("damaged");
assert!(refused.0.contains(FILE), "the file is named: {refused}");
}
#[test]
fn an_empty_prefix_is_claimed_and_a_cut_off_claim_finished() {
assert_eq!(
decide(None, None, false, PLACE),
Ok(Step::Claim),
"an empty prefix with no claim is claimed"
);
assert_eq!(
decide(Some(A), None, false, PLACE),
Ok(Step::Finish),
"a claim made only in the data directory is finished in the bucket"
);
}
#[test]
fn objects_no_claim_covers_are_never_taken_over() {
for local in [None, Some(A)] {
let refused = decide(local, None, true, PLACE).expect_err("unclaimed objects");
assert!(
refused.0.contains(PLACE) && refused.0.contains("no claim"),
"{local:?}: {refused}"
);
}
}
}