Skip to main content

rbnx/cmd/
shutdown.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx shutdown` — tear down a stack previously brought up by `rbnx boot`.
3//
4// Reads `<manifest-dir>/rbnx-boot/state.json` (written incrementally by
5// boot at every successful spawn), kills each component's process group,
6// and sweeps any docker container boot recorded as a driver host. After
7// teardown the state file is deleted so a stale state can't confuse the
8// next `rbnx shutdown`.
9
10use anyhow::{Context, Result};
11use robonix_cli::output;
12use std::path::PathBuf;
13
14use super::teardown;
15
16pub async fn execute(file: PathBuf) -> Result<()> {
17    let manifest_path = file.canonicalize().with_context(|| {
18        format!(
19            "manifest not found: {} (rbnx shutdown defaults to ./robonix_manifest.yaml; \
20             pass -f to point elsewhere)",
21            file.display()
22        )
23    })?;
24    let manifest_dir = manifest_path
25        .parent()
26        .context("manifest has no parent directory")?
27        .to_path_buf();
28
29    // `rbnx boot` applies the deploy's top-level env before it spawns package
30    // wrappers. Do the same for a separately invoked `rbnx shutdown`, so a
31    // package's manifest `stop:` hook sees the same native/docker/profile
32    // selection as its corresponding `start:` hook.
33    let raw_manifest = std::fs::read_to_string(&manifest_path)
34        .with_context(|| format!("read {}", manifest_path.display()))?;
35    let manifest_value: serde_yaml::Value = serde_yaml::from_str(&raw_manifest)
36        .with_context(|| format!("parse {}", manifest_path.display()))?;
37    super::deploy::prepare_manifest(manifest_value, None)
38        .context("apply top-level deploy env for shutdown hooks")?;
39
40    let state_path = teardown::state_path(&manifest_dir);
41    if !state_path.exists() {
42        anyhow::bail!(
43            "no boot state at {} — has `rbnx boot` been run from this manifest? \
44             (state is written when boot starts spawning components and removed on shutdown.)",
45            state_path.display()
46        );
47    }
48
49    let state = teardown::read_state(&state_path)?;
50    output::action(
51        "Shutting down",
52        &format!(
53            "{} ({} component(s))",
54            state.manifest_path,
55            state.components.len()
56        ),
57    );
58
59    let boot_id = (!state.boot_id.is_empty()).then_some(state.boot_id.as_str());
60    let complete =
61        teardown::teardown(Some(&state.atlas_endpoint), &state.components, boot_id).await;
62    if !complete {
63        anyhow::bail!(
64            "shutdown refused one or more stale/mismatched process groups; preserving {}",
65            state_path.display()
66        );
67    }
68
69    // Best-effort: also signal the boot process itself (which is probably
70    // already dead because we just killed all its children, but if the
71    // user ran `rbnx boot` in the foreground and `rbnx shutdown` from
72    // another shell, this lets boot exit its signal-wait loop cleanly).
73    let boot_identity_matches = state.boot_start_time_ticks.is_none_or(|expected| {
74        robonix_cli::launch::proc_start_time_ticks(state.boot_pid) == Some(expected)
75    });
76    if state.boot_pid != 0 && state.boot_pid != std::process::id() && boot_identity_matches {
77        let pid = nix::unistd::Pid::from_raw(state.boot_pid as i32);
78        let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
79    }
80
81    let _ = std::fs::remove_file(&state_path);
82    output::success(&format!("torn down — removed {}", state_path.display()));
83    Ok(())
84}