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.
package/dist/package-versions.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
10387
|
-
let
|
|
10388
|
-
|
|
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 =
|
|
10392
|
-
let
|
|
10393
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
File without changes
|