Implement transactional draft editing and client creation vertical slice
This commit is contained in:
@@ -131,7 +131,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
|||||||
| 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 | accepted |
|
| 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 | accepted |
|
||||||
| 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 |
|
||||||
| 25 | Explicit restart verification scenario | 24 | pending |
|
| 25 | Explicit restart verification scenario | 24 | pending |
|
||||||
@@ -3985,7 +3985,7 @@ M21.1 and M21.2 focused checks passed. Independent reviewer found M21-R1: `commi
|
|||||||
|
|
||||||
### Milestone 22 — Transactional draft editing and client creation vertical slice
|
### Milestone 22 — Transactional draft editing and client creation vertical slice
|
||||||
|
|
||||||
Status: pending. Prerequisite: M21 accepted.
|
Status: accepted (2026-10-29T23:14:00+02:00; independent review and integrated Rust gates passed). Prerequisite: M21 accepted (2026-10-29T23:00:09+02:00). The user previously confirmed the coordinator GPT-6-Sol/medium session; project configuration and explicit dispatch selected GPT-6-Luna/high for workers. Initial tree clean at `2766c62`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 verified. M9 is retired/unaccepted by user direction and is not an M22 prerequisite.
|
||||||
|
|
||||||
#### Outcome and scope
|
#### Outcome and scope
|
||||||
|
|
||||||
@@ -3993,9 +3993,9 @@ Give document/client/vault drafts one coordinated publication boundary and make
|
|||||||
|
|
||||||
| Task | Owner | State | Bounded deliverable |
|
| Task | Owner | State | Bounded deliverable |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| M22.1 | implementer | pending | Internal draft owner and staged replacement API |
|
| M22.1 | implementer `/root/m22_draft_owner` | complete | DraftChanges owns document/states/vault; staged replacement API; app (62), fmt and diff checks passed |
|
||||||
| M22.2 | implementer | pending | Create/rename/delete/discard and setup/import migration |
|
| M22.2 | implementer `/root/m22_draft_owner` | complete | Create/rename/delete/discard and setup/import use staged publication; focused checks passed |
|
||||||
| M22.3 | reviewer; repairer; coordinator | pending | Vertical-slice/failure review, repair and RUST acceptance |
|
| M22.3 | reviewer `/root/m22_review`; coordinator | complete | Independent review found no actionable issue; integrated RUST and evidence passed |
|
||||||
|
|
||||||
1. Build an internal `DraftChanges` owner containing the lossless document, restricted client projection, encrypted vault draft and draft-only reservation changes, or the concrete composition agreed in M19. It belongs to the application boundary because it coordinates crypto/document components. Avoid deep-cloning plaintext keys and avoid generic transactions/closures that obscure what is staged.
|
1. Build an internal `DraftChanges` owner containing the lossless document, restricted client projection, encrypted vault draft and draft-only reservation changes, or the concrete composition agreed in M19. It belongs to the application boundary because it coordinates crypto/document components. Avoid deep-cloning plaintext keys and avoid generic transactions/closures that obscure what is staged.
|
||||||
2. For each edit, prepare all fallible replacements locally, validate the edited document and corresponding public projection, then replace live draft components together. No file/runtime mutation is allowed. Document is authoritative: never rebuild it from Snapshot, and do not drop malformed/unsupported records merely because projection fails.
|
2. For each edit, prepare all fallible replacements locally, validate the edited document and corresponding public projection, then replace live draft components together. No file/runtime mutation is allowed. Document is authoritative: never rebuild it from Snapshot, and do not drop malformed/unsupported records merely because projection fails.
|
||||||
@@ -4006,12 +4006,12 @@ Give document/client/vault drafts one coordinated publication boundary and make
|
|||||||
|
|
||||||
#### Acceptance and validation
|
#### Acceptance and validation
|
||||||
|
|
||||||
- [ ] M22-A1: create/rename/delete/import/setup/discard use coordinated draft publication; intermediate errors leave no partial draft or newly authorized issue.
|
- [x] M22-A1: create/rename/delete/import/setup/discard use coordinated draft publication; intermediate errors leave no partial draft or newly authorized issue.
|
||||||
- [ ] M22-A2: source bytes outside allowed edits, existing client parameters, unsupported records and original import files remain preserved.
|
- [x] M22-A2: source bytes outside allowed edits, existing client parameters, unsupported records and original import files remain preserved.
|
||||||
- [ ] M22-A3: synthetic create -> preview -> Commit -> first delivery -> explicit fixture restart and deletion/discard flows still pass; no live runtime operation occurs during draft/Commit.
|
- [x] M22-A3: synthetic create -> preview -> Commit -> first delivery -> explicit fixture restart and deletion/discard flows still pass; no live runtime operation occurs during draft/Commit.
|
||||||
- [ ] M22-A4: independent review checks authority/secret lifetimes; RUST passes with unchanged formats/dependencies.
|
- [x] M22-A4: independent review checks authority/secret lifetimes; RUST passes with unchanged formats/dependencies.
|
||||||
|
|
||||||
Focused commands: `cargo test --locked app::`, `cargo test --locked --test config_preservation`, `cargo test --locked --test client_delivery`, `cargo test --locked --test vault_import`, `cargo test --locked --test tui_workflow`. Evidence: docs/evidence/milestone-22.md. Outcome: not accepted. Handoff: M23 after acceptance.
|
M22.1 added the internal owner and staged replacement API; M22.2 migrated assigned flows and narrowed draft mutation. Fresh independent review reran the five required focused suites and diff check and found no actionable issue. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (401 passed, 3 ignored), and `cargo build --locked --release` exited 0; `git diff --check` passed. [M22 evidence](docs/evidence/milestone-22.md) records focused results, final dirty source and artifact fingerprints, review and limits. Outcome: accepted for host/synthetic scope; stop after M22. Next action only on a separate request is M23.1; no fresh Alpine/VM result is claimed.
|
||||||
|
|
||||||
### Milestone 23 — Readable saved-state Commit and restore scenarios
|
### Milestone 23 — Readable saved-state Commit and restore scenarios
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
- 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.
|
- M22 is accepted for host/synthetic scope: coordinated draft ownership, edit/setup/import migration, failure tests, fresh independent review and required Rust gates passed. [M22 evidence](docs/evidence/milestone-22.md) records final inputs and limits. M23–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–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.
|
- M18–M22 handoffs remain complete. Stop after M22; begin M23.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
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ src/
|
|||||||
config.rs # Application settings and path resolution
|
config.rs # Application settings and path resolution
|
||||||
domain.rs # Clients, identities, address allocation/reservations, change states
|
domain.rs # Clients, identities, address allocation/reservations, change states
|
||||||
app.rs # Application operations and orchestration
|
app.rs # Application operations and orchestration
|
||||||
|
app/
|
||||||
|
draft.rs # Coordinated in-memory document, client-state, and vault drafts
|
||||||
wireguard.rs # Server configuration module boundary
|
wireguard.rs # Server configuration module boundary
|
||||||
wireguard/
|
wireguard/
|
||||||
document.rs # Parsing and edits preserving original text
|
document.rs # Parsing and edits preserving original text
|
||||||
@@ -49,7 +51,8 @@ src/
|
|||||||
tests/
|
tests/
|
||||||
config_preservation.rs # Untouched server configuration remains identical
|
config_preservation.rs # Untouched server configuration remains identical
|
||||||
commit_restore.rs # Persistence, backups, restore, runtime isolation
|
commit_restore.rs # Persistence, backups, restore, runtime isolation
|
||||||
client_lifecycle.rs # Import, creation, delivery, deletion, state transitions
|
client_delivery.rs # Client file delivery and lifecycle workflows
|
||||||
|
vault_import.rs # Existing-client import and reconciliation workflows
|
||||||
tui_workflow.rs # Synthetic worker-level TUI workflows
|
tui_workflow.rs # Synthetic worker-level TUI workflows
|
||||||
fixtures/ # Synthetic configuration and archive inputs
|
fixtures/ # Synthetic configuration and archive inputs
|
||||||
support/
|
support/
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Milestone 22 evidence — coordinated draft editing
|
||||||
|
|
||||||
|
Run: 2026-10-29T23:14:00+02:00. Base commit: `2766c62` (clean at start). The integrated dirty source inputs are `src/app.rs`, new `src/app/draft.rs`, `src/domain.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce source identity with `sha256sum`:
|
||||||
|
|
||||||
|
| Input | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/app.rs` | `a2ae18af4a5755397d86f36da3938baba01bc434da1b024aad64720267cebb7d` |
|
||||||
|
| `src/app/draft.rs` | `4b4c7b4ef9f8d673dad44d017badad8edbe1c73c19a74c1454008229e8fb1cbc` |
|
||||||
|
| `src/domain.rs` | `47740c902eee54053c1a4e46c7dacaf18204dc6366cb32d1189d6a04b293a516` |
|
||||||
|
| `docs/PROJECT_LAYOUT.md` | `500fd91848e5896153fa39a330131103a50a2f442e58ecfd6ad2bafc6ba9ba07` |
|
||||||
|
| `target/release/wireguard-admin` | `1b955bc2858924811043f48a66bfc7e2180ff0ecbc6d93b8b0339b791a9646e5` |
|
||||||
|
|
||||||
|
The native host toolchain was rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, persistence and archive code have no diff.
|
||||||
|
|
||||||
|
## Implementation and independent review
|
||||||
|
|
||||||
|
M22.1 added private `DraftChanges` to own the lossless document, narrower client states with pending address reservations, and encrypted vault draft. Its explicit staged replacement publishes selected prepared components without saved-file or runtime effects. M22.2 moved create/add/rename/delete, discard, initial vault setup and selected-file import through that boundary. Fallible document parsing/projection, state insertion, vault serialization/encryption/import and saved-state reading finish before publication. Vault copies are made through encrypted TOML only when a vault-changing operation needs one; document-only edits keep the current vault. A new client's session-first-delivery material is registered after successful draft publication.
|
||||||
|
|
||||||
|
The unrestricted `ClientStates::draft_mut` and test-only public `Application::vault_draft_mut` methods were removed after caller audit. `Application` facade entry points remain. Three behavioral app tests cover a late missing-vault creation failure, selected import failure, and discard parse failure without partial draft or issue-state mutation. Layout documentation now names the actual draft module and existing integration targets.
|
||||||
|
|
||||||
|
Fresh GPT-6-Luna/high reviewer `/root/m22_review`, separate from the implementer, inspected the base-to-current diff and relevant startup, TUI, integration and container-driver callers. The reviewer found no actionable issue. Review covered authority, secret lifetime, all publication callers, unsupported peers, source-file preservation, public API, fixture restart scope and unchanged formats/dependencies. No repair cycle was needed.
|
||||||
|
|
||||||
|
## Validation on integrated source
|
||||||
|
|
||||||
|
| Exact command | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `cargo fmt --all -- --check` | exit 0 |
|
||||||
|
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||||
|
| `cargo test --locked` | exit 0; 401 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
|
||||||
|
| `cargo build --locked --release` | exit 0 |
|
||||||
|
| `git diff --check` | exit 0 |
|
||||||
|
|
||||||
|
The implementer and reviewer also ran the required focused suites: `cargo test --locked app::` (64), `cargo test --locked --test config_preservation` (10), `cargo test --locked --test client_delivery` (5), `cargo test --locked --test vault_import` (5), and `cargo test --locked --test tui_workflow` (8), all passed. The implementer additionally ran `cargo test --locked domain::` (15), passed. The final full test command supersedes the focused runs as integrated host evidence.
|
||||||
|
|
||||||
|
## Acceptance scope
|
||||||
|
|
||||||
|
M22-A1 is supported by staged publication and failure tests. M22-A2 is supported by byte-preserving document and import tests plus source review. M22-A3 is supported by synthetic app/TUI creation, Commit, first delivery, explicit fixture restart, deletion and discard coverage; draft and Commit flows make no live runtime call. M22-A4 is supported by the independent review and completion gates. This is host/synthetic evidence. The opt-in Alpine driver and disposable systemd VM were not run for M22; no real systemd, live VPN, production or IPv6 integration result is claimed. The M23 unprojectable-peer restore reservation gap remains assigned to M23 and is not claimed resolved here.
|
||||||
+243
-129
@@ -4,6 +4,10 @@
|
|||||||
//! encrypted vault data, backups, the recoverable saved-file adapter, and the
|
//! encrypted vault data, backups, the recoverable saved-file adapter, and the
|
||||||
//! explicit runtime-restart confirmation boundary.
|
//! explicit runtime-restart confirmation boundary.
|
||||||
|
|
||||||
|
mod draft;
|
||||||
|
|
||||||
|
use draft::DraftChanges;
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -78,9 +82,7 @@ pub struct SessionDisplay {
|
|||||||
pub struct Application {
|
pub struct Application {
|
||||||
store: SavedStore,
|
store: SavedStore,
|
||||||
saved: SavedBytes,
|
saved: SavedBytes,
|
||||||
document: ConfigDocument,
|
draft: DraftChanges,
|
||||||
states: ClientStates,
|
|
||||||
vault: Option<VaultBundle>,
|
|
||||||
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>,
|
||||||
@@ -466,9 +468,9 @@ impl fmt::Debug for Application {
|
|||||||
formatter
|
formatter
|
||||||
.debug_struct("Application")
|
.debug_struct("Application")
|
||||||
.field("saved", &self.saved)
|
.field("saved", &self.saved)
|
||||||
.field("document", &self.document)
|
.field("document", &self.draft.document)
|
||||||
.field("states", &self.states)
|
.field("states", &self.draft.states)
|
||||||
.field("vault_present", &self.vault.is_some())
|
.field("vault_present", &self.draft.vault.is_some())
|
||||||
.field("historical_apply", &self.historical_apply)
|
.field("historical_apply", &self.historical_apply)
|
||||||
.field(
|
.field(
|
||||||
"restart_attempt_active",
|
"restart_attempt_active",
|
||||||
@@ -477,6 +479,7 @@ impl fmt::Debug for Application {
|
|||||||
.field(
|
.field(
|
||||||
"pending_reservation_count",
|
"pending_reservation_count",
|
||||||
&self
|
&self
|
||||||
|
.draft
|
||||||
.states
|
.states
|
||||||
.address_reservations()
|
.address_reservations()
|
||||||
.pending_host_routes()
|
.pending_host_routes()
|
||||||
@@ -531,9 +534,7 @@ impl Application {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
store,
|
store,
|
||||||
saved,
|
saved,
|
||||||
document,
|
draft: DraftChanges::new(document, states, vault),
|
||||||
states,
|
|
||||||
vault,
|
|
||||||
historical_apply,
|
historical_apply,
|
||||||
verified_saved_projection: None,
|
verified_saved_projection: None,
|
||||||
active_restart_attempt: None,
|
active_restart_attempt: None,
|
||||||
@@ -548,24 +549,25 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn document(&self) -> &ConfigDocument {
|
pub fn document(&self) -> &ConfigDocument {
|
||||||
&self.document
|
&self.draft.document
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn states(&self) -> &ClientStates {
|
pub fn states(&self) -> &ClientStates {
|
||||||
&self.states
|
&self.draft.states
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a secret-free projection that includes unsupported and
|
/// Return a secret-free projection that includes unsupported and
|
||||||
/// ambiguous peers instead of silently omitting them from the client list.
|
/// ambiguous peers instead of silently omitting them from the client list.
|
||||||
pub fn display_projection(&self) -> SessionDisplay {
|
pub fn display_projection(&self) -> SessionDisplay {
|
||||||
let clients = self
|
let clients = self
|
||||||
|
.draft
|
||||||
.document
|
.document
|
||||||
.peers()
|
.peers()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|peer| {
|
.map(|peer| {
|
||||||
let projected = peer
|
let projected = peer
|
||||||
.identity
|
.identity
|
||||||
.and_then(|identity| self.document.project_client(&identity).ok());
|
.and_then(|identity| self.draft.document.project_client(&identity).ok());
|
||||||
let metadata_label = match &peer.metadata {
|
let metadata_label = match &peer.metadata {
|
||||||
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||||
Some(metadata.alias.clone())
|
Some(metadata.alias.clone())
|
||||||
@@ -588,14 +590,14 @@ impl Application {
|
|||||||
has_subnet_routes: peer.has_subnet_routes,
|
has_subnet_routes: peer.has_subnet_routes,
|
||||||
issues: peer.issues.clone(),
|
issues: peer.issues.clone(),
|
||||||
mutable_identity: projected
|
mutable_identity: projected
|
||||||
.filter(|client| self.states.draft().get(&client.identity).is_some())
|
.filter(|client| self.draft.states.draft().get(&client.identity).is_some())
|
||||||
.map(|client| client.identity),
|
.map(|client| client.identity),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
SessionDisplay {
|
SessionDisplay {
|
||||||
clients,
|
clients,
|
||||||
document_issues: self.document.issues().to_vec(),
|
document_issues: self.draft.document.issues().to_vec(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,10 +608,18 @@ impl Application {
|
|||||||
client: Client,
|
client: Client,
|
||||||
created_utc: Option<&str>,
|
created_utc: Option<&str>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let edited = self.document.add_client(&client, created_utc)?;
|
let edited = ConfigDocument::parse(
|
||||||
self.states.draft_mut().insert(client)?;
|
self.draft
|
||||||
|
.document
|
||||||
|
.add_client(&client, created_utc)?
|
||||||
|
.render(),
|
||||||
|
)?;
|
||||||
|
ensure_document_client(&edited, &client)?;
|
||||||
|
let mut next_states = self.draft.states.clone();
|
||||||
|
next_states.stage_client(client)?;
|
||||||
|
let replacement = DraftChanges::stage_clients(edited, next_states);
|
||||||
|
self.draft.publish_replacement(replacement);
|
||||||
self.invalidate_active_issue_authorizations();
|
self.invalidate_active_issue_authorizations();
|
||||||
self.document = edited;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,14 +638,20 @@ impl Application {
|
|||||||
let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?;
|
let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?;
|
||||||
let identity = key.public_key();
|
let identity = key.public_key();
|
||||||
|
|
||||||
let server_addresses = server_interface_addresses(&self.document)?;
|
let server_addresses = server_interface_addresses(&self.draft.document)?;
|
||||||
let mut occupied = self
|
let mut occupied = self
|
||||||
|
.draft
|
||||||
.states
|
.states
|
||||||
.addresses_in_use()
|
.addresses_in_use()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
occupied.extend(document_host_addresses(&self.document));
|
occupied.extend(document_host_addresses(&self.draft.document));
|
||||||
let reserved = self.states.address_reservations().all().collect::<Vec<_>>();
|
let reserved = self
|
||||||
|
.draft
|
||||||
|
.states
|
||||||
|
.address_reservations()
|
||||||
|
.all()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let availability = AddressAvailability {
|
let availability = AddressAvailability {
|
||||||
server: &server_addresses,
|
server: &server_addresses,
|
||||||
occupied: &occupied,
|
occupied: &occupied,
|
||||||
@@ -668,10 +684,11 @@ impl Application {
|
|||||||
// Prepare every fallible replacement off to the side. The live app is
|
// Prepare every fallible replacement off to the side. The live app is
|
||||||
// mutated only after document editing, state insertion, and encryption
|
// mutated only after document editing, state insertion, and encryption
|
||||||
// have all succeeded.
|
// have all succeeded.
|
||||||
let mut next_document = self.document.add_client(&client, created_utc)?;
|
let next_document = self.draft.document.add_client(&client, created_utc)?;
|
||||||
let mut next_states = self.states.clone();
|
let mut next_states = self.draft.states.clone();
|
||||||
next_states.draft_mut().insert(client)?;
|
next_states.stage_client(client.clone())?;
|
||||||
let existing_vault = self
|
let existing_vault = self
|
||||||
|
.draft
|
||||||
.vault
|
.vault
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(AppError::VaultMissingForCreation)?;
|
.ok_or(AppError::VaultMissingForCreation)?;
|
||||||
@@ -680,12 +697,14 @@ impl Application {
|
|||||||
next_vault.encrypt_record(identity, &key)?;
|
next_vault.encrypt_record(identity, &key)?;
|
||||||
// Parse once before publication so a malformed editor result cannot
|
// Parse once before publication so a malformed editor result cannot
|
||||||
// leave the three in-memory drafts out of sync.
|
// leave the three in-memory drafts out of sync.
|
||||||
next_document = ConfigDocument::parse(next_document.render())?;
|
let next_document = ConfigDocument::parse(next_document.render())?;
|
||||||
|
ensure_document_client(&next_document, &client)?;
|
||||||
|
next_vault.to_toml()?;
|
||||||
let context = issue_context(&next_document, &next_vault, identity)?;
|
let context = issue_context(&next_document, &next_vault, identity)?;
|
||||||
|
let replacement =
|
||||||
|
DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault));
|
||||||
self.invalidate_active_issue_authorizations();
|
self.invalidate_active_issue_authorizations();
|
||||||
self.document = next_document;
|
self.draft.publish_replacement(replacement);
|
||||||
self.states = next_states;
|
|
||||||
self.vault = Some(next_vault);
|
|
||||||
self.initial_issues.insert(
|
self.initial_issues.insert(
|
||||||
identity,
|
identity,
|
||||||
InitialIssue {
|
InitialIssue {
|
||||||
@@ -977,7 +996,7 @@ impl Application {
|
|||||||
let Ok((projection, _)) = restart_projection_from_document(document) else {
|
let Ok((projection, _)) = restart_projection_from_document(document) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.states.applied() == &ApplyStatus::Verified
|
self.draft.states.applied() == &ApplyStatus::Verified
|
||||||
&& self.verified_saved_projection.as_ref() == Some(&projection)
|
&& self.verified_saved_projection.as_ref() == Some(&projection)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,13 +1232,13 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn vault(&self) -> Option<&VaultBundle> {
|
pub fn vault(&self) -> Option<&VaultBundle> {
|
||||||
self.vault.as_ref()
|
self.draft.vault.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a vault is present in either saved state or the current draft.
|
/// Whether a vault is present in either saved state or the current draft.
|
||||||
/// This reveals only setup availability, never vault contents.
|
/// This reveals only setup availability, never vault contents.
|
||||||
pub fn vault_initialized(&self) -> bool {
|
pub fn vault_initialized(&self) -> bool {
|
||||||
self.vault.is_some()
|
self.draft.vault.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the in-memory client/document/vault draft differs from saved.
|
/// Whether the in-memory client/document/vault draft differs from saved.
|
||||||
@@ -1235,24 +1254,44 @@ impl Application {
|
|||||||
selected_name: &OsStr,
|
selected_name: &OsStr,
|
||||||
) -> Result<SelectedFileImportReport, AppError> {
|
) -> Result<SelectedFileImportReport, AppError> {
|
||||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||||
let vault = self
|
let existing_vault = self
|
||||||
|
.draft
|
||||||
.vault
|
.vault
|
||||||
.as_mut()
|
.as_ref()
|
||||||
.ok_or(AppError::VaultMissingForCreation)?;
|
.ok_or(AppError::VaultMissingForCreation)?;
|
||||||
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name)
|
let encoded = existing_vault.to_toml()?;
|
||||||
.map_err(AppError::SourceImport)
|
let mut next_vault = VaultBundle::from_toml(&encoded)?;
|
||||||
|
let report = crate::vault::source::import_selected_file(
|
||||||
|
&mut next_vault,
|
||||||
|
&settings,
|
||||||
|
&self.draft.document,
|
||||||
|
selected_name,
|
||||||
|
)
|
||||||
|
.map_err(AppError::SourceImport)?;
|
||||||
|
next_vault.to_toml()?;
|
||||||
|
let replacement = DraftChanges::stage_vault(Some(next_vault));
|
||||||
|
self.draft.publish_replacement(replacement);
|
||||||
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace a client's alias through the span-preserving config editor.
|
/// Replace a client's alias through the span-preserving config editor.
|
||||||
/// A peer outside the narrower M1 state projection remains editable.
|
/// A peer outside the narrower M1 state projection remains editable.
|
||||||
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
|
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
|
||||||
let edited = self.document.rename_client(&identity, alias)?;
|
let edited = ConfigDocument::parse(
|
||||||
if let Some(client) = self.states.draft().get(&identity).cloned() {
|
self.draft
|
||||||
|
.document
|
||||||
|
.rename_client(&identity, alias)?
|
||||||
|
.render(),
|
||||||
|
)?;
|
||||||
|
let mut next_states = self.draft.states.clone();
|
||||||
|
if let Some(client) = next_states.draft().get(&identity).cloned() {
|
||||||
let replacement = Client::new(identity, alias, client.addresses)?;
|
let replacement = Client::new(identity, alias, client.addresses)?;
|
||||||
self.states.draft_mut().insert(replacement)?;
|
next_states.stage_client(replacement.clone())?;
|
||||||
|
ensure_document_client(&edited, &replacement)?;
|
||||||
}
|
}
|
||||||
|
let replacement = DraftChanges::stage_clients(edited, next_states);
|
||||||
|
self.draft.publish_replacement(replacement);
|
||||||
self.invalidate_active_issue_authorizations();
|
self.invalidate_active_issue_authorizations();
|
||||||
self.document = edited;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,8 +1303,8 @@ impl Application {
|
|||||||
.peers()
|
.peers()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|peer| peer.identity == Some(identity));
|
.any(|peer| peer.identity == Some(identity));
|
||||||
let mut next_states = self.states.clone();
|
let mut next_states = self.draft.states.clone();
|
||||||
if saved_peer_exists && self.states.saved().get(&identity).is_none() {
|
if saved_peer_exists && self.draft.states.saved().get(&identity).is_none() {
|
||||||
let peer = saved_document
|
let peer = saved_document
|
||||||
.peers()
|
.peers()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1275,33 +1314,42 @@ impl Application {
|
|||||||
.stage_exact_host_routes(peer.allowed_ips.iter().copied())
|
.stage_exact_host_routes(peer.allowed_ips.iter().copied())
|
||||||
.map_err(|_| AppError::DeletedPeerAddressUnknown)?;
|
.map_err(|_| AppError::DeletedPeerAddressUnknown)?;
|
||||||
}
|
}
|
||||||
let edited = self.document.delete_client(&identity)?;
|
let edited = ConfigDocument::parse(self.draft.document.delete_client(&identity)?.render())?;
|
||||||
let removed_record = match self.vault.as_mut() {
|
if edited
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.any(|peer| peer.identity == Some(identity))
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidSavedState);
|
||||||
|
}
|
||||||
|
let mut next_vault = match self.draft.vault.as_ref() {
|
||||||
|
Some(vault) => Some(VaultBundle::from_toml(&vault.to_toml()?)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let removed_record = match next_vault.as_mut() {
|
||||||
Some(vault) => vault.remove_record(identity)?,
|
Some(vault) => vault.remove_record(identity)?,
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
next_states.draft_mut().remove(&identity);
|
if let Some(vault) = &next_vault {
|
||||||
self.states = next_states;
|
vault.to_toml()?;
|
||||||
self.document = edited;
|
}
|
||||||
|
next_states.remove_draft_client(&identity);
|
||||||
|
let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault);
|
||||||
|
self.draft.publish_replacement(replacement);
|
||||||
self.invalidate_active_issue_authorizations();
|
self.invalidate_active_issue_authorizations();
|
||||||
self.initial_issues.remove(&identity);
|
self.initial_issues.remove(&identity);
|
||||||
Ok(removed_record)
|
Ok(removed_record)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Edit the in-memory vault draft without exposing or replacing its identity.
|
|
||||||
/// The next commit validates that an installed identity is not changed.
|
|
||||||
pub fn vault_draft_mut(&mut self) -> Option<&mut VaultBundle> {
|
|
||||||
self.vault.as_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stage an initial vault from a prepared read-only source import. It cannot
|
/// Stage an initial vault from a prepared read-only source import. It cannot
|
||||||
/// replace an installed vault and does not change files until Commit.
|
/// replace an installed vault and does not change files until Commit.
|
||||||
pub fn prepare_initial_vault(&mut self, bundle: VaultBundle) -> Result<(), AppError> {
|
pub fn prepare_initial_vault(&mut self, bundle: VaultBundle) -> Result<(), AppError> {
|
||||||
if self.vault.is_some() || self.saved.vault.is_some() {
|
if self.draft.vault.is_some() || self.saved.vault.is_some() {
|
||||||
return Err(AppError::VaultAlreadyInstalled);
|
return Err(AppError::VaultAlreadyInstalled);
|
||||||
}
|
}
|
||||||
bundle.to_toml()?;
|
bundle.to_toml()?;
|
||||||
self.vault = Some(bundle);
|
let replacement = DraftChanges::stage_vault(Some(bundle));
|
||||||
|
self.draft.publish_replacement(replacement);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,8 +1372,11 @@ impl Application {
|
|||||||
vault_password: &str,
|
vault_password: &str,
|
||||||
) -> Result<SourceImportReport, AppError> {
|
) -> Result<SourceImportReport, AppError> {
|
||||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||||
let prepared =
|
let prepared = crate::vault::source::prepare_initial_setup(
|
||||||
crate::vault::source::prepare_initial_setup(&settings, &self.document, vault_password)
|
&settings,
|
||||||
|
&self.draft.document,
|
||||||
|
vault_password,
|
||||||
|
)
|
||||||
.map_err(AppError::SourceImport)?;
|
.map_err(AppError::SourceImport)?;
|
||||||
self.prepare_initial_setup(prepared)
|
self.prepare_initial_setup(prepared)
|
||||||
}
|
}
|
||||||
@@ -1334,13 +1385,8 @@ impl Application {
|
|||||||
/// successfully installed session snapshot. No files are written.
|
/// successfully installed session snapshot. No files are written.
|
||||||
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
||||||
let installed = self.store.read_saved()?;
|
let installed = self.store.read_saved()?;
|
||||||
if !same_saved_bytes(&self.saved, &installed) {
|
let next_document = ConfigDocument::parse(&installed.server_config)?;
|
||||||
self.verified_saved_projection = None;
|
let next_vault = installed
|
||||||
}
|
|
||||||
self.saved = installed;
|
|
||||||
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
|
||||||
self.vault = self
|
|
||||||
.saved
|
|
||||||
.vault
|
.vault
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(std::str::from_utf8)
|
.map(std::str::from_utf8)
|
||||||
@@ -1348,19 +1394,29 @@ impl Application {
|
|||||||
.map_err(|_| AppError::InvalidSavedState)?
|
.map_err(|_| AppError::InvalidSavedState)?
|
||||||
.map(VaultBundle::from_toml)
|
.map(VaultBundle::from_toml)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
self.states.discard_draft();
|
let mut next_states = self.draft.states.clone();
|
||||||
if let Some(bytes) = &self.saved.internal_state {
|
next_states.discard_draft();
|
||||||
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
|
if let Some(bytes) = &installed.internal_state {
|
||||||
|
PersistedState::from_toml(bytes)?.apply_to(&mut next_states)?;
|
||||||
} else {
|
} else {
|
||||||
self.states.runtime_knowledge_unknown();
|
next_states.runtime_knowledge_unknown();
|
||||||
}
|
}
|
||||||
self.historical_apply = historical_apply_from_saved(&self.saved)?;
|
let next_historical_apply = historical_apply_from_saved(&installed)?;
|
||||||
|
let replacement =
|
||||||
|
DraftChanges::stage_clients_and_vault(next_document, next_states, next_vault);
|
||||||
|
let saved_changed = !same_saved_bytes(&self.saved, &installed);
|
||||||
|
self.saved = installed;
|
||||||
|
self.draft.publish_replacement(replacement);
|
||||||
|
if saved_changed {
|
||||||
|
self.verified_saved_projection = None;
|
||||||
|
}
|
||||||
|
self.historical_apply = next_historical_apply;
|
||||||
self.initial_issues
|
self.initial_issues
|
||||||
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
|
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
|
||||||
retain_matching_issue_map(
|
retain_matching_issue_map(
|
||||||
&mut self.initial_issues,
|
&mut self.initial_issues,
|
||||||
&self.document,
|
&self.draft.document,
|
||||||
self.vault.as_ref(),
|
self.draft.vault.as_ref(),
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1421,7 +1477,8 @@ impl Application {
|
|||||||
}
|
}
|
||||||
let (projection, expected_clients) =
|
let (projection, expected_clients) =
|
||||||
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
|
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
|
||||||
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
let expected_clients =
|
||||||
|
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
|
||||||
Ok(RestartPreview {
|
Ok(RestartPreview {
|
||||||
saved: clone_saved(&installed),
|
saved: clone_saved(&installed),
|
||||||
interface: settings.interface_name,
|
interface: settings.interface_name,
|
||||||
@@ -1434,13 +1491,14 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn has_draft(&self) -> bool {
|
fn has_draft(&self) -> bool {
|
||||||
let vault_differs = match &self.vault {
|
let vault_differs = match &self.draft.vault {
|
||||||
Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault,
|
Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault,
|
||||||
None => self.saved.vault.is_some(),
|
None => self.saved.vault.is_some(),
|
||||||
};
|
};
|
||||||
self.states.draft() != self.states.saved()
|
self.draft.states.draft() != self.draft.states.saved()
|
||||||
|| self.document.render() != self.saved.server_config
|
|| self.draft.document.render() != self.saved.server_config
|
||||||
|| self
|
|| self
|
||||||
|
.draft
|
||||||
.states
|
.states
|
||||||
.address_reservations()
|
.address_reservations()
|
||||||
.pending_host_routes()
|
.pending_host_routes()
|
||||||
@@ -1622,7 +1680,8 @@ impl Application {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
let expected_clients =
|
||||||
|
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
|
||||||
if settings.interface_name != preview.interface
|
if settings.interface_name != preview.interface
|
||||||
|| settings.systemd_unit != preview.unit
|
|| settings.systemd_unit != preview.unit
|
||||||
|| settings.restart_timeout != preview.timeout
|
|| settings.restart_timeout != preview.timeout
|
||||||
@@ -1696,7 +1755,7 @@ impl Application {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.states.runtime_knowledge_unknown();
|
self.draft.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;
|
||||||
@@ -1847,12 +1906,13 @@ impl Application {
|
|||||||
let snapshot = snapshot_from_document(&document);
|
let snapshot = snapshot_from_document(&document);
|
||||||
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
||||||
let mut archived_reservations = BTreeSet::new();
|
let mut archived_reservations = BTreeSet::new();
|
||||||
let mut reservations = self.states.reserved_addresses().clone();
|
let mut reservations = self.draft.states.reserved_addresses().clone();
|
||||||
// A restore can remove clients that are currently saved even when their
|
// A restore can remove clients that are currently saved even when their
|
||||||
// addresses were not yet in the persisted reservation set. Runtime state
|
// addresses were not yet in the persisted reservation set. Runtime state
|
||||||
// remains unchanged, so carry those addresses across the replacement.
|
// remains unchanged, so carry those addresses across the replacement.
|
||||||
reservations.extend(
|
reservations.extend(
|
||||||
self.states
|
self.draft
|
||||||
|
.states
|
||||||
.saved()
|
.saved()
|
||||||
.clients()
|
.clients()
|
||||||
.flat_map(|client| client.addresses.iter()),
|
.flat_map(|client| client.addresses.iter()),
|
||||||
@@ -1938,9 +1998,9 @@ impl Application {
|
|||||||
}
|
}
|
||||||
self.saved = preview.saved_bytes;
|
self.saved = preview.saved_bytes;
|
||||||
self.store.set_resolved_settings(resolved);
|
self.store.set_resolved_settings(resolved);
|
||||||
self.document = document;
|
self.draft.document = document;
|
||||||
self.vault = vault;
|
self.draft.vault = vault;
|
||||||
self.states = states;
|
self.draft.states = states;
|
||||||
self.initial_issues.clear();
|
self.initial_issues.clear();
|
||||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||||
self.verified_saved_projection = None;
|
self.verified_saved_projection = None;
|
||||||
@@ -1966,7 +2026,7 @@ impl Application {
|
|||||||
{
|
{
|
||||||
return Err(AppError::SavedGenerationChanged);
|
return Err(AppError::SavedGenerationChanged);
|
||||||
}
|
}
|
||||||
let mut next_states = self.states.clone();
|
let mut next_states = self.draft.states.clone();
|
||||||
next_states.prepare_technically_verified_result(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)?,
|
||||||
@@ -2015,8 +2075,9 @@ impl Application {
|
|||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
self.saved = proposed;
|
self.saved = proposed;
|
||||||
self.states = next_states;
|
self.draft.states = next_states;
|
||||||
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
|
self.verified_saved_projection =
|
||||||
|
Some(SavedPeerProjection::from_document(&self.draft.document));
|
||||||
self.historical_apply = historical_apply;
|
self.historical_apply = historical_apply;
|
||||||
self.active_restart_attempt = None;
|
self.active_restart_attempt = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2047,6 +2108,7 @@ impl Application {
|
|||||||
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
||||||
let existing = VaultBundle::from_toml(existing_text)?;
|
let existing = VaultBundle::from_toml(existing_text)?;
|
||||||
if self
|
if self
|
||||||
|
.draft
|
||||||
.vault
|
.vault
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
||||||
@@ -2055,7 +2117,7 @@ impl Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut next_states = self.states.clone();
|
let mut next_states = self.draft.states.clone();
|
||||||
next_states.commit_draft();
|
next_states.commit_draft();
|
||||||
let reservations = next_states
|
let reservations = next_states
|
||||||
.reserved_addresses()
|
.reserved_addresses()
|
||||||
@@ -2064,6 +2126,7 @@ impl Application {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
||||||
let vault_bytes = self
|
let vault_bytes = self
|
||||||
|
.draft
|
||||||
.vault
|
.vault
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(VaultBundle::to_toml)
|
.map(VaultBundle::to_toml)
|
||||||
@@ -2071,7 +2134,7 @@ impl Application {
|
|||||||
.map(String::into_bytes);
|
.map(String::into_bytes);
|
||||||
let proposed = SavedBytes {
|
let proposed = SavedBytes {
|
||||||
settings: installed.settings.clone(),
|
settings: installed.settings.clone(),
|
||||||
server_config: self.document.render().to_vec(),
|
server_config: self.draft.document.render().to_vec(),
|
||||||
internal_state,
|
internal_state,
|
||||||
vault: vault_bytes,
|
vault: vault_bytes,
|
||||||
};
|
};
|
||||||
@@ -2108,13 +2171,13 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.saved = proposed;
|
self.saved = proposed;
|
||||||
self.states = next_states;
|
self.draft.states = next_states;
|
||||||
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
self.draft.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(
|
||||||
&mut self.initial_issues,
|
&mut self.initial_issues,
|
||||||
&self.document,
|
&self.draft.document,
|
||||||
self.vault.as_ref(),
|
self.draft.vault.as_ref(),
|
||||||
);
|
);
|
||||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||||
self.verified_saved_projection = None;
|
self.verified_saved_projection = None;
|
||||||
@@ -2415,6 +2478,13 @@ fn snapshot_from_document(document: &ConfigDocument) -> Snapshot {
|
|||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_document_client(document: &ConfigDocument, client: &Client) -> Result<(), AppError> {
|
||||||
|
if document.project_client(&client.identity)? != *client {
|
||||||
|
return Err(AppError::InvalidSavedState);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("saved state is invalid or incompatible with this session")]
|
#[error("saved state is invalid or incompatible with this session")]
|
||||||
@@ -2476,6 +2546,11 @@ mod tests {
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
|
||||||
|
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
|
||||||
|
RestartStage, clone_saved, snapshot_from_document,
|
||||||
|
};
|
||||||
use crate::backup::{ArchiveCheckpoint, create_archive_with_hook};
|
use crate::backup::{ArchiveCheckpoint, create_archive_with_hook};
|
||||||
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
|
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
|
||||||
use crate::config::SettingsFile;
|
use crate::config::SettingsFile;
|
||||||
@@ -2492,13 +2567,6 @@ mod tests {
|
|||||||
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
|
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
|
||||||
use crate::vault::{VaultBundle, WireGuardPrivateKey};
|
use crate::vault::{VaultBundle, WireGuardPrivateKey};
|
||||||
use crate::wireguard::ConfigDocument;
|
use crate::wireguard::ConfigDocument;
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
|
|
||||||
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
|
|
||||||
RestartStage, clone_saved, snapshot_from_document,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SETTINGS: &str = r#"
|
const SETTINGS: &str = r#"
|
||||||
interface_name = "wg0"
|
interface_name = "wg0"
|
||||||
@@ -2687,6 +2755,25 @@ restart_timeout_seconds = 30
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_vault_after_client_preparation_does_not_publish_any_draft_or_issue() {
|
||||||
|
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 before_document = app.document().render().to_vec();
|
||||||
|
let before_draft = app.states().draft().clone();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.prepare_new_client("missing vault", None, None),
|
||||||
|
Err(AppError::VaultMissingForCreation)
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(app.document().render(), before_document);
|
||||||
|
assert_eq!(app.states().draft(), &before_draft);
|
||||||
|
assert!(app.vault().is_none());
|
||||||
|
assert!(app.initial_issues.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
|
fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -2717,6 +2804,36 @@ restart_timeout_seconds = 30
|
|||||||
assert_eq!(app.saved_bytes().vault, saved_vault);
|
assert_eq!(app.saved_bytes().vault, saved_vault);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_import_error_keeps_vault_and_pending_issue_unchanged() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let mut app = setup_with_vault(root.path());
|
||||||
|
let identity = app
|
||||||
|
.prepare_new_client("pending import", None, None)
|
||||||
|
.expect("client stages");
|
||||||
|
let before_document = app.document().render().to_vec();
|
||||||
|
let before_states = app.states().clone();
|
||||||
|
let before_vault = app.vault().expect("vault exists").to_toml().unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.import_selected_file(OsStr::new("../invalid.conf")),
|
||||||
|
Err(AppError::SourceImport(
|
||||||
|
crate::vault::source::SourceImportError::InvalidSelection
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(app.document().render(), before_document);
|
||||||
|
assert_eq!(app.states(), &before_states);
|
||||||
|
assert_eq!(
|
||||||
|
app.vault()
|
||||||
|
.expect("vault remains present")
|
||||||
|
.to_toml()
|
||||||
|
.unwrap(),
|
||||||
|
before_vault
|
||||||
|
);
|
||||||
|
assert!(app.initial_issues.contains_key(&identity));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
|
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -3471,37 +3588,6 @@ restart_timeout_seconds = 30
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replacing_the_encrypted_record_invalidates_committed_first_issue() {
|
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
|
||||||
let mut app = setup_with_vault(root.path());
|
|
||||||
let identity = app
|
|
||||||
.prepare_new_client("replacement", None, None)
|
|
||||||
.expect("client stages");
|
|
||||||
app.commit().expect("client commits");
|
|
||||||
let replacement_bytes = Zeroizing::new(
|
|
||||||
app.initial_issues
|
|
||||||
.get(&identity)
|
|
||||||
.expect("session key retained")
|
|
||||||
.private_key
|
|
||||||
.to_base64(),
|
|
||||||
);
|
|
||||||
let replacement_key = WireGuardPrivateKey::parse_base64(&replacement_bytes)
|
|
||||||
.expect("same synthetic key parses");
|
|
||||||
let vault = app.vault_draft_mut().expect("vault exists");
|
|
||||||
assert!(vault.remove_record(identity).expect("record removed"));
|
|
||||||
vault
|
|
||||||
.encrypt_record(identity, &replacement_key)
|
|
||||||
.expect("replacement record encrypted");
|
|
||||||
app.commit().expect("replacement commits");
|
|
||||||
assert!(matches!(
|
|
||||||
app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(
|
|
||||||
()
|
|
||||||
)),
|
|
||||||
Err(AppError::FirstIssueUnavailable)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deletion_and_discard_revoke_only_session_bound_initial_material() {
|
fn deletion_and_discard_revoke_only_session_bound_initial_material() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -3677,7 +3763,7 @@ restart_timeout_seconds = 30
|
|||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
let (store, states, _, _) = setup(root.path(), false);
|
let (store, states, _, _) = setup(root.path(), false);
|
||||||
let mut app = Application::open(store, states).expect("application opens");
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
app.states.runtime_knowledge_unavailable();
|
app.draft.states.runtime_knowledge_unavailable();
|
||||||
|
|
||||||
app.commit().expect("unchanged commit completes");
|
app.commit().expect("unchanged commit completes");
|
||||||
|
|
||||||
@@ -3724,6 +3810,34 @@ restart_timeout_seconds = 30
|
|||||||
assert_eq!(app.states().draft(), app.states().saved());
|
assert_eq!(app.states().draft(), app.states().saved());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discard_parse_failure_preserves_every_live_draft_component_and_issue() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let mut app = setup_with_vault(root.path());
|
||||||
|
let issue = app
|
||||||
|
.prepare_new_client("discard failure", None, None)
|
||||||
|
.expect("new client stages");
|
||||||
|
let before_saved = clone_saved(app.saved_bytes());
|
||||||
|
let before_document = app.document().render().to_vec();
|
||||||
|
let before_states = app.states().clone();
|
||||||
|
let before_vault = app.vault().expect("vault exists").to_toml().unwrap();
|
||||||
|
fs::write(root.path().join("server.conf"), [0xff]).expect("corrupt synthetic saved file");
|
||||||
|
|
||||||
|
assert!(app.discard_draft().is_err());
|
||||||
|
|
||||||
|
assert!(super::same_saved_bytes(app.saved_bytes(), &before_saved));
|
||||||
|
assert_eq!(app.document().render(), before_document);
|
||||||
|
assert_eq!(app.states(), &before_states);
|
||||||
|
assert_eq!(
|
||||||
|
app.vault()
|
||||||
|
.expect("vault remains present")
|
||||||
|
.to_toml()
|
||||||
|
.unwrap(),
|
||||||
|
before_vault
|
||||||
|
);
|
||||||
|
assert!(app.initial_issues.contains_key(&issue));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initial_vault_is_prepared_then_installed_only_after_commit() {
|
fn initial_vault_is_prepared_then_installed_only_after_commit() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -4925,7 +5039,7 @@ restart_timeout_seconds = 30
|
|||||||
.install(&saved, BackupComplete(()))
|
.install(&saved, BackupComplete(()))
|
||||||
.expect("install synthetic unsafe generation");
|
.expect("install synthetic unsafe generation");
|
||||||
let reopened_store = app.store.clone();
|
let reopened_store = app.store.clone();
|
||||||
let states = app.states.clone();
|
let states = app.draft.states.clone();
|
||||||
app = Application::open(reopened_store, states).expect("reopen fixture");
|
app = Application::open(reopened_store, states).expect("reopen fixture");
|
||||||
app.rename_client(key, "draft")
|
app.rename_client(key, "draft")
|
||||||
.expect("stage a draft for preservation");
|
.expect("stage a draft for preservation");
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Coordinated in-memory draft state for one application session.
|
||||||
|
//!
|
||||||
|
//! This owner groups the authoritative lossless document with its restricted
|
||||||
|
//! public-client projection, encrypted vault draft, and draft-only address
|
||||||
|
//! reservations (held inside `ClientStates`). It performs no I/O or
|
||||||
|
//! cryptographic operations itself.
|
||||||
|
|
||||||
|
use crate::domain::ClientStates;
|
||||||
|
use crate::vault::VaultBundle;
|
||||||
|
use crate::wireguard::ConfigDocument;
|
||||||
|
|
||||||
|
pub(super) struct DraftChanges {
|
||||||
|
pub(super) document: ConfigDocument,
|
||||||
|
pub(super) states: ClientStates,
|
||||||
|
pub(super) vault: Option<VaultBundle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully prepared replacement for selected coordinated in-memory draft parts.
|
||||||
|
///
|
||||||
|
/// Callers build this only after all fallible editing, projection, and vault
|
||||||
|
/// work has succeeded. Publishing it is a narrow, infallible operation and
|
||||||
|
/// never writes saved files or changes runtime state.
|
||||||
|
pub(super) struct StagedDraftReplacement {
|
||||||
|
document: Option<ConfigDocument>,
|
||||||
|
states: Option<ClientStates>,
|
||||||
|
vault: VaultChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VaultChange {
|
||||||
|
Keep,
|
||||||
|
Replace(Option<VaultBundle>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DraftChanges {
|
||||||
|
pub(super) fn new(
|
||||||
|
document: ConfigDocument,
|
||||||
|
states: ClientStates,
|
||||||
|
vault: Option<VaultBundle>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
document,
|
||||||
|
states,
|
||||||
|
vault,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stage_clients(
|
||||||
|
document: ConfigDocument,
|
||||||
|
states: ClientStates,
|
||||||
|
) -> StagedDraftReplacement {
|
||||||
|
StagedDraftReplacement {
|
||||||
|
document: Some(document),
|
||||||
|
states: Some(states),
|
||||||
|
vault: VaultChange::Keep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stage_clients_and_vault(
|
||||||
|
document: ConfigDocument,
|
||||||
|
states: ClientStates,
|
||||||
|
vault: Option<VaultBundle>,
|
||||||
|
) -> StagedDraftReplacement {
|
||||||
|
StagedDraftReplacement {
|
||||||
|
document: Some(document),
|
||||||
|
states: Some(states),
|
||||||
|
vault: VaultChange::Replace(vault),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stage_vault(vault: Option<VaultBundle>) -> StagedDraftReplacement {
|
||||||
|
StagedDraftReplacement {
|
||||||
|
document: None,
|
||||||
|
states: None,
|
||||||
|
vault: VaultChange::Replace(vault),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn publish_replacement(&mut self, replacement: StagedDraftReplacement) {
|
||||||
|
if let Some(document) = replacement.document {
|
||||||
|
self.document = document;
|
||||||
|
}
|
||||||
|
if let Some(states) = replacement.states {
|
||||||
|
self.states = states;
|
||||||
|
}
|
||||||
|
if let VaultChange::Replace(vault) = replacement.vault {
|
||||||
|
self.vault = vault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-6
@@ -351,8 +351,12 @@ impl ClientStates {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draft_mut(&mut self) -> &mut Snapshot {
|
pub(crate) fn stage_client(&mut self, client: Client) -> Result<(), DomainError> {
|
||||||
&mut self.draft
|
self.draft.insert(client).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_draft_client(&mut self, identity: &PublicKey) -> Option<Client> {
|
||||||
|
self.draft.remove(identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -726,11 +730,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let mut states =
|
let mut states =
|
||||||
ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap();
|
ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap();
|
||||||
states.draft_mut().remove(&key(2));
|
states.remove_draft_client(&key(2));
|
||||||
states.discard_draft();
|
states.discard_draft();
|
||||||
assert_eq!(states.draft(), &saved);
|
assert_eq!(states.draft(), &saved);
|
||||||
|
|
||||||
states.draft_mut().remove(&key(2));
|
states.remove_draft_client(&key(2));
|
||||||
states.commit_draft();
|
states.commit_draft();
|
||||||
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);
|
||||||
@@ -758,7 +762,7 @@ mod tests {
|
|||||||
fn a_draft_only_deletion_does_not_create_a_committed_reservation() {
|
fn a_draft_only_deletion_does_not_create_a_committed_reservation() {
|
||||||
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
|
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
|
||||||
let mut states = ClientStates::new(saved, None, ApplyStatus::Unknown).unwrap();
|
let mut states = ClientStates::new(saved, None, ApplyStatus::Unknown).unwrap();
|
||||||
states.draft_mut().remove(&key(1));
|
states.remove_draft_client(&key(1));
|
||||||
assert!(states.reserved_addresses().is_empty());
|
assert!(states.reserved_addresses().is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
states
|
states
|
||||||
@@ -840,7 +844,7 @@ mod tests {
|
|||||||
assert_eq!(states.applied(), &ApplyStatus::Unknown);
|
assert_eq!(states.applied(), &ApplyStatus::Unknown);
|
||||||
assert_eq!(states.applied_snapshot(), Some(&initial));
|
assert_eq!(states.applied_snapshot(), Some(&initial));
|
||||||
|
|
||||||
states.draft_mut().remove(&key(1));
|
states.remove_draft_client(&key(1));
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user