Implement readable saved-state Commit and restore scenarios
This commit is contained in:
+241
-200
@@ -4,7 +4,10 @@
|
||||
//! encrypted vault data, backups, the recoverable saved-file adapter, and the
|
||||
//! explicit runtime-restart confirmation boundary.
|
||||
|
||||
mod backups;
|
||||
mod commit;
|
||||
mod draft;
|
||||
mod restore;
|
||||
|
||||
use draft::DraftChanges;
|
||||
|
||||
@@ -334,6 +337,7 @@ pub struct RestorePreview {
|
||||
created_at_utc: String,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
installed_generation: SavedBytes,
|
||||
saved_bytes: SavedBytes,
|
||||
}
|
||||
|
||||
@@ -1424,14 +1428,7 @@ impl Application {
|
||||
/// Create the mandatory archive from current installed bytes without
|
||||
/// changing the current session or saved files.
|
||||
pub fn create_backup(&self, reason: BackupReason) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = self.store.read_saved()?;
|
||||
let historical_apply = historical_apply_from_saved(&installed)?;
|
||||
Ok(backup::create_archive(
|
||||
self.store.archives_directory(),
|
||||
reason,
|
||||
historical_apply,
|
||||
&installed,
|
||||
)?)
|
||||
backups::create_backup(self, reason)
|
||||
}
|
||||
|
||||
pub fn create_manual_backup(&self) -> Result<std::path::PathBuf, AppError> {
|
||||
@@ -1439,20 +1436,13 @@ impl Application {
|
||||
}
|
||||
|
||||
pub fn list_backups(&self) -> Result<Vec<backup::ArchiveSummary>, AppError> {
|
||||
match backup::list_archives(self.store.archives_directory()) {
|
||||
Ok(archives) => Ok(archives),
|
||||
Err(BackupError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
backups::list_backups(self)
|
||||
}
|
||||
|
||||
/// Reusable mandatory backup boundary for M5 before it invokes restart.
|
||||
/// This method only archives saved bytes; it never calls a runtime adapter.
|
||||
pub fn create_pre_restart_backup(&mut self) -> Result<std::path::PathBuf, AppError> {
|
||||
self.active_restart_attempt = None;
|
||||
self.create_backup(BackupReason::PreRestart)
|
||||
backups::create_pre_restart_backup(self)
|
||||
}
|
||||
|
||||
/// Prepare a read-only preview bound to the exact currently installed generation.
|
||||
@@ -1895,61 +1885,7 @@ impl Application {
|
||||
/// Validate a selected archive without changing saved files or the draft.
|
||||
/// The returned value carries the exact validated bytes used at confirmation.
|
||||
pub fn preview_restore(&self, file_name: &str) -> Result<RestorePreview, AppError> {
|
||||
if !safe_archive_name(file_name) {
|
||||
return Err(AppError::InvalidArchiveSelection);
|
||||
}
|
||||
let path = self.store.archives_directory().join(file_name);
|
||||
let preview = backup::preview_archive(&path)?;
|
||||
self.store.validate_saved(&preview.saved_bytes)?;
|
||||
let mut proposed = preview.saved_bytes;
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
let snapshot = snapshot_from_document(&document);
|
||||
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
||||
let mut archived_reservations = BTreeSet::new();
|
||||
let mut reservations = self.draft.states.reserved_addresses().clone();
|
||||
// A restore can remove clients that are currently saved even when their
|
||||
// addresses were not yet in the persisted reservation set. Runtime state
|
||||
// remains unchanged, so carry those addresses across the replacement.
|
||||
reservations.extend(
|
||||
self.draft
|
||||
.states
|
||||
.saved()
|
||||
.clients()
|
||||
.flat_map(|client| client.addresses.iter()),
|
||||
);
|
||||
if let Some(state_bytes) = &proposed.internal_state {
|
||||
let archived = PersistedState::from_toml(state_bytes)?;
|
||||
archived_reservations = archived
|
||||
.reserved_addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
address
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::InvalidSavedState)
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
PersistedState::new(archived_reservations.iter().map(ToString::to_string), None)?
|
||||
.apply_to(&mut restored_states)?;
|
||||
}
|
||||
reservations.extend(archived_reservations);
|
||||
reservations.retain(|address| {
|
||||
!restored_states
|
||||
.saved()
|
||||
.clients()
|
||||
.any(|client| client.addresses.iter().any(|saved| saved == *address))
|
||||
});
|
||||
if !reservations.is_empty() || proposed.internal_state.is_some() {
|
||||
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
|
||||
state.apply_to(&mut restored_states)?;
|
||||
proposed.internal_state = Some(state.to_toml()?);
|
||||
}
|
||||
self.store.validate_saved(&proposed)?;
|
||||
Ok(RestorePreview {
|
||||
created_at_utc: preview.created_at_utc,
|
||||
reason: preview.reason,
|
||||
historical_apply: preview.historical_apply,
|
||||
saved_bytes: proposed,
|
||||
})
|
||||
restore::preview_restore(self, file_name)
|
||||
}
|
||||
|
||||
/// Explicitly confirm a previously previewed restore. The pre-restore
|
||||
@@ -1958,54 +1894,18 @@ impl Application {
|
||||
&mut self,
|
||||
preview: RestorePreview,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
self.confirm_restore_with_install(preview, |store, saved, _backup| {
|
||||
restore::confirm_restore(self, preview, |store, saved, _backup| {
|
||||
store.install(saved, BackupComplete(()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn confirm_restore_with_install(
|
||||
&mut self,
|
||||
preview: RestorePreview,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
self.store.validate_saved(&preview.saved_bytes)?;
|
||||
let current = self.store.read_saved()?;
|
||||
let archive = backup::create_archive(
|
||||
self.store.archives_directory(),
|
||||
BackupReason::PreRestore,
|
||||
historical_apply_from_saved(¤t)?,
|
||||
¤t,
|
||||
)?;
|
||||
let resolved = self.store.resolved_settings_for(&preview.saved_bytes)?;
|
||||
if let Err(error) = install(&self.store, &preview.saved_bytes, BackupComplete(())) {
|
||||
self.initial_issues.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
let document = ConfigDocument::parse(&preview.saved_bytes.server_config)?;
|
||||
let vault = preview
|
||||
.saved_bytes
|
||||
.vault
|
||||
.as_deref()
|
||||
.map(std::str::from_utf8)
|
||||
.transpose()
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
let snapshot = snapshot_from_document(&document);
|
||||
let mut states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
||||
if let Some(bytes) = &preview.saved_bytes.internal_state {
|
||||
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
|
||||
}
|
||||
self.saved = preview.saved_bytes;
|
||||
self.store.set_resolved_settings(resolved);
|
||||
self.draft.document = document;
|
||||
self.draft.vault = vault;
|
||||
self.draft.states = states;
|
||||
self.initial_issues.clear();
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
restore::confirm_restore(self, preview, install)
|
||||
}
|
||||
|
||||
/// Persist applied state only with the one-use authorization produced by
|
||||
@@ -2102,87 +2002,7 @@ impl Application {
|
||||
) -> Result<std::path::PathBuf, BackupError>,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = self.store.read_saved()?;
|
||||
if let Some(existing_bytes) = &installed.vault {
|
||||
let existing_text =
|
||||
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
||||
let existing = VaultBundle::from_toml(existing_text)?;
|
||||
if self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
||||
{
|
||||
return Err(AppError::VaultIdentityChange);
|
||||
}
|
||||
}
|
||||
|
||||
let mut next_states = self.draft.states.clone();
|
||||
next_states.commit_draft();
|
||||
let reservations = next_states
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.map(IpAddr::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
||||
let vault_bytes = self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.map(VaultBundle::to_toml)
|
||||
.transpose()?
|
||||
.map(String::into_bytes);
|
||||
let proposed = SavedBytes {
|
||||
settings: installed.settings.clone(),
|
||||
server_config: self.draft.document.render().to_vec(),
|
||||
internal_state,
|
||||
vault: vault_bytes,
|
||||
};
|
||||
self.store.validate_saved(&proposed)?;
|
||||
|
||||
let archive = create_backup(
|
||||
self.store.archives_directory(),
|
||||
BackupReason::PreCommit,
|
||||
historical_apply_from_saved(&installed)?,
|
||||
&installed,
|
||||
)?;
|
||||
if let Err(error) = install(&self.store, &proposed, BackupComplete(())) {
|
||||
match self.store.read_saved() {
|
||||
Ok(current) => {
|
||||
let document = ConfigDocument::parse(¤t.server_config).ok();
|
||||
let vault = current
|
||||
.vault
|
||||
.as_deref()
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
retain_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&document,
|
||||
vault.as_ref(),
|
||||
);
|
||||
} else {
|
||||
self.initial_issues.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => self.initial_issues.clear(),
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
self.saved = proposed;
|
||||
self.draft.states = next_states;
|
||||
self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||
self.invalidate_active_issue_authorizations();
|
||||
promote_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&self.draft.document,
|
||||
self.draft.vault.as_ref(),
|
||||
);
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
commit::commit(self, create_backup, install)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2555,7 +2375,10 @@ mod tests {
|
||||
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
|
||||
use crate::config::SettingsFile;
|
||||
use crate::delivery::qr::QrError;
|
||||
use crate::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot};
|
||||
use crate::domain::{
|
||||
AddressAvailability, AddressFamily, ApplyStatus, Client, ClientAddresses, ClientStates,
|
||||
DomainError, PublicKey, Snapshot, allocate_ipv4, allocate_ipv6,
|
||||
};
|
||||
use crate::persistence::{
|
||||
BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore,
|
||||
};
|
||||
@@ -4202,6 +4025,212 @@ restart_timeout_seconds = 30
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_restore_preview_is_rejected_before_backup_and_keeps_the_draft() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (store, states, key, _) = setup(root.path(), false);
|
||||
let mut app = Application::open(store.clone(), states).expect("application opens");
|
||||
let saved = store.read_saved().expect("saved bytes");
|
||||
let selected = create_archive(
|
||||
store.archives_directory(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("restore archive");
|
||||
let preview = app
|
||||
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
||||
.expect("preview is prepared");
|
||||
|
||||
app.rename_client(key, "committed after preview")
|
||||
.expect("stage new saved generation");
|
||||
app.commit().expect("save new generation");
|
||||
app.rename_client(key, "draft retained after stale preview")
|
||||
.expect("stage follow-up draft");
|
||||
let backup_count = app.list_backups().expect("list backups").len();
|
||||
|
||||
assert!(matches!(
|
||||
app.confirm_restore(preview),
|
||||
Err(AppError::SavedGenerationChanged)
|
||||
));
|
||||
assert_eq!(
|
||||
app.list_backups().expect("no precautionary backup").len(),
|
||||
backup_count
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.draft()
|
||||
.get(&key)
|
||||
.expect("draft survives")
|
||||
.alias,
|
||||
"draft retained after stale preview"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.read_saved()
|
||||
.expect("current generation remains installed")
|
||||
.server_config,
|
||||
app.saved_bytes().server_config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_retains_removed_unprojectable_exact_hosts_through_reopen_and_allocation() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (store, states, _, _) = setup(root.path(), false);
|
||||
let original = store.read_saved().expect("initial saved bytes");
|
||||
let selected = create_archive(
|
||||
store.archives_directory(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&original,
|
||||
)
|
||||
.expect("archive without unsupported peers");
|
||||
|
||||
let broad_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic broad-route peer key")
|
||||
.public_key();
|
||||
let pending_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic exact-route peer key")
|
||||
.public_key();
|
||||
let mut current = clone_saved(&original);
|
||||
current.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {broad_key}\nAllowedIPs = 10.20.0.77/32, 10.20.0.0/24, fd20::77/128\n\n[Peer]\nPublicKey = {pending_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
store
|
||||
.install(¤t, BackupComplete(()))
|
||||
.expect("install synthetic current generation");
|
||||
let mut app = Application::open(store.clone(), states).expect("open with peer routes");
|
||||
|
||||
// Missing managed metadata makes this peer unprojectable. Deleting it
|
||||
// stages its exact addresses without requiring a client snapshot.
|
||||
assert!(
|
||||
!app.delete_client(pending_key)
|
||||
.expect("stage exact-host deletion")
|
||||
);
|
||||
let preview = app
|
||||
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
||||
.expect("preview combines current and pending reservations");
|
||||
app.confirm_restore(preview).expect("restore succeeds");
|
||||
|
||||
let reopened = Application::open_from_saved_store(store.clone())
|
||||
.expect("reservations survive a fresh application session");
|
||||
let reserved = reopened
|
||||
.states()
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
for address in ["10.20.0.77", "fd20::77", "10.20.0.79", "fd20::79"] {
|
||||
assert!(reserved.contains(&address.parse().unwrap()), "{address}");
|
||||
}
|
||||
assert!(
|
||||
!reserved.contains(&"10.20.0.1".parse().unwrap()),
|
||||
"broad subnet routes must not be expanded into guessed reservations"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
allocate_ipv4(
|
||||
"10.20.0.77/32".parse().unwrap(),
|
||||
&AddressAvailability {
|
||||
server: &[],
|
||||
occupied: &[],
|
||||
reserved: &reserved,
|
||||
},
|
||||
),
|
||||
Err(DomainError::AddressExhausted(AddressFamily::Ipv4))
|
||||
);
|
||||
assert_eq!(
|
||||
allocate_ipv6(
|
||||
"fd20::77/128".parse().unwrap(),
|
||||
&AddressAvailability {
|
||||
server: &[],
|
||||
occupied: &[],
|
||||
reserved: &reserved,
|
||||
},
|
||||
),
|
||||
Err(DomainError::AddressExhausted(AddressFamily::Ipv6))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (store, _, _, _) = setup(root.path(), false);
|
||||
let mut current = store.read_saved().expect("initial saved bytes");
|
||||
let retained_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic retained-peer key")
|
||||
.public_key();
|
||||
let deleted_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic pending-deletion key")
|
||||
.public_key();
|
||||
current.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {retained_key}\nAllowedIPs = 10.20.0.77/32, fd20::77/128\n\n[Peer]\nPublicKey = {deleted_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
current.internal_state = Some(
|
||||
PersistedState::new(["10.20.0.88".to_owned()], None)
|
||||
.expect("synthetic archived reservation")
|
||||
.to_toml()
|
||||
.expect("archived reservation serializes"),
|
||||
);
|
||||
store
|
||||
.install(¤t, BackupComplete(()))
|
||||
.expect("install synthetic unprojectable peers");
|
||||
let archive = create_archive(
|
||||
store.archives_directory(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
¤t,
|
||||
)
|
||||
.expect("archive restored peer routes");
|
||||
let mut app = Application::open_from_saved_store(store.clone())
|
||||
.expect("application opens with unprojectable peers");
|
||||
|
||||
assert!(
|
||||
!app.delete_client(deleted_key)
|
||||
.expect("stage pending peer deletion")
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.address_reservations()
|
||||
.pending_host_routes()
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let preview = app
|
||||
.preview_restore(archive.file_name().unwrap().to_str().unwrap())
|
||||
.expect("preview archive restoring exact routes");
|
||||
app.confirm_restore(preview).expect("restore succeeds");
|
||||
|
||||
assert_eq!(app.states().reserved_addresses().len(), 1);
|
||||
assert!(
|
||||
app.states()
|
||||
.reserved_addresses()
|
||||
.contains(&"10.20.0.88".parse().expect("archived reservation"))
|
||||
);
|
||||
assert!(
|
||||
app.states()
|
||||
.address_reservations()
|
||||
.pending_host_routes()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
let restored_state = PersistedState::from_toml(
|
||||
app.saved_bytes()
|
||||
.internal_state
|
||||
.as_deref()
|
||||
.expect("archived reservation remains persisted"),
|
||||
)
|
||||
.expect("restored state remains valid");
|
||||
assert_eq!(restored_state.reserved_addresses, ["10.20.0.88"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_absent_optional_components_removes_installed_vault_and_state() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
@@ -4219,6 +4248,19 @@ restart_timeout_seconds = 30
|
||||
.expect("vault staged");
|
||||
app.commit().expect("vault installed");
|
||||
assert!(store.read_saved().expect("installed").vault.is_some());
|
||||
let mut current = store.read_saved().expect("current saved bytes");
|
||||
let removed_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic removed-peer key")
|
||||
.public_key();
|
||||
current.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {removed_key}\nAllowedIPs = 10.20.0.9/32, fd20::9/128\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
store
|
||||
.install(¤t, BackupComplete(()))
|
||||
.expect("install current peer removed by archive");
|
||||
|
||||
let preview = app
|
||||
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
||||
@@ -4230,10 +4272,11 @@ restart_timeout_seconds = 30
|
||||
restored
|
||||
.internal_state
|
||||
.as_deref()
|
||||
.expect("pending current address is retained"),
|
||||
.expect("removed current routes are retained"),
|
||||
)
|
||||
.expect("restored reservation state parses");
|
||||
assert!(state.reserved_addresses.contains(&"fd20::2".to_owned()));
|
||||
assert!(state.reserved_addresses.contains(&"10.20.0.9".to_owned()));
|
||||
assert!(state.reserved_addresses.contains(&"fd20::9".to_owned()));
|
||||
assert!(app.vault().is_none());
|
||||
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||
}
|
||||
@@ -5277,11 +5320,9 @@ restart_timeout_seconds = 30
|
||||
}
|
||||
);
|
||||
let restored = store.read_saved().expect("restored saved bytes parse");
|
||||
assert_eq!(
|
||||
PersistedState::from_toml(restored.internal_state.as_deref().unwrap())
|
||||
.unwrap()
|
||||
.last_verified_at_utc,
|
||||
None
|
||||
assert!(
|
||||
restored.internal_state.is_none(),
|
||||
"the selected archive had no state and restored routes need no reservation"
|
||||
);
|
||||
let restored_document = ConfigDocument::parse(&restored.server_config).unwrap();
|
||||
let reopened_states = ClientStates::new(
|
||||
|
||||
Reference in New Issue
Block a user