create-feltdb 0.11.7 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.11.7';
3
+ export const FELTDB_PACKAGE_VERSION = '0.11.8';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -4,11 +4,32 @@ use std::{
4
4
  fs::{self, File},
5
5
  io::{self, Read, Write},
6
6
  path::{Path, PathBuf},
7
- time::{SystemTime, UNIX_EPOCH},
7
+ time::{Instant, SystemTime, UNIX_EPOCH},
8
8
  };
9
9
 
10
10
  pub const BACKUP_FORMAT: u32 = 1;
11
11
 
12
+ /// Where the backed-up state came from. Optional in the manifest so that
13
+ /// bundles written before provenance was recorded still verify: the format is
14
+ /// unchanged, and an absent field is reported as unknown rather than invented.
15
+ #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
16
+ pub struct BackupSource {
17
+ /// Absolute path of the durable state file the bundle was taken from.
18
+ #[serde(default, skip_serializing_if = "Option::is_none")]
19
+ pub data: Option<String>,
20
+ /// SHA-256 of the authoritative state artifact (`state.log`, or the
21
+ /// consistent `database.snapshot.json` of an online backup).
22
+ #[serde(default, skip_serializing_if = "Option::is_none")]
23
+ pub state_sha256: Option<String>,
24
+ /// The `feltdb-server` crate version that wrote the bundle (not the npm
25
+ /// package version).
26
+ #[serde(default, skip_serializing_if = "Option::is_none")]
27
+ pub server_version: Option<String>,
28
+ /// The durable format version of the build that wrote the bundle.
29
+ #[serde(default, skip_serializing_if = "Option::is_none")]
30
+ pub durable_format: Option<u32>,
31
+ }
32
+
12
33
  #[derive(Clone, Debug, Serialize, Deserialize)]
