diff --git a/CLAUDE.md b/CLAUDE.md index 3c25347..fabc715 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ Single Rust binary — the CLI and actix-web server share the same codebase. The - **`src/tls.rs`** — TLS certificate loading/generation for the HTTPS proxy. - **`src/dirs.rs`** — `QumaDirs` struct: resolves the directory layout (`spt-server/`, `headless/`, `overlay/`) with legacy flat-layout migration. - **`src/invite.rs`** — Invite code generation and expiry parsing. -- **`src/client/`** — Fika headless client management. `supervisor.rs` runs the convergence loop, `converge.rs` handles container creation/scaling/overlay setup. Exit watchers cache restart policy/backoff values at spawn time (config changes to those require supervisor restart). +- **`src/client/`** — Fika headless client management. `supervisor.rs` runs the convergence loop, `converge.rs` handles container creation/scaling/overlay setup. Exit watchers read restart policy/backoff values from config on each iteration, so config changes take effect without restarting the supervisor. - **`src/headless/`** — Headless client service layer. `service.rs` defines `HeadlessService` (scaling, lifecycle actions, status). `operations.rs` provides `OperationTracker` for async operation tracking. `error.rs` defines `HeadlessError`. - **`src/spt/headless.rs`** — SPT server API types for headless client queries. - **`src/spt/game_data.rs`** — Loads quest/trader/hideout metadata from SPT data files for profile display. diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 4b47f29..c3b2ca0 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -145,19 +145,17 @@ pub async fn run(bind: Option<&str>, port: Option, cli: &Cli) -> Result<()> "Running initial convergence for {} headless client(s)", headless_config.client_count() ); - if let Err(e) = crate::client::converge::converge( - container_mgr_arc, + let ctx = crate::client::converge::ConvergeContext { + container_mgr: container_mgr_arc, headless_config, - &config, - &dirs, - &spt_client, - &forge, - &spt_info.spt_version, - Arc::clone(&converging), - &db_arc, - ) - .await - { + config: &config, + dirs: &dirs, + spt_client: &spt_client, + forge: &forge, + converging: &converging, + db: &db_arc, + }; + if let Err(e) = crate::client::converge::converge(&ctx).await { tracing::error!(err = %e, "Initial convergence failed"); } } diff --git a/src/client/converge.rs b/src/client/converge.rs index caa8c02..e8298a5 100644 --- a/src/client/converge.rs +++ b/src/client/converge.rs @@ -26,6 +26,18 @@ impl Drop for ConvergingGuard { } } +/// Shared context for convergence operations, avoiding long argument lists. +pub struct ConvergeContext<'a> { + pub container_mgr: &'a ContainerManager, + pub headless_config: &'a HeadlessConfig, + pub config: &'a Config, + pub dirs: &'a QumaDirs, + pub spt_client: &'a SptClient, + pub forge: &'a ForgeClient, + pub converging: &'a Arc, + pub db: &'a Arc>, +} + /// Label key for marking containers as managed by quartermaster pub const MANAGED_BY_LABEL: &str = "quma.managed-by"; pub const MANAGED_BY_VALUE: &str = "quartermaster-clients"; @@ -768,28 +780,24 @@ pub fn find_name_conflicts( /// /// The `converging` flag is an Arc that prevents concurrent convergence /// operations and signals to the supervisor that state is in flux. -#[allow(clippy::too_many_arguments)] #[allow(deprecated)] -pub async fn converge( - container_mgr: &ContainerManager, - headless_config: &HeadlessConfig, - config: &Config, - dirs: &QumaDirs, - spt_client: &SptClient, - forge: &ForgeClient, - _spt_version: &str, - converging: Arc, - db: &Arc>, -) -> Result<()> { +pub async fn converge(ctx: &ConvergeContext<'_>) -> Result<()> { // Set converging flag (atomic compare-exchange for race-free check-and-set) - if converging + if ctx + .converging .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { bail!("Convergence already in progress"); } - let _guard = ConvergingGuard(converging.clone()); + let _guard = ConvergingGuard(ctx.converging.clone()); + + let container_mgr = ctx.container_mgr; + let headless_config = ctx.headless_config; + let dirs = ctx.dirs; + let forge = ctx.forge; + let db = ctx.db; // Legacy layout guard — overlay paths are empty in legacy mode if dirs.is_legacy() { @@ -929,7 +937,9 @@ pub async fn converge( // changes from install/update/remove are visible automatically. if desired_count > 0 { // Ensure Fika.Headless plugin is installed (GitHub-only, not on Forge). - ensure_fika_headless(forge, &dirs.headless_base).await?; + // Write to mod_overlay so it's visible to both SPT server and headless + // overlays without modifying the base headless install directory. + ensure_fika_headless(forge, &dirs.mod_overlay()).await?; } // Look up installed Fika version for Last Version overlay @@ -945,11 +955,7 @@ pub async fn converge( // Scale up or down if current_count < desired_count { ensure_clients( - container_mgr, - headless_config, - config, - dirs, - spt_client, + ctx, current_count, desired_count, ntsync_available, @@ -958,16 +964,7 @@ pub async fn converge( ) .await?; } else if current_count > desired_count { - remove_excess_clients( - container_mgr, - headless_config, - config, - dirs, - spt_client, - current_count, - desired_count, - ) - .await?; + remove_excess_clients(ctx, current_count, desired_count).await?; } else { info!("Already at desired count ({desired_count}), checking for overlay updates"); } @@ -1031,10 +1028,7 @@ pub async fn converge( .with_context(|| format!("failed to remove client {i} for NUMA reconciliation"))?; create_client_container( - container_mgr, - headless_config, - config, - dirs, + ctx, i, profile_id, ntsync_available, @@ -1149,13 +1143,8 @@ async fn warn_active_players(spt_client: &SptClient, reason: &str) { } /// Ensure containers exist from current_count up to desired_count. -#[allow(clippy::too_many_arguments)] async fn ensure_clients( - container_mgr: &ContainerManager, - headless_config: &HeadlessConfig, - config: &Config, - dirs: &QumaDirs, - spt_client: &SptClient, + ctx: &ConvergeContext<'_>, current_count: u32, desired_count: u32, ntsync_available: bool, @@ -1168,46 +1157,47 @@ async fn ensure_clients( { // ponytail: no fika_config_lock here — convergence is serialized by the converging flag, // and the config UI save handler is the only other writer. Race window is narrow. - let fika_path = crate::fika::config::fika_config_path(dirs); + let fika_path = crate::fika::config::fika_config_path(ctx.dirs); let cst = crate::fika::config::read_fika_cst(&fika_path)?; crate::fika::config::set_headless_amount(&cst, desired_count); crate::fika::config::write_fika_cst(&cst, &fika_path)?; } // 2. Restart SPT server to pick up new headless count and generate profiles - let container = config + let container = ctx + .config .server_container .as_deref() .expect("server_container validated by HeadlessConfig::validate"); - warn_active_players(spt_client, "scaling up headless clients").await; + warn_active_players(ctx.spt_client, "scaling up headless clients").await; info!("Stopping SPT server"); - container_mgr + ctx.container_mgr .stop(container) .await .context("failed to stop SPT server for headless config update")?; info!("Starting SPT server"); - container_mgr + ctx.container_mgr .start(container) .await .context("failed to start SPT server after headless config update")?; info!( "Waiting for SPT server to become ready (timeout: {}s)", - headless_config.server_ready_timeout + ctx.headless_config.server_ready_timeout ); - if !await_server_ready(spt_client, headless_config.server_ready_timeout).await { + if !await_server_ready(ctx.spt_client, ctx.headless_config.server_ready_timeout).await { bail!( "SPT server did not become ready within {}s after restart. \ Headless clients will not be started against a half-initialized server. \ Increase headless.server_ready_timeout if your server needs more time to load.", - headless_config.server_ready_timeout + ctx.headless_config.server_ready_timeout ); } // 3. Restart existing containers so they reconnect to the fresh server - restart_running_clients(container_mgr, current_count).await?; + restart_running_clients(ctx.container_mgr, current_count).await?; // 4. Discover available profiles for assignment // Headless profiles are created by the SPT server when it starts with Fika's @@ -1215,28 +1205,19 @@ async fn ensure_clients( // since headless amount was increased), containers are created without PROFILE_ID // and will need a re-scale after the server generates the profiles. let new_count = desired_count - current_count; - let managed = container_mgr + let managed = ctx + .container_mgr .detect_containers_by_label(MANAGED_BY_LABEL, MANAGED_BY_VALUE) .await .unwrap_or_default(); let profile_assignments = - select_profiles_for_assignment(container_mgr, dirs, &managed, new_count).await; + select_profiles_for_assignment(ctx.container_mgr, ctx.dirs, &managed, new_count).await; // 5. Create containers for new clients for (offset, profile_id) in profile_assignments.into_iter().enumerate() { let i = current_count + 1 + offset as u32; - create_client_container( - container_mgr, - headless_config, - config, - dirs, - i, - profile_id, - ntsync_available, - topology, - fika_version, - ) - .await?; + create_client_container(ctx, i, profile_id, ntsync_available, topology, fika_version) + .await?; } Ok(()) @@ -1247,11 +1228,7 @@ async fn ensure_clients( /// In-raid checks are now handled by the CLI `headless delete` command before calling /// converge, so this function simply stops and removes excess containers. async fn remove_excess_clients( - container_mgr: &ContainerManager, - headless_config: &HeadlessConfig, - config: &Config, - dirs: &QumaDirs, - spt_client: &SptClient, + ctx: &ConvergeContext<'_>, current_count: u32, desired_count: u32, ) -> Result<()> { @@ -1263,21 +1240,21 @@ async fn remove_excess_clients( info!("Removing container {name}"); // Stop first if running - if container_mgr.is_running(&name).await? { - container_mgr.stop(&name).await?; + if ctx.container_mgr.is_running(&name).await? { + ctx.container_mgr.stop(&name).await?; } - container_mgr.remove_container(&name).await?; + ctx.container_mgr.remove_container(&name).await?; } // Clean up overlay mounts and directories for removed clients for i in (desired_count + 1)..=current_count { // Unmount the overlay before removing the directory tree - if let Err(e) = dirs.headless_overlay_mount(i).unmount() { + if let Err(e) = ctx.dirs.headless_overlay_mount(i).unmount() { warn!("Failed to unmount overlay for client {i}: {e}"); } - let overlay = dirs.headless_overlay(i); + let overlay = ctx.dirs.headless_overlay(i); if overlay.exists() { if let Err(e) = std::fs::remove_dir_all(&overlay) { warn!("Failed to clean overlay dir for client {i}: {e}"); @@ -1291,25 +1268,26 @@ async fn remove_excess_clients( { // ponytail: no fika_config_lock here — convergence is serialized by the converging flag, // and the config UI save handler is the only other writer. Race window is narrow. - let fika_path = crate::fika::config::fika_config_path(dirs); + let fika_path = crate::fika::config::fika_config_path(ctx.dirs); let cst = crate::fika::config::read_fika_cst(&fika_path)?; crate::fika::config::set_headless_amount(&cst, desired_count); crate::fika::config::write_fika_cst(&cst, &fika_path)?; } // 3. Restart SPT server to deregister removed clients - let container = config + let container = ctx + .config .server_container .as_deref() .expect("server_container validated by HeadlessConfig::validate"); - warn_active_players(spt_client, "scaling down headless clients").await; + warn_active_players(ctx.spt_client, "scaling down headless clients").await; info!("Restarting SPT server to deregister removed headless clients"); - container_mgr + ctx.container_mgr .stop(container) .await .context("failed to stop SPT server for client deregistration")?; - container_mgr + ctx.container_mgr .start(container) .await .context("failed to start SPT server after client deregistration")?; @@ -1317,19 +1295,19 @@ async fn remove_excess_clients( // 4. Wait for server readiness before restarting remaining clients info!( "Waiting for SPT server to become ready (timeout: {}s)", - headless_config.server_ready_timeout + ctx.headless_config.server_ready_timeout ); - if !await_server_ready(spt_client, headless_config.server_ready_timeout).await { + if !await_server_ready(ctx.spt_client, ctx.headless_config.server_ready_timeout).await { bail!( "SPT server did not become ready within {}s after restart. \ Remaining clients will not be restarted against a half-initialized server. \ Increase headless.server_ready_timeout if your server needs more time to load.", - headless_config.server_ready_timeout + ctx.headless_config.server_ready_timeout ); } // 5. Restart remaining clients so they reconnect to the fresh server - restart_running_clients(container_mgr, desired_count).await?; + restart_running_clients(ctx.container_mgr, desired_count).await?; Ok(()) } @@ -1399,19 +1377,18 @@ fn resolve_numa_cpuset( /// The game directory is mounted via Podman's overlay mount (`:O`), giving each /// container a private writable layer over the shared install_dir. A first-boot /// flow waits for Fika to generate its config before patching and restarting. -#[allow(clippy::too_many_arguments)] -#[allow(deprecated)] async fn create_client_container( - container_mgr: &ContainerManager, - headless_config: &HeadlessConfig, - config: &Config, - dirs: &QumaDirs, + ctx: &ConvergeContext<'_>, index: u32, profile_id: Option, ntsync_available: bool, topology: &NumaTopology, fika_version: Option<&str>, ) -> Result<()> { + let headless_config = ctx.headless_config; + let dirs = ctx.dirs; + let container_mgr = ctx.container_mgr; + let name = client_container_name(index); let overlay_dir = dirs.headless_overlay(index); @@ -1478,12 +1455,12 @@ async fn create_client_container( // Route through quma's HTTPS proxy. // Host-networked containers are on the host's network stack directly, // so use 127.0.0.1 instead of host.containers.internal. - let proxy_host = match config.web_bind.as_str() { + let proxy_host = match ctx.config.web_bind.as_str() { "0.0.0.0" | "127.0.0.1" | "localhost" | "" => "127.0.0.1", other => other, }; env.push(("SERVER_URL".to_string(), proxy_host.to_string())); - env.push(("SERVER_PORT".to_string(), config.web_port.to_string())); + env.push(("SERVER_PORT".to_string(), ctx.config.web_port.to_string())); env.push(("ESYNC".to_string(), headless_config.esync.to_string())); env.push(("FSYNC".to_string(), headless_config.fsync.to_string())); diff --git a/src/client/supervisor.rs b/src/client/supervisor.rs index 9d2521e..84093ca 100644 --- a/src/client/supervisor.rs +++ b/src/client/supervisor.rs @@ -17,6 +17,18 @@ use crate::container::ContainerManager; use crate::spt::headless::{EHeadlessStatus, GetHeadlessesResponse}; use crate::spt::server::SptClient; +/// Shared context for exit watchers, avoiding long argument lists. +/// Config is read dynamically on each loop iteration so restart policy +/// changes take effect without restarting the supervisor. +struct ExitWatcherCtx { + container_mgr: ContainerManager, + state: Arc>>, + watcher_handles: Arc>>, + converging: Arc, + cancel_token: CancellationToken, + config: Arc>, +} + struct RestartingGuard { state: Arc>>, index: u32, @@ -211,21 +223,8 @@ impl ClientSupervisor { if state.container_status == ContainerStatus::Running { let has_watcher = self.watcher_handles.read().await.contains_key(&state.index); if !has_watcher { - ClientSupervisor::spawn_exit_watcher( - self.container_mgr.clone(), - Arc::clone(&self.state), - Arc::clone(&self.watcher_handles), - state.index, - state.container_name.clone(), - headless_config.restart_policy.clone(), - headless_config.max_restart_attempts, - headless_config.restart_backoff_cap, - self.converging.clone(), - self.cancel_token.clone(), - Arc::clone(&self.config), - Arc::clone(&self.db), - ) - .await; + self.spawn_exit_watcher(state.index, state.container_name.clone()) + .await; } } } @@ -407,86 +406,66 @@ impl ClientSupervisor { Ok(state) } - #[allow(clippy::too_many_arguments)] - #[allow(clippy::too_many_arguments)] - async fn spawn_exit_watcher( - container_mgr: ContainerManager, - state: Arc>>, - watcher_handles: Arc>>, - index: u32, - container_name: String, - restart_policy: RestartPolicy, - max_restart_attempts: u32, - backoff_cap: u64, - converging: Arc, - cancel_token: CancellationToken, - config: Arc>, - db: Arc>, - ) { + async fn spawn_exit_watcher(&self, index: u32, container_name: String) { + let ctx = ExitWatcherCtx { + container_mgr: self.container_mgr.clone(), + state: Arc::clone(&self.state), + watcher_handles: Arc::clone(&self.watcher_handles), + converging: self.converging.clone(), + cancel_token: self.cancel_token.clone(), + config: Arc::clone(&self.config), + }; + // Child token: cancelled when either the supervisor shuts down // (cancel_token) or this specific watcher is replaced/removed. - let watcher_cancel = cancel_token.child_token(); + let watcher_cancel = ctx.cancel_token.child_token(); // Register this watcher, cancelling any previous one for the same index { - let mut handles = watcher_handles.write().await; + let mut handles = ctx.watcher_handles.write().await; if let Some(old) = handles.insert(index, watcher_cancel.clone()) { old.cancel(); } } - let watcher_cancel_clone = watcher_cancel.clone(); - let container_mgr_clone = container_mgr.clone(); - let state_clone = Arc::clone(&state); - let watcher_handles_clone = Arc::clone(&watcher_handles); - tokio::spawn(async move { - exit_watcher_loop( - container_mgr_clone, - state_clone, - watcher_handles_clone, - index, - container_name, - restart_policy, - max_restart_attempts, - backoff_cap, - converging, - watcher_cancel_clone, - config, - db, - ) - .await; + exit_watcher_loop(ctx, index, container_name, watcher_cancel).await; }); } } -#[allow(clippy::too_many_arguments)] async fn exit_watcher_loop( - container_mgr: ContainerManager, - state: Arc>>, - watcher_handles: Arc>>, + ctx: ExitWatcherCtx, index: u32, container_name: String, - restart_policy: RestartPolicy, - max_restart_attempts: u32, - backoff_cap: u64, - converging: Arc, cancel_token: CancellationToken, - _config: Arc>, - _db: Arc>, ) { let mut retry_delay = Duration::from_secs(1); let max_retry_delay = Duration::from_secs(30); loop { + // Read restart config fresh each iteration so changes take effect + // without restarting the supervisor. + let (restart_policy, max_restart_attempts, backoff_cap) = { + let cfg = ctx.config.read(); + match cfg.headless { + Some(ref hc) => ( + hc.restart_policy.clone(), + hc.max_restart_attempts, + hc.restart_backoff_cap, + ), + None => (RestartPolicy::Auto, 5, 300), + } + }; + // Watch the container for exit - let mut stream = container_mgr.wait_container(&container_name); + let mut stream = ctx.container_mgr.wait_container(&container_name); let wait_result = tokio::select! { _ = cancel_token.cancelled() => { tracing::debug!(container = %container_name, "Exit watcher cancelled"); // Clean up our handle - watcher_handles.write().await.remove(&index); + ctx.watcher_handles.write().await.remove(&index); return; } result = stream.next() => result, @@ -539,7 +518,7 @@ async fn exit_watcher_loop( container = %container_name, "Exit watcher stream ended unexpectedly" ); - watcher_handles.write().await.remove(&index); + ctx.watcher_handles.write().await.remove(&index); return; } }; @@ -547,7 +526,7 @@ async fn exit_watcher_loop( // Check for OOM kill: the kernel kills the game process but the // entrypoint exits cleanly (code 0). Without this check, OOM kills // look like normal restarts and never trigger backoff. - if is_clean_exit && container_mgr.was_oom_killed(&container_name).await { + if is_clean_exit && ctx.container_mgr.was_oom_killed(&container_name).await { is_clean_exit = false; tracing::warn!( container = %container_name, @@ -557,7 +536,7 @@ async fn exit_watcher_loop( // Update state with exit info let should_restart = { - let mut state_lock = state.write().await; + let mut state_lock = ctx.state.write().await; if let Some(s) = state_lock.iter_mut().find(|s| s.index == index) { s.container_status = ContainerStatus::Stopped; s.health = ClientHealth::Down; @@ -583,7 +562,7 @@ async fn exit_watcher_loop( } }; - if converging.load(Ordering::Relaxed) { + if ctx.converging.load(Ordering::Relaxed) { tracing::debug!( container = %container_name, "Skipping exit-watcher restart (convergence in progress)" @@ -595,7 +574,7 @@ async fn exit_watcher_loop( if !should_restart { let reason = { - let state_lock = state.read().await; + let state_lock = ctx.state.read().await; if let Some(s) = state_lock.iter().find(|s| s.index == index) { if s.manually_stopped { "manually stopped" @@ -611,13 +590,13 @@ async fn exit_watcher_loop( exit_code, "Not restarting ({reason})" ); - watcher_handles.write().await.remove(&index); + ctx.watcher_handles.write().await.remove(&index); return; } // Mark as restarting { - let mut state_lock = state.write().await; + let mut state_lock = ctx.state.write().await; if let Some(s) = state_lock.iter_mut().find(|s| s.index == index) { s.restarting = true; } @@ -625,7 +604,7 @@ async fn exit_watcher_loop( // Get failure count for backoff calculation let failures = { - let state_lock = state.read().await; + let state_lock = ctx.state.read().await; state_lock .iter() .find(|s| s.index == index) @@ -634,7 +613,7 @@ async fn exit_watcher_loop( }; let _guard = RestartingGuard { - state: state.clone(), + state: ctx.state.clone(), index, }; @@ -652,9 +631,9 @@ async fn exit_watcher_loop( // Start the already-stopped container (don't use restart() — // calling stop() on an exited container errors with 304) - match container_mgr.start(&container_name).await { + match ctx.container_mgr.start(&container_name).await { Ok(()) => { - let mut state_lock = state.write().await; + let mut state_lock = ctx.state.write().await; if let Some(s) = state_lock.iter_mut().find(|s| s.index == index) { s.restart_count += 1; s.last_restart = Some(Utc::now()); @@ -674,7 +653,7 @@ async fn exit_watcher_loop( err = %e, "Failed to restart" ); - watcher_handles.write().await.remove(&index); + ctx.watcher_handles.write().await.remove(&index); return; } } diff --git a/src/headless/service.rs b/src/headless/service.rs index 43329c1..b34c3f7 100644 --- a/src/headless/service.rs +++ b/src/headless/service.rs @@ -125,26 +125,23 @@ impl HeadlessService { let dirs = Arc::clone(&self.dirs); let spt_client = self.spt_client(); let forge = self.forge.clone(); - let spt_version = ""; let converging = Arc::clone(&self.converging); let db = Arc::clone(&self.db); let ops = self.operations.clone(); let events = self.events.clone(); tokio::spawn(async move { - match crate::client::converge::converge( - &mgr, - &headless_config, - &config, - &dirs, - &spt_client, - &forge, - spt_version, - converging, - &db, - ) - .await - { + let ctx = crate::client::converge::ConvergeContext { + container_mgr: &mgr, + headless_config: &headless_config, + config: &config, + dirs: &dirs, + spt_client: &spt_client, + forge: &forge, + converging: &converging, + db: &db, + }; + match crate::client::converge::converge(&ctx).await { Ok(()) => { ops.complete(&op_id); let _ = events.send(ServerEvent::HeadlessChanged); @@ -554,7 +551,6 @@ impl HeadlessService { let dirs = Arc::clone(&self.dirs); let spt_client = self.spt_client(); let forge = self.forge.clone(); - let spt_version = ""; let converging = Arc::clone(&self.converging); let db = Arc::clone(&self.db); let ops = self.operations.clone(); @@ -599,19 +595,17 @@ impl HeadlessService { let _ = std::fs::remove_dir_all(&overlay); } - match crate::client::converge::converge( - &mgr, - &updated_config, - &config, - &dirs, - &spt_client, - &forge, - spt_version, - converging, - &db, - ) - .await - { + let ctx = crate::client::converge::ConvergeContext { + container_mgr: &mgr, + headless_config: &updated_config, + config: &config, + dirs: &dirs, + spt_client: &spt_client, + forge: &forge, + converging: &converging, + db: &db, + }; + match crate::client::converge::converge(&ctx).await { Ok(()) => { ops.complete(&op_id); let _ = events.send(ServerEvent::HeadlessChanged); @@ -664,7 +658,6 @@ impl HeadlessService { let dirs = Arc::clone(&self.dirs); let spt_client = self.spt_client(); let forge = self.forge.clone(); - let spt_version = ""; let converging = Arc::clone(&self.converging); let db = Arc::clone(&self.db); let ops = self.operations.clone(); @@ -699,19 +692,17 @@ impl HeadlessService { converging.store(false, std::sync::atomic::Ordering::Release); - match crate::client::converge::converge( - &mgr, - &headless_config, - &config, - &dirs, - &spt_client, - &forge, - spt_version, - converging, - &db, - ) - .await - { + let ctx = crate::client::converge::ConvergeContext { + container_mgr: &mgr, + headless_config: &headless_config, + config: &config, + dirs: &dirs, + spt_client: &spt_client, + forge: &forge, + converging: &converging, + db: &db, + }; + match crate::client::converge::converge(&ctx).await { Ok(()) => { ops.complete(&op_id); let _ = events.send(ServerEvent::HeadlessChanged);