isothrum-runner 0.11.0

The Isothrum runner: executes the run manifest Isoloom writes next to a target's files (lifecycle, state and locks, drivers, checks, events).
Documentation
//! The launcher's runtime risks (runner-extraction.md ยง6), each pinned by a test that failed on
//! its old behaviour, and the hooks around lifecycle edges.

use super::*;
use crate::hooks::{HookError, Hooks, LabView};
use crate::{BoxFuture, ErrorCode, Park};

#[tokio::test]
async fn a_run_whose_lock_cant_be_taken_never_starts() {
    // R2: the launcher's lab lock failed open, and the operation ran unlocked.
    let f = fixture();
    let lab = f.runner.open(&f.manifest, None).unwrap();
    std::fs::create_dir_all(f.runner.store.lock_path(lab.name())).unwrap();
    let events = Recorder::new();
    let err = lab.up(&opts(), &events).await.unwrap_err();
    assert_eq!(err.code(), ErrorCode::Io);
    assert!(f.fake.calls().is_empty(), "no tool ran: {:?}", f.fake.lines());
    assert!(matches!(kinds(&events).as_slice(), [EventKind::Error { code: ErrorCode::Io, .. }]));
}

#[tokio::test]
async fn a_down_replays_the_params_the_run_was_started_with() {
    // R2: a destroy used the profile's current settings (region, credentials), not the applied ones.
    let f = fixture();
    let lab = f.runner.open(&f.manifest, None).unwrap();
    lab.up(&opts(), &Recorder::new()).await.unwrap();
    let mut now = RunOptions::default();
    now.params.insert("ISOLOOM_PUBLISH_ADDRESS".into(), "0.0.0.0".into());
    lab.down(&now, &Recorder::new()).await.unwrap();
    let down = f.fake.last(" down --volumes").unwrap();
    assert_eq!(down.env("ISOLOOM_PUBLISH_ADDRESS"), Some("127.0.0.1"));
}

#[tokio::test]
async fn a_pinned_port_another_run_is_picking_is_never_handed_out_twice() {
    // R9: ports were picked, then released before Compose bound them, so two starts could pin
    // the same one. The OS here hands 41873 out twice; the runner's reservation refuses the second.
    let f = fixture();
    let other = crate::RunName::new("other-lab").unwrap();
    assert!(
        f.runner.reserve(&other, 41873, isoloom_manifest::Protocol::Tcp),
        "another run starting meanwhile"
    );
    let lab = f.runner.open(&f.manifest, None).unwrap();
    let run = lab.up(&opts(), &Recorder::new()).await.unwrap();
    assert_eq!(run.outputs[0].value.as_deref(), Some("http://127.0.0.1:41874/"));
}

#[tokio::test]
async fn a_port_held_on_the_host_is_refused_before_anything_starts() {
    let f = fixture();
    f.ports.take(8080, isoloom_manifest::Protocol::Tcp);
    let lab = f.runner.open(&f.manifest, None).unwrap();
    let mut fixed = opts();
    fixed.params.insert("ISOLOOM_PUBLISH_FIXED".into(), "1".into());
    let err = lab.up(&fixed, &Recorder::new()).await.unwrap_err();
    assert_eq!(err.to_string(), "host port 8080/tcp is in use");
    assert_eq!(f.called(" up --detach "), 0);
}

#[tokio::test]
async fn a_run_recorded_elsewhere_is_refused() {
    let f = fixture();
    let lab = f.runner.open(&f.manifest, None).unwrap();
    let mut here = opts();
    here.host_label = Some("pve-lab".into());
    lab.up(&here, &Recorder::new()).await.unwrap();
    here.host_label = Some("other-server".into());
    assert_eq!(lab.down(&here, &Recorder::new()).await.unwrap_err().code(), ErrorCode::RunningElsewhere);
}

/// Hooks that record their calls, failing after `up`.
#[derive(Default)]
struct Sidecar(std::sync::Mutex<Vec<String>>);

impl Sidecar {
    fn note(&self, what: &str, lab: &LabView<'_>) {
        self.0
            .lock()
            .unwrap()
            .push(format!("{what} {} {}", lab.name, lab.networks[0].native.clone().unwrap_or_default()));
    }
}

impl Hooks for Sidecar {
    fn after_up<'a>(&'a self, lab: &'a LabView<'a>) -> BoxFuture<'a, Result<(), HookError>> {
        self.note("after_up", lab);
        Box::pin(async { Err(HookError::from("the attack box didn't start")) })
    }

    fn before_park<'a>(&'a self, lab: &'a LabView<'a>) -> BoxFuture<'a, Result<(), HookError>> {
        self.note("before_park", lab);
        Box::pin(async { Ok(()) })
    }

    fn after_resume<'a>(&'a self, lab: &'a LabView<'a>) -> BoxFuture<'a, Result<(), HookError>> {
        self.note("after_resume", lab);
        Box::pin(async { Ok(()) })
    }

    fn before_down<'a>(&'a self, lab: &'a LabView<'a>) -> BoxFuture<'a, Result<(), HookError>> {
        self.note("before_down", lab);
        Box::pin(async { Ok(()) })
    }
}

#[tokio::test]
async fn hooks_go_around_the_lifecycle_and_their_failure_is_a_warning() {
    let mut f = fixture();
    let sidecar = Arc::new(Sidecar::default());
    let hooks: Arc<dyn Hooks> = Arc::<Sidecar>::clone(&sidecar);
    f.runner.add_hooks(hooks);
    let lab = f.runner.open(&f.manifest, None).unwrap();
    let events = Recorder::new();
    lab.up(&opts(), &events).await.unwrap();
    assert!(
        kinds(&events).iter().any(
            |k| matches!(k, EventKind::Warning { code, message } if *code == crate::WarningCode::HookFailed && message.contains("after_up: the attack box"))
        )
    );
    lab.park(Park::Shutdown, &Recorder::new()).await.unwrap();
    lab.resume(&RunOptions::default(), &Recorder::new()).await.unwrap();
    lab.down(&RunOptions::default(), &Recorder::new()).await.unwrap();
    let calls = sidecar.0.lock().unwrap().clone();
    let expected = ["after_up", "before_park", "after_resume", "before_down"].map(|e| format!("{e} flag-portal flag-portal_lab"));
    assert_eq!(calls, expected);
}