13
34
  pub struct BackupEntry {
14
35
  pub path: String,
@@ -21,6 +42,93 @@ pub struct BackupManifest {
21
42
  pub format: u32,
22
43
  pub created_ms: u64,
23
44
  pub entries: Vec<BackupEntry>,
45
+ #[serde(default, skip_serializing_if = "Option::is_none")]
46
+ pub source: Option<BackupSource>,
47
+ }
48
+
49
+ /// The explicit outcome of a backup operation.
50
+ ///
51
+ /// `status` is `ok` only when the bundle was written *and* independently
52
+ /// verified; the CLI exit code follows it, so a successful backup is never
53
+ /// reported as a failure and an unverifiable one is never reported as valid.
54
+ #[derive(Clone, Debug, Serialize, Deserialize)]
55
+ pub struct BackupReport {
56
+ pub operation: String,
57
+ pub status: String,
58
+ /// Absolute path of the bundle.
59
+ pub path: String,
60
+ /// SHA-256 of the bundle manifest: the bundle's stable identity.
61
+ pub backup_id: String,
62
+ pub source: BackupSource,
63
+ pub format: u32,
64
+ pub created_ms: u64,
65
+ pub files: usize,
66
+ pub bytes_written: u64,
67
+ /// `verified` or `failed`.
68
+ pub verification: String,
69
+ #[serde(skip_serializing_if = "Option::is_none")]
70
+ pub error: Option<String>,
71
+ pub duration_ms: u64,
72
+ }
73
+
74
+ /// Resolve a user-supplied path against the working directory.
75
+ ///
76
+ /// A bare relative path such as `backup` has an *empty* parent, not `.`; every
77
+ /// operation that syncs or stages next to the output therefore works on the
78
+ /// absolute form, which is what makes `backup`, `./backup` and
79
+ /// `/abs/path/backup` behave identically.
80
+ pub fn normalize_path(path: &Path) -> io::Result<PathBuf> {
81
+ if path.as_os_str().is_empty() {
82
+ return Err(io::Error::new(
83
+ io::ErrorKind::InvalidInput,
84
+ "backup path must not be empty",
85
+ ));
86
+ }
87
+ std::path::absolute(path)
88
+ }
89
+
90
+ /// SHA-256 of a bundle's manifest bytes, used as the bundle identity.
91
+ pub fn backup_id(archive: &Path) -> io::Result<String> {
92
+ Ok(format!(
93
+ "{:x}",
94
+ Sha256::digest(fs::read(archive.join("manifest.json"))?)
95
+ ))
96
+ }
97
+
98
+ /// Verify a bundle and describe it as a report, without ever turning a
99
+ /// verification failure into success.
100
+ pub fn verification_report(archive: &Path, started: Instant, operation: &str) -> BackupReport {
101
+ let path = normalize_path(archive).unwrap_or_else(|_| archive.to_path_buf());
102
+ match verify_bundle(&path).and_then(|manifest| Ok((backup_id(&path)?, manifest))) {
103
+ Ok((id, manifest)) => BackupReport {
104
+ operation: operation.into(),
105
+ status: "ok".into(),
106
+ path: path.display().to_string(),
107
+ backup_id: id,
108
+ source: manifest.source.clone().unwrap_or_default(),
109
+ format: manifest.format,
110
+ created_ms: manifest.created_ms,
111
+ files: manifest.entries.len(),
112
+ bytes_written: manifest.entries.iter().map(|entry| entry.bytes).sum(),
113
+ verification: "verified".into(),
114
+ error: None,
115
+ duration_ms: started.elapsed().as_millis() as u64,
116
+ },
117
+ Err(error) => BackupReport {
118
+ operation: operation.into(),
119
+ status: "failed".into(),
120
+ path: path.display().to_string(),
121
+ backup_id: backup_id(&path).unwrap_or_default(),
122
+ source: BackupSource::default(),
123
+ format: 0,
124
+ created_ms: 0,
125
+ files: 0,
126
+ bytes_written: 0,
127
+ verification: "failed".into(),
128
+ error: Some(error.to_string()),
129
+ duration_ms: started.elapsed().as_millis() as u64,
130
+ },
131
+ }
24
132
  }
25
133
 
26
134
  pub fn server_artifacts(data: &Path, keys: &Path, audit: &Path) -> Vec<(String, PathBuf)> {
@@ -72,6 +180,20 @@ pub fn create_bundle_with_virtual(
72
180
  artifacts: &[(String, PathBuf)],
73
181
  virtual_files: &[(String, Vec<u8>)],
74
182
  ) -> io::Result<BackupManifest> {
183
+ create_bundle_with_source(output, artifacts, virtual_files, BackupSource::default())
184
+ }
185
+
186
+ /// Write a bundle and record where its state came from.
187
+ ///
188
+ /// The bundle is staged beside the output and published with one rename, so a
189
+ /// reader sees either no bundle or a complete one.
190
+ pub fn create_bundle_with_source(
191
+ output: &Path,
192
+ artifacts: &[(String, PathBuf)],
193
+ virtual_files: &[(String, Vec<u8>)],
194
+ mut source: BackupSource,
195
+ ) -> io::Result<BackupManifest> {
196
+ let output = &normalize_path(output)?;
75
197
  if output.exists() {
76
198
  return Err(io::Error::new(
77
199
  io::ErrorKind::AlreadyExists,
@@ -114,6 +236,14 @@ pub fn create_bundle_with_virtual(
114
236
  });
115
237
  }
116
238
  entries.sort_by(|left, right| left.path.cmp(&right.path));
239
+ if source.state_sha256.is_none() {
240
+ source.state_sha256 = entries
241
+ .iter()
242
+ .find(|entry| {
243
+ entry.path == "files/database.snapshot.json" || entry.path == "files/state.log"
244
+ })
245
+ .map(|entry| entry.sha256.clone());
246
+ }
117
247
  let manifest = BackupManifest {
118
248
  format: BACKUP_FORMAT,
119
249
  created_ms: SystemTime::now()
@@ -121,6 +251,7 @@ pub fn create_bundle_with_virtual(
121
251
  .unwrap_or_default()
122
252
  .as_millis() as u64,
123
253
  entries,
254
+ source: Some(source),
124
255
  };
125
256
  write_synced(
126
257
  &staging.join("manifest.json"),
@@ -128,9 +259,7 @@ pub fn create_bundle_with_virtual(
128
259
  )?;
129
260
  sync_directory(&staging)?;
130
261
  fs::rename(&staging, output)?;
131
- if let Some(parent) = output.parent() {
132
- sync_directory(parent)?;
133
- }
262
+ sync_parent(output)?;
134
263
  Ok(manifest)
135
264
  })();
136
265
  if result.is_err() {
@@ -140,6 +269,7 @@ pub fn create_bundle_with_virtual(
140
269
  }
141
270
 
142
271
  pub fn verify_bundle(archive: &Path) -> io::Result<BackupManifest> {
272
+ let archive = &normalize_path(archive)?;
143
273
  if archive.with_extension("partial").exists() {
144
274
  return Err(io::Error::new(
145
275
  io::ErrorKind::InvalidData,
@@ -167,6 +297,25 @@ pub fn verify_bundle(archive: &Path) -> io::Result<BackupManifest> {
167
297
  ));
168
298
  }
169
299
  }
300
+ // Completeness runs both ways: every manifest entry is present and intact
301
+ // (above), and nothing unrecorded sits in the bundle. An extra file would be
302
+ // restored as state that no checksum ever covered.
303
+ let recorded: std::collections::BTreeSet<&str> = manifest
304
+ .entries
305
+ .iter()
306
+ .map(|entry| entry.path.as_str())
307
+ .collect();
308
+ let mut present = Vec::new();
309
+ collect_files(&archive.join("files"), "files", &mut present)?;
310
+ if let Some(extra) = present
311
+ .iter()
312
+ .find(|path| !recorded.contains(path.as_str()))
313
+ {
314
+ return Err(io::Error::new(
315
+ io::ErrorKind::InvalidData,
316
+ format!("backup contains a file absent from its manifest: {extra}"),
317
+ ));
318
+ }
170
319
  Ok(manifest)
171
320
  }
172
321
 
@@ -174,6 +323,7 @@ pub fn verify_bundle(archive: &Path) -> io::Result<BackupManifest> {
174
323
  /// does not exceed `at_ms`. Every manifest-bearing child is verified first so a
175
324
  /// corrupt recent backup cannot cause a silent fallback to older data.
176
325
  pub fn select_bundle_at(catalog: &Path, at_ms: u64) -> io::Result<PathBuf> {
326
+ let catalog = &normalize_path(catalog)?;
177
327
  let mut candidates = Vec::new();
178
328
  for entry in fs::read_dir(catalog)? {
179
329
  let path = entry?.path();
@@ -198,6 +348,8 @@ pub fn select_bundle_at(catalog: &Path, at_ms: u64) -> io::Result<PathBuf> {
198
348
  }
199
349
 
200
350
  pub fn restore_bundle(archive: &Path, output: &Path) -> io::Result<BackupManifest> {
351
+ let archive = &normalize_path(archive)?;
352
+ let output = &normalize_path(output)?;
201
353
  let manifest = verify_bundle(archive)?;
202
354
  if output.exists() {
203
355
  return Err(io::Error::new(
@@ -221,9 +373,7 @@ pub fn restore_bundle(archive: &Path, output: &Path) -> io::Result<BackupManifes
221
373
  )?;
222
374
  sync_directory(&staging)?;
223
375
  fs::rename(&staging, output)?;
224
- if let Some(parent) = output.parent() {
225
- sync_directory(parent)?;
226
- }
376
+ sync_parent(output)?;
227
377
  Ok(manifest.clone())
228
378
  })();
229
379
  if result.is_err() {
@@ -271,6 +421,22 @@ fn copy_source(
271
421
  }
272
422
  Ok(())
273
423
  }
424
+ fn collect_files(directory: &Path, relative: &str, found: &mut Vec<String>) -> io::Result<()> {
425
+ if !directory.exists() {
426
+ return Ok(());
427
+ }
428
+ for child in fs::read_dir(directory)? {
429
+ let child = child?;
430
+ let name = format!("{relative}/{}", child.file_name().to_string_lossy());
431
+ let kind = child.file_type()?;
432
+ if kind.is_dir() {
433
+ collect_files(&child.path(), &name, found)?;
434
+ } else {
435
+ found.push(name);
436
+ }
437
+ }
438
+ Ok(())
439
+ }
274
440
  fn copy_tree(source: &Path, destination: &Path) -> io::Result<()> {
275
441
  if !source.exists() {
276
442
  return Ok(());
@@ -322,6 +488,14 @@ fn sync_file(path: &Path) -> io::Result<()> {
322
488
  fn sync_directory(path: &Path) -> io::Result<()> {
323
489
  File::open(path)?.sync_all()
324
490
  }
491
+ /// Sync the directory that holds `path`. A relative path with no directory
492
+ /// component lives in the working directory; its parent is `.`, never `""`.
493
+ fn sync_parent(path: &Path) -> io::Result<()> {
494
+ match path.parent() {
495
+ Some(parent) if !parent.as_os_str().is_empty() => sync_directory(parent),
496
+ _ => sync_directory(Path::new(".")),
497
+ }
498
+ }
325
499
 
326
500
  #[cfg(test)]
327
501
  mod tests {
@@ -409,4 +583,113 @@ mod tests {
409
583
  assert!(select_bundle_at(&root.join("catalog"), first.created_ms - 1).is_err());
410
584
  fs::remove_dir_all(root).unwrap();
411
585
  }
586
+
587
+ #[test]
588
+ fn relative_and_absolute_paths_normalize_identically() {
589
+ let cwd = std::env::current_dir().unwrap();
590
+ assert_eq!(
591
+ normalize_path(Path::new("backup")).unwrap(),
592
+ cwd.join("backup")
593
+ );
594
+ assert_eq!(
595
+ normalize_path(Path::new("./backup")).unwrap(),
596
+ cwd.join("./backup")
597
+ );
598
+ assert_eq!(
599
+ normalize_path(&cwd.join("backup")).unwrap(),
600
+ cwd.join("backup")
601
+ );
602
+ assert!(normalize_path(Path::new("")).is_err());
603
+ // The defect: a bare name has an empty parent, which cannot be opened
604
+ // for the post-rename directory sync. `sync_parent` must treat it as `.`.
605
+ assert_eq!(Path::new("backup").parent(), Some(Path::new("")));
606
+ sync_parent(Path::new("backup")).unwrap();
607
+ }
608
+
609
+ #[test]
610
+ fn backup_into_missing_parent_records_provenance_and_verifies() {
611
+ let root = std::env::temp_dir().join(format!(
612
+ "feltdb-provenance-{}-{}",
613
+ std::process::id(),
614
+ rand::random::<u64>()
615
+ ));
616
+ fs::create_dir_all(&root).unwrap();
617
+ fs::write(root.join("state.log"), b"state").unwrap();
618
+ let archive = root.join("does/not/exist/yet/archive");
619
+ let manifest = create_bundle_with_source(
620
+ &archive,
621
+ &[("state.log".into(), root.join("state.log"))],
622
+ &[],
623
+ BackupSource {
624
+ data: Some("/data/state.log".into()),
625
+ ..BackupSource::default()
626
+ },
627
+ )
628
+ .unwrap();
629
+ let source = manifest.source.unwrap();
630
+ assert_eq!(source.data.as_deref(), Some("/data/state.log"));
631
+ assert_eq!(
632
+ source.state_sha256.as_deref(),
633
+ Some(manifest.entries[0].sha256.as_str())
634
+ );
635
+ let report = verification_report(&archive, Instant::now(), "verify");
636
+ assert_eq!(report.status, "ok");
637
+ assert_eq!(report.verification, "verified");
638
+ assert_eq!(report.bytes_written, 5);
639
+ assert_eq!(report.backup_id, backup_id(&archive).unwrap());
640
+ fs::remove_dir_all(root).unwrap();
641
+ }
642
+
643
+ #[test]
644
+ fn unrecorded_files_and_missing_manifests_fail_verification() {
645
+ let root = std::env::temp_dir().join(format!(
646
+ "feltdb-completeness-{}-{}",
647
+ std::process::id(),
648
+ rand::random::<u64>()
649
+ ));
650
+ fs::create_dir_all(&root).unwrap();
651
+ fs::write(root.join("state.log"), b"state").unwrap();
652
+ let archive = root.join("archive");
653
+ create_bundle(&archive, &[("state.log".into(), root.join("state.log"))]).unwrap();
654
+ fs::write(archive.join("files/injected"), b"unrecorded").unwrap();
655
+ let report = verification_report(&archive, Instant::now(), "verify");
656
+ assert_eq!(report.status, "failed");
657
+ assert!(report.error.unwrap().contains("absent from its manifest"));
658
+ fs::remove_file(archive.join("files/injected")).unwrap();
659
+ fs::remove_file(archive.join("files/state.log")).unwrap();
660
+ assert_eq!(
661
+ verification_report(&archive, Instant::now(), "verify").status,
662
+ "failed"
663
+ );
664
+ fs::remove_file(archive.join("manifest.json")).unwrap();
665
+ assert_eq!(
666
+ verification_report(&archive, Instant::now(), "verify").verification,
667
+ "failed"
668
+ );
669
+ fs::remove_dir_all(root).unwrap();
670
+ }
671
+
672
+ #[test]
673
+ fn manifests_written_before_provenance_still_verify() {
674
+ let root = std::env::temp_dir().join(format!(
675
+ "feltdb-legacy-manifest-{}-{}",
676
+ std::process::id(),
677
+ rand::random::<u64>()
678
+ ));
679
+ fs::create_dir_all(root.join("archive/files")).unwrap();
680
+ fs::write(root.join("archive/files/state.log"), b"legacy").unwrap();
681
+ let digest = format!("{:x}", Sha256::digest(b"legacy"));
682
+ fs::write(
683
+ root.join("archive/manifest.json"),
684
+ format!(r#"{{"format":1,"created_ms":1,"entries":[{{"path":"files/state.log","bytes":6,"sha256":"{digest}"}}]}}"#),
685
+ )
686
+ .unwrap();
687
+ let manifest = verify_bundle(&root.join("archive")).unwrap();
688
+ assert!(manifest.source.is_none());
689
+ assert_eq!(
690
+ verification_report(&root.join("archive"), Instant::now(), "verify").source,
691
+ BackupSource::default()
692
+ );
693
+ fs::remove_dir_all(root).unwrap();
694
+ }
412
695
  }
@@ -75,7 +75,7 @@ use feltdb_server::{
75
75
  authenticated_principal::AuthenticatedPrincipal,
76
76
  authorization::{authorize, AuthorizationRequest, Subject},
77
77
  backup::{
78
- create_bundle, create_bundle_with_virtual, restore_bundle, select_bundle_at,
78
+ restore_bundle, select_bundle_at,
79
79
  server_artifacts, verify_bundle,
80
80
  },
81
81
  causal::{
@@ -8529,6 +8529,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8529
8529
  if manage_backup()? {
8530
8530
  return Ok(());
8531
8531
  }
8532
+ if manage_doctor()? {
8533
+ return Ok(());
8534
+ }
8532
8535
  if manage_application_runtime()? {
8533
8536
  return Ok(());
8534
8537
  }
@@ -9989,8 +9992,16 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
9989
9992
  match outcome {
9990
9993
  Ok(Ok(Ok(health))) => {
9991
9994
  let ready = health.is_nominal();
9995
+ let recovering = matches!(
9996
+ state.cluster.proposal().map(|value| value.phase),
9997
+ Some(ProposalPhase::Preparing | ProposalPhase::Prepared)
9998
+ );
9992
9999
  let body = Json(json!({
9993
10000
  "ready": ready,
10001
+ // The explicit health model. A running server has already
10002
+ // refused a corrupt or incompatible log at open, so `corrupt`
10003
+ // is reported offline by `feltdb-server doctor`, never here.
10004
+ "state": if !ready { "degraded" } else if recovering { "recovering" } else { "healthy" },
9994
10005
  "storage": health.storage.label(),
9995
10006
  "durable_format": health.durable_format.to_string(),
9996
10007
  "probe_ms": waited_ms,
@@ -10006,6 +10017,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
10006
10017
  StatusCode::SERVICE_UNAVAILABLE,
10007
10018
  Json(json!({
10008
10019
  "ready": false,
10020
+ "state": "unavailable",
10009
10021
  "error": error.to_string(),
10010
10022
  "code": "STORAGE_UNAVAILABLE",
10011
10023
  "probe_ms": waited_ms,
@@ -10016,6 +10028,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
10016
10028
  StatusCode::SERVICE_UNAVAILABLE,
10017
10029
  Json(json!({
10018
10030
  "ready": false,
10031
+ "state": "unavailable",
10019
10032
  "error": error.to_string(),
10020
10033
  "code": "STORAGE_PROBE_FAILED",
10021
10034
  "probe_ms": waited_ms,
@@ -10026,6 +10039,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
10026
10039
  StatusCode::SERVICE_UNAVAILABLE,
10027
10040
  Json(json!({
10028
10041
  "ready": false,
10042
+ "state": "unavailable",
10029
10043
  "error": "storage probe exceeded the server deadline",
10030
10044
  "code": "STORAGE_STALLED",
10031
10045
  "probe_ms": waited_ms,
@@ -10370,7 +10384,6 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
10370
10384
  if arguments.get(1).map(String::as_str) != Some("backup") {
10371
10385
  return Ok(false);
10372
10386
  }
10373
- let command = arguments.get(2).map(String::as_str).unwrap_or("");
10374
10387
  let option = |name: &str| {
10375
10388
  arguments
10376
10389
  .iter()
@@ -10378,27 +10391,105 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
10378
10391
  .and_then(|index| arguments.get(index + 1))
10379
10392
  .cloned()
10380
10393
  };
10394
+ let json_output = arguments.iter().any(|value| value == "--json");
10395
+ // `backup <path>` is `backup create --output <path>`; `backup verify <path>`
10396
+ // is `backup verify --archive <path>`. The flag forms remain supported.
10397
+ let positional = |from: usize| {
10398
+ let mut index = from;
10399
+ while let Some(value) = arguments.get(index) {
10400
+ if value == "--json" {
10401
+ index += 1;
10402
+ } else if value.starts_with("--") {
10403
+ index += 2;
10404
+ } else {
10405
+ return Some(value.clone());
10406
+ }
10407
+ }
10408
+ None
10409
+ };
10410
+ let (command, target) = match arguments.get(2).map(String::as_str) {
10411
+ Some(command @ ("create" | "verify" | "restore")) => (command, positional(3)),
10412
+ Some(value) if !value.starts_with("--") => ("create", Some(value.to_string())),
10413
+ _ if option("--output").is_some() && option("--archive").is_none() => ("create", None),
10414
+ _ => ("", None),
10415
+ };
10416
+ let usage = "usage: feltdb-server backup [create] <output> | backup verify <archive> | backup restore --archive <archive> --output <directory> [--at-ms timestamp] (options: --data path --keys path --audit path --json)";
10417
+ let started = Instant::now();
10418
+ let emit = |report: &feltdb_server::backup::BackupReport, human: String| {
10419
+ if json_output {
10420
+ println!("{}", serde_json::to_string_pretty(report).unwrap_or_default());
10421
+ } else if report.status == "ok" {
10422
+ println!("{human}");
10423
+ } else {
10424
+ eprintln!(
10425
+ "Backup {} failed: {} ({})",
10426
+ report.operation,
10427
+ report.path,
10428
+ report.error.as_deref().unwrap_or("unknown error")
10429
+ );
10430
+ }
10431
+ };
10381
10432
  match command {
10382
10433
  "create" => {
10383
10434
  let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
10384
10435
  let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
10385
10436
  let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
10386
- let output = PathBuf::from(option("--output").ok_or("backup create requires --output")?);
10387
- let manifest = create_bundle(&output, &server_artifacts(&data, &keys, &audit))?;
10388
- println!("Backup created: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len());
10437
+ let output = option("--output").or(target).ok_or("backup create requires an output path")?;
10438
+ let output = feltdb_server::backup::normalize_path(std::path::Path::new(&output))?;
10439
+ let data = feltdb_server::backup::normalize_path(&data)?;
10440
+ if !data.exists() {
10441
+ return Err(format!("backup source does not exist: {}", data.display()).into());
10442
+ }
10443
+ let source = feltdb_server::backup::BackupSource {
10444
+ data: Some(data.display().to_string()),
10445
+ state_sha256: None,
10446
+ server_version: Some(env!("CARGO_PKG_VERSION").into()),
10447
+ durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10448
+ };
10449
+ feltdb_server::backup::create_bundle_with_source(&output, &server_artifacts(&data, &keys, &audit), &[], source)?;
10450
+ // A backup is reported as successful only after it verifies
10451
+ // independently of the code path that wrote it.
10452
+ let mut report = feltdb_server::backup::verification_report(&output, started, "create");
10453
+ if report.status == "ok" {
10454
+ if let Err(error) = verify_restorable(&output) {
10455
+ report.status = "failed".into();
10456
+ report.verification = "failed".into();
10457
+ report.error = Some(error.to_string());
10458
+ }
10459
+ }
10460
+ let human = format!("Backup created: {} (id {}, format {}, {} files, {} bytes, verified, {} ms)", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, report.duration_ms);
10461
+ emit(&report, human);
10462
+ if report.status != "ok" {
10463
+ std::process::exit(1);
10464
+ }
10389
10465
  }
10390
10466
  "verify" => {
10391
- let archive = PathBuf::from(option("--archive").ok_or("backup verify requires --archive")?);
10392
- let manifest = verify_bundle(&archive)?;
10393
- println!("Backup verified: {} (format {}, {} files)", archive.display(), manifest.format, manifest.entries.len());
10467
+ let archive = option("--archive").or(target).ok_or("backup verify requires an archive path")?;
10468
+ let mut report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
10469
+ let mut restorable = None;
10470
+ if report.status == "ok" {
10471
+ match verify_restorable(std::path::Path::new(&report.path)) {
10472
+ Ok(detail) => restorable = Some(detail),
10473
+ Err(error) => {
10474
+ report.status = "failed".into();
10475
+ report.verification = "failed".into();
10476
+ report.error = Some(error.to_string());
10477
+ }
10478
+ }
10479
+ }
10480
+ let human = format!("Backup verified: {} (id {}, format {}, {} files, {} bytes{})", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, restorable.map(|detail| format!(", {detail}")).unwrap_or_default());
10481
+ emit(&report, human);
10482
+ if report.status != "ok" {
10483
+ std::process::exit(1);
10484
+ }
10394
10485
  }
10395
10486
  "restore" => {
10396
- let archive = PathBuf::from(option("--archive").ok_or("backup restore requires --archive")?);
10487
+ let archive = PathBuf::from(option("--archive").or(target).ok_or("backup restore requires --archive")?);
10397
10488
  let archive = match option("--at-ms") {
10398
10489
  Some(value) => select_bundle_at(&archive, value.parse().map_err(|_| "backup restore --at-ms must be an unsigned millisecond timestamp")?)?,
10399
10490
  None => archive,
10400
10491
  };
10401
- let output = PathBuf::from(option("--output").ok_or("backup restore requires --output")?);
10492
+ let output = feltdb_server::backup::normalize_path(std::path::Path::new(&option("--output").ok_or("backup restore requires --output")?))?;
10402
10493
  let manifest = restore_bundle(&archive, &output)?;
10403
10494
  let snapshot_path = output.join("files/database.snapshot.json");
10404
10495
  if snapshot_path.exists() {
@@ -10406,13 +10497,199 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
10406
10497
  let database = FeltDb::open(output.join("files/state.log"))?;
10407
10498
  database.install_snapshot(snapshot)?;
10408
10499
  }
10409
- println!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len());
10500
+ let mut report = feltdb_server::backup::verification_report(&archive, started, "restore");
10501
+ report.path = output.display().to_string();
10502
+ emit(&report, format!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len()));
10503
+ if report.status != "ok" {
10504
+ std::process::exit(1);
10505
+ }
10410
10506
  }
10411
- _ => return Err("usage: feltdb-server backup <create|verify|restore> [--data path] [--keys path] [--audit path] --output directory | --archive directory [--at-ms timestamp]".into()),
10507
+ _ => return Err(usage.into()),
10412
10508
  }
10413
10509
  Ok(true)
10414
10510
  }
10415
10511
 
10512
+ /// `feltdb-server doctor` — what an operator needs to know, offline.
10513
+ ///
10514
+ /// Answers, for a data path: is the database healthy; is the durable log
10515
+ /// valid; what state/version is it in; is recovery pending; can a backup be
10516
+ /// created; can that backup be restored; which server and format versions are
10517
+ /// active. It never opens the live files: the state is bundled exactly as
10518
+ /// `backup create` would, and every probe — open, read, write — runs against a
10519
+ /// private restored copy. `--backup <archive>` additionally verifies an
10520
+ /// existing bundle.
10521
+ ///
10522
+ /// The health state is one of `healthy`, `recovering`, `degraded`,
10523
+ /// `unavailable`, or `corrupt`, and `healthy` requires every probe to pass:
10524
+ /// a readable durable log in a supported format, a clean replay, a working
10525
+ /// read, a working write, and a restorable backup.
10526
+ fn manage_doctor() -> Result<bool, Box<dyn std::error::Error>> {
10527
+ let arguments: Vec<String> = std::env::args().collect();
10528
+ if arguments.get(1).map(String::as_str) != Some("doctor") {
10529
+ return Ok(false);
10530
+ }
10531
+ let option = |name: &str| {
10532
+ arguments
10533
+ .iter()
10534
+ .position(|value| value == name)
10535
+ .and_then(|index| arguments.get(index + 1))
10536
+ .cloned()
10537
+ };
10538
+ let json_output = arguments.iter().any(|value| value == "--json");
10539
+ let data = feltdb_server::backup::normalize_path(&PathBuf::from(
10540
+ option("--data").unwrap_or_else(|| "./data/feltdb.log".into()),
10541
+ ))?;
10542
+ let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
10543
+ let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
10544
+
10545
+ let mut checks: Vec<Value> = Vec::new();
10546
+ let mut check = |name: &str, ok: bool, detail: String| {
10547
+ checks.push(json!({ "name": name, "status": if ok { "PASS" } else { "FAIL" }, "detail": detail }));
10548
+ ok
10549
+ };
10550
+ let scratch = std::env::temp_dir().join(format!(
10551
+ "feltdb-doctor-{}-{}",
10552
+ std::process::id(),
10553
+ rand::random::<u64>()
10554
+ ));
10555
+ let bundle = scratch.join("bundle");
10556
+ let restored = scratch.join("restored");
10557
+ let mut state = "healthy";
10558
+ let mut facts = json!({
10559
+ "data": data.display().to_string(),
10560
+ "server_version": env!("CARGO_PKG_VERSION"),
10561
+ "supported_durable_format": feltdb::DURABLE_FORMAT_VERSION,
10562
+ "backup_format": feltdb_server::backup::BACKUP_FORMAT,
10563
+ });
10564
+
10565
+ if !check("data present", data.exists(), data.display().to_string()) {
10566
+ state = "unavailable";
10567
+ } else {
10568
+ std::fs::create_dir_all(&scratch)?;
10569
+ let source = feltdb_server::backup::BackupSource {
10570
+ data: Some(data.display().to_string()),
10571
+ state_sha256: None,
10572
+ server_version: Some(env!("CARGO_PKG_VERSION").into()),
10573
+ durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10574
+ };
10575
+ let bundled = feltdb_server::backup::create_bundle_with_source(&bundle, &server_artifacts(&data, &keys, &audit), &[], source);
10576
+ if !check("backup can be created", bundled.is_ok(), bundled.as_ref().map(|manifest| format!("{} files", manifest.entries.len())).unwrap_or_else(|error| error.to_string())) {
10577
+ state = "unavailable";
10578
+ } else {
10579
+ let restorable = restore_bundle(&bundle, &restored);
10580
+ check("backup can be restored", restorable.is_ok(), restorable.as_ref().map(|_| "restored to a private copy".to_string()).unwrap_or_else(|error| error.to_string()));
10581
+ match FeltDb::open(restored.join("files/state.log")) {
10582
+ Err(error) => {
10583
+ let corrupt = matches!(error, feltdb::FlowError::CorruptLogLine(_) | feltdb::FlowError::IncompatibleFormat(_));
10584
+ check("durable log valid", false, error.to_string());
10585
+ state = if corrupt { "corrupt" } else { "unavailable" };
10586
+ }
10587
+ Ok(database) => {
10588
+ let health = database.health();
10589
+ facts["durable_format"] = json!(health.durable_format.to_string());
10590
+ facts["storage"] = json!(health.storage.label());
10591
+ facts["recovery"] = json!(health.recovery.to_string());
10592
+ facts["sequence"] = json!(database.sequence().unwrap_or(0));
10593
+ check("durable log valid", true, format!("durable format {}", health.durable_format));
10594
+ if !check("replay clean", health.storage.is_clean(), health.recovery.to_string()) {
10595
+ state = "degraded";
10596
+ }
10597
+ let read = database.list_collection_page("_flow_capabilities", None, 1);
10598
+ if !check("read path", read.is_ok(), read.as_ref().map(|_| "bounded read succeeded".to_string()).unwrap_or_else(|error| error.to_string())) {
10599
+ state = "unavailable";
10600
+ }
10601
+ let probe_key = "_feltdb_doctor:probe";
10602
+ let wrote = database
10603
+ .insert(probe_key, json!({ "probe": true }))
10604
+ .and_then(|_| database.get_value(probe_key))
10605
+ .map(|value| value.is_some());
10606
+ if !check("mutation path", matches!(wrote, Ok(true)), match &wrote { Ok(true) => "write and read-back on the private copy".into(), Ok(false) => "write was not readable".into(), Err(error) => error.to_string() }) {
10607
+ state = "unavailable";
10608
+ }
10609
+ }
10610
+ }
10611
+ }
10612
+ if let Ok(cluster) = ClusterStore::load(data.with_extension("cluster.json"), Vec::new()) {
10613
+ let pending = matches!(cluster.proposal().map(|value| value.phase), Some(ProposalPhase::Preparing | ProposalPhase::Prepared));
10614
+ facts["pending_recovery"] = json!(pending);
10615
+ check("no pending membership recovery", !pending, if pending { "a membership change is mid-flight".into() } else { "none".into() });
10616
+ if pending && state == "healthy" {
10617
+ state = "recovering";
10618
+ }
10619
+ }
10620
+ }
10621
+ if let Some(archive) = option("--backup") {
10622
+ let started = Instant::now();
10623
+ let report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
10624
+ let restorable = if report.status == "ok" { verify_restorable(std::path::Path::new(&report.path)).map_err(|error| error.to_string()) } else { Err(report.error.clone().unwrap_or_default()) };
10625
+ check("supplied backup restorable", restorable.is_ok(), restorable.unwrap_or_else(|error| error));
10626
+ facts["backup"] = json!(report);
10627
+ }
10628
+ let _ = std::fs::remove_dir_all(&scratch);
10629
+ let healthy_requires_all = checks.iter().all(|entry| entry["status"] == "PASS");
10630
+ if state == "healthy" && !healthy_requires_all {
10631
+ state = "degraded";
10632
+ }
10633
+ let report = json!({ "command": "doctor", "state": state, "facts": facts, "checks": checks });
10634
+ if json_output {
10635
+ println!("{}", serde_json::to_string_pretty(&report)?);
10636
+ } else {
10637
+ println!("FeltDB doctor: {state}");
10638
+ for entry in report["checks"].as_array().into_iter().flatten() {
10639
+ println!(" {} {}: {}", if entry["status"] == "PASS" { "✓" } else { "✗" }, entry["name"].as_str().unwrap_or(""), entry["detail"].as_str().unwrap_or(""));
10640
+ }
10641
+ }
10642
+ if state != "healthy" {
10643
+ std::process::exit(1);
10644
+ }
10645
+ Ok(true)
10646
+ }
10647
+
10648
+ /// Prove a bundle restores into a database this build can open.
10649
+ ///
10650
+ /// Checksums prove the bytes are the ones recorded; they cannot prove the
10651
+ /// recorded bytes are a FeltDB database in a format this build understands.
10652
+ /// This restores into a private temporary directory, opens it, and reads it —
10653
+ /// the same path `backup restore` and a subsequent server start take — and
10654
+ /// never touches the bundle or any live database.
10655
+ fn verify_restorable(archive: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
10656
+ let manifest = verify_bundle(archive)?;
10657
+ let has_log = manifest.entries.iter().any(|entry| entry.path == "files/state.log");
10658
+ let has_snapshot = manifest
10659
+ .entries
10660
+ .iter()
10661
+ .any(|entry| entry.path == "files/database.snapshot.json");
10662
+ if !has_log && !has_snapshot {
10663
+ return Err("backup holds no authoritative state (neither state.log nor database.snapshot.json)".into());
10664
+ }
10665
+ let scratch = std::env::temp_dir().join(format!(
10666
+ "feltdb-backup-verify-{}-{}",
10667
+ std::process::id(),
10668
+ rand::random::<u64>()
10669
+ ));
10670
+ let restored = scratch.join("restored");
10671
+ let result = (|| -> Result<String, Box<dyn std::error::Error>> {
10672
+ std::fs::create_dir_all(&scratch)?;
10673
+ restore_bundle(archive, &restored)?;
10674
+ let database = FeltDb::open(restored.join("files/state.log"))?;
10675
+ if has_snapshot {
10676
+ let snapshot: DatabaseSnapshot = serde_json::from_slice(&std::fs::read(
10677
+ restored.join("files/database.snapshot.json"),
10678
+ )?)?;
10679
+ database.install_snapshot(snapshot)?;
10680
+ }
10681
+ let health = database.health();
10682
+ Ok(format!(
10683
+ "restorable: durable format {}, storage {}, sequence {}",
10684
+ health.durable_format,
10685
+ health.storage.label(),
10686
+ database.sequence()?
10687
+ ))
10688
+ })();
10689
+ let _ = std::fs::remove_dir_all(&scratch);
10690
+ result
10691
+ }
10692
+
10416
10693
  fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
10417
10694
  let arguments: Vec<String> = std::env::args().collect();
10418
10695
  if arguments.get(1).map(String::as_str) != Some("app") {
@@ -10515,12 +10792,24 @@ async fn create_online_backup(
10515
10792
  .cloned()
10516
10793
  .collect();
10517
10794
  let output = PathBuf::from(request.output);
10795
+ let source = feltdb_server::backup::BackupSource {
10796
+ data: state
10797
+ .backup_artifacts
10798
+ .iter()
10799
+ .find(|(label, _)| label == "state.log")
10800
+ .and_then(|(_, path)| feltdb_server::backup::normalize_path(path).ok())
10801
+ .map(|path| path.display().to_string()),
10802
+ state_sha256: None,
10803
+ server_version: Some(env!("CARGO_PKG_VERSION").into()),
10804
+ durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10805
+ };
10518
10806
  let manifest = tokio::task::spawn_blocking(move || {
10519
10807
  let _guard = guard;
10520
- create_bundle_with_virtual(
10808
+ feltdb_server::backup::create_bundle_with_source(
10521
10809
  &output,
10522
10810
  &artifacts,
10523
10811
  &[("database.snapshot.json".to_string(), snapshot_bytes)],
10812
+ source,
10524
10813
  )
10525
10814
  })
10526
10815
  .await
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.11.7",
5
+ "version": "0.11.8",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"