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
811use std:: path:: Path ;
912use anyhow:: { Context , Result } ;
1013use 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+ } ;
1220use 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 ;
1424use wharf_core:: sync:: { sync_to_remote, check_rsync, check_ssh_connection, SyncConfig } ;
1525
1626/// Options for the mooring process
27+ #[ allow( dead_code) ]
1728pub 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
88262fn 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) ]
113311pub 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}
0 commit comments