数据库备份测试
This commit is contained in:
1 parent
f571da637f
commit
db7afde7af
4 files changed
+250
-3
No files matched your search
@@ -176,10 +176,18 @@ database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=d
|
||||
- ZIP 备份上限为 512 MiB,解压后不超过 4 GiB;导出也会检查这些上限,超限时请减少历史数据后重试。
|
||||
- 新节点首次有效上报前不发送离线告警;后续按最近实时上报时间判断,后端重启后会等待一个离线告警周期供 Agent 重连。
|
||||
- 历史指标默认保留 30 天,过期数据自动清理;完整空间回收在后台手动触发。
|
||||
- 升级建议先更新服务端再更新 Agent;恢复备份建议使用相同版本。
|
||||
- 升级建议先更新服务端再更新 Agent;支持当前版本和 v1.0.3 导出的 ZIP 备份。
|
||||
|
||||
SQLite / PostgreSQL 均使用一份 `01_initial.sql`,首次启动时自动创建完整数据库结构。ZIP 恢复校验表结构并导入业务数据,保留目标库的初始化记录。服务端与 Agent 须配套使用,当前 Agent 协议为 `2`。
|
||||
|
||||
从 v1.0.3 导入数据:
|
||||
|
||||
1. 在 v1.0.3 后台导出 ZIP 备份,并保留原配置和数据库。
|
||||
2. 为新版配置全新的 SQLite 文件或空 PostgreSQL 数据库,设置临时管理员账号和密码后启动。
|
||||
3. 登录新版后台恢复 ZIP;恢复后使用备份中的管理员账号、密码和两步验证重新登录,再更新各节点 Agent。
|
||||
|
||||
导入会保留设置、节点、历史数据、主题文件和安全配置;旧版最新状态中的重复指标列会忽略,完整指标从 `latest_json` 保留。原库的迁移记录不会导入。额外安装 Token 的恢复规则见上文。
|
||||
|
||||
## 从源码开发
|
||||
|
||||
```bash
|
||||
|
||||
+225
-2
@@ -786,7 +786,27 @@ async fn restore_table<R: Read + Seek + Send>(
|
||||
if read_bounded_line(&mut reader, &mut line)? == 0 {
|
||||
bail!("{table} 备份表头无效");
|
||||
}
|
||||
let header: BackupTableHeader = serde_json::from_str(line.trim_end())?;
|
||||
let mut header: BackupTableHeader = serde_json::from_str(line.trim_end())?;
|
||||
// v1.0.3 stores these metrics both as columns and in latest_json. Accept only
|
||||
// that exact layout; retained columns still undergo the full schema check.
|
||||
let legacy_latest_state = table == "server_latest_state"
|
||||
&& header.columns.len() == 12
|
||||
&& header.columns[2..10]
|
||||
.iter()
|
||||
.map(|column| (column.name.as_str(), column.kind))
|
||||
.eq([
|
||||
("cpu", BackupKind::Double),
|
||||
("mem_used", BackupKind::BigInt),
|
||||
("mem_total", BackupKind::BigInt),
|
||||
("disk_used", BackupKind::BigInt),
|
||||
("disk_total", BackupKind::BigInt),
|
||||
("net_in", BackupKind::Double),
|
||||
("net_out", BackupKind::Double),
|
||||
("uptime", BackupKind::BigInt),
|
||||
]);
|
||||
if legacy_latest_state {
|
||||
header.columns.drain(2..10);
|
||||
}
|
||||
if header.table != table || header.columns != columns {
|
||||
bail!("{table} 备份结构与当前数据库不兼容");
|
||||
}
|
||||
@@ -802,7 +822,14 @@ async fn restore_table<R: Read + Seek + Send>(
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
batch.push(serde_json::from_str::<Vec<Value>>(trimmed)?);
|
||||
let mut row = serde_json::from_str::<Vec<Value>>(trimmed)?;
|
||||
if legacy_latest_state {
|
||||
if row.len() != 12 {
|
||||
bail!("{table} 备份行字段数量不匹配");
|
||||
}
|
||||
row.drain(2..10);
|
||||
}
|
||||
batch.push(row);
|
||||
batch_bytes = batch_bytes.saturating_add(read);
|
||||
if batch.len() >= INSERT_BATCH_ROWS || batch_bytes >= MAX_NDJSON_LINE_BYTES {
|
||||
insert_rows(db, transaction, table, columns, &batch).await?;
|
||||
@@ -923,6 +950,202 @@ pub async fn copy_database(source: &Database, target: &Database) -> Result<usize
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const V1_0_3_BACKUP: &[u8] = include_bytes!("../tests/fixtures/v1.0.3-backup.zip");
|
||||
|
||||
fn edit_v1_0_3_latest_state(edit: impl FnOnce(&mut Vec<Value>)) -> Vec<u8> {
|
||||
let mut archive = ZipArchive::new(Cursor::new(V1_0_3_BACKUP)).unwrap();
|
||||
let entry_name = table_entry_name("server_latest_state");
|
||||
let mut lines = BufReader::new(archive.by_name(&entry_name).unwrap())
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(&line.unwrap()).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
edit(&mut lines);
|
||||
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for index in 0..archive.len() {
|
||||
let entry = archive.by_index(index).unwrap();
|
||||
if entry.name() == entry_name {
|
||||
writer
|
||||
.start_file(&entry_name, SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
for line in &lines {
|
||||
serde_json::to_writer(&mut writer, line).unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
}
|
||||
} else {
|
||||
writer.raw_copy_file(entry).unwrap();
|
||||
}
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_v1_0_3_layouts_and_rows_without_changing_the_target() {
|
||||
type FixtureEdit = fn(&mut Vec<Value>);
|
||||
let cases: [(&str, FixtureEdit); 7] = [
|
||||
("unknown metric column", |lines| {
|
||||
lines[0]["columns"][2]["name"] = Value::from("unknown_metric");
|
||||
}),
|
||||
("wrong metric type", |lines| {
|
||||
lines[0]["columns"][2]["kind"] = Value::from("text");
|
||||
}),
|
||||
("wrong retained column", |lines| {
|
||||
lines[0]["columns"][10]["name"] = Value::from("unknown_json");
|
||||
}),
|
||||
("extra column", |lines| {
|
||||
lines[0]["columns"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(serde_json::json!({"name": "extra", "kind": "text"}));
|
||||
}),
|
||||
("missing value", |lines| {
|
||||
lines[1].as_array_mut().unwrap().pop();
|
||||
}),
|
||||
("extra value", |lines| {
|
||||
lines[1].as_array_mut().unwrap().push(Value::Null);
|
||||
}),
|
||||
("invalid timestamp", |lines| {
|
||||
lines[1][1] = Value::from("invalid");
|
||||
}),
|
||||
];
|
||||
for (name, edit) in cases {
|
||||
let db = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
crate::db::set_setting(&db, "site_name", "Preserved")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at) VALUES ('preserved','Preserved','hash',1,1)")
|
||||
.execute(db.pool()).await.unwrap();
|
||||
sqlx::query("INSERT INTO sessions(id,token_hash,username,ip_address,user_agent,created_at,last_seen_at,expires_at) VALUES ('session','hash','admin','127.0.0.1','test',1,1,9999999999)")
|
||||
.execute(db.pool()).await.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let themes = directory.path().join("themes");
|
||||
fs::create_dir(&themes).unwrap();
|
||||
fs::write(themes.join("current.txt"), "Preserved").unwrap();
|
||||
|
||||
assert!(
|
||||
restore_archive(&db, &themes, &edit_v1_0_3_latest_state(edit))
|
||||
.await
|
||||
.is_err(),
|
||||
"{name} was accepted"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::db::get_setting(&db, "site_name")
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("Preserved"),
|
||||
"{name} changed settings"
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM servers")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
["preserved"],
|
||||
"{name} changed servers"
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM sessions")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
["session"],
|
||||
"{name} changed sessions"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(themes.join("current.txt")).unwrap(),
|
||||
"Preserved"
|
||||
);
|
||||
assert_eq!(fs::read_dir(&themes).unwrap().count(), 1);
|
||||
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_v1_0_3_backup_without_legacy_migrations() {
|
||||
let db = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let themes = directory.path().join("themes");
|
||||
restore_archive(&db, &themes, V1_0_3_BACKUP).await.unwrap();
|
||||
|
||||
let servers = crate::db::queries::list_servers(&db, true).await.unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].cpu, Some(42.5));
|
||||
assert_eq!(servers[0].mem_used, Some(512));
|
||||
let latest = sqlx::query("SELECT * FROM server_latest_state")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(latest.columns().len(), 4);
|
||||
assert_eq!(latest.get::<String, _>("last_batch_id"), "fixture-batch");
|
||||
let report: crate::models::AgentReport =
|
||||
serde_json::from_str(&latest.get::<String, _>("latest_json")).unwrap();
|
||||
assert_eq!(report.gpu_usage, 32.5);
|
||||
assert_eq!(latest.get::<i64, _>("latest_timestamp"), report.timestamp);
|
||||
let history = sqlx::query("SELECT * FROM metric_history ORDER BY timestamp")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].get::<f64, _>("cpu_min"), 75.0);
|
||||
assert_eq!(history[0].get::<f64, _>("memory_avg"), 50.0);
|
||||
assert_eq!(history[1].get::<i64, _>("sample_count"), 4);
|
||||
assert_eq!(history[1].get::<f64, _>("cpu_min"), 10.0);
|
||||
assert_eq!(history[1].get::<f64, _>("cpu_max"), 90.0);
|
||||
assert_eq!(history[1].get::<f64, _>("memory_min"), 10.0);
|
||||
assert_eq!(
|
||||
crate::db::get_setting(&db, "site_name")
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("v1.0.3 fixture")
|
||||
);
|
||||
assert!(
|
||||
crate::db::queries::agent_identity(&db, "fixture-primary-token")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
crate::db::queries::agent_identity(&db, "fixture-install-token")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT totp_secret FROM admin_2fa WHERE enabled=1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
"JBSWY3DPEHPK3PXP"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::db::queries::remote_task(&db, "fixture-task")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.result,
|
||||
"fixture"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(themes.join("theme-12345678/index.html")).unwrap(),
|
||||
"<main>v1.0.3 fixture</main>"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(themes.join("theme-12345678/assets/app.css")).unwrap(),
|
||||
"main { color: red; }"
|
||||
);
|
||||
db.migrate().await.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_archives_must_fit_both_restore_limits() {
|
||||
let compressed = DATABASE_BACKUP_MAX_BYTES as u64;
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# v1.0.3 backup fixture
|
||||
|
||||
`v1.0.3-backup.zip` was exported with the unchanged `backup::export_archive`
|
||||
implementation and migrations from tag `v1.0.3` (commit `58dea92`). A temporary
|
||||
Rust test populated an in-memory SQLite database and called that exporter.
|
||||
The archive uses backup format 2 and contains only synthetic data.
|
||||
|
||||
It includes settings, a hidden free node, latest metrics, both a snapshot and an
|
||||
aggregated history row, a completed remote task, enabled TOTP, and a theme with
|
||||
an asset. The redundant SQL CPU value is deliberately 99 while `latest_json`
|
||||
contains 42.5, so tests verify that the complete JSON report is preserved.
|
||||
|
||||
The fake administrator is `fixture-admin` with password `FixturePassword123`;
|
||||
the TOTP secret is `JBSWY3DPEHPK3PXP`. The primary Agent token is
|
||||
`fixture-primary-token`. An extra `fixture-install-token` was created in the
|
||||
source database but is excluded by v1.0.3's export policy.
|
||||
BIN
Binary file not shown.
Reference in new issue
Block a user