Add domain ownership of reservations and apply knowledge
This commit is contained in:
@@ -130,7 +130,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
|||||||
| 18 | Readable, complete in-app help for implemented workflows | 17 | accepted |
|
| 18 | Readable, complete in-app help for implemented workflows | 17 | accepted |
|
||||||
| 19 | Refactoring contract, terminology and behavior map | 18 | accepted |
|
| 19 | Refactoring contract, terminology and behavior map | 18 | accepted |
|
||||||
| 20 | Explicit vocabulary and named operation contexts | 19 | accepted |
|
| 20 | Explicit vocabulary and named operation contexts | 19 | accepted |
|
||||||
| 21 | Domain ownership of reservations and apply knowledge | 20 | pending |
|
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
|
||||||
| 22 | Transactional draft editing and client creation vertical slice | 21 | pending |
|
| 22 | Transactional draft editing and client creation vertical slice | 21 | pending |
|
||||||
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending |
|
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending |
|
||||||
| 24 | Session-owned first delivery and password reissue scenarios | 23 | pending |
|
| 24 | Session-owned first delivery and password reissue scenarios | 23 | pending |
|
||||||
@@ -3877,7 +3877,7 @@ Accepted for presentation and synthetic fixture scope after independent review,
|
|||||||
|
|
||||||
## Approved domain refactoring programme — milestones 19–27
|
## Approved domain refactoring programme — milestones 19–27
|
||||||
|
|
||||||
Status: M19–M20 accepted; M21–M27 pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work.
|
Status: M19–M20 accepted; M21 in progress; M22–M27 pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work.
|
||||||
|
|
||||||
### Shared execution contract for M19–M27
|
### Shared execution contract for M19–M27
|
||||||
|
|
||||||
@@ -3957,7 +3957,7 @@ Evidence/handoff: [M20 evidence](docs/evidence/milestone-20.md) records the clea
|
|||||||
|
|
||||||
### Milestone 21 — Domain ownership of reservations and apply knowledge
|
### Milestone 21 — Domain ownership of reservations and apply knowledge
|
||||||
|
|
||||||
Status: pending. Prerequisite: M20 accepted.
|
Status: accepted (2026-10-29T23:00:09+02:00; independent M21-R1 re-review and integrated Rust gates passed). Prerequisite: M20 accepted (2026-10-29T22:40:28+02:00). The user confirmed this session's coordinator model GPT-6-Sol/medium earlier; dispatches selected GPT-6-Luna/high explicitly. Initial tree clean at `8413b382b178991d2e1e597f1422d147f3454581`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 available. M9 is retired/unaccepted by user direction and is not an M21 prerequisite.
|
||||||
|
|
||||||
#### Outcome and scope
|
#### Outcome and scope
|
||||||
|
|
||||||
@@ -3965,9 +3965,9 @@ Encapsulate address retention and verification transitions without changing pers
|
|||||||
|
|
||||||
| Task | Owner | State | Bounded deliverable |
|
| Task | Owner | State | Bounded deliverable |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| M21.1 | implementer | pending | Encapsulated reservation rules and existing allocator integration |
|
| M21.1 | implementer `/root/m21_reservations` | complete | Domain-owned committed/pending reservation sets, exact-host policy and allocator/Commit/discard integration; focused tests passed |
|
||||||
| M21.2 | implementer | pending | Explicit current/historical verification transitions and caller migration |
|
| M21.2 | implementer `/root/m21_reservations` | complete | Explicit current/historical verification transitions and caller migration; focused tests passed |
|
||||||
| M21.3 | reviewer; repairer; coordinator | pending | Review invariant matrix, repair and RUST acceptance |
|
| M21.3 | reviewer `/root/m21_reviewer`; repairer `/root/m21_repair`; coordinator | complete | M21-R1 closed on independent re-review; integrated RUST and diff check passed |
|
||||||
|
|
||||||
1. Extract `AddressReservations` with private storage and intention-revealing operations to retain removed host addresses, restore conservative persisted reservations and expose read-only iteration/availability. Preserve conflict rejection, draft unsupported-peer reservations, Commit timing, discard behavior, IPv4/IPv6 allocation and broad-route deletion restrictions. Do not enumerate networks or infer free addresses from unavailable runtime data.
|
1. Extract `AddressReservations` with private storage and intention-revealing operations to retain removed host addresses, restore conservative persisted reservations and expose read-only iteration/availability. Preserve conflict rejection, draft unsupported-peer reservations, Commit timing, discard behavior, IPv4/IPv6 allocation and broad-route deletion restrictions. Do not enumerate networks or infer free addresses from unavailable runtime data.
|
||||||
2. Keep draft-only pending reservations distinct from committed reservations until publication. Follow M19's transition table; preserve reservations from unprojectable exact host routes, and reject unsafe broad-route deletion as before. Move policy, not filesystem/parsing/crypto, into domain.
|
2. Keep draft-only pending reservations distinct from committed reservations until publication. Follow M19's transition table; preserve reservations from unprojectable exact host routes, and reject unsafe broad-route deletion as before. Move policy, not filesystem/parsing/crypto, into domain.
|
||||||
@@ -3977,11 +3977,11 @@ Encapsulate address retention and verification transitions without changing pers
|
|||||||
|
|
||||||
#### Acceptance and validation
|
#### Acceptance and validation
|
||||||
|
|
||||||
- [ ] M21-A1: only named domain transitions can release committed reservations; backup/service/read/mismatch/persistence failures cannot release them.
|
- [x] M21-A1: only named domain transitions can release committed reservations; backup/service/read/mismatch/persistence failures cannot release them.
|
||||||
- [ ] M21-A2: legacy technical verification without a management snapshot, reopening and current vs historical status retain prior semantics.
|
- [x] M21-A2: legacy technical verification without a management snapshot, reopening and current vs historical status retain prior semantics.
|
||||||
- [ ] M21-A3: allocator, reservation, persisted round-trip and app failure tests pass; independent invariant review and RUST pass.
|
- [x] M21-A3: allocator, reservation, persisted round-trip and app failure tests pass; independent invariant review and RUST pass.
|
||||||
|
|
||||||
Focused starting commands: `cargo test --locked domain::`, `cargo test --locked persistence::`, `cargo test --locked app::`; reviewer specifically inspects reservation-release callers, not only tests. Evidence: docs/evidence/milestone-21.md. Outcome: not accepted. Handoff: M22 after acceptance.
|
M21.1 and M21.2 focused checks passed. Independent reviewer found M21-R1: `commit_with_adapters` reset current apply status on unchanged Commit, changing prior Verified/Unavailable semantics. Repair removed that call and added three behavioral tests. Independent re-review closed M21-R1 with no remaining scoped finding. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (399 passed, 3 ignored) and `cargo build --locked --release` all exited 0; `git diff --check` passed. [M21 evidence](docs/evidence/milestone-21.md) records final dirty source and artifact fingerprints, review and validation limits. Outcome: accepted for host/synthetic scope; stop after M21. Next action only on a separate request is M22.1; no fresh Alpine/VM result is claimed.
|
||||||
|
|
||||||
### Milestone 22 — Transactional draft editing and client creation vertical slice
|
### Milestone 22 — Transactional draft editing and client creation vertical slice
|
||||||
|
|
||||||
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
|||||||
|
|
||||||
## Current progress and resume point
|
## Current progress and resume point
|
||||||
|
|
||||||
- M20 is accepted (2026-10-29T22:40:28+02:00): renamed apply knowledge, named prepared contexts, independent M20-R1 closure and final RUST gates passed; [M20 evidence](docs/evidence/milestone-20.md) records source/artifact identity and limits. Stop after M20; M21–M27 remain pending. M19 remains accepted; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
- M21 is accepted for host/synthetic scope: domain reservation and apply-knowledge ownership, M21-R1 repair, independent re-review and required Rust gates passed. [M21 evidence](docs/evidence/milestone-21.md) records final inputs and limits. M22–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
||||||
|
|
||||||
- M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
|
- M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
|
||||||
|
|
||||||
@@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
|||||||
|
|
||||||
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
||||||
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
||||||
- M18/M19 handoffs remain complete. M20 is accepted; the next implementation action, when separately requested, is M21.1. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
|
- M18–M21 handoffs remain complete. Stop after M21; begin M22.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
|
||||||
|
|
||||||
## Plan maintenance
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ src/
|
|||||||
main.rs # Startup, arguments, process exit
|
main.rs # Startup, arguments, process exit
|
||||||
lib.rs # Module declarations and application API
|
lib.rs # Module declarations and application API
|
||||||
config.rs # Application settings and path resolution
|
config.rs # Application settings and path resolution
|
||||||
domain.rs # Clients, identities, address allocation, change states
|
domain.rs # Clients, identities, address allocation/reservations, change states
|
||||||
app.rs # Application operations and orchestration
|
app.rs # Application operations and orchestration
|
||||||
wireguard.rs # Server configuration module boundary
|
wireguard.rs # Server configuration module boundary
|
||||||
wireguard/
|
wireguard/
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Milestone 21 evidence — domain reservations and apply knowledge
|
||||||
|
|
||||||
|
Run: 2026-10-29T23:00:09+02:00. Base commit: `8413b382b178991d2e1e597f1422d147f3454581` (clean at start). Final dirty source inputs: `src/domain.rs`, `src/app.rs`, and `docs/PROJECT_LAYOUT.md`; `PLANS.md` and this evidence record are coordinator documentation. `git diff -- src/domain.rs src/app.rs docs/PROJECT_LAYOUT.md | sha256sum` returned `d9056e1fd05db2425a127ac57f906333ae311bdb0f3cce13b9153f39c041c982`. `target/release/wireguard-admin` SHA-256: `60b3ad8b9c1bf2750239d3500b319cf42e5581fe027e5b6e39c8ff4115feb024`. `Cargo.toml` and `Cargo.lock` have no diff. Rust and Cargo: 1.97.1, pinned toolchain.
|
||||||
|
|
||||||
|
## Implementation and invariant evidence
|
||||||
|
|
||||||
|
- M21.1: `AddressReservations` owns private committed and draft-only pending host-address sets. Staging validates the entire exact `/32` or `/128` route list before mutation. The allocator sees both sets. Draft discard drops pending addresses; successful Commit promotes pending and retains addresses removed from the prior manageable saved snapshot. Persisted restoration is crate-scoped and rejects saved-client conflicts. No serialization field or version changed.
|
||||||
|
- M21.2: private `ApplyKnowledge` owns current runtime status and optional complete last-verified management snapshot. Historical verification remains separate in persisted application history. Session open resets current knowledge to `Unknown`; technical verification may publish `Verified` with no complete management snapshot. The restart scenario prepares a cloned verified candidate, then publishes it only after internal-state installation succeeds. Only that verified transition releases committed reservations. Backup, service, read, mismatch and persistence failure paths retain reservations.
|
||||||
|
- M21-R1: independent review found unconditional Commit demotion of `Verified` and `Unavailable` on unchanged saved client snapshots. The repair removed the extra application call; the domain Commit transition still demotes `Verified` when the saved management snapshot changes. Three behavioral app tests cover unchanged `Verified`, unchanged `Unavailable`, and changed `Verified`. The same independent reviewer re-reviewed the repair and full affected reservation/apply paths, closed M21-R1, and reported no remaining actionable finding.
|
||||||
|
|
||||||
|
## Validation on integrated source
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `cargo fmt --all -- --check` | exit 0 |
|
||||||
|
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||||
|
| `cargo test --locked` | exit 0; 399 passed, 3 ignored (opt-in Alpine drivers), 0 failed, including 2 doctests |
|
||||||
|
| `cargo build --locked --release` | exit 0 |
|
||||||
|
| `git diff --check` | exit 0 |
|
||||||
|
|
||||||
|
Focused implementation, repair and review runs covered `domain::`, `persistence::`, `app::`, `--test commit_restore`, and `--test runtime_restart`; the final full test command supersedes those focused source results. The independent reviewer specifically inspected reservation mutation/release callers, pending/committed timing, exact-host policy, unchanged/changed Commit behavior, reopen/current versus historical state, legacy technical matching without a management snapshot, and failed verified publication.
|
||||||
|
|
||||||
|
## Scope and acceptance
|
||||||
|
|
||||||
|
M21-A1: passed by reviewed transition callers and synthetic failure-path tests. M21-A2: passed by legacy/reopen/history tests and the M21-R1 behavioral repair. M21-A3: passed by allocator, reservation, persistence and application tests, independent re-review and all required Rust gates. Evidence is host/synthetic. The opt-in Alpine suites and disposable systemd VM were not run for M21, and no real systemd, live VPN, production or IPv6 integration result is claimed. M9 remains retired/unaccepted. The previously recorded unprojectable-peer restore reservation gap belongs to M23 and is not represented as resolved here.
|
||||||
+85
-48
@@ -81,7 +81,6 @@ pub struct Application {
|
|||||||
document: ConfigDocument,
|
document: ConfigDocument,
|
||||||
states: ClientStates,
|
states: ClientStates,
|
||||||
vault: Option<VaultBundle>,
|
vault: Option<VaultBundle>,
|
||||||
draft_unsupported_reservations: BTreeSet<IpAddr>,
|
|
||||||
historical_apply: HistoricalApplyStatus,
|
historical_apply: HistoricalApplyStatus,
|
||||||
verified_saved_projection: Option<SavedPeerProjection>,
|
verified_saved_projection: Option<SavedPeerProjection>,
|
||||||
active_restart_attempt: Option<u64>,
|
active_restart_attempt: Option<u64>,
|
||||||
@@ -477,7 +476,11 @@ impl fmt::Debug for Application {
|
|||||||
)
|
)
|
||||||
.field(
|
.field(
|
||||||
"pending_reservation_count",
|
"pending_reservation_count",
|
||||||
&self.draft_unsupported_reservations.len(),
|
&self
|
||||||
|
.states
|
||||||
|
.address_reservations()
|
||||||
|
.pending_host_routes()
|
||||||
|
.count(),
|
||||||
)
|
)
|
||||||
.field("initial_issue_count", &self.initial_issues.len())
|
.field("initial_issue_count", &self.initial_issues.len())
|
||||||
.finish()
|
.finish()
|
||||||
@@ -511,7 +514,7 @@ impl Application {
|
|||||||
saved: SavedBytes,
|
saved: SavedBytes,
|
||||||
mut states: ClientStates,
|
mut states: ClientStates,
|
||||||
) -> Result<Self, AppError> {
|
) -> Result<Self, AppError> {
|
||||||
states.mark_runtime_unknown();
|
states.begin_runtime_session();
|
||||||
let document = ConfigDocument::parse(&saved.server_config)?;
|
let document = ConfigDocument::parse(&saved.server_config)?;
|
||||||
let vault = saved
|
let vault = saved
|
||||||
.vault
|
.vault
|
||||||
@@ -531,7 +534,6 @@ impl Application {
|
|||||||
document,
|
document,
|
||||||
states,
|
states,
|
||||||
vault,
|
vault,
|
||||||
draft_unsupported_reservations: BTreeSet::new(),
|
|
||||||
historical_apply,
|
historical_apply,
|
||||||
verified_saved_projection: None,
|
verified_saved_projection: None,
|
||||||
active_restart_attempt: None,
|
active_restart_attempt: None,
|
||||||
@@ -633,13 +635,7 @@ impl Application {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
occupied.extend(document_host_addresses(&self.document));
|
occupied.extend(document_host_addresses(&self.document));
|
||||||
let reserved = self
|
let reserved = self.states.address_reservations().all().collect::<Vec<_>>();
|
||||||
.states
|
|
||||||
.reserved_addresses()
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.chain(self.draft_unsupported_reservations.iter().copied())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let availability = AddressAvailability {
|
let availability = AddressAvailability {
|
||||||
server: &server_addresses,
|
server: &server_addresses,
|
||||||
occupied: &occupied,
|
occupied: &occupied,
|
||||||
@@ -1268,36 +1264,24 @@ impl Application {
|
|||||||
.peers()
|
.peers()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|peer| peer.identity == Some(identity));
|
.any(|peer| peer.identity == Some(identity));
|
||||||
let mut additional_reservations = BTreeSet::new();
|
let mut next_states = self.states.clone();
|
||||||
if saved_peer_exists && self.states.saved().get(&identity).is_none() {
|
if saved_peer_exists && self.states.saved().get(&identity).is_none() {
|
||||||
let peer = saved_document
|
let peer = saved_document
|
||||||
.peers()
|
.peers()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|peer| peer.identity == Some(identity))
|
.find(|peer| peer.identity == Some(identity))
|
||||||
.ok_or(AppError::DeletedPeerAddressUnknown)?;
|
.ok_or(AppError::DeletedPeerAddressUnknown)?;
|
||||||
for network in &peer.allowed_ips {
|
next_states
|
||||||
match network {
|
.stage_exact_host_routes(peer.allowed_ips.iter().copied())
|
||||||
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
|
.map_err(|_| AppError::DeletedPeerAddressUnknown)?;
|
||||||
additional_reservations.insert(IpAddr::V4(network.addr()));
|
|
||||||
}
|
|
||||||
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
|
|
||||||
additional_reservations.insert(IpAddr::V6(network.addr()));
|
|
||||||
}
|
|
||||||
_ => return Err(AppError::DeletedPeerAddressUnknown),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if peer.allowed_ips.is_empty() || additional_reservations.is_empty() {
|
|
||||||
return Err(AppError::DeletedPeerAddressUnknown);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let edited = self.document.delete_client(&identity)?;
|
let edited = self.document.delete_client(&identity)?;
|
||||||
let removed_record = match self.vault.as_mut() {
|
let removed_record = match self.vault.as_mut() {
|
||||||
Some(vault) => vault.remove_record(identity)?,
|
Some(vault) => vault.remove_record(identity)?,
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
self.states.draft_mut().remove(&identity);
|
next_states.draft_mut().remove(&identity);
|
||||||
self.draft_unsupported_reservations
|
self.states = next_states;
|
||||||
.extend(additional_reservations);
|
|
||||||
self.document = edited;
|
self.document = edited;
|
||||||
self.invalidate_active_issue_authorizations();
|
self.invalidate_active_issue_authorizations();
|
||||||
self.initial_issues.remove(&identity);
|
self.initial_issues.remove(&identity);
|
||||||
@@ -1365,11 +1349,10 @@ impl Application {
|
|||||||
.map(VaultBundle::from_toml)
|
.map(VaultBundle::from_toml)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
self.states.discard_draft();
|
self.states.discard_draft();
|
||||||
self.draft_unsupported_reservations.clear();
|
|
||||||
if let Some(bytes) = &self.saved.internal_state {
|
if let Some(bytes) = &self.saved.internal_state {
|
||||||
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
|
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
|
||||||
} else {
|
} else {
|
||||||
self.states.mark_runtime_unknown();
|
self.states.runtime_knowledge_unknown();
|
||||||
}
|
}
|
||||||
self.historical_apply = historical_apply_from_saved(&self.saved)?;
|
self.historical_apply = historical_apply_from_saved(&self.saved)?;
|
||||||
self.initial_issues
|
self.initial_issues
|
||||||
@@ -1457,7 +1440,12 @@ impl Application {
|
|||||||
};
|
};
|
||||||
self.states.draft() != self.states.saved()
|
self.states.draft() != self.states.saved()
|
||||||
|| self.document.render() != self.saved.server_config
|
|| self.document.render() != self.saved.server_config
|
||||||
|| !self.draft_unsupported_reservations.is_empty()
|
|| self
|
||||||
|
.states
|
||||||
|
.address_reservations()
|
||||||
|
.pending_host_routes()
|
||||||
|
.next()
|
||||||
|
.is_some()
|
||||||
|| vault_differs
|
|| vault_differs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1708,7 +1696,7 @@ impl Application {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.states.mark_runtime_unknown();
|
self.states.runtime_knowledge_unknown();
|
||||||
let deadline = RestartDeadline::new(now(), settings.restart_timeout);
|
let deadline = RestartDeadline::new(now(), settings.restart_timeout);
|
||||||
let execution = service(&settings.systemd_unit, deadline);
|
let execution = service(&settings.systemd_unit, deadline);
|
||||||
let job = execution.outcome;
|
let job = execution.outcome;
|
||||||
@@ -1954,7 +1942,6 @@ impl Application {
|
|||||||
self.vault = vault;
|
self.vault = vault;
|
||||||
self.states = states;
|
self.states = states;
|
||||||
self.initial_issues.clear();
|
self.initial_issues.clear();
|
||||||
self.draft_unsupported_reservations.clear();
|
|
||||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||||
self.verified_saved_projection = None;
|
self.verified_saved_projection = None;
|
||||||
self.active_restart_attempt = None;
|
self.active_restart_attempt = None;
|
||||||
@@ -1980,7 +1967,7 @@ impl Application {
|
|||||||
return Err(AppError::SavedGenerationChanged);
|
return Err(AppError::SavedGenerationChanged);
|
||||||
}
|
}
|
||||||
let mut next_states = self.states.clone();
|
let mut next_states = self.states.clone();
|
||||||
next_states.mark_technically_applied_after_verification(observed_management_snapshot)?;
|
next_states.prepare_technically_verified_result(observed_management_snapshot)?;
|
||||||
let mut persisted = match &installed.internal_state {
|
let mut persisted = match &installed.internal_state {
|
||||||
Some(bytes) => PersistedState::from_toml(bytes)?,
|
Some(bytes) => PersistedState::from_toml(bytes)?,
|
||||||
None => PersistedState::new(
|
None => PersistedState::new(
|
||||||
@@ -2070,17 +2057,6 @@ impl Application {
|
|||||||
|
|
||||||
let mut next_states = self.states.clone();
|
let mut next_states = self.states.clone();
|
||||||
next_states.commit_draft();
|
next_states.commit_draft();
|
||||||
if !self.draft_unsupported_reservations.is_empty() {
|
|
||||||
let reservations = next_states
|
|
||||||
.reserved_addresses()
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.chain(self.draft_unsupported_reservations.iter().copied())
|
|
||||||
.map(|address| address.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let persisted = PersistedState::new(reservations, None)?;
|
|
||||||
persisted.apply_to(&mut next_states)?;
|
|
||||||
}
|
|
||||||
let reservations = next_states
|
let reservations = next_states
|
||||||
.reserved_addresses()
|
.reserved_addresses()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2133,7 +2109,6 @@ impl Application {
|
|||||||
|
|
||||||
self.saved = proposed;
|
self.saved = proposed;
|
||||||
self.states = next_states;
|
self.states = next_states;
|
||||||
self.draft_unsupported_reservations.clear();
|
|
||||||
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||||
self.invalidate_active_issue_authorizations();
|
self.invalidate_active_issue_authorizations();
|
||||||
promote_matching_issue_map(
|
promote_matching_issue_map(
|
||||||
@@ -3105,7 +3080,7 @@ restart_timeout_seconds = 30
|
|||||||
.expect("saved generation verified");
|
.expect("saved generation verified");
|
||||||
|
|
||||||
let mut runtime_unknown = app.states().clone();
|
let mut runtime_unknown = app.states().clone();
|
||||||
runtime_unknown.mark_runtime_unknown();
|
runtime_unknown.runtime_knowledge_unknown();
|
||||||
let reopened = Application::open(app.store.clone(), runtime_unknown)
|
let reopened = Application::open(app.store.clone(), runtime_unknown)
|
||||||
.expect("application reopens with unknown runtime");
|
.expect("application reopens with unknown runtime");
|
||||||
let destination = reopened
|
let destination = reopened
|
||||||
@@ -3681,6 +3656,56 @@ restart_timeout_seconds = 30
|
|||||||
assert!(String::from_utf8_lossy(&saved.server_config).contains("# admin note\n"));
|
assert!(String::from_utf8_lossy(&saved.server_config).contains("# admin note\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_commit_preserves_verified_apply_knowledge() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, _, _) = setup(root.path(), false);
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
let observed = app.states().saved().clone();
|
||||||
|
test_mark_verified(&mut app, observed, "2026-10-29T12:00:00Z".to_owned())
|
||||||
|
.expect("synthetic verification is published");
|
||||||
|
assert_eq!(app.states().applied(), &ApplyStatus::Verified);
|
||||||
|
|
||||||
|
app.commit().expect("unchanged commit completes");
|
||||||
|
|
||||||
|
assert_eq!(app.states().applied(), &ApplyStatus::Verified);
|
||||||
|
assert_eq!(app.states().applied_snapshot(), Some(app.states().saved()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_commit_preserves_unavailable_apply_knowledge() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, _, _) = setup(root.path(), false);
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
app.states.runtime_knowledge_unavailable();
|
||||||
|
|
||||||
|
app.commit().expect("unchanged commit completes");
|
||||||
|
|
||||||
|
assert_eq!(app.states().applied(), &ApplyStatus::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_saved_snapshot_demotes_verified_apply_knowledge_on_commit() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, key, _) = setup(root.path(), false);
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
let observed = app.states().saved().clone();
|
||||||
|
test_mark_verified(
|
||||||
|
&mut app,
|
||||||
|
observed.clone(),
|
||||||
|
"2026-10-29T12:00:00Z".to_owned(),
|
||||||
|
)
|
||||||
|
.expect("synthetic verification is published");
|
||||||
|
app.rename_client(key, "changed saved peer")
|
||||||
|
.expect("peer change is staged");
|
||||||
|
|
||||||
|
app.commit().expect("changed commit completes");
|
||||||
|
|
||||||
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||||
|
assert_eq!(app.states().applied_snapshot(), Some(&observed));
|
||||||
|
assert_ne!(app.states().saved(), &observed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discard_restores_draft_without_writing_installed_bytes() {
|
fn discard_restores_draft_without_writing_installed_bytes() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -4976,6 +5001,18 @@ restart_timeout_seconds = 30
|
|||||||
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_does_not_treat_caller_seeded_verified_state_as_runtime_evidence() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, seeded, _, _) = setup(root.path(), false);
|
||||||
|
assert_eq!(seeded.applied(), &ApplyStatus::Verified);
|
||||||
|
let app = Application::open(store, seeded).expect("application opens");
|
||||||
|
|
||||||
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||||
|
assert!(app.states().applied_snapshot().is_some());
|
||||||
|
assert!(!app.saved_generation_is_verified_applied(app.saved_bytes(), app.document()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() {
|
fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
|||||||
+226
-39
@@ -8,6 +8,70 @@ use std::str::FromStr;
|
|||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use ipnet::{Ipv4Net, Ipv6Net};
|
use ipnet::{Ipv4Net, Ipv6Net};
|
||||||
|
|
||||||
|
/// Committed addresses retained until verified application and exact host
|
||||||
|
/// routes staged by a draft deletion.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct AddressReservations {
|
||||||
|
committed: BTreeSet<IpAddr>,
|
||||||
|
pending_host_routes: BTreeSet<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddressReservations {
|
||||||
|
pub fn committed(&self) -> &BTreeSet<IpAddr> {
|
||||||
|
&self.committed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_host_routes(&self) -> impl Iterator<Item = IpAddr> + '_ {
|
||||||
|
self.pending_host_routes.iter().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all(&self) -> impl Iterator<Item = IpAddr> + '_ {
|
||||||
|
self.committed
|
||||||
|
.iter()
|
||||||
|
.chain(&self.pending_host_routes)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the complete route list before staging any pending address.
|
||||||
|
pub(crate) fn stage_exact_host_routes(
|
||||||
|
&mut self,
|
||||||
|
routes: impl IntoIterator<Item = ipnet::IpNet>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let mut addresses = BTreeSet::new();
|
||||||
|
let mut route_count = 0;
|
||||||
|
for route in routes {
|
||||||
|
route_count += 1;
|
||||||
|
match route {
|
||||||
|
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||||
|
addresses.insert(IpAddr::V4(network.addr()));
|
||||||
|
}
|
||||||
|
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||||
|
addresses.insert(IpAddr::V6(network.addr()));
|
||||||
|
}
|
||||||
|
_ => return Err(DomainError::UnsafeReservationRoute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if route_count == 0 || addresses.is_empty() {
|
||||||
|
return Err(DomainError::UnsafeReservationRoute);
|
||||||
|
}
|
||||||
|
self.pending_host_routes.extend(addresses);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_committed(&mut self, reservations: impl IntoIterator<Item = IpAddr>) {
|
||||||
|
self.committed = reservations.into_iter().collect();
|
||||||
|
self.pending_host_routes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn promote_pending(&mut self) {
|
||||||
|
self.committed.append(&mut self.pending_host_routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_committed(&mut self) {
|
||||||
|
self.committed.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// A WireGuard public key, decoded and validated as exactly 32 bytes.
|
/// A WireGuard public key, decoded and validated as exactly 32 bytes.
|
||||||
@@ -154,18 +218,63 @@ pub enum ApplyStatus {
|
|||||||
Verified,
|
Verified,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current runtime knowledge and the optional complete management snapshot
|
||||||
|
/// most recently verified. Historical verification time is persisted
|
||||||
|
/// separately and never initializes this value on reopen.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct ApplyKnowledge {
|
||||||
|
current: ApplyStatus,
|
||||||
|
last_verified_snapshot: Option<Snapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplyKnowledge {
|
||||||
|
fn new(current: ApplyStatus, last_verified_snapshot: Option<Snapshot>) -> Self {
|
||||||
|
Self {
|
||||||
|
current,
|
||||||
|
last_verified_snapshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_unknown(&mut self) {
|
||||||
|
self.current = ApplyStatus::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_unavailable(&mut self) {
|
||||||
|
self.current = ApplyStatus::Unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saved_state_changed(&mut self) {
|
||||||
|
self.current = ApplyStatus::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_session(&mut self) {
|
||||||
|
self.current = ApplyStatus::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persisted_state_restored(&mut self) {
|
||||||
|
self.current = ApplyStatus::Unknown;
|
||||||
|
self.last_verified_snapshot = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_verified_result(&mut self, complete_snapshot: Option<Snapshot>) {
|
||||||
|
self.last_verified_snapshot = complete_snapshot;
|
||||||
|
self.current = ApplyStatus::Verified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Draft, saved, and last verified applied data remain independent snapshots.
|
/// Draft, saved, and last verified applied data remain independent snapshots.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ClientStates {
|
pub struct ClientStates {
|
||||||
draft: Snapshot,
|
draft: Snapshot,
|
||||||
saved: Snapshot,
|
saved: Snapshot,
|
||||||
applied_snapshot: Option<Snapshot>,
|
apply_knowledge: ApplyKnowledge,
|
||||||
applied: ApplyStatus,
|
reservations: AddressReservations,
|
||||||
reserved_addresses: BTreeSet<IpAddr>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientStates {
|
impl ClientStates {
|
||||||
/// Start with a saved snapshot and an explicit initial runtime observation.
|
/// Start with a saved snapshot and an explicit initial runtime observation.
|
||||||
|
/// `Application::open` treats caller-provided state as untrusted runtime
|
||||||
|
/// evidence and resets current knowledge before exposing a session.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
saved: Snapshot,
|
saved: Snapshot,
|
||||||
applied_snapshot: Option<Snapshot>,
|
applied_snapshot: Option<Snapshot>,
|
||||||
@@ -185,9 +294,11 @@ impl ClientStates {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
draft: saved.clone(),
|
draft: saved.clone(),
|
||||||
saved,
|
saved,
|
||||||
applied_snapshot,
|
apply_knowledge: ApplyKnowledge::new(applied, applied_snapshot),
|
||||||
applied,
|
reservations: AddressReservations {
|
||||||
reserved_addresses,
|
committed: reserved_addresses,
|
||||||
|
pending_host_routes: BTreeSet::new(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,20 +311,31 @@ impl ClientStates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn applied(&self) -> &ApplyStatus {
|
pub fn applied(&self) -> &ApplyStatus {
|
||||||
&self.applied
|
&self.apply_knowledge.current
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applied_snapshot(&self) -> Option<&Snapshot> {
|
pub fn applied_snapshot(&self) -> Option<&Snapshot> {
|
||||||
self.applied_snapshot.as_ref()
|
self.apply_knowledge.last_verified_snapshot.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> {
|
pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> {
|
||||||
&self.reserved_addresses
|
self.reservations.committed()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn address_reservations(&self) -> &AddressReservations {
|
||||||
|
&self.reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stage_exact_host_routes(
|
||||||
|
&mut self,
|
||||||
|
routes: impl IntoIterator<Item = ipnet::IpNet>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.reservations.stage_exact_host_routes(routes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore conservative reservations from saved metadata without asserting
|
/// Restore conservative reservations from saved metadata without asserting
|
||||||
/// that the running interface matches the saved snapshot.
|
/// that the running interface matches the saved snapshot.
|
||||||
pub fn restore_persisted_reservations(
|
pub(crate) fn restore_persisted_reservations(
|
||||||
&mut self,
|
&mut self,
|
||||||
reservations: impl IntoIterator<Item = IpAddr>,
|
reservations: impl IntoIterator<Item = IpAddr>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
@@ -224,9 +346,8 @@ impl ClientStates {
|
|||||||
{
|
{
|
||||||
return Err(DomainError::ReservationConflictsWithSavedClient);
|
return Err(DomainError::ReservationConflictsWithSavedClient);
|
||||||
}
|
}
|
||||||
self.reserved_addresses = reservations;
|
self.reservations.restore_committed(reservations);
|
||||||
self.applied_snapshot = None;
|
self.apply_knowledge.persisted_state_restored();
|
||||||
self.applied = ApplyStatus::Unknown;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,36 +358,38 @@ impl ClientStates {
|
|||||||
/// Save the draft in the model only. This performs no persistence or runtime operation.
|
/// Save the draft in the model only. This performs no persistence or runtime operation.
|
||||||
pub fn commit_draft(&mut self) {
|
pub fn commit_draft(&mut self) {
|
||||||
let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>();
|
let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>();
|
||||||
self.reserved_addresses.extend(
|
self.reservations.committed.extend(
|
||||||
self.saved
|
self.saved
|
||||||
.addresses()
|
.addresses()
|
||||||
.filter(|address| !next_addresses.contains(address)),
|
.filter(|address| !next_addresses.contains(address)),
|
||||||
);
|
);
|
||||||
|
self.reservations.promote_pending();
|
||||||
self.saved = self.draft.clone();
|
self.saved = self.draft.clone();
|
||||||
if self.applied == ApplyStatus::Verified
|
if self.apply_knowledge.current == ApplyStatus::Verified
|
||||||
&& self.applied_snapshot.as_ref() != Some(&self.saved)
|
&& self.apply_knowledge.last_verified_snapshot.as_ref() != Some(&self.saved)
|
||||||
{
|
{
|
||||||
self.applied = ApplyStatus::Unknown;
|
self.apply_knowledge.saved_state_changed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discard draft edits by returning the draft to the saved snapshot.
|
/// Discard draft edits by returning the draft to the saved snapshot.
|
||||||
pub fn discard_draft(&mut self) {
|
pub fn discard_draft(&mut self) {
|
||||||
self.draft = self.saved.clone();
|
self.draft = self.saved.clone();
|
||||||
|
self.reservations.pending_host_routes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an applied state only when the verified observation matches saved clients.
|
/// Prepare a verified candidate only when the observation matches saved
|
||||||
pub(crate) fn mark_applied_after_verification(
|
/// clients. Callers publish the candidate after durable state installation.
|
||||||
|
pub(crate) fn prepare_verified_result(
|
||||||
&mut self,
|
&mut self,
|
||||||
observed: Snapshot,
|
observed: Snapshot,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if observed != self.saved {
|
if observed != self.saved {
|
||||||
self.applied = ApplyStatus::Unknown;
|
self.apply_knowledge.runtime_unknown();
|
||||||
return Err(DomainError::AppliedSnapshotMismatch);
|
return Err(DomainError::AppliedSnapshotMismatch);
|
||||||
}
|
}
|
||||||
self.applied_snapshot = Some(observed);
|
self.apply_knowledge.prepare_verified_result(Some(observed));
|
||||||
self.applied = ApplyStatus::Verified;
|
self.reservations.release_committed();
|
||||||
self.reserved_addresses.clear();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,25 +397,28 @@ impl ClientStates {
|
|||||||
/// be represented by the narrower management snapshot. `None` deliberately
|
/// be represented by the narrower management snapshot. `None` deliberately
|
||||||
/// keeps `applied_snapshot` absent instead of treating a partial snapshot as
|
/// keeps `applied_snapshot` absent instead of treating a partial snapshot as
|
||||||
/// evidence that every saved peer was applied.
|
/// evidence that every saved peer was applied.
|
||||||
pub(crate) fn mark_technically_applied_after_verification(
|
pub(crate) fn prepare_technically_verified_result(
|
||||||
&mut self,
|
&mut self,
|
||||||
observed_management_snapshot: Option<Snapshot>,
|
observed_management_snapshot: Option<Snapshot>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if let Some(observed) = observed_management_snapshot {
|
if let Some(observed) = observed_management_snapshot {
|
||||||
return self.mark_applied_after_verification(observed);
|
return self.prepare_verified_result(observed);
|
||||||
}
|
}
|
||||||
self.applied_snapshot = None;
|
self.apply_knowledge.prepare_verified_result(None);
|
||||||
self.applied = ApplyStatus::Verified;
|
self.reservations.release_committed();
|
||||||
self.reserved_addresses.clear();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_runtime_unavailable(&mut self) {
|
pub fn runtime_knowledge_unavailable(&mut self) {
|
||||||
self.applied = ApplyStatus::Unavailable;
|
self.apply_knowledge.runtime_unavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_runtime_unknown(&mut self) {
|
pub(crate) fn runtime_knowledge_unknown(&mut self) {
|
||||||
self.applied = ApplyStatus::Unknown;
|
self.apply_knowledge.runtime_unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_runtime_session(&mut self) {
|
||||||
|
self.apply_knowledge.new_session();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Addresses in draft, saved, or last verified applied data cannot be reused.
|
/// Addresses in draft, saved, or last verified applied data cannot be reused.
|
||||||
@@ -301,8 +427,13 @@ impl ClientStates {
|
|||||||
self.draft
|
self.draft
|
||||||
.addresses()
|
.addresses()
|
||||||
.chain(self.saved.addresses())
|
.chain(self.saved.addresses())
|
||||||
.chain(self.reserved_addresses.iter().copied())
|
.chain(self.reservations.all())
|
||||||
.chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses))
|
.chain(
|
||||||
|
self.apply_knowledge
|
||||||
|
.last_verified_snapshot
|
||||||
|
.iter()
|
||||||
|
.flat_map(Snapshot::addresses),
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,6 +464,8 @@ pub enum DomainError {
|
|||||||
AddressUnavailable,
|
AddressUnavailable,
|
||||||
#[error("verified applied clients do not match the saved snapshot")]
|
#[error("verified applied clients do not match the saved snapshot")]
|
||||||
AppliedSnapshotMismatch,
|
AppliedSnapshotMismatch,
|
||||||
|
#[error("only exact IPv4/32 or IPv6/128 host routes can be reserved")]
|
||||||
|
UnsafeReservationRoute,
|
||||||
#[error("persisted reservation conflicts with a saved client address")]
|
#[error("persisted reservation conflicts with a saved client address")]
|
||||||
ReservationConflictsWithSavedClient,
|
ReservationConflictsWithSavedClient,
|
||||||
}
|
}
|
||||||
@@ -602,7 +735,7 @@ mod tests {
|
|||||||
assert_eq!(states.saved().clients().count(), 1);
|
assert_eq!(states.saved().clients().count(), 1);
|
||||||
assert_eq!(states.applied(), &ApplyStatus::Unknown);
|
assert_eq!(states.applied(), &ApplyStatus::Unknown);
|
||||||
assert_eq!(states.applied_snapshot(), Some(&saved));
|
assert_eq!(states.applied_snapshot(), Some(&saved));
|
||||||
states.mark_runtime_unavailable();
|
states.runtime_knowledge_unavailable();
|
||||||
assert_eq!(states.applied(), &ApplyStatus::Unavailable);
|
assert_eq!(states.applied(), &ApplyStatus::Unavailable);
|
||||||
assert!(states.applied_snapshot().is_some());
|
assert!(states.applied_snapshot().is_some());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -634,6 +767,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_host_routes_stay_pending_until_commit_and_discard_drops_them() {
|
||||||
|
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
|
||||||
|
let address = IpAddr::V4("10.0.0.9".parse().unwrap());
|
||||||
|
let route = "10.0.0.9/32".parse::<ipnet::IpNet>().unwrap();
|
||||||
|
let mut states = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap();
|
||||||
|
|
||||||
|
states.stage_exact_host_routes([route]).unwrap();
|
||||||
|
assert!(states.reserved_addresses().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
states
|
||||||
|
.address_reservations()
|
||||||
|
.pending_host_routes()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[address]
|
||||||
|
);
|
||||||
|
assert!(states.addresses_in_use().contains(&address));
|
||||||
|
states.discard_draft();
|
||||||
|
assert!(
|
||||||
|
states
|
||||||
|
.address_reservations()
|
||||||
|
.pending_host_routes()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(!states.addresses_in_use().contains(&address));
|
||||||
|
|
||||||
|
states.stage_exact_host_routes([route]).unwrap();
|
||||||
|
states.commit_draft();
|
||||||
|
assert!(
|
||||||
|
states
|
||||||
|
.address_reservations()
|
||||||
|
.pending_host_routes()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(states.reserved_addresses().contains(&address));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_route_cannot_partially_change_pending_reservations() {
|
||||||
|
let mut reservations = AddressReservations::default();
|
||||||
|
let exact = "10.0.0.9/32".parse::<ipnet::IpNet>().unwrap();
|
||||||
|
let broad = "10.0.0.0/24".parse::<ipnet::IpNet>().unwrap();
|
||||||
|
reservations.stage_exact_host_routes([exact]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reservations.stage_exact_host_routes(["10.0.0.10/32".parse().unwrap(), broad]),
|
||||||
|
Err(DomainError::UnsafeReservationRoute)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reservations.pending_host_routes().collect::<Vec<_>>(),
|
||||||
|
[IpAddr::V4("10.0.0.9".parse().unwrap())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() {
|
fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() {
|
||||||
let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
|
let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
|
||||||
@@ -645,7 +834,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap();
|
let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
states.mark_applied_after_verification(wrong),
|
states.prepare_verified_result(wrong),
|
||||||
Err(DomainError::AppliedSnapshotMismatch)
|
Err(DomainError::AppliedSnapshotMismatch)
|
||||||
);
|
);
|
||||||
assert_eq!(states.applied(), &ApplyStatus::Unknown);
|
assert_eq!(states.applied(), &ApplyStatus::Unknown);
|
||||||
@@ -655,9 +844,7 @@ mod tests {
|
|||||||
states.commit_draft();
|
states.commit_draft();
|
||||||
assert!(!states.reserved_addresses().is_empty());
|
assert!(!states.reserved_addresses().is_empty());
|
||||||
let saved = states.saved().clone();
|
let saved = states.saved().clone();
|
||||||
states
|
states.prepare_verified_result(saved.clone()).unwrap();
|
||||||
.mark_applied_after_verification(saved.clone())
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(states.applied(), &ApplyStatus::Verified);
|
assert_eq!(states.applied(), &ApplyStatus::Verified);
|
||||||
assert!(states.reserved_addresses().is_empty());
|
assert!(states.reserved_addresses().is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in New Issue
Block a user