Skip to content

Commit 6df1f15

Browse files
committed
feat(core+bin): adopt WP-edition source — mooring client, integrity updates, config layer
The previous five commits brought in the WP-edition's outer scaffolding (workspace shape, adapters, deploy, RSR, docs) but I missed the actual source-code delta inside crates/ + bin/ — those directories existed in both trees but their contents differed substantially. Without this, the workspace failed to resolve `ed25519-dalek` and friends because the workspace.dependencies block was the WP-edition's but the source files referencing those deps weren't carried over. Carries forward: - crates/wharf-core/src/{mooring.rs, mooring_client.rs, config.rs} (new modules — yacht-mooring fleet protocol) - crates/wharf-core/src/{crypto.rs, db_policy.rs, errors.rs, fleet.rs, integrity.rs, lib.rs} updated to WP-edition state - bin/{wharf-cli,yacht-agent} updated to consume the new core API - Cargo.lock regenerated by cargo update — picks up security patches for any dep with a fix available Per the 2026-04-26 graduation plan (AI-WORK-todo.md item -4).
1 parent 484a2d8 commit 6df1f15

24 files changed

Lines changed: 5287 additions & 519 deletions

Cargo.lock

Lines changed: 311 additions & 297 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/wharf-cli/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ anyhow = { workspace = true }
3333

3434
# Date/Time
3535
chrono = { workspace = true }
36+
37+
# HTTP client (for mooring)
38+
reqwest = { workspace = true }
39+
40+
# Crypto utilities
41+
hex = { workspace = true }

bin/wharf-cli/src/main.rs

Lines changed: 329 additions & 4 deletions
Large diffs are not rendered by default.

bin/wharf-cli/src/ops/moor.rs

Lines changed: 211 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
1-
// SPDX-License-Identifier: MIT
2-
// SPDX-FileCopyrightText: 2025 Jonathan D. A. Jewell <hyperpolymath>
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33

44
//! # Mooring Operations
55
//!
6-
//! Handles the "mooring" process - syncing state between Wharf and Yacht.
6+
//! Handles the "mooring" process — syncing state between Wharf and Yacht
7+
//! via the yacht-agent's HTTP mooring API.
8+
//!
9+
//! Flow: init → verify → rsync → commit
710
811
use std::path::Path;
912
use anyhow::{Context, Result};
1013
use tracing::{info, warn};
1114

15+
use wharf_core::config::MooringConfig;
16+
use wharf_core::crypto::{
17+
generate_hybrid_keypair, serialize_keypair_raw, deserialize_keypair_raw,
18+
hybrid_public_key, serialize_public_key, HybridKeypair,
19+
};
1220
use wharf_core::fleet::{Fleet, Yacht};
13-
use wharf_core::integrity::{generate_manifest, save_manifest, verify_manifest};
21+
use wharf_core::integrity::{generate_manifest, save_manifest};
22+
use wharf_core::mooring::MooringLayer;
23+
use wharf_core::mooring_client::MooringClient;
1424
use wharf_core::sync::{sync_to_remote, check_rsync, check_ssh_connection, SyncConfig};
1525

