diff --git a/crates/system-manager-engine/src/activate.rs b/crates/system-manager-engine/src/activate.rs index fce3d8da..ac6185fb 100644 --- a/crates/system-manager-engine/src/activate.rs +++ b/crates/system-manager-engine/src/activate.rs @@ -10,6 +10,7 @@ use std::collections::HashSet; use std::fs::DirBuilder; use std::io::Seek; use std::path::{Path, PathBuf}; +use std::time::Duration; use std::{fs, io, process}; use thiserror::Error; @@ -102,7 +103,7 @@ impl StateV1 { backup_path.add_extension("v0back"); log::info!( "Create a backup of the v0 state at {}.", - &backup_path.display() + backup_path.display() ); fs::copy(state_file, backup_path)?; Ok(filetree.into()) @@ -128,7 +129,7 @@ impl StateV1 { } } -pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { +pub fn activate(store_path: &StorePath, ephemeral: bool, timeout: &Option) -> Result<()> { log::info!("Activating system-manager profile: {store_path}"); if ephemeral { log::info!("Running in ephemeral mode"); @@ -147,10 +148,10 @@ pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { match etc_files::activate(store_path, old_state.file_tree, ephemeral) { Ok(etc_tree) => { log::info!("Restarting sysinit-reactivation.target..."); - services::restart_sysinit_reactivation_target()?; + services::restart_sysinit_reactivation_target(timeout)?; // Restart userborn before tmpfiles so users exist when tmpfiles runs - if let Err(e) = services::restart_userborn_if_exists() { + if let Err(e) = services::restart_userborn_if_exists(timeout) { log::error!("Error restarting userborn.service: {e}"); } @@ -164,21 +165,22 @@ pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { } log::info!("Activating systemd services..."); - let final_state = match services::activate(store_path, old_state.services, ephemeral) { - Ok(services) => StateV1 { - file_tree: etc_tree, - services, - version: 1, - }, - Err(ActivationError::WithPartialResult { result, source }) => { - log::error!("Error during activation: {source:?}"); - StateV1 { + let final_state = + match services::activate(store_path, old_state.services, ephemeral, timeout) { + Ok(services) => StateV1 { file_tree: etc_tree, - services: result, + services, version: 1, + }, + Err(ActivationError::WithPartialResult { result, source }) => { + log::error!("Error during activation: {source:?}"); + StateV1 { + file_tree: etc_tree, + services: result, + version: 1, + } } - } - }; + }; final_state.write_to_file(state_file)?; if let Err(e) = tmp_result { diff --git a/crates/system-manager-engine/src/activate/etc_files.rs b/crates/system-manager-engine/src/activate/etc_files.rs index 3904d87b..43e77ec8 100644 --- a/crates/system-manager-engine/src/activate/etc_files.rs +++ b/crates/system-manager-engine/src/activate/etc_files.rs @@ -332,13 +332,13 @@ fn create_etc_file( if old_state.contains(&target) { log::debug!( "{} is managed by system-manager. Deleting.", - &target.display() + target.display() ); fs::remove_file(&target).map_err(|e| ActivationError::WithPartialResult { result: state.clone(), source: e.into(), })?; - log::debug!("{} is managed by system-manager.", &target.display()); + log::debug!("{} is managed by system-manager.", target.display()); unix::fs::symlink(file.source.store_path, &target).map_err(|e| { ActivationError::WithPartialResult { result: state.clone(), @@ -422,7 +422,7 @@ fn find_uid(entry: &EtcFile) -> anyhow::Result { || { log::warn!( "Specified user {} not found, defaulting to root", - &entry.user + entry.user ); 0 }, @@ -443,7 +443,7 @@ fn find_gid(entry: &EtcFile) -> anyhow::Result { || { log::warn!( "Specified group {} not found, defaulting to root", - &entry.group + entry.group ); 0 }, diff --git a/crates/system-manager-engine/src/activate/services.rs b/crates/system-manager-engine/src/activate/services.rs index 3b570329..34b5248f 100644 --- a/crates/system-manager-engine/src/activate/services.rs +++ b/crates/system-manager-engine/src/activate/services.rs @@ -62,6 +62,7 @@ pub fn activate( store_path: &StorePath, old_services: Services, ephemeral: bool, + timeout: &Option, ) -> ServiceActivationResult { verify_systemd_dir(ephemeral) .map_err(|e| ActivationError::with_partial_result(old_services.clone(), e))?; @@ -82,7 +83,6 @@ pub fn activate( let job_monitor = service_manager .monitor_jobs_init() .map_err(|e| ActivationError::with_partial_result(old_services.clone(), e))?; - let timeout = Some(Duration::from_secs(30)); // Stop removed services and any masked services that might still be running // (e.g. distro-provided units). Must happen before daemon-reload so systemd @@ -93,7 +93,7 @@ pub fn activate( &service_manager, &job_monitor, stop_services(&service_manager, units_to_stop), - &timeout, + timeout, ) .map_err(|e| ActivationError::with_partial_result(services.clone(), e))?; @@ -102,7 +102,7 @@ pub fn activate( &job_monitor, reload_or_restart_units(&service_manager, convert_services(&services_to_reload)) + start_units(&service_manager, ["system-manager.target"]), - &timeout, + timeout, ) .map_err(|e| ActivationError::with_partial_result(services.clone(), e))?; @@ -228,7 +228,7 @@ fn verify_systemd_dir(ephemeral: bool) -> anyhow::Result<()> { Ok(()) } -pub fn deactivate(old_services: Services) -> ServiceActivationResult { +pub fn deactivate(old_services: Services, timeout: &Option) -> ServiceActivationResult { log::debug!("{:?}", old_services); restore_ephemeral_system_dir() @@ -247,7 +247,6 @@ pub fn deactivate(old_services: Services) -> ServiceActivationResult { let job_monitor = service_manager .monitor_jobs_init() .map_err(|e| ActivationError::with_partial_result(old_services.clone(), e))?; - let timeout = Some(Duration::from_secs(30)); let mut units_to_stop = convert_services(&stoppable); units_to_stop.push("system-manager.target"); @@ -257,7 +256,7 @@ pub fn deactivate(old_services: Services) -> ServiceActivationResult { &service_manager, &job_monitor, stop_services(&service_manager, units_to_stop), - &timeout, + timeout, ) // We consider all jobs stopped now.. .map_err(|e| ActivationError::with_partial_result(im::HashMap::new(), e))?; @@ -397,10 +396,9 @@ impl From for String { } } -pub fn restart_sysinit_reactivation_target() -> anyhow::Result<()> { +pub fn restart_sysinit_reactivation_target(timeout: &Option) -> anyhow::Result<()> { let service_manager = systemd::ServiceManager::new_session()?; let job_monitor = service_manager.monitor_jobs_init()?; - let timeout = Some(Duration::from_secs(30)); log::info!("Reloading the systemd daemon..."); service_manager.daemon_reload()?; @@ -411,14 +409,14 @@ pub fn restart_sysinit_reactivation_target() -> anyhow::Result<()> { "restarting", ); - wait_for_jobs(&service_manager, &job_monitor, jobs, &timeout)?; + wait_for_jobs(&service_manager, &job_monitor, jobs, timeout)?; Ok(()) } /// This must be called after daemon-reload so systemd knows about the unit, /// but before tmpfiles activation since tmpfiles may reference users that /// userborn needs to create. -pub fn restart_userborn_if_exists() -> anyhow::Result<()> { +pub fn restart_userborn_if_exists(timeout: &Option) -> anyhow::Result<()> { let service_manager = systemd::ServiceManager::new_session()?; // Check if userborn.service exists by listing units matching the pattern @@ -431,7 +429,6 @@ pub fn restart_userborn_if_exists() -> anyhow::Result<()> { log::info!("Restarting userborn.service to create users before tmpfiles..."); let job_monitor = service_manager.monitor_jobs_init()?; - let timeout = Some(Duration::from_secs(30)); // We use restart rather than start because userborn is a oneshot service // with RemainAfterExit=true. @@ -441,7 +438,7 @@ pub fn restart_userborn_if_exists() -> anyhow::Result<()> { "restarting", ); - wait_for_jobs(&service_manager, &job_monitor, jobs, &timeout)?; + wait_for_jobs(&service_manager, &job_monitor, jobs, timeout)?; log::info!("userborn.service completed"); Ok(()) } diff --git a/crates/system-manager-engine/src/deactivate.rs b/crates/system-manager-engine/src/deactivate.rs index 3b80f2bc..2633fb24 100644 --- a/crates/system-manager-engine/src/deactivate.rs +++ b/crates/system-manager-engine/src/deactivate.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::time::Duration; use crate::activate::etc_files; use crate::activate::services; @@ -7,7 +8,7 @@ use crate::activate::{get_state_file, ActivationError, StateV1}; /// Deactivates system-manager by locking managed users, removing etc files, /// and stopping systemd services. -pub fn deactivate() -> Result<()> { +pub fn deactivate(timeout: &Option) -> Result<()> { log::info!("Deactivating system-manager"); let state_file = &get_state_file()?; let old_state = StateV1::from_file(state_file)?; @@ -24,7 +25,7 @@ pub fn deactivate() -> Result<()> { match etc_files::deactivate(old_state.file_tree) { Ok(etc_tree) => { log::info!("Deactivating systemd services..."); - match services::deactivate(old_state.services) { + match services::deactivate(old_state.services, timeout) { Ok(services) => StateV1 { file_tree: etc_tree, services, diff --git a/crates/system-manager-engine/src/main.rs b/crates/system-manager-engine/src/main.rs index 879a7078..0a81c35e 100644 --- a/crates/system-manager-engine/src/main.rs +++ b/crates/system-manager-engine/src/main.rs @@ -13,6 +13,7 @@ use anyhow::Result; use clap::Parser; use std::process::ExitCode; +use std::time::Duration; use system_manager_engine::{NixOptions, StorePath, PROFILE_DIR}; @@ -41,6 +42,23 @@ struct ActivationArgs { ephemeral: bool, } +#[derive(clap::Args, Debug)] +struct TimeoutArgs { + /// Set the timeout for the action, in seconds. 0 disables the timeout and + /// waits indefinitely for the action to finish + #[arg(short, long, default_value_t = 30u64)] + timeout: u64, +} + +impl From<&TimeoutArgs> for Option { + fn from(timeout_args: &TimeoutArgs) -> Self { + match timeout_args.timeout { + 0 => None, + secs => Some(Duration::from_secs(secs)), + } + } +} + #[derive(clap::Args, Debug)] struct StorePathArg { /// The store path containing the system-manager profile @@ -64,11 +82,15 @@ enum Action { store_path_arg: StorePathArg, #[command(flatten)] activation_args: ActivationArgs, + #[command(flatten)] + timeout_args: TimeoutArgs, }, /// Deactivate the system-manager profile (remove managed configuration) Deactivate { #[command(flatten)] store_path_arg: OptionalStorePathArg, + #[command(flatten)] + timeout_args: TimeoutArgs, }, /// Pre-populate files without starting services Prepopulate { @@ -104,17 +126,23 @@ fn go(args: Args) -> Result<()> { Action::Activate { store_path_arg: StorePathArg { store_path }, activation_args: ActivationArgs { ephemeral }, - } => system_manager_engine::activate::activate(&store_path, ephemeral), + timeout_args, + } => system_manager_engine::activate::activate( + &store_path, + ephemeral, + &Option::::from(&timeout_args), + ), Action::Deactivate { store_path_arg: OptionalStorePathArg { store_path }, + timeout_args, } => { // Log which store path we're using if it was auto-detected if store_path.is_none() { let path = std::path::Path::new(PROFILE_DIR).join("system-manager"); log::info!("No store path provided, using {}", path.display()); } - system_manager_engine::deactivate::deactivate() + system_manager_engine::deactivate::deactivate(&Option::::from(&timeout_args)) } Action::Prepopulate { diff --git a/crates/system-manager/src/main.rs b/crates/system-manager/src/main.rs index 910ab327..c9690889 100644 --- a/crates/system-manager/src/main.rs +++ b/crates/system-manager/src/main.rs @@ -174,6 +174,49 @@ impl From<&BuildArgs> for NixBuildOptions { } } +#[derive(clap::Args, Debug)] +#[group(multiple = false)] +struct TimeoutArgs { + #[arg(long, action)] + /// If set disable timeout; wait indefinitely until for action to finish + no_timeout: bool, + /// Set timeout for action, in seconds. Defaults to the engine's timeout. + #[arg(short, long)] + timeout: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EngineTimeoutArg { + Default, + Disabled, + Seconds(u64), +} + +impl EngineTimeoutArg { + fn push_to(self, args: &mut Vec) { + let value = match self { + Self::Default => return, + Self::Disabled => 0, + Self::Seconds(seconds) => seconds, + }; + + args.push("--timeout".to_string()); + args.push(value.to_string()); + } +} + +impl From<&TimeoutArgs> for EngineTimeoutArg { + fn from(timeout_args: &TimeoutArgs) -> Self { + if timeout_args.no_timeout { + Self::Disabled + } else if let Some(seconds) = timeout_args.timeout { + Self::Seconds(seconds) + } else { + Self::Default + } + } +} + #[derive(clap::Args, Debug)] struct ActivationArgs { #[arg(long, action)] @@ -223,6 +266,8 @@ enum Action { activation_args: ActivationArgs, #[command(flatten)] sudo_args: SudoArgs, + #[command(flatten)] + timeout_args: TimeoutArgs, }, /// Build a new system-manager generation and register it as the active system-manager profile Register { @@ -242,6 +287,8 @@ enum Action { optional_store_path_args: OptionalStorePathArg, #[command(flatten)] sudo_args: SudoArgs, + #[command(flatten)] + timeout_args: TimeoutArgs, }, /// Put all files defined by the given generation in place, but do not start services PrePopulate { @@ -261,6 +308,8 @@ enum Action { activation_args: ActivationArgs, #[command(flatten)] sudo_args: SudoArgs, + #[command(flatten)] + timeout_args: TimeoutArgs, }, } @@ -327,6 +376,7 @@ fn go(args: Args) -> Result<()> { Action::Deactivate { optional_store_path_args: OptionalStorePathArg { maybe_store_path }, sudo_args, + timeout_args, } => { let sudo_options = sudo_args.to_sudo_options(legacy_use_remote_sudo)?; deactivate( @@ -335,6 +385,7 @@ fn go(args: Args) -> Result<()> { &sudo_options, &ssh_options, verbose, + &EngineTimeoutArg::from(&timeout_args), ) } @@ -405,6 +456,7 @@ fn go(args: Args) -> Result<()> { build_args, activation_args: ActivationArgs { ephemeral }, sudo_args, + timeout_args, } => { let mut nix_build_options = NixBuildOptions::from(&build_args); let sudo_options = sudo_args.to_sudo_options(legacy_use_remote_sudo)?; @@ -424,6 +476,7 @@ fn go(args: Args) -> Result<()> { &sudo_options, &ssh_options, verbose, + &EngineTimeoutArg::from(&timeout_args), ) } @@ -431,6 +484,7 @@ fn go(args: Args) -> Result<()> { store_path, activation_args: ActivationArgs { ephemeral }, sudo_args, + timeout_args, } => { let sudo_options = sudo_args.to_sudo_options(legacy_use_remote_sudo)?; copy_closure(&store_path, &target_host, &ssh_options)?; @@ -441,6 +495,7 @@ fn go(args: Args) -> Result<()> { &sudo_options, &ssh_options, verbose, + &EngineTimeoutArg::from(&timeout_args), ) } } @@ -618,9 +673,17 @@ fn deactivate( sudo_options: &SudoOptions, ssh_options: &[String], verbose: bool, + timeout: &EngineTimeoutArg, ) -> Result<()> { let store_path = store_path_or_active_profile(maybe_store_path); - invoke_engine_deactivate(&store_path, target_host, sudo_options, ssh_options, verbose) + invoke_engine_deactivate( + &store_path, + target_host, + sudo_options, + ssh_options, + verbose, + timeout, + ) } // --- Engine invocation functions --- @@ -653,6 +716,7 @@ fn invoke_engine_activate( sudo_options: &SudoOptions, ssh_options: &[String], verbose: bool, + timeout: &EngineTimeoutArg, ) -> Result<()> { let engine_path = store_path.store_path.join("bin").join(ENGINE_BIN); let mut args = vec![ @@ -666,6 +730,7 @@ fn invoke_engine_activate( if verbose { args.push("--verbose".to_string()); } + timeout.push_to(&mut args); invoke_engine(&engine_path, &args, target_host, sudo_options, ssh_options) } @@ -700,6 +765,7 @@ fn invoke_engine_deactivate( sudo_options: &SudoOptions, ssh_options: &[String], verbose: bool, + timeout: &EngineTimeoutArg, ) -> Result<()> { // For deactivate, we need to find the engine in the profile // If we have a specific store path, use it; otherwise use the active profile @@ -717,6 +783,7 @@ fn invoke_engine_deactivate( if verbose { args.push("--verbose".to_string()); } + timeout.push_to(&mut args); invoke_engine(&engine_path, &args, target_host, sudo_options, ssh_options) } @@ -1031,4 +1098,76 @@ mod tests { assert!(args.ssh_options.is_empty()); } + + fn switch_timeout_args(extra: &[&str]) -> EngineTimeoutArg { + let argv = ["system-manager", "switch", "--flake", ".#test"] + .into_iter() + .chain(extra.iter().copied()); + let args = Args::try_parse_from(argv).expect("failed to parse args"); + + match args.action { + Action::Switch { timeout_args, .. } => EngineTimeoutArg::from(&timeout_args), + other => panic!("expected switch, got {other:?}"), + } + } + + #[test] + fn omitted_timeout_uses_the_engine_default() { + let timeout = switch_timeout_args(&[]); + assert_eq!(timeout, EngineTimeoutArg::Default); + + let mut args = vec!["activate".to_string()]; + timeout.push_to(&mut args); + assert_eq!(args, vec!["activate"]); + } + + #[test] + fn no_timeout_conflicts_with_timeout() { + Args::try_parse_from([ + "system-manager", + "switch", + "--flake", + ".#test", + "--no-timeout", + "--timeout", + "5", + ]) + .expect_err("--no-timeout and --timeout should be mutually exclusive"); + } + + #[test] + fn pre_populate_rejects_timeout() { + // pre-populate starts no service, so it must not advertise a timeout + Args::try_parse_from([ + "system-manager", + "pre-populate", + "--flake", + ".#test", + "--timeout", + "5", + ]) + .expect_err("pre-populate should not accept --timeout"); + } + + #[test] + fn no_timeout_is_passed_to_the_engine_as_zero() { + // The engine defaults to 30s, so omitting the flag would silently + // re-enable the timeout instead of disabling it. + let timeout = switch_timeout_args(&["--no-timeout"]); + assert_eq!(timeout, EngineTimeoutArg::Disabled); + + let mut args = vec!["activate".to_string()]; + timeout.push_to(&mut args); + assert_eq!(args, vec!["activate", "--timeout", "0"]); + } + + #[test] + fn explicit_timeout_is_passed_to_the_engine() { + let timeout = switch_timeout_args(&["--timeout", "90"]); + assert_eq!(timeout, EngineTimeoutArg::Seconds(90)); + + let mut args = vec!["activate".to_string()]; + timeout.push_to(&mut args); + assert_eq!(args, vec!["activate", "--timeout", "90"]); + } } diff --git a/docs/site/how-to/test-configuration.md b/docs/site/how-to/test-configuration.md index a267adca..1d672767 100644 --- a/docs/site/how-to/test-configuration.md +++ b/docs/site/how-to/test-configuration.md @@ -89,7 +89,7 @@ The test script has access to these symbols: | Method | Description | |--------|-------------| -| `machine.activate()` | Activate system-manager profile and display output | +| `machine.activate(profile=None, timeout=30)` | Activate a system-manager profile and display output | | `machine.succeed(cmd)` | Run command, fail test if exit code != 0 | | `machine.fail(cmd)` | Run command, fail test if exit code == 0 | | `machine.wait_for_unit(unit)` | Wait for systemd unit to be active | @@ -99,6 +99,19 @@ The test script has access to these symbols: | `machine.execute(cmd)` | Run command, return result (does not fail test) | | `machine.systemctl(args)` | Run systemctl with given arguments | +Pass `timeout` to control how long each systemd job batch may run during activation: + +```python +# Allow slow services up to 90 seconds per job batch. +machine.activate(timeout=90) + +# Wait indefinitely for systemd jobs. +machine.activate(timeout=None) +``` + +The command runner automatically allows enough time for the configured activation timeout. +Passing `0` is equivalent to passing `None`. + ### Testinfra assertions The `machine` object provides testinfra assertions for declarative checks on services, files, and users. diff --git a/docs/site/reference/cli.md b/docs/site/reference/cli.md index 130d8149..4c68a55c 100644 --- a/docs/site/reference/cli.md +++ b/docs/site/reference/cli.md @@ -45,6 +45,17 @@ The `switch` subcommand builds and activates your configuration immediately, mak **--sudo**: Specifies that System Manager can use sudo. +**--timeout SECONDS**: Sets how long each systemd job batch may run during activation. +The default is 30 seconds. +Set the value to `0` to wait indefinitely. + +**--no-timeout**: Waits indefinitely for systemd jobs. +This option cannot be combined with `--timeout`. + +```sh +system-manager switch --flake . --sudo --timeout 90 +``` + ### register The `register` subcommand builds and registers a System Manager configuration, but does not activate it. Compare this to `switch`, which does everything register does, but then activates it. @@ -56,6 +67,7 @@ The `build` subcommand builds everything needed for a switch, but does not regis ### deactivate The `deactivate` deactivates System Manager. +It accepts the same `--timeout SECONDS` and `--no-timeout` options as `switch`. ### pre-populate diff --git a/lib/container-test-driver/test_driver/machine.py b/lib/container-test-driver/test_driver/machine.py index fe93eb64..cccfff0b 100644 --- a/lib/container-test-driver/test_driver/machine.py +++ b/lib/container-test-driver/test_driver/machine.py @@ -20,6 +20,11 @@ from .utils import CONTAINER_PATH, prepare_machine_root, retry +DEFAULT_ACTIVATION_TIMEOUT = 30 +MINIMUM_ACTIVATION_COMMAND_TIMEOUT = 300 +ACTIVATION_COMMAND_GRACE_PERIOD = 30 + + class TestInfraBackendNix(testinfra.backend.base.BaseBackend): """Testinfra backend that uses the container-test-driver Machine to run commands.""" @@ -482,24 +487,48 @@ def succeed(self, command: str, timeout: int | None = None) -> str: raise RuntimeError(msg) return res.stdout - def activate(self, profile: str | None = None) -> str: + def activate( + self, + profile: str | None = None, + timeout: int | None = DEFAULT_ACTIVATION_TIMEOUT, + ) -> str: """Activate system-manager profile and display the output. Args: profile: Path to system-manager profile. If None, uses the profile passed to the container. + timeout: Maximum time in seconds to wait for systemd jobs. None or + zero disables the timeout. """ if profile is None: profile = str(self.profile) if self.profile else None if profile is None: msg = "No profile specified for activation" raise Error(msg) - - activate_cmd = f"RUST_LOG=debug {profile}/bin/activate" + if timeout is not None and timeout < 0: + msg = "Activation timeout cannot be negative" + raise ValueError(msg) + + timeout_seconds = 0 if timeout is None else timeout + timeout_arg = ( + "" + if timeout_seconds == DEFAULT_ACTIVATION_TIMEOUT + else f" --timeout {timeout_seconds}" + ) + activate_path = shlex.quote(f"{profile}/bin/activate") + activate_cmd = f"RUST_LOG=debug {activate_path}{timeout_arg}" + command_timeout = ( + None + if timeout_seconds == 0 + else max( + MINIMUM_ACTIVATION_COMMAND_TIMEOUT, + timeout_seconds + ACTIVATION_COMMAND_GRACE_PERIOD, + ) + ) print(f"\n{Fore.CYAN}=== Activating system-manager ==={Style.RESET_ALL}") print(f"Profile: {profile}") - res = self.execute(activate_cmd, timeout=300) + res = self.execute(activate_cmd, timeout=command_timeout) # Always show the output if res.stdout.strip(): diff --git a/nix/lib.nix b/nix/lib.nix index c204d962..085e96df 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -218,17 +218,29 @@ let node, profile, action, + arguments ? [ ], }: + let + command = lib.escapeShellArgs ([ "${profile}/bin/${action}" ] ++ arguments); + in '' - ${node}.succeed("RUST_LOG=debug ${profile}/bin/${action} 2>&1 | tee /tmp/output.log") + ${node}.succeed("RUST_LOG=debug ${command} 2>&1 | tee /tmp/output.log") ${node}.succeed("! grep -F 'ERROR' /tmp/output.log") ''; activateProfileSnippet = - { node, profile }: + { + node, + profile, + timeout ? 30, + }: self.mkTestPreamble { inherit node profile; action = "activate"; + arguments = lib.optionals (timeout != 30) [ + "--timeout" + (if timeout == null then "0" else toString timeout) + ]; }; deactivateProfileSnippet = { node, profile }: