1use {
32 crate::ids::{ClusterId, DirectoryId, KafkaUuidError},
33 std::{
34 collections::HashMap,
35 fs,
36 io::{self, Write as _},
37 mem,
38 path::Path,
39 time::{SystemTime, UNIX_EPOCH},
40 },
41 thiserror::Error,
42 time::OffsetDateTime,
43};
44
45pub const META_PROPERTIES: &str = "meta.properties";
47
48pub const META_PROPERTIES_TMP: &str = "meta.properties.tmp";
51
52pub const META_PROPERTIES_VERSION: i32 = 1;
55
56const VERSION_PROP: &str = "version";
58const CLUSTER_ID_PROP: &str = "cluster.id";
59const NODE_ID_PROP: &str = "node.id";
60const DIRECTORY_ID_PROP: &str = "directory.id";
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct MetaProperties {
71 pub cluster_id: ClusterId,
73 pub node_id: i32,
75 pub directory_id: Option<DirectoryId>,
77}
78
79#[derive(Debug, Error)]
85#[non_exhaustive]
86pub enum MetaPropertiesError {
87 #[error(transparent)]
89 Io(#[from] io::Error),
90
91 #[error("Malformed \\uxxxx encoding.")]
93 MalformedEscape,
94
95 #[error("Invalid meta.properties version string '{0}'")]
97 InvalidVersion(String),
98
99 #[error("Unknown meta.properties version number {0}")]
101 UnknownVersion(i32),
102
103 #[error(
106 "Unsupported meta.properties version 0: krabka reads version 1, which a KRaft node writes"
107 )]
108 VersionZero,
109
110 #[error("Failed to find node.id")]
112 NoNodeId,
113
114 #[error("Unable to read node.id as a base-10 number.")]
116 NodeIdNotANumber,
117
118 #[error("cluster.id was not found.")]
120 NoClusterId,
121
122 #[error("Unable to read cluster.id as a Uuid: {0}")]
126 ClusterIdNotAUuid(KafkaUuidError),
127
128 #[error("Unable to read directory.id as a Uuid: {0}")]
130 DirectoryIdNotAUuid(KafkaUuidError),
131}
132
133impl MetaProperties {
134 pub fn read(dir: &Path) -> Result<Option<Self>, MetaPropertiesError> {
143 match fs::read(dir.join(META_PROPERTIES)) {
144 Ok(bytes) => Self::parse(&bytes).map(Some),
145 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
146 Err(error) => Err(error.into()),
147 }
148 }
149
150 pub fn parse(bytes: &[u8]) -> Result<Self, MetaPropertiesError> {
161 let props = load_properties(bytes)?;
162 let version = props.get(VERSION_PROP).map_or("0", String::as_str);
163 let number: i32 = version
166 .trim_matches(|c: char| c <= ' ')
167 .parse()
168 .map_err(|_| MetaPropertiesError::InvalidVersion(version.to_owned()))?;
169 match number {
170 META_PROPERTIES_VERSION => {}
171 0 => return Err(MetaPropertiesError::VersionZero),
172 other => return Err(MetaPropertiesError::UnknownVersion(other)),
173 }
174 let node_id: i32 = props
175 .get(NODE_ID_PROP)
176 .ok_or(MetaPropertiesError::NoNodeId)?
177 .parse()
178 .map_err(|_| MetaPropertiesError::NodeIdNotANumber)?;
179 let cluster_id = props.get(CLUSTER_ID_PROP);
180 let directory_id: Option<DirectoryId> = props
181 .get(DIRECTORY_ID_PROP)
182 .map(|id| id.parse().map_err(MetaPropertiesError::DirectoryIdNotAUuid))
183 .transpose()?;
184 let cluster_id: ClusterId = cluster_id
185 .ok_or(MetaPropertiesError::NoClusterId)?
186 .parse()
187 .map_err(MetaPropertiesError::ClusterIdNotAUuid)?;
188 Ok(Self {
189 cluster_id,
190 node_id,
191 directory_id,
192 })
193 }
194
195 #[must_use]
202 pub fn to_file_text(&self, now: SystemTime) -> String {
203 let directory_id = self
204 .directory_id
205 .map_or_else(String::new, |id| format!("{DIRECTORY_ID_PROP}={id}\n"));
206 format!(
207 "#\n#{}\n{CLUSTER_ID_PROP}={}\n{directory_id}{NODE_ID_PROP}={}\n\
208 {VERSION_PROP}={META_PROPERTIES_VERSION}\n",
209 java_date(now),
210 self.cluster_id,
211 self.node_id,
212 )
213 }
214
215 pub fn write(&self, dir: &Path) -> io::Result<()> { self.write_observed(dir, |_| Ok(())) }
226
227 pub(crate) fn write_observed(
232 &self,
233 dir: &Path,
234 mut after: impl FnMut(&Path) -> io::Result<()>,
235 ) -> io::Result<()> {
236 let text = self.to_file_text(SystemTime::now());
237 let tmp = dir.join(META_PROPERTIES_TMP);
238 let mut file = fs::File::create(&tmp)?;
239 file.write_all(text.as_bytes())?;
240 file.sync_all()?;
241 drop(file);
242 after(&tmp)?;
243 let path = dir.join(META_PROPERTIES);
244 if let Err(error) = fs::rename(&tmp, &path) {
245 let _ = fs::remove_file(&tmp);
247 return Err(error);
248 }
249 #[cfg(unix)]
254 fs::File::open(dir)?.sync_all()?;
255 after(&path)
256 }
257}
258
259#[bon::builder]
260pub fn node_id_mismatch_message(stored: i32, previous: i32, path: &Path) -> String {
261 format!(
262 "Stored node id {stored} doesn't match previous node id {previous} in {}. If you \
263 moved your data, make sure your configured node id matches. If you intend \
264 to create a new node, you should remove all data in your data directories.",
265 path.display(),
266 )
267}
268
269pub fn verify_ensemble<'a>(
282 formatted: impl IntoIterator<Item = (&'a Path, &'a MetaProperties)>,
283 expected_cluster_id: Option<ClusterId>,
284 expected_node_id: Option<i32>,
285) -> Result<Option<ClusterId>, String> {
286 let mut cluster_id = expected_cluster_id;
287 let mut node_id = expected_node_id;
288 let mut seen: HashMap<DirectoryId, &Path> = HashMap::new();
289 for (dir, meta) in formatted {
290 let path = dir.join(META_PROPERTIES);
291 match cluster_id {
292 None => cluster_id = Some(meta.cluster_id),
293 Some(expected) if expected != meta.cluster_id => {
294 return Err(format!(
295 "Invalid cluster.id in: {}. Expected {expected}, but read {}",
296 path.display(),
297 meta.cluster_id,
298 ));
299 }
300 Some(_) => {}
301 }
302 match node_id {
303 None => node_id = Some(meta.node_id),
304 Some(expected) if expected != meta.node_id => {
305 return Err(node_id_mismatch_message()
306 .stored(meta.node_id)
307 .previous(expected)
308 .path(&path)
309 .call());
310 }
311 Some(_) => {}
312 }
313 if let Some(directory_id) = meta.directory_id {
314 if directory_id.is_reserved() {
315 return Err(format!(
316 "Invalid reserved directory ID {directory_id} found in {}",
317 dir.display(),
318 ));
319 }
320 if let Some(previous) = seen.insert(directory_id, dir) {
321 return Err(format!(
323 "Duplicate directory ID Optional[{directory_id}] found. It was the ID of {}, \
324 but also of {}",
325 previous.display(),
326 dir.display(),
327 ));
328 }
329 }
330 }
331 Ok(cluster_id)
332}
333
334fn load_properties(bytes: &[u8]) -> Result<HashMap<String, String>, MetaPropertiesError> {
340 let mut props = HashMap::new();
341 for line in logical_lines(bytes) {
342 let RawEntry { key, value } = split_entry(&line);
343 props.insert(unescape(key)?, unescape(value)?);
344 }
345 Ok(props)
346}
347
348fn is_white_space(byte: u8) -> bool { matches!(byte, b' ' | b'\t' | b'\x0c') }
350
351fn is_line_end(byte: u8) -> bool { matches!(byte, b'\n' | b'\r') }
352
353fn logical_lines(input: &[u8]) -> Vec<Vec<u8>> {
362 let mut lines = Vec::new();
363 let mut line = Vec::new();
364 let mut skip_white_space = true;
365 let mut appended_line_begin = false;
366 let mut preceding_backslash = false;
367 let mut skip_lf = false;
368 let mut bytes = input.iter().copied();
369 while let Some(byte) = bytes.next() {
370 if skip_lf {
371 skip_lf = false;
372 if byte == b'\n' {
373 continue;
374 }
375 }
376 if skip_white_space {
377 if is_white_space(byte) || (!appended_line_begin && is_line_end(byte)) {
378 continue;
379 }
380 skip_white_space = false;
381 appended_line_begin = false;
382 }
383 if line.is_empty() && matches!(byte, b'#' | b'!') {
384 if !bytes.by_ref().any(is_line_end) {
385 break;
386 }
387 skip_white_space = true;
388 continue;
389 }
390 if !is_line_end(byte) {
391 line.push(byte);
392 preceding_backslash = byte == b'\\' && !preceding_backslash;
393 } else if line.is_empty() {
394 skip_white_space = true;
395 } else if preceding_backslash {
396 line.pop();
397 skip_white_space = true;
398 appended_line_begin = true;
399 preceding_backslash = false;
400 skip_lf = byte == b'\r';
401 } else {
402 lines.push(mem::take(&mut line));
403 skip_white_space = true;
404 }
405 }
406 if !line.is_empty() {
407 if preceding_backslash {
408 line.pop();
409 }
410 lines.push(line);
411 }
412 lines
413}
414
415struct RawEntry<'a> {
417 key: &'a [u8],
418 value: &'a [u8],
419}
420
421fn split_entry(line: &[u8]) -> RawEntry<'_> {
428 let mut key_len = 0;
429 let mut value_start = line.len();
430 let mut has_separator = false;
431 let mut preceding_backslash = false;
432 while let Some(&byte) = line.get(key_len) {
433 if !preceding_backslash && matches!(byte, b'=' | b':') {
434 value_start = key_len + 1;
435 has_separator = true;
436 break;
437 }
438 if !preceding_backslash && is_white_space(byte) {
439 value_start = key_len + 1;
440 break;
441 }
442 preceding_backslash = byte == b'\\' && !preceding_backslash;
443 key_len += 1;
444 }
445 while let Some(&byte) = line.get(value_start) {
446 if !is_white_space(byte) {
447 if !has_separator && matches!(byte, b'=' | b':') {
448 has_separator = true;
449 } else {
450 break;
451 }
452 }
453 value_start += 1;
454 }
455 RawEntry {
457 key: line.get(..key_len).unwrap_or(line),
458 value: line.get(value_start..).unwrap_or_default(),
459 }
460}
461
462fn unescape(raw: &[u8]) -> Result<String, MetaPropertiesError> {
466 let mut units: Vec<u16> = Vec::with_capacity(raw.len());
467 let mut bytes = raw.iter().copied();
468 while let Some(byte) = bytes.next() {
469 if byte != b'\\' {
470 units.push(u16::from(byte));
471 continue;
472 }
473 let Some(escaped) = bytes.next() else {
474 break;
475 };
476 let unit = match escaped {
477 b'u' => {
478 let mut value = 0u16;
479 for _ in 0..4 {
480 let digit = bytes
481 .next()
482 .and_then(|digit| char::from(digit).to_digit(16))
483 .and_then(|digit| u16::try_from(digit).ok())
484 .ok_or(MetaPropertiesError::MalformedEscape)?;
485 value = (value << 4) | digit;
486 }
487 value
488 }
489 b't' => u16::from(b'\t'),
490 b'r' => u16::from(b'\r'),
491 b'n' => u16::from(b'\n'),
492 b'f' => 0x0c,
493 other => u16::from(other),
494 };
495 units.push(unit);
496 }
497 Ok(String::from_utf16_lossy(&units))
498}
499
500fn java_date(now: SystemTime) -> String {
506 let date = OffsetDateTime::from(now.max(UNIX_EPOCH));
507 let short = |name: String| name.chars().take(3).collect::<String>();
509 format!(
510 "{} {} {:02} {:02}:{:02}:{:02} UTC {}",
511 short(date.weekday().to_string()),
512 short(date.month().to_string()),
513 date.day(),
514 date.hour(),
515 date.minute(),
516 date.second(),
517 date.year(),
518 )
519}
520
521#[cfg(test)]
522mod tests {
523 use {super::*, std::time::Duration, uuid::Uuid};
524
525 const CLUSTER: &str = "AQIDBAUGBwgJCgsMDQ4PEA";
526 const DIRECTORY: &str = "U-hCdTzaSPyFB3swwc7Qdw";
527
528 struct EnsembleCase {
531 what: &'static str,
532 set: Vec<DirectoryFile>,
533 cluster_id: Option<ClusterId>,
534 node_id: Option<i32>,
535 want: Result<Option<ClusterId>, String>,
536 }
537
538 struct DirectoryFile {
540 dir: &'static str,
541 meta: MetaProperties,
542 }
543
544 fn directory(dir: &'static str, meta: MetaProperties) -> DirectoryFile {
545 DirectoryFile { dir, meta }
546 }
547
548 fn at(seconds: u64) -> SystemTime { UNIX_EPOCH + Duration::from_secs(seconds) }
549
550 fn meta(node_id: i32, directory_id: Option<&str>) -> MetaProperties {
551 MetaProperties {
552 cluster_id: CLUSTER.parse().expect("cluster id"),
553 node_id,
554 directory_id: directory_id.map(|id| id.parse().expect("directory id")),
555 }
556 }
557
558 #[yare::parameterized(
560 epoch = { 0, "Thu Jan 01 00:00:00 UTC 1970" },
561 leap_day_2000 = { 951_782_400, "Tue Feb 29 00:00:00 UTC 2000" },
562 kafka_epoch_example = { 1_000_000_000, "Sun Sep 09 01:46:40 UTC 2001" },
563 leap_day_2024 = { 1_709_210_096, "Thu Feb 29 12:34:56 UTC 2024" },
564 year_2099_end = { 4_102_444_799, "Thu Dec 31 23:59:59 UTC 2099" },
565 zero_padded = { 1_767_596_889, "Mon Jan 05 07:08:09 UTC 2026" },
566 )]
567 fn the_date_comment_is_java_date_to_string(seconds: u64, want: &str) {
568 assert2::check!(java_date(at(seconds)) == want, "{seconds}");
569 }
570
571 #[test]
572 fn pre_epoch_date_clamps_to_epoch() {
573 assert2::check!(
574 java_date(UNIX_EPOCH - Duration::from_secs(1)) == "Thu Jan 01 00:00:00 UTC 1970"
575 );
576 }
577
578 #[yare::parameterized(
583 with_directory_id = {
584 "a formatted directory",
585 meta(1, Some(DIRECTORY)),
586 "#\n#Thu Feb 29 12:34:56 UTC 2024\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n\
587 directory.id=U-hCdTzaSPyFB3swwc7Qdw\nnode.id=1\nversion=1\n"
588 },
589 without_directory_id = {
590 "no directory id",
591 meta(2_147_483_647, None),
592 "#\n#Thu Feb 29 12:34:56 UTC 2024\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n\
593 node.id=2147483647\nversion=1\n"
594 },
595 )]
596 fn the_file_is_the_one_kafka_writes(what: &str, meta: MetaProperties, want: &str) {
597 assert2::check!(meta.to_file_text(at(1_709_210_096)) == want, "{what}");
598 }
599
600 #[yare::parameterized(
602 ordinary = { meta(0, Some(DIRECTORY)) },
603 reserved_directory_id = { meta(1, Some("AAAAAAAAAAAAAAAAAAAAZA")) },
604 high_bit_directory_id = { meta(2_147_483_647, Some("_____________________w")) },
605 no_directory_id = { meta(7, None) },
606 random_directory_id = { MetaProperties {
607 cluster_id: ClusterId(Uuid::from_u128(0)),
608 node_id: 3,
609 directory_id: Some(DirectoryId::random()),
610 } },
611 )]
612 fn the_written_file_parses_back_to_the_same_ids(written: MetaProperties) {
613 let tmp = tempfile::tempdir().expect("tempdir");
614 written.write(tmp.path()).expect("write");
615 assert2::check!(MetaProperties::read(tmp.path()).expect("read") == Some(written));
616 assert2::check!(!tmp.path().join(META_PROPERTIES_TMP).exists());
617 }
618
619 #[test]
620 fn an_empty_directory_has_no_meta_properties() {
621 let empty = tempfile::tempdir().expect("tempdir");
622 assert2::check!(MetaProperties::read(empty.path()).expect("read").is_none());
623 }
624
625 #[yare::parameterized(
630 kafka_hash_order = { "#\n#Thu Feb 29 12:34:56 UTC 2024\nnode.id=1\nversion=1\n\
631 directory.id=U-hCdTzaSPyFB3swwc7Qdw\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n" },
632 no_comments = { "version=1\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\nnode.id=1\n\
633 directory.id=U-hCdTzaSPyFB3swwc7Qdw\n" },
634 colon_and_whitespace_separators = { "version : 1\n cluster.id:AQIDBAUGBwgJCgsMDQ4PEA\nnode.id 1\n\
635 \tdirectory.id \t= U-hCdTzaSPyFB3swwc7Qdw" },
636 comments_and_blank_lines = { "! a comment\n\n # another\nversion=1\n\n\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n\
637 node.id=1\ndirectory.id=U-hCdTzaSPyFB3swwc7Qdw\n" },
638 crlf_and_cr_endings = { "#\r\nversion=1\r\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\rnode.id=1\r\n\
639 directory.id=U-hCdTzaSPyFB3swwc7Qdw\r\n" },
640 continued_line = { "version=1\ncluster.id=AQIDBAUGBwgJ\\\n CgsMDQ4PEA\nnode.id=1\n\
641 directory.id=U-hCdTzaSPyFB3swwc7Qdw\\\r\n" },
642 escaped_key_and_value = { "vers\\ion=\\u0031\ncluster\\.id=AQIDBAUGBwgJCgsMDQ4PEA\nnode.id=1\n\
643 directory.id=U-hCdTzaSPyF\\B3swwc7Qdw\n" },
644 duplicate_key_keeps_last = { "version=1\nnode.id=9\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\nnode.id=1\n\
645 directory.id=U-hCdTzaSPyFB3swwc7Qdw\n" },
646 unknown_key_is_ignored = { "version=1\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\nnode.id=1\nbroker.id=4\n\
647 directory.id=U-hCdTzaSPyFB3swwc7Qdw\n" },
648 )]
649 fn the_reader_accepts_what_properties_load_accepts(text: &str) {
650 let want = meta(1, Some(DIRECTORY));
651 assert2::check!(
652 MetaProperties::parse(text.as_bytes()).map_err(|e| e.to_string()) == Ok(want),
653 "{text:?}"
654 );
655 }
656
657 #[yare::parameterized(
660 bad_version = { "version=one\n", "Invalid meta.properties version string 'one'" },
661 unknown_version = {
662 "version=2\nnode.id=1\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n",
663 "Unknown meta.properties version number 2",
664 },
665 unsupported_zero = {
666 "version=0\nbroker.id=1\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n",
667 "Unsupported meta.properties version 0: krabka reads version 1, which a KRaft node writes",
668 },
669 missing_version = {
670 "node.id=1\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n",
671 "Unsupported meta.properties version 0: krabka reads version 1, which a KRaft node writes",
672 },
673 old_json = {
674 "{\n \"cluster_id\": \"AQIDBAUGBwgJCgsMDQ4PEA\",\n \"version\": 3\n}\n",
675 "Unsupported meta.properties version 0: krabka reads version 1, which a KRaft node writes",
676 },
677 no_node_or_cluster = { "version=1\n", "Failed to find node.id" },
678 node_trailing_space = {
679 "version=1\nnode.id=1 \ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n",
680 "Unable to read node.id as a base-10 number.",
681 },
682 node_overflow = {
683 "version=1\nnode.id=2147483648\ncluster.id=AQIDBAUGBwgJCgsMDQ4PEA\n",
684 "Unable to read node.id as a base-10 number.",
685 },
686 bad_directory_precedes_cluster_missing = {
687 "version=1\nnode.id=1\ndirectory.id=nope\n",
688 "Unable to read directory.id as a Uuid: Input string `nope` decoded as 3 bytes, which is not equal to the expected 16 bytes of a base64-encoded UUID",
689 },
690 no_cluster = { "version=1\nnode.id=1\n", "cluster.id was not found." },
691 invalid_cluster = {
692 "version=1\nnode.id=1\ncluster.id=AAAAAA\n",
693 "Unable to read cluster.id as a Uuid: Input string `AAAAAA` decoded as 4 bytes, which is not equal to the expected 16 bytes of a base64-encoded UUID",
694 },
695 malformed_escape = {
696 "version=1\nnode.id=1\ncluster.id=\\u00zz\n",
697 "Malformed \\uxxxx encoding.",
698 },
699 )]
700 fn a_bad_file_gets_kafkas_message(text: &str, want: &str) {
701 assert2::check!(
702 MetaProperties::parse(text.as_bytes()).map_err(|e| e.to_string())
703 == Err(want.to_owned()),
704 "{text:?}"
705 );
706 }
707
708 #[yare::parameterized(
711 nothing_formatted = { "nothing formatted" },
712 one_cluster_and_node = { "one cluster, one node" },
713 set_supplies_node_id = { "the set supplies the node id" },
714 cluster_id_disagrees = { "the given cluster id disagrees" },
715 node_id_disagrees = { "the configured node id disagrees" },
716 directory_node_ids_disagree = { "two directories disagree on the node id" },
717 reserved_directory_id = { "a reserved directory id" },
718 shared_directory_id = { "a shared directory id" },
719 missing_directory_ids = { "no directory id is no conflict" },
720 )]
721 fn verify_ensemble_matches_kafka(scenario: &str) {
722 let c1 = ClusterId(Uuid::from_u128(0xc1));
723 let c2 = ClusterId(Uuid::from_u128(0xc2));
724 let file = |cluster_id, node_id, directory: u128| MetaProperties {
725 cluster_id,
726 node_id,
727 directory_id: Some(DirectoryId(Uuid::from_u128(directory))),
728 };
729 let cases: Vec<EnsembleCase> = vec![
730 EnsembleCase {
731 what: "nothing formatted",
732 set: vec![],
733 cluster_id: None,
734 node_id: Some(1),
735 want: Ok(None),
736 },
737 EnsembleCase {
738 what: "one cluster, one node",
739 set: vec![
740 directory("/a", file(c1, 1, 0x1000)),
741 directory("/b", file(c1, 1, 0x2000)),
742 ],
743 cluster_id: None,
744 node_id: Some(1),
745 want: Ok(Some(c1)),
746 },
747 EnsembleCase {
748 what: "the set supplies the node id",
749 set: vec![
750 directory("/a", file(c1, 4, 0x1000)),
751 directory("/b", file(c1, 4, 0x2000)),
752 ],
753 cluster_id: Some(c1),
754 node_id: None,
755 want: Ok(Some(c1)),
756 },
757 EnsembleCase {
758 what: "the given cluster id disagrees",
759 set: vec![directory("/a", file(c1, 1, 0x1000))],
760 cluster_id: Some(c2),
761 node_id: Some(1),
762 want: Err(format!(
763 "Invalid cluster.id in: {}. Expected {c2}, but read {c1}",
764 Path::new("/a").join(META_PROPERTIES).display()
765 )),
766 },
767 EnsembleCase {
768 what: "the configured node id disagrees",
769 set: vec![directory("/a", file(c1, 2, 0x1000))],
770 cluster_id: None,
771 node_id: Some(1),
772 want: Err(node_id_mismatch_message()
773 .stored(2)
774 .previous(1)
775 .path(&Path::new("/a").join(META_PROPERTIES))
776 .call()),
777 },
778 EnsembleCase {
779 what: "two directories disagree on the node id",
780 set: vec![
781 directory("/a", file(c1, 3, 0x1000)),
782 directory("/b", file(c1, 4, 0x2000)),
783 ],
784 cluster_id: None,
785 node_id: None,
786 want: Err(format!(
787 "Stored node id 4 doesn't match previous node id 3 in {}. If you moved \
788 your data, make sure your configured node id matches. If you intend to \
789 create a new node, you should remove all data in your data directories.",
790 Path::new("/b").join(META_PROPERTIES).display()
791 )),
792 },
793 EnsembleCase {
794 what: "a reserved directory id",
795 set: vec![directory("/a", file(c1, 1, 2))],
796 cluster_id: None,
797 node_id: Some(1),
798 want: Err(
799 "Invalid reserved directory ID AAAAAAAAAAAAAAAAAAAAAg found in /a".to_owned(),
800 ),
801 },
802 EnsembleCase {
803 what: "a shared directory id",
804 set: vec![
805 directory("/a", file(c1, 1, 0x1000)),
806 directory("/b", file(c1, 1, 0x1000)),
807 ],
808 cluster_id: None,
809 node_id: Some(1),
810 want: Err(format!(
811 "Duplicate directory ID Optional[{}] found. It was the ID of /a, but also of \
812 /b",
813 DirectoryId(Uuid::from_u128(0x1000))
814 )),
815 },
816 EnsembleCase {
817 what: "no directory id is no conflict",
818 set: vec![
819 directory("/a", MetaProperties {
820 directory_id: None,
821 ..file(c1, 1, 0)
822 }),
823 directory("/b", MetaProperties {
824 directory_id: None,
825 ..file(c1, 1, 0)
826 }),
827 ],
828 cluster_id: None,
829 node_id: Some(1),
830 want: Ok(Some(c1)),
831 },
832 ];
833 let EnsembleCase {
834 what,
835 set,
836 cluster_id,
837 node_id,
838 want,
839 } = cases
840 .into_iter()
841 .find(|case| case.what == scenario)
842 .expect("named yare case exists");
843 let got = verify_ensemble(
844 set.iter().map(|file| (Path::new(file.dir), &file.meta)),
845 cluster_id,
846 node_id,
847 );
848 assert2::check!(got == want, "{what}");
849 }
850}