diff --git a/README.md b/README.md index eec1ca0..9d6a706 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/src/backup.rs b/backend/src/backup.rs index 2d50ff5..08e735c 100644 --- a/backend/src/backup.rs +++ b/backend/src/backup.rs @@ -786,7 +786,27 @@ async fn restore_table( 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( if trimmed.is_empty() { continue; } - batch.push(serde_json::from_str::>(trimmed)?); + let mut row = serde_json::from_str::>(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)) -> Vec { + 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::(&line.unwrap()).unwrap()) + .collect::>(); + 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); + 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::("last_batch_id"), "fixture-batch"); + let report: crate::models::AgentReport = + serde_json::from_str(&latest.get::("latest_json")).unwrap(); + assert_eq!(report.gpu_usage, 32.5); + assert_eq!(latest.get::("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::("cpu_min"), 75.0); + assert_eq!(history[0].get::("memory_avg"), 50.0); + assert_eq!(history[1].get::("sample_count"), 4); + assert_eq!(history[1].get::("cpu_min"), 10.0); + assert_eq!(history[1].get::("cpu_max"), 90.0); + assert_eq!(history[1].get::("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(), + "
v1.0.3 fixture
" + ); + 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; diff --git a/backend/tests/fixtures/README.md b/backend/tests/fixtures/README.md new file mode 100644 index 0000000..ce5e71d --- /dev/null +++ b/backend/tests/fixtures/README.md @@ -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. diff --git a/backend/tests/fixtures/v1.0.3-backup.zip b/backend/tests/fixtures/v1.0.3-backup.zip new file mode 100644 index 0000000..d85b168 Binary files /dev/null and b/backend/tests/fixtures/v1.0.3-backup.zip differ