1626
/// Options for the mooring process
27+
#[allow(dead_code)]
1728
pub struct MoorOptions {
1829
pub force: bool,
1930
pub dry_run: bool,
@@ -26,14 +37,17 @@ pub struct MoorResult {
2637
pub files_synced: u64,
2738
pub integrity_verified: bool,
2839
pub yacht_name: String,
40+
pub snapshot_id: Option<String>,
2941
}
3042

31-
/// Execute the mooring process
32-
pub fn execute_moor(
43+
/// Execute the mooring process via yacht-agent HTTP API
44+
pub async fn execute_moor(
3345
fleet: &Fleet,
3446
yacht_name: &str,
3547
source_dir: &Path,
3648
options: &MoorOptions,
49+
_keypair: &HybridKeypair,
50+
mooring_config: &MooringConfig,
3751
) -> Result<MoorResult> {
3852
// Find the yacht
3953
let yacht = fleet.get_yacht(yacht_name)
@@ -44,30 +58,80 @@ pub fn execute_moor(
4458
// Pre-flight checks
4559
preflight_checks(yacht)?;
4660

61+
// Determine layers to sync
62+
let layers = parse_layers(&options.layers);
63+
4764
// Generate integrity manifest for local files
4865
info!("Generating integrity manifest...");
4966
let manifest = generate_manifest(source_dir, &fleet.sync_excludes)
5067
.context("Failed to generate integrity manifest")?;
5168

5269
info!("Manifest contains {} files", manifest.files.len());
5370

54-
// Save manifest
71+
// Save manifest locally
5572
let manifest_path = source_dir.join(".wharf-manifest.json");
5673
save_manifest(&manifest, &manifest_path)
5774
.context("Failed to save manifest")?;
5875

59-
// Prepare sync configuration
76+
// Create mooring client targeting the yacht-agent using the persistent keypair
77+
let base_url = format!("http://{}:9001", yacht.ip);
78+
let client_keypair = generate_hybrid_keypair()
79+
.context("Failed to generate session keypair")?;
80+
let client = MooringClient::new(&base_url, client_keypair);
81+
82+
// Step 1: Init mooring session
83+
info!("Initiating mooring session with {}...", yacht.name);
84+
let init_resp = client
85+
.init_session(layers.clone(), options.force, options.dry_run)
86+
.await
87+
.context("Mooring init failed")?;
88+
89+
let session_id = &init_resp.session_id;
90+
info!("Session established: {}", session_id);
91+
info!("Accepted layers: {:?}", init_resp.accepted_layers);
92+
93+
// Step 2: Verify each layer
94+
for layer in &init_resp.accepted_layers {
95+
info!("Verifying layer: {:?}", layer);
96+
let layer_manifest = wharf_core::mooring::LayerManifest {
97+
files: manifest.files.iter().map(|(k, v)| (k.clone(), v.hash.clone())).collect(),
98+
total_size: manifest.files.values().map(|f| f.size).sum(),
99+
file_count: manifest.files.len(),
100+
root_hash: wharf_core::crypto::hash_blake3(
101+
&serde_json::to_vec(&manifest.files).unwrap_or_default(),
102+
),
103+
};
104+
105+
let verify_resp = client
106+
.verify_layer(session_id, *layer, layer_manifest)
107+
.await
108+
.context(format!("Verify failed for layer {:?}", layer))?;
109+
110+
if verify_resp.verified {
111+
info!("Layer {:?}: {} files matched", layer, verify_resp.matched_files);
112+
} else {
113+
warn!("Layer {:?}: {} files differ, {} missing",
114+
layer,
115+
verify_resp.differing_files.len(),
116+
verify_resp.missing_files.len(),
117+
);
118+
}
119+
}
120+
121+
// Step 3: Rsync files
122+
let identity_file = resolve_identity_file(yacht, mooring_config)
123+
.map(std::path::PathBuf::from);
124+
60125
let sync_config = SyncConfig {
61126
source: source_dir.to_path_buf(),
62127
destination: yacht.rsync_destination(),
63128
ssh_port: yacht.ssh_port,
64-
identity_file: None, // TODO: Load from config
129+
identity_file,
65130
excludes: fleet.sync_excludes.clone(),
66131
dry_run: options.dry_run,
67-
delete: options.force, // Only delete if force is enabled
132+
delete: options.force,
68133
};
69134

70-
// Execute sync
71135
if options.dry_run {
72136
info!("[DRY RUN] Would sync {} files to {}", manifest.files.len(), yacht.domain);
73137
} else {
@@ -77,25 +141,135 @@ pub fn execute_moor(
77141
info!("Transferred {} files", result.files_transferred);
78142
}
79143

144+
// Step 4: Commit
145+
info!("Committing mooring session...");
146+
let commit_resp = client
147+
.commit(session_id, init_resp.accepted_layers)
148+
.await
149+
.context("Mooring commit failed")?;
150+
151+
if !commit_resp.success {
152+
anyhow::bail!("Commit failed: {}", commit_resp.error.unwrap_or_default());
153+
}
154+
155+
info!("Mooring committed successfully. Snapshot: {:?}", commit_resp.snapshot_id);
156+
80157
Ok(MoorResult {
81158
files_synced: manifest.files.len() as u64,
82159
integrity_verified: true,
83160
yacht_name: yacht_name.to_string(),
161+
snapshot_id: commit_resp.snapshot_id,
84162
})
85163
}
86164

165+
/// Resolve the SSH identity file for rsync
166+
///
167+
/// Resolution order:
168+
/// 1. Yacht-specific override (`yacht.ssh_identity_file`)
169+
/// 2. Fleet-wide default (`mooring_config.ssh_identity`)
170+
/// 3. Default Ed448 key (`~/.ssh/id_ed448`)
171+
/// 4. None (use SSH agent)
172+
fn resolve_identity_file(yacht: &Yacht, config: &MooringConfig) -> Option<String> {
173+
// Yacht-specific override
174+
if let Some(ref identity) = yacht.ssh_identity_file {
175+
if Path::new(identity).exists() {
176+
return Some(identity.clone());
177+
}
178+
warn!("Yacht identity file '{}' not found, trying fleet default", identity);
179+
}
180+
181+
// Fleet-wide default
182+
if let Some(ref identity) = config.ssh_identity {
183+
if Path::new(identity).exists() {
184+
return Some(identity.clone());
185+
}
186+
warn!("Fleet identity file '{}' not found, trying ~/.ssh/id_ed448", identity);
187+
}
188+
189+
// Default Ed448 key
190+
if let Ok(home) = std::env::var("HOME") {
191+
let ed448_key = std::path::Path::new(&home).join(".ssh").join("id_ed448");
192+
if ed448_key.exists() {
193+
return Some(ed448_key.to_string_lossy().to_string());
194+
}
195+
}
196+
197+
// Fall back to SSH agent
198+
None
199+
}
200+
201+
/// Load or generate a hybrid keypair for the CLI.
202+
///
203+
/// Looks for `wharf.key` in the key directory. If found, loads it.
204+
/// If not found, generates a new keypair and persists it with
205+
/// restrictive file permissions (0600 private key, 0644 public key).
206+
pub fn load_or_generate_keypair(config_dir: &Path) -> Result<HybridKeypair> {
207+
let key_dir = config_dir.join("keys");
208+
let key_path = key_dir.join("wharf.key");
209+
let pubkey_path = key_dir.join("wharf.pub");
210+
211+
if key_path.exists() {
212+
info!("Loading keypair from {}", key_path.display());
213+
let data = std::fs::read(&key_path)
214+
.context(format!("Failed to read keypair from {}", key_path.display()))?;
215+
let keypair = deserialize_keypair_raw(&data)
216+
.context("Failed to deserialize keypair (file may be corrupted)")?;
217+
return Ok(keypair);
218+
}
219+
220+
info!("No keypair found, generating new hybrid keypair...");
221+
std::fs::create_dir_all(&key_dir)
222+
.context(format!("Failed to create key directory: {}", key_dir.display()))?;
223+
224+
let keypair = generate_hybrid_keypair()
225+
.context("Failed to generate hybrid keypair")?;
226+
227+
// Save private key with 0600 permissions
228+
let data = serialize_keypair_raw(&keypair)
229+
.context("Failed to serialize keypair")?;
230+
std::fs::write(&key_path, &data)
231+
.context(format!("Failed to write keypair to {}", key_path.display()))?;
232+
set_permissions(&key_path, 0o600)?;
233+
234+
// Save public key for distribution with 0644 permissions
235+
let pubkey = hybrid_public_key(&keypair);
236+
let pubkey_json = serialize_public_key(&pubkey);
237+
std::fs::write(&pubkey_path, pubkey_json.as_bytes())
238+
.context(format!("Failed to write public key to {}", pubkey_path.display()))?;
239+
set_permissions(&pubkey_path, 0o644)?;
240+
241+
info!("Keypair saved to {}", key_path.display());
242+
info!("Public key saved to {}", pubkey_path.display());
243+
244+
Ok(keypair)
245+
}
246+
247+
/// Set Unix file permissions
248+
#[cfg(unix)]
249+
fn set_permissions(path: &Path, mode: u32) -> Result<()> {
250+
use std::os::unix::fs::PermissionsExt;
251+
let perms = std::fs::Permissions::from_mode(mode);
252+
std::fs::set_permissions(path, perms)
253+
.context(format!("Failed to set permissions on {}", path.display()))
254+
}
255+
256+
#[cfg(not(unix))]
257+
fn set_permissions(_path: &Path, _mode: u32) -> Result<()> {
258+
Ok(())
259+
}
260+
87261
/// Run pre-flight checks before mooring
88262
fn preflight_checks(yacht: &Yacht) -> Result<()> {
89263
// Check rsync is available
90264
if !check_rsync() {
91265
anyhow::bail!("rsync is not installed. Please install rsync.");
92266
}
93-
info!("rsync available");
267+
info!("rsync available");
94268

95269
// Check SSH connection
96270
info!("Testing SSH connection to {}...", yacht.ip);
97271
match check_ssh_connection(&yacht.ssh_destination(), yacht.ssh_port, None) {
98-
Ok(true) => info!("SSH connection successful"),
272+
Ok(true) => info!("SSH connection successful"),
99273
Ok(false) => {
100274
warn!("SSH connection test returned false");
101275
anyhow::bail!("Cannot connect to yacht via SSH. Check your credentials.");
@@ -109,22 +283,41 @@ fn preflight_checks(yacht: &Yacht) -> Result<()> {
109283
Ok(())
110284
}
111285

286+
/// Parse layer names to MooringLayer enum values
287+
fn parse_layers(layer_names: &[String]) -> Vec<MooringLayer> {
288+
if layer_names.is_empty() {
289+
// Default layers
290+
return vec![MooringLayer::Config, MooringLayer::Files];
291+
}
292+
293+
layer_names
294+
.iter()
295+
.filter_map(|name| match name.to_lowercase().as_str() {
296+
"config" => Some(MooringLayer::Config),
297+
"files" => Some(MooringLayer::Files),
298+
"database" | "db" => Some(MooringLayer::Database),
299+
"assets" => Some(MooringLayer::Assets),
300+
"secrets" => Some(MooringLayer::Secrets),
301+
_ => {
302+
warn!("Unknown layer '{}', skipping", name);
303+
None
304+
}
305+
})
306+
.collect()
307+
}
308+
112309
/// Verify yacht state matches local manifest
310+
#[allow(dead_code)]
113311
pub fn verify_yacht_state(
114312
yacht: &Yacht,
115313
local_manifest_path: &Path,
116314
) -> Result<bool> {
117-
// This would require running a remote command to verify
118-
// For now, we trust the sync and verify locally
119315
info!("Verifying yacht state for {}...", yacht.name);
120316

121317
let manifest = wharf_core::integrity::load_manifest(local_manifest_path)
122318
.context("Failed to load manifest")?;
123319

124320
info!("Manifest loaded: {} files", manifest.files.len());
125321

126-
// In a full implementation, we'd run a remote verification command
127-
// For v1.0, we trust the rsync completed successfully
128-
129322
Ok(true)
130323
}

bin/yacht-agent/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ anyhow = { workspace = true }
4343

4444
# eBPF Userspace Loader (optional - only used in ebpf mode)
4545
aya = { workspace = true }
46+
47+
[dev-dependencies]
48+
reqwest = { workspace = true }

bin/yacht-agent/src/ebpf.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// SPDX-License-Identifier: MIT
1+
// SPDX-License-Identifier: PMPL-1.0
22
// SPDX-FileCopyrightText: 2025 Jonathan D. A. Jewell <hyperpolymath>
33

44
//! # eBPF XDP Shield Loader
@@ -38,6 +38,7 @@ pub struct Shield {
3838
interface: String,
3939
}
4040

41+
#[allow(dead_code)]
4142
impl Shield {
4243
/// Load and attach the eBPF XDP firewall
4344
///

0 commit comments

Comments
 (0)