From 61f327a004f9c81f63de2e1f977debacc85a11e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:28:51 +0200 Subject: [PATCH] feat: capture early-boot logs to a configurable path Buffer early-boot stdout/stderr in RAM with byte and line limits, tee raw bytes to the console, and flush to earlyBoot.logsPath after the script exits so NVMe /data migration still lands on the final mount. Harden the reader against invalid UTF-8 and unterminated lines, fsync the log file, record terminating signals in the header, and best-effort persist on fatal early-boot via --early-boot-logs-path or an existing config peek. Co-authored-by: Cursor --- Cargo.toml | 1 + docs/architecture.md | 2 + docs/configuration.md | 7 + docs/operator.md | 10 +- examples/microinit.json.example | 4 + man/man5/microinit.json.5.mdoc | 37 +++- man/man8/early-boot.sh.8.mdoc | 33 ++++ man/man8/microinit.8.mdoc | 12 ++ src/config.rs | 85 +++++++++ src/constants.rs | 7 + src/early_boot.rs | 319 ++++++++++++++++++++++++++++---- src/init.rs | 134 +++++++++++++- src/main.rs | 7 + src/supervisor.rs | 7 + tests/config_test.rs | 99 ++++++++++ tests/early_boot_test.rs | 284 +++++++++++++++++++++++++++- tests/init_opts_test.rs | 1 + tests/supervisor_test.rs | 3 +- 18 files changed, 1002 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2182129..ac0f1d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "microinit" version = "0.1.0" edition = "2021" +rust-version = "1.87" description = "PID 1 init system and service supervisor for BigFred OS" license = "MIT" authors = ["BigFred"] diff --git a/docs/architecture.md b/docs/architecture.md index 18822ee..af197f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,6 +116,8 @@ Mount policy therefore lives in a script / distro overlay, not hard-coded in Rus **Configuration is always loaded (or re-loaded) from disk only after early-boot returns**, so seeding of `$DATA_DIR/etc/microinit.json` and drop-ins by the script is visible to the supervisor. microinit does not create the config file before the script runs (that would race with mounting `/data`). +Script stdout/stderr are teed live as raw bytes (the child sees a pipe, so `isatty` is false) and captured into a bounded RAM buffer. The buffer is flushed to `earlyBoot.logsPath` after the script exits when `earlyBoot.captureLogs` is true. If the script fails before JSON can be loaded, the buffer is still written when `--early-boot-logs-path` is set or an existing live/image config enables capture. + --- ## Late unmount (shutdown) diff --git a/docs/configuration.md b/docs/configuration.md index b920a0d..2e2c7cf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,10 @@ You do not have to put every service in one big `microinit.json`. Extra JSON und "logToFiles": false, "dir": "/data/logs" }, + "earlyBoot": { + "captureLogs": false, + "logsPath": "/var/log/early-boot.log" + }, "services": [] } ``` @@ -83,6 +87,8 @@ You do not have to put every service in one big `microinit.json`. Extra JSON und | `logs.tty` | Service logs (init mode) | | `logs.initTty` | microinit’s own messages | | `logs.logToFiles` | If `true`, also files under `$DATA_DIR/logs/` | +| `earlyBoot.captureLogs` | If `true`, write the RAM-buffered early-boot script output to `earlyBoot.logsPath` after the script exits. Does **not** skip early-boot. Default `false`. The file is `fsync`ed. If early-boot fails before this JSON is loaded, microinit still tries `--early-boot-logs-path`, then an existing live config, then the image JSON next to `early-boot.sh`. | +| `earlyBoot.logsPath` | Absolute path for that file (default `/var/log/early-boot.log`). Opened only after early-boot returns, so a script that remounts `$DATA_DIR` (NVMe migration) still writes to the final mount. Must sit on a filesystem the script left writable — the root is typically remounted read-only. | | `openTelemetry` | Optional metrics (see README); also `$DATA_DIR/etc/otel.env` | Most operators only edit **`services`**. @@ -211,6 +217,7 @@ Requires **microinit restart** (on PID 1 hosts: reboot): still be able to open a `0660` socket for that group (put the intended socket-group owner first). - `logs.*` (TTYs, `logToFiles`, buffer size) +- `earlyBoot.*` (capture is applied once at boot, after the script has already run) - `console` --- diff --git a/docs/operator.md b/docs/operator.md index 15569be..b02dee5 100644 --- a/docs/operator.md +++ b/docs/operator.md @@ -285,11 +285,12 @@ microinit start --force redis # debugging only ## Boot sequence (init mode as PID 1) 1. Kernel starts `/sbin/init` (microinit). -2. **Early-boot** (mount `/data`, seed config, …). +2. **Early-boot** (mount `/data`, seed config, …). stdout/stderr are teed live to the console (raw bytes; the script sees a pipe, not a TTY) and buffered in RAM (bounded). 3. Config loaded from disk. -4. Enabled services start in topological order (`dependsOn` hard edges; among ready services lower `orderPriority` first, then name). `background: true` services are started first (in that order), then foreground sequentially. Details: [Service ordering](configuration.md#service-ordering). -5. Console `[ OK ]` / `[ FAIL ]`; getty. -6. IPC socket; JSON files watched for reload. +4. If `earlyBoot.captureLogs` is true, the buffer is truncate-written and `fsync`ed to `earlyBoot.logsPath` (best-effort; a write failure is a warning, not a boot abort). If early-boot itself fails, the same write is attempted via `--early-boot-logs-path` or an existing JSON with `captureLogs: true` before aborting. +5. Enabled services start in topological order (`dependsOn` hard edges; among ready services lower `orderPriority` first, then name). `background: true` services are started first (in that order), then foreground sequentially. Details: [Service ordering](configuration.md#service-ordering). +6. Console `[ OK ]` / `[ FAIL ]`; getty. +7. IPC socket; JSON files watched for reload. On shutdown in **`init`** mode (`shutdown -r`, IPC `shutdown`, SIGTERM, …): services stop in **reverse** of that start order, then the **unmount** script runs (unbind mounts / umount `/data`), then reboot or power-off. @@ -303,6 +304,7 @@ In **`supervise`** mode there is no early-boot, getty, late unmount, or machine | `/dev/tty3` | microinit messages | | `microinit logs …` | Same via socket | | `$DATA_DIR/logs/` | Files when `logs.logToFiles: true` | +| `earlyBoot.logsPath` | Early-boot script stdout/stderr when `earlyBoot.captureLogs: true` (one file per boot, truncated) | --- diff --git a/examples/microinit.json.example b/examples/microinit.json.example index 265fad5..5c8356d 100644 --- a/examples/microinit.json.example +++ b/examples/microinit.json.example @@ -7,6 +7,10 @@ "dir": "/data/logs", "logToFiles": false }, + "earlyBoot": { + "captureLogs": false, + "logsPath": "/var/log/early-boot.log" + }, "socket": "/data/run/microinit.sock", "console": "/dev/tty1", "services": [ diff --git a/man/man5/microinit.json.5.mdoc b/man/man5/microinit.json.5.mdoc index 9de7926..9f4e78c 100644 --- a/man/man5/microinit.json.5.mdoc +++ b/man/man5/microinit.json.5.mdoc @@ -40,6 +40,39 @@ files when .Cm logs.logToFiles is true (default .Pa /data/logs ) +.It Cm earlyBoot.captureLogs +If true, write the RAM-buffered early-boot script output to +.Cm earlyBoot.logsPath +after the script exits +.Pq default false . +Does +.Em not +skip early-boot; the script stdout/stderr are always teed live to the console +as raw bytes and always buffered in RAM +.Pq bounded by line count and total bytes . +The on-disk write is followed by +.Xr fsync 2 . +If the script fails before this file is loaded, capture is still persisted +when +.Nm microinit Cm init Fl -early-boot-logs-path +is set, or when an existing copy of this file +.Pq or the image JSON next to +.Pa early-boot.sh +.Pc +has +.Cm captureLogs +true. +.It Cm earlyBoot.logsPath +Absolute path for the captured early-boot log +.Pq default +.Pa /var/log/early-boot.log . +Opened only after the script returns, so a distro overlay that remounts +.Pa $DATA_DIR +.Pq for example NVMe migration +still writes to the final mount. +The path must sit on a filesystem the early-boot script mounted and left +writable; the root filesystem is typically remounted read-only before the +script exits. .It Cm socket Unix control socket path .It Cm console @@ -71,8 +104,10 @@ then applies Changes to these JSON files are picked up via Linux inotify .Pq hot-reload without restarting microinit ; .Cm socket -and +, .Cm logs.* +, and +.Cm earlyBoot.* changes require a process restart. .Ss Service fields .Bl -tag -width successExitCodes diff --git a/man/man8/early-boot.sh.8.mdoc b/man/man8/early-boot.sh.8.mdoc index 82b18c7..e97b5cd 100644 --- a/man/man8/early-boot.sh.8.mdoc +++ b/man/man8/early-boot.sh.8.mdoc @@ -60,6 +60,39 @@ Product images may install with a distro-specific script .Pq for example BigFred OS also fscks the Pa /data candidates, mounts Pa /data , seeds configs, bind-mounts Pa /etc/shadow or an override under the data root; otherwise the embedded portable script runs. +.Pp +stdout and stderr of the script are teed live to the +.Nm microinit +console +.Pq kernel serial / HDMI on PID 1 +as raw bytes and captured into a bounded RAM buffer +.Pq line count and total bytes . +Invalid UTF-8 is replaced in the on-disk copy and does not stop capture. +The child sees a pipe, not a TTY: +.Fn isatty +on stdout/stderr is false, which may change the output of tools that detect a +terminal. +The buffer is flushed to +.Cm earlyBoot.logsPath +only after the script exits +.Pq and only when Cm earlyBoot.captureLogs is true , +so a script that migrates or remounts the log target is safe. +The write is followed by +.Xr fsync 2 +so a hard power cut shortly after boot still leaves the file. +If early-boot fails before configuration is loaded, microinit still tries to +write the buffer +.Pq +.Nm microinit Cm init Fl -early-boot-logs-path , +an existing live +.Pa microinit.json +with +.Cm captureLogs +true, or the image JSON next to this script +.Pc ; +if +.Pa /data +never mounted, that write fails and only the console copy remains. .Ss Environment .Bl -tag -width MICROINIT_INIT_LOGS_TTY .It Ev DATA_DIR diff --git a/man/man8/microinit.8.mdoc b/man/man8/microinit.8.mdoc index e194def..72b85de 100644 --- a/man/man8/microinit.8.mdoc +++ b/man/man8/microinit.8.mdoc @@ -15,6 +15,7 @@ .Op Fl -no-early-boot .Op Fl -allow-no-early-boot .Op Fl -log-to-files +.Op Fl -early-boot-logs-path Ns = Ns Ar path .Nm .Op Fl -socket Ns = Ns Ar path .Cm supervise @@ -145,6 +146,17 @@ is off by default or .Cm logs.logToFiles true . .Pp +.Fl -early-boot-logs-path +is a fallback used only when early-boot fails before +.Xr microinit.json 5 +can be loaded. +PID 1 otherwise peeks an existing live or image JSON for +.Cm earlyBoot.logsPath +when +.Cm captureLogs +is true. +A write failure is logged and does not change the fatal early-boot error. +.Pp During boot, operational messages are written both to the console/stderr and to .Fl -init-logs-tty . After diff --git a/src/config.rs b/src/config.rs index 28d0340..573262d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ pub const DEFAULT_INIT_LOGS_TTY: &str = "/dev/tty3"; pub const DEFAULT_LOG_LINES: usize = 300; pub const DEFAULT_EARLY_BOOT: &str = "/etc/microinit/early-boot.sh"; pub const DEFAULT_UNMOUNT: &str = "/etc/microinit/unmount.sh"; +pub const DEFAULT_EARLY_BOOT_LOGS_PATH: &str = "/var/log/early-boot.log"; /// Hub-default config path (`/data/etc/...` when data root is unset). /// Prefer [`default_config_path`] which honors `DATA_DIR`. @@ -120,6 +121,78 @@ impl LogsConfig { } } +/// Early-boot phase options. Applied at boot only (never hot-reloaded): +/// the script has already run by the time this file is read. +/// +/// Script stdout/stderr are always buffered in RAM (bounded). [`Self::capture_logs`] +/// only gates the truncate-write to [`Self::logs_path`] after the script exits, +/// so a distro overlay that remounts `$DATA_DIR` (for example NVMe migration) +/// still lands the file on the final mount. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EarlyBootConfig { + /// Persist the buffered early-boot script output to [`Self::logs_path`]. + /// Output is always buffered in RAM; this only gates the disk write. + #[serde(default)] + pub capture_logs: bool, + #[serde(default = "default_early_boot_logs_path")] + pub logs_path: String, +} + +fn default_early_boot_logs_path() -> String { + DEFAULT_EARLY_BOOT_LOGS_PATH.to_string() +} + +impl Default for EarlyBootConfig { + fn default() -> Self { + Self { + capture_logs: false, + logs_path: default_early_boot_logs_path(), + } + } +} + +/// Result of reading `earlyBoot` from an existing JSON file without creating +/// or validating the rest of the config. Used when early-boot failed and we +/// must not seed a default `microinit.json`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EarlyBootCapturePeek { + /// Path does not exist, is unreadable, or is not JSON. + Absent, + /// File exists and `captureLogs` is false (or `logsPath` is empty). + Disabled, + /// `captureLogs` is true; persist to this path. + Enabled(PathBuf), +} + +/// Read only the `earlyBoot` object from `config_path`. Never creates files. +#[must_use] +pub fn peek_early_boot_capture(config_path: &Path) -> EarlyBootCapturePeek { + if !config_path.is_file() { + return EarlyBootCapturePeek::Absent; + } + let Ok(data) = fs::read_to_string(config_path) else { + return EarlyBootCapturePeek::Absent; + }; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Peek { + #[serde(default)] + early_boot: EarlyBootConfig, + } + let Ok(peek) = serde_json::from_str::(&data) else { + return EarlyBootCapturePeek::Absent; + }; + if !peek.early_boot.capture_logs { + return EarlyBootCapturePeek::Disabled; + } + let p = peek.early_boot.logs_path.trim(); + if p.is_empty() { + return EarlyBootCapturePeek::Disabled; + } + EarlyBootCapturePeek::Enabled(PathBuf::from(p)) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct LivenessProbe { @@ -465,6 +538,8 @@ pub struct Config { pub version: u32, #[serde(default)] pub logs: LogsConfig, + #[serde(default)] + pub early_boot: EarlyBootConfig, #[serde(default = "default_socket")] pub socket: String, #[serde(default = "default_console")] @@ -496,6 +571,7 @@ impl Default for Config { Self { version: 1, logs: LogsConfig::default(), + early_boot: EarlyBootConfig::default(), socket: default_socket(), console: default_console(), socket_allow_users: Vec::new(), @@ -507,6 +583,14 @@ impl Default for Config { impl Config { pub fn validate(&self) -> Result<()> { + if self.early_boot.capture_logs { + let p = self.early_boot.logs_path.trim(); + if p.is_empty() || !Path::new(p).is_absolute() { + return Err(Error::Config( + "earlyBoot.logsPath must be an absolute path".into(), + )); + } + } let mut names = std::collections::HashSet::new(); for svc in &self.services { if svc.name.is_empty() { @@ -848,6 +932,7 @@ pub fn example_config() -> Config { dir: Some(default_logs_dir().display().to_string()), log_to_files: false, }, + early_boot: EarlyBootConfig::default(), socket: DEFAULT_SOCKET.to_string(), console: DEFAULT_CONSOLE.to_string(), socket_allow_users: Vec::new(), diff --git a/src/constants.rs b/src/constants.rs index c90624d..cd9479b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -46,3 +46,10 @@ pub const TERMINATE_POLL: Duration = Duration::from_millis(100); pub const EVENT_RING_CAP: usize = 16; /// How many recent lifecycle events `describe` returns (= ring capacity). pub const EVENT_RETURN: usize = EVENT_RING_CAP; +/// Max early-boot script lines retained for `earlyBoot.logsPath` (bounded RAM on PID 1). +pub const MAX_EARLY_BOOT_CAPTURE_LINES: usize = 2000; +/// Total captured-text budget (UTF-8 bytes of retained lines) on PID 1. +pub const MAX_EARLY_BOOT_CAPTURE_BYTES: usize = 256 * 1024; +/// Max single captured line length before truncation. Enforced while reading so a +/// line without a newline cannot grow without bound. +pub const MAX_EARLY_BOOT_LINE_BYTES: usize = 4096; diff --git a/src/early_boot.rs b/src/early_boot.rs index b4d5e90..b92386e 100644 --- a/src/early_boot.rs +++ b/src/early_boot.rs @@ -4,12 +4,27 @@ //! 1. override under the data root (`$DATA_DIR/etc/microinit/early-boot.sh`) //! 2. `/etc/microinit/early-boot.sh` //! 3. portable script embedded in this binary (`scripts/early-boot.sh`) +//! +//! stdout and stderr are teed live to this process's stderr (kernel console on +//! PID 1) as raw bytes and captured into a bounded RAM buffer. The buffer is +//! flushed to `earlyBoot.logsPath` only **after** the script exits, so a distro +//! overlay that remounts `$DATA_DIR` (NVMe migration) still writes to the final +//! mount. -use std::io::Write; +use std::collections::VecDeque; +use std::fs::{self, OpenOptions}; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::thread; + +use chrono::Utc; use crate::config::Paths; +use crate::constants::{ + MAX_EARLY_BOOT_CAPTURE_BYTES, MAX_EARLY_BOOT_CAPTURE_LINES, MAX_EARLY_BOOT_LINE_BYTES, +}; use crate::datadir; use crate::error::{Error, Result}; @@ -25,6 +40,29 @@ pub enum ScriptSource { Embedded, } +/// Bounded capture of early-boot script stdout+stderr. +#[derive(Debug, Clone, Default)] +pub struct EarlyBootOutput { + pub lines: Vec, + /// Lines evicted because the line or byte capture limit was hit. + pub dropped: usize, + /// False when the pipe could not be created and stdio was inherited. + pub captured: bool, + /// Script source label for the on-disk header (`path` or `embedded`). + pub source: String, + /// Child exit code, or `None` if the process was killed by a signal / wait failed. + pub exit_code: Option, + /// Terminating signal when [`Self::exit_code`] is `None` because of a signal. + /// `None` together with `exit_code: None` means `wait` failed. + pub signal: Option, +} + +#[derive(Debug, Default)] +struct Collected { + lines: Vec, + dropped: usize, +} + /// Resolve early-boot script: data-root override, then `/etc`, then embedded. #[must_use] pub fn resolve_script(paths: &Paths) -> ScriptSource { @@ -42,7 +80,15 @@ pub fn resolve_script(paths: &Paths) -> ScriptSource { /// Does **not** create `$DATA_DIR/etc` beforehand: that path may sit on an /// unmounted mountpoint; the script itself mounts `/data` and seeds configs. /// Callers must load `microinit.json` **after** this returns successfully. -pub fn run(paths: &Paths, logs_tty: &str, init_logs_tty: &str, console: &str) -> Result<()> { +/// +/// The [`EarlyBootOutput`] is populated even when the script fails, so a +/// `--allow-no-early-boot` boot can still persist the captured lines. +pub fn run( + paths: &Paths, + logs_tty: &str, + init_logs_tty: &str, + console: &str, +) -> (EarlyBootOutput, Result<()>) { match resolve_script(paths) { ScriptSource::Path(script) => run_script(&script, logs_tty, init_logs_tty, console), ScriptSource::Embedded => { @@ -52,22 +98,20 @@ pub fn run(paths: &Paths, logs_tty: &str, init_logs_tty: &str, console: &str) -> } } -pub fn run_script(script: &Path, logs_tty: &str, init_logs_tty: &str, console: &str) -> Result<()> { +pub fn run_script( + script: &Path, + logs_tty: &str, + init_logs_tty: &str, + console: &str, +) -> (EarlyBootOutput, Result<()>) { let data_root = datadir::root(); - let status = Command::new("/bin/sh") - .arg(script) + let mut cmd = Command::new("/bin/sh"); + cmd.arg(script) .env("MICROINIT_LOGS_TTY", logs_tty) .env("MICROINIT_INIT_LOGS_TTY", init_logs_tty) .env("MICROINIT_CONSOLE", console) - .env(datadir::ENV_DATA_DIR, &data_root) - .status() - .map_err(|e| Error::Other(format!("failed to exec {}: {e}", script.display())))?; - - match status.code() { - Some(0) => Ok(()), - Some(code) => Err(Error::EarlyBoot(code)), - None => Err(Error::EarlyBoot(1)), - } + .env(datadir::ENV_DATA_DIR, &data_root); + run_command(cmd, script.display().to_string()) } /// Run script content via `sh -s` (used for the embedded default). @@ -76,32 +120,243 @@ pub fn run_script_bytes( logs_tty: &str, init_logs_tty: &str, console: &str, -) -> Result<()> { +) -> (EarlyBootOutput, Result<()>) { let data_root = datadir::root(); - let mut child = Command::new("/bin/sh") - .arg("-s") + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-s") .stdin(Stdio::piped()) .env("MICROINIT_LOGS_TTY", logs_tty) .env("MICROINIT_INIT_LOGS_TTY", init_logs_tty) .env("MICROINIT_CONSOLE", console) - .env(datadir::ENV_DATA_DIR, &data_root) - .spawn() - .map_err(|e| Error::Other(format!("failed to exec /bin/sh -s: {e}")))?; + .env(datadir::ENV_DATA_DIR, &data_root); + let capture = try_stdio_capture(&mut cmd); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + return ( + EarlyBootOutput { + source: "embedded".into(), + ..EarlyBootOutput::default() + }, + Err(Error::Other(format!("failed to exec /bin/sh -s: {e}"))), + ); + } + }; + // Close the parent's copies of the capture write ends so the reader sees EOF + // after the child exits. `Command` keeps the original Fds until dropped. + drop(cmd); + let captured = capture.is_some(); + let handle = start_capture(capture); + { + let write_err = match child.stdin.take() { + Some(mut stdin) => stdin + .write_all(script.as_bytes()) + .map_err(|e| Error::Other(format!("failed to write early-boot script: {e}"))) + .err(), + None => Some(Error::Other("failed to open sh stdin".into())), + }; + if let Some(e) = write_err { + let _ = child.kill(); + let _ = child.wait(); + let collected = join_capture(handle); + let out = output_from_collected("embedded".into(), collected, captured); + return (out, Err(e)); + } + } + finish_wait(child, handle, captured, "embedded".into()) +} + +fn run_command(mut cmd: Command, source: String) -> (EarlyBootOutput, Result<()>) { + let capture = try_stdio_capture(&mut cmd); + let child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + return ( + EarlyBootOutput { + source: source.clone(), + ..EarlyBootOutput::default() + }, + Err(Error::Other(format!("failed to exec {source}: {e}"))), + ); + } + }; + // Close the parent's copies of the capture write ends so the reader sees EOF + // after the child exits. `Command` keeps the original Fds until dropped. + // Omitting this drop leaves the pipe open forever and `join_capture` hangs. + drop(cmd); + let captured = capture.is_some(); + let handle = start_capture(capture); + finish_wait(child, handle, captured, source) +} + +/// Attach stdout and stderr to one pipe so interleaving is preserved. Returns +/// the parent read end, or `None` to inherit stdio (capture must never break boot). +fn try_stdio_capture(cmd: &mut Command) -> Option { + let (reader, writer) = io::pipe().ok()?; + let writer2 = writer.try_clone().ok()?; + cmd.stdout(Stdio::from(writer)); + cmd.stderr(Stdio::from(writer2)); + Some(reader) +} + +fn start_capture(capture: Option) -> Option> { + capture.map(|reader| thread::spawn(move || collect_lines(reader))) +} +fn join_capture(handle: Option>) -> Collected { + match handle { + Some(h) => h.join().unwrap_or_default(), + None => Collected::default(), + } +} + +fn finish_wait( + mut child: std::process::Child, + handle: Option>, + captured: bool, + source: String, +) -> (EarlyBootOutput, Result<()>) { + let status = child.wait(); + let collected = join_capture(handle); + let mut out = output_from_collected(source, collected, captured); + match status { + Ok(st) => { + out.exit_code = st.code(); + out.signal = st.signal(); + let result = match st.code() { + Some(0) => Ok(()), + Some(code) => Err(Error::EarlyBoot(code)), + None => Err(Error::EarlyBoot(1)), + }; + (out, result) + } + Err(e) => ( + out, + Err(Error::Other(format!("failed to wait for early-boot: {e}"))), + ), + } +} + +fn output_from_collected(source: String, collected: Collected, captured: bool) -> EarlyBootOutput { + EarlyBootOutput { + lines: collected.lines, + dropped: collected.dropped, + captured, + source, + exit_code: None, + signal: None, + } +} + +/// Read stdout+stderr as bytes. Invalid UTF-8 must not stop the reader: that +/// would fill the pipe and deadlock the script (PID 1 hang). Line length and +/// total buffer size are enforced while reading so a line without `\n` cannot +/// OOM init. Raw chunks are teed to stderr unchanged (console stays a tty-like +/// byte stream even though the child sees a pipe). +fn collect_lines(reader: impl Read) -> Collected { + let mut lines: VecDeque = VecDeque::new(); + let mut dropped = 0usize; + let mut captured_bytes = 0usize; + let mut reader = BufReader::new(reader); + let mut cur: Vec = Vec::with_capacity(256); + let mut overflowed = false; + + loop { + let consumed = { + let chunk = match reader.fill_buf() { + Ok([]) => break, + Ok(c) => c, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + }; + let _ = io::stderr().write_all(chunk); + let n = chunk.len(); + for &b in chunk { + if b == b'\n' { + push_captured_line(&mut lines, &mut dropped, &mut captured_bytes, &cur); + cur.clear(); + overflowed = false; + } else if !overflowed { + if cur.len() >= MAX_EARLY_BOOT_LINE_BYTES { + overflowed = true; + } else { + cur.push(b); + } + } + } + n + }; + reader.consume(consumed); + } + if !cur.is_empty() { + push_captured_line(&mut lines, &mut dropped, &mut captured_bytes, &cur); + } + Collected { + lines: lines.into_iter().collect(), + dropped, + } +} + +fn push_captured_line( + lines: &mut VecDeque, + dropped: &mut usize, + captured_bytes: &mut usize, + raw: &[u8], +) { + let msg = String::from_utf8_lossy(raw).into_owned(); + let add = msg.len(); + while !lines.is_empty() + && (lines.len() >= MAX_EARLY_BOOT_CAPTURE_LINES + || *captured_bytes + add > MAX_EARLY_BOOT_CAPTURE_BYTES) { - let mut stdin = child - .stdin - .take() - .ok_or_else(|| Error::Other("failed to open sh stdin".into()))?; - stdin - .write_all(script.as_bytes()) - .map_err(|e| Error::Other(format!("failed to write early-boot script: {e}")))?; + if let Some(old) = lines.pop_front() { + *captured_bytes = captured_bytes.saturating_sub(old.len()); + *dropped += 1; + } } + lines.push_back(msg); + *captured_bytes += add; +} - match child.wait().map(|s| s.code()) { - Ok(Some(0)) => Ok(()), - Ok(Some(code)) => Err(Error::EarlyBoot(code)), - Ok(None) => Err(Error::EarlyBoot(1)), - Err(e) => Err(Error::Other(format!("failed to wait for early-boot: {e}"))), +fn exit_header(out: &EarlyBootOutput) -> String { + match (out.exit_code, out.signal) { + (Some(c), _) => c.to_string(), + (None, Some(sig)) => format!("signal:{sig}"), + (None, None) => "unknown".into(), + } +} + +/// Truncate-write captured early-boot lines to `path`. Best effort. +/// +/// Called only after the script exits, so `path` resolves against the final +/// mount (NVMe migration may have replaced `/data` mid-script). +pub fn write_captured(path: &Path, out: &EarlyBootOutput) -> Result { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; + } + } + let mut f = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .map_err(|e| Error::io_at(path, e))?; + + let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let exit = exit_header(out); + writeln!(f, "# early-boot ts={ts} source={} exit={exit}", out.source) + .map_err(|e| Error::io_at(path, e))?; + if out.dropped > 0 { + writeln!(f, "... {} earlier line(s) dropped", out.dropped) + .map_err(|e| Error::io_at(path, e))?; + } + for line in &out.lines { + writeln!(f, "{line}").map_err(|e| Error::io_at(path, e))?; } + f.flush().map_err(|e| Error::io_at(path, e))?; + // `/data` is typically mounted with commit=15; this file exists to explain a + // boot that ended in a hard power cut, so page cache is not enough. + f.sync_all().map_err(|e| Error::io_at(path, e))?; + Ok(out.lines.len()) } diff --git a/src/init.rs b/src/init.rs index 8e5f8d9..2221cb2 100644 --- a/src/init.rs +++ b/src/init.rs @@ -33,6 +33,9 @@ pub struct InitOpts { pub require_early_boot: bool, /// Force `logs.logToFiles` on (CLI override; config may also enable it). pub log_to_files: bool, + /// Fallback path for early-boot capture when the config cannot be loaded + /// (script failed before mounting the data root). Best-effort. + pub early_boot_logs_path: Option, /// Spawn getty on the console when PID 1 (full init only). pub spawn_getty: bool, /// Attach service/init TTYs to LogHub (false for container supervise). @@ -54,6 +57,7 @@ impl Default for InitOpts { skip_early_boot: false, require_early_boot: true, log_to_files: false, + early_boot_logs_path: None, spawn_getty: true, attach_ttys: true, socket: crate::config::default_socket_path().display().to_string(), @@ -78,6 +82,7 @@ pub fn supervise_opts( skip_early_boot: true, require_early_boot: false, log_to_files, + early_boot_logs_path: None, spawn_getty: false, attach_ttys: false, socket, @@ -100,6 +105,9 @@ pub fn run(opts: InitOpts) -> Result<()> { ); } + #[cfg(feature = "init")] + let mut early_boot_out: Option = None; + #[cfg(feature = "init")] { if opts.skip_early_boot { @@ -108,23 +116,27 @@ pub fn run(opts: InitOpts) -> Result<()> { "skipping early-boot (--no-early-boot / supervise)", ); } else { - match crate::early_boot::run( + let (out, result) = crate::early_boot::run( &opts.paths, &opts.logs_tty, &opts.init_logs_tty, &opts.console, - ) { + ); + match result { Ok(()) => { boot_note( init_logs_preview, "early-boot finished; loading configuration from disk", ); + early_boot_out = Some(out); } Err(e) => { boot_note(init_logs_preview, &format!("early-boot failed: {e}")); if opts.require_early_boot { + persist_early_boot_logs(init_logs_preview, &opts, &out); return Err(e); } + early_boot_out = Some(out); boot_note( init_logs_preview, "continuing without early-boot; loading configuration from disk", @@ -137,6 +149,7 @@ pub fn run(opts: InitOpts) -> Result<()> { { let _ = opts.skip_early_boot; let _ = opts.require_early_boot; + let _ = &opts.early_boot_logs_path; boot_note( init_logs_preview, "early-boot disabled (supervise-only / no-init build)", @@ -145,7 +158,16 @@ pub fn run(opts: InitOpts) -> Result<()> { // Always (re)load JSON after early-boot: the script mounts `$DATA_DIR` and // may seed/update `microinit.json`, drop-ins, and the enabled-override. - let mut cfg = load_config_after_early_boot(&opts)?; + let mut cfg = match load_config_after_early_boot(&opts) { + Ok(c) => c, + Err(e) => { + #[cfg(feature = "init")] + if let Some(ref out) = early_boot_out { + persist_early_boot_logs(init_logs_preview, &opts, out); + } + return Err(e); + } + }; if let Err(e) = crate::otelenv::load_default() { boot_note( @@ -179,6 +201,8 @@ pub fn run(opts: InitOpts) -> Result<()> { let console = Arc::new(Console::open_with_hub(&opts.console, Some(hub.clone()))); hub.emit_init(LogLevel::Info, "configuration loaded"); + #[cfg(feature = "init")] + flush_early_boot_logs(&hub, &cfg, early_boot_out.as_ref(), opts.skip_early_boot); if opts.attach_ttys { hub.emit_init( LogLevel::Info, @@ -395,6 +419,110 @@ fn load_config_after_early_boot(opts: &InitOpts) -> Result, + skipped: bool, +) { + if !cfg.early_boot.capture_logs { + hub.emit_init(LogLevel::Info, "early-boot log capture disabled"); + return; + } + if skipped { + hub.emit_init( + LogLevel::Info, + "early-boot log capture not applicable (supervise / --no-early-boot)", + ); + return; + } + let Some(out) = captured else { + hub.emit_init( + LogLevel::Warn, + "early-boot log capture enabled but nothing to write", + ); + return; + }; + if !out.captured { + hub.emit_init( + LogLevel::Warn, + format!( + "early-boot log capture enabled but stdio was not captured; skipping {}", + cfg.early_boot.logs_path + ), + ); + return; + } + let path = Path::new(&cfg.early_boot.logs_path); + match crate::early_boot::write_captured(path, out) { + Ok(n) => hub.emit_init( + LogLevel::Info, + format!("early-boot logs written to {} ({n} lines)", path.display()), + ), + Err(e) => hub.emit_init( + LogLevel::Warn, + format!("early-boot logs not written to {}: {e}", path.display()), + ), + } +} + +/// Best-effort persist when we will not reach the normal post-config flush +/// (required early-boot failed, or config load failed). Never creates a +/// default `microinit.json`. +#[cfg(feature = "init")] +fn persist_early_boot_logs( + init_logs_preview: Option<&str>, + opts: &InitOpts, + out: &crate::early_boot::EarlyBootOutput, +) { + if !out.captured { + boot_note( + init_logs_preview, + "early-boot log capture: stdio was not captured; nothing to write", + ); + return; + } + let Some(path) = fatal_early_boot_logs_path(opts) else { + boot_note( + init_logs_preview, + "early-boot logs not written (no --early-boot-logs-path and captureLogs not enabled in an existing config)", + ); + return; + }; + match crate::early_boot::write_captured(&path, out) { + Ok(n) => boot_note( + init_logs_preview, + &format!("early-boot logs written to {} ({n} lines)", path.display()), + ), + Err(e) => boot_note( + init_logs_preview, + &format!("early-boot logs not written to {}: {e}", path.display()), + ), + } +} + +/// CLI path wins; otherwise the live config if it already exists; otherwise +/// the image overlay next to the base early-boot script. Does not seed JSON. +#[cfg(feature = "init")] +fn fatal_early_boot_logs_path(opts: &InitOpts) -> Option { + if let Some(p) = &opts.early_boot_logs_path { + return Some(p.clone()); + } + match config::peek_early_boot_capture(&opts.paths.config) { + config::EarlyBootCapturePeek::Enabled(p) => return Some(p), + config::EarlyBootCapturePeek::Disabled => return None, + config::EarlyBootCapturePeek::Absent => {} + } + let image = opts.paths.early_boot.parent()?.join("microinit.json"); + match config::peek_early_boot_capture(&image) { + config::EarlyBootCapturePeek::Enabled(p) => Some(p), + _ => None, + } +} + fn handle_ipc( req: Request, stream: &mut UnixStream, diff --git a/src/main.rs b/src/main.rs index 8168e63..8cba0ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,9 @@ enum Commands { /// Append service/init logs to files under logs.dir (overrides config logToFiles) #[arg(long)] log_to_files: bool, + /// Fallback path for early-boot capture if the script fails before config load + #[arg(long)] + early_boot_logs_path: Option, }, /// Run as container / host supervisor (no early-boot, no getty, no TTYs) Supervise { @@ -158,6 +161,7 @@ fn default_init_opts(socket: String) -> init::InitOpts { skip_early_boot: false, require_early_boot: true, log_to_files: false, + early_boot_logs_path: None, spawn_getty: true, attach_ttys: true, socket, @@ -225,6 +229,7 @@ fn main() -> ExitCode { no_early_boot: false, allow_no_early_boot: false, log_to_files: false, + early_boot_logs_path: None, }, None => { eprintln!("microinit: missing subcommand (try --help)"); @@ -242,6 +247,7 @@ fn main() -> ExitCode { no_early_boot, allow_no_early_boot, log_to_files, + early_boot_logs_path, } => init::run(init::InitOpts { logs_tty, init_logs_tty, @@ -250,6 +256,7 @@ fn main() -> ExitCode { skip_early_boot: no_early_boot, require_early_boot: !allow_no_early_boot && !no_early_boot, log_to_files, + early_boot_logs_path, spawn_getty: true, attach_ttys: true, socket, diff --git a/src/supervisor.rs b/src/supervisor.rs index 418f782..6d19bae 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -829,6 +829,13 @@ impl Supervisor { "reload: logs.* change ignored (restart microinit required)", ); } + if old.early_boot != new_cfg.early_boot { + self.hub.emit( + INIT_SERVICE, + LogLevel::Warn, + "reload: earlyBoot change ignored (restart microinit required)", + ); + } if old.console != new_cfg.console { self.hub.emit( INIT_SERVICE, diff --git a/tests/config_test.rs b/tests/config_test.rs index ac05a05..4c2465a 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -572,3 +572,102 @@ fn security_context_rejects_empty_user() { let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("runAsUser"), "{err}"); } + +#[test] +fn early_boot_defaults_when_section_missing() { + let cfg: Config = serde_json::from_str(r#"{"version":1,"services":[]}"#).unwrap(); + assert!(!cfg.early_boot.capture_logs); + assert_eq!(cfg.early_boot.logs_path, DEFAULT_EARLY_BOOT_LOGS_PATH); + cfg.validate().unwrap(); +} + +#[test] +fn early_boot_round_trip_camel_case() { + let raw = r#"{ + "earlyBoot": { + "captureLogs": true, + "logsPath": "/data/early-boot.log" + }, + "services": [] + }"#; + let cfg: Config = serde_json::from_str(raw).unwrap(); + cfg.validate().unwrap(); + assert!(cfg.early_boot.capture_logs); + assert_eq!(cfg.early_boot.logs_path, "/data/early-boot.log"); + let dumped = serde_json::to_value(&cfg).unwrap(); + assert_eq!(dumped["earlyBoot"]["captureLogs"], true); + assert_eq!(dumped["earlyBoot"]["logsPath"], "/data/early-boot.log"); +} + +#[test] +fn early_boot_validate_rejects_relative_logs_path() { + let mut cfg = Config::default(); + cfg.early_boot.capture_logs = true; + cfg.early_boot.logs_path = "early-boot.log".into(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("earlyBoot.logsPath"), "{err}"); +} + +#[test] +fn early_boot_validate_rejects_empty_logs_path() { + let mut cfg = Config::default(); + cfg.early_boot.capture_logs = true; + cfg.early_boot.logs_path = " ".into(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("earlyBoot.logsPath"), "{err}"); +} + +#[test] +fn early_boot_relative_path_ok_when_capture_disabled() { + let mut cfg = Config::default(); + cfg.early_boot.logs_path = "early-boot.log".into(); + cfg.validate().unwrap(); +} + +#[test] +fn peek_early_boot_capture_absent_missing_file() { + let path = PathBuf::from("/tmp/microinit-does-not-exist-early-boot-peek.json"); + assert_eq!(peek_early_boot_capture(&path), EarlyBootCapturePeek::Absent); +} + +#[test] +fn peek_early_boot_capture_disabled_and_enabled() { + let dir = std::env::temp_dir().join(format!( + "microinit-eb-peek-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let disabled = dir.join("disabled.json"); + fs::write(&disabled, r#"{"version":1,"services":[]}"#).unwrap(); + assert_eq!( + peek_early_boot_capture(&disabled), + EarlyBootCapturePeek::Disabled + ); + + let enabled = dir.join("enabled.json"); + fs::write( + &enabled, + r#"{"earlyBoot":{"captureLogs":true,"logsPath":"/data/early-boot.log"},"services":[]}"#, + ) + .unwrap(); + assert_eq!( + peek_early_boot_capture(&enabled), + EarlyBootCapturePeek::Enabled(PathBuf::from("/data/early-boot.log")) + ); + + let empty_path = dir.join("empty-path.json"); + fs::write( + &empty_path, + r#"{"earlyBoot":{"captureLogs":true,"logsPath":" "},"services":[]}"#, + ) + .unwrap(); + assert_eq!( + peek_early_boot_capture(&empty_path), + EarlyBootCapturePeek::Disabled + ); + let _ = fs::remove_dir_all(dir); +} diff --git a/tests/early_boot_test.rs b/tests/early_boot_test.rs index 87462a9..92b08c4 100644 --- a/tests/early_boot_test.rs +++ b/tests/early_boot_test.rs @@ -7,6 +7,9 @@ use std::os::unix::fs::PermissionsExt; use std::path::Path; use microinit::config::Paths; +use microinit::constants::{ + MAX_EARLY_BOOT_CAPTURE_BYTES, MAX_EARLY_BOOT_CAPTURE_LINES, MAX_EARLY_BOOT_LINE_BYTES, +}; use microinit::early_boot::*; use microinit::error::Error; @@ -80,18 +83,20 @@ fn resolve_embedded_when_missing() { #[test] fn run_uses_embedded_when_no_on_disk_script() { let (paths, dir) = temp_paths("emb"); - // Tiny stand-in would be nicer, but run() uses the real embedded script. - // Ensure config parent is created and embedded path is selected. assert_eq!(resolve_script(&paths), ScriptSource::Embedded); - run_script_bytes("#!/bin/sh\nexit 0\n", "/dev/null", "/dev/null", "/dev/null").unwrap(); + let (out, res) = run_script_bytes("#!/bin/sh\nexit 0\n", "/dev/null", "/dev/null", "/dev/null"); + res.unwrap(); + assert!(out.captured); + assert_eq!(out.exit_code, Some(0)); let _ = fs::remove_dir_all(dir); } #[test] fn run_script_bytes_failure() { - let err = - run_script_bytes("#!/bin/sh\nexit 9\n", "/dev/null", "/dev/null", "/dev/null").unwrap_err(); - assert!(matches!(err, Error::EarlyBoot(9))); + let (out, err) = run_script_bytes("#!/bin/sh\nexit 9\n", "/dev/null", "/dev/null", "/dev/null"); + assert!(matches!(err.unwrap_err(), Error::EarlyBoot(9))); + assert!(out.captured); + assert_eq!(out.exit_code, Some(9)); } #[test] @@ -101,10 +106,271 @@ fn run_script_success_and_failure() { &paths.early_boot, "#!/bin/sh\ntest \"$MICROINIT_LOGS_TTY\" = /dev/ttyX \\\n -a \"$MICROINIT_INIT_LOGS_TTY\" = /dev/ttyZ \\\n -a -n \"$DATA_DIR\"\n", ); - run_script(&paths.early_boot, "/dev/ttyX", "/dev/ttyZ", "/dev/ttyY").unwrap(); + let (out, res) = run_script(&paths.early_boot, "/dev/ttyX", "/dev/ttyZ", "/dev/ttyY"); + res.unwrap(); + assert!(out.captured); + assert_eq!(out.exit_code, Some(0)); write_exec(&paths.early_boot, "#!/bin/sh\nexit 7\n"); - let err = run_script(&paths.early_boot, "/dev/null", "/dev/null", "/dev/null").unwrap_err(); - assert!(matches!(err, Error::EarlyBoot(7))); + let (out, err) = run_script(&paths.early_boot, "/dev/null", "/dev/null", "/dev/null"); + assert!(matches!(err.unwrap_err(), Error::EarlyBoot(7))); + assert!(out.captured); + assert_eq!(out.exit_code, Some(7)); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn capture_stdout_stderr_and_final_line() { + let (out, res) = run_script_bytes( + "#!/bin/sh\necho stdout-a\necho stderr-b >&2\necho stdout-c\necho last-line\n", + "/dev/null", + "/dev/null", + "/dev/null", + ); + res.unwrap(); + assert!(out.captured); + assert_eq!( + out.lines, + vec!["stdout-a", "stderr-b", "stdout-c", "last-line"] + ); + assert_eq!(out.dropped, 0); +} + +#[test] +fn capture_survives_nonzero_exit() { + let (out, err) = run_script_bytes( + "#!/bin/sh\necho boom\nexit 9\n", + "/dev/null", + "/dev/null", + "/dev/null", + ); + assert!(matches!(err.unwrap_err(), Error::EarlyBoot(9))); + assert!(out.captured); + assert_eq!(out.lines, vec!["boom"]); +} + +#[test] +fn capture_evicts_oldest_lines() { + let n = MAX_EARLY_BOOT_CAPTURE_LINES + 10; + let script = format!( + "#!/bin/sh\ni=1\nwhile [ \"$i\" -le {n} ]; do\n echo \"line-$i\"\n i=$((i + 1))\ndone\n" + ); + let (out, res) = run_script_bytes(&script, "/dev/null", "/dev/null", "/dev/null"); + res.unwrap(); + assert!(out.captured); + assert_eq!(out.dropped, 10); + assert_eq!(out.lines.len(), MAX_EARLY_BOOT_CAPTURE_LINES); + assert_eq!(out.lines.first().map(String::as_str), Some("line-11")); + assert_eq!( + out.lines.last().map(String::as_str), + Some(format!("line-{n}").as_str()) + ); +} + +#[test] +fn capture_truncates_long_line() { + let (out, res) = run_script_bytes( + "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do\n printf x\n i=$((i + 1))\ndone\necho\n", + "/dev/null", + "/dev/null", + "/dev/null", + ); + res.unwrap(); + assert_eq!(out.lines.len(), 1); + assert_eq!(out.lines[0].len(), MAX_EARLY_BOOT_LINE_BYTES); +} + +#[test] +fn write_captured_truncates_and_writes_header() { + let dir = std::env::temp_dir().join(format!( + "microinit-eb-write-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("early-boot.log"); + fs::write(&path, "OLD CONTENT\n").unwrap(); + + let out = EarlyBootOutput { + lines: vec!["hello".into(), "world".into()], + dropped: 3, + captured: true, + source: "/etc/microinit/early-boot.sh".into(), + exit_code: Some(0), + signal: None, + }; + let n = write_captured(&path, &out).unwrap(); + assert_eq!(n, 2); + let body = fs::read_to_string(&path).unwrap(); + assert!(!body.contains("OLD CONTENT"), "{body}"); + assert!( + body.starts_with("# early-boot ts=") + && body.contains("source=/etc/microinit/early-boot.sh"), + "{body}" + ); + assert!(body.contains("exit=0"), "{body}"); + assert!(body.contains("... 3 earlier line(s) dropped"), "{body}"); + assert!(body.contains("hello\nworld\n"), "{body}"); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn write_captured_unwritable_path_is_error() { + let dir = std::env::temp_dir().join(format!( + "microinit-eb-nowrite-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let not_a_dir = dir.join("notdir"); + fs::write(¬_a_dir, "x").unwrap(); + let out = EarlyBootOutput { + lines: vec!["x".into()], + captured: true, + source: "embedded".into(), + exit_code: Some(0), + ..EarlyBootOutput::default() + }; + assert!(write_captured(¬_a_dir.join("log"), &out).is_err()); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn capture_survives_invalid_utf8_and_keeps_later_lines() { + let (out, res) = run_script_bytes( + "#!/bin/sh\nprintf '\\377\\376\\n'\necho after-utf8\n", + "/dev/null", + "/dev/null", + "/dev/null", + ); + res.unwrap(); + assert!(out.captured); + assert!( + out.lines.iter().any(|l| l.contains("after-utf8")), + "reader must not stop on invalid UTF-8; got {:?}", + out.lines + ); + assert!( + out.lines.iter().any(|l| l.contains('\u{FFFD}')), + "invalid bytes should become U+FFFD; got {:?}", + out.lines + ); +} + +#[test] +fn capture_truncates_unterminated_long_line() { + let (out, res) = run_script_bytes( + "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do\n printf x\n i=$((i + 1))\ndone\n", + "/dev/null", + "/dev/null", + "/dev/null", + ); + res.unwrap(); + assert_eq!(out.lines.len(), 1); + assert_eq!(out.lines[0].len(), MAX_EARLY_BOOT_LINE_BYTES); +} + +#[test] +fn capture_evicts_by_byte_budget() { + let line_len = 4000usize; + let n = (MAX_EARLY_BOOT_CAPTURE_BYTES / line_len) + 20; + let script = format!( + "#!/bin/sh\nline=$(printf '%{line_len}s' | tr ' ' x)\ni=1\nwhile [ \"$i\" -le {n} ]; do\n printf '%s\\n' \"$line\"\n i=$((i + 1))\ndone\n" + ); + let (out, res) = run_script_bytes(&script, "/dev/null", "/dev/null", "/dev/null"); + res.unwrap(); + assert!(out.captured); + assert!(out.dropped > 0, "expected byte-budget eviction, dropped=0"); + let retained: usize = out.lines.iter().map(String::len).sum(); + assert!( + retained <= MAX_EARLY_BOOT_CAPTURE_BYTES, + "retained {retained} bytes over budget" + ); + assert!(out.lines.iter().all(|l| l.len() == line_len)); +} + +#[test] +fn capture_records_terminating_signal() { + let (out, err) = run_script_bytes( + "#!/bin/sh\nkill -9 $$\n", + "/dev/null", + "/dev/null", + "/dev/null", + ); + let kill_denied = out.lines.iter().any(|l| { + let l = l.to_ascii_lowercase(); + l.contains("permission denied") + || l.contains("operation not permitted") + || l.contains("brak dostępu") + || l.contains("brak dostepu") + }); + if kill_denied { + // Some test sandboxes block kill(2); the header format is covered by + // write_captured_signal_header. + return; + } + assert!( + matches!(err.unwrap_err(), Error::EarlyBoot(1)), + "signal death is reported as EarlyBoot(1)" + ); + assert_eq!(out.exit_code, None, "signal death has no exit code"); + assert_eq!(out.signal, Some(9), "expected SIGKILL"); +} + +#[test] +fn write_captured_signal_header() { + let dir = std::env::temp_dir().join(format!( + "microinit-eb-sig-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("early-boot.log"); + let out = EarlyBootOutput { + lines: vec!["killed".into()], + captured: true, + source: "embedded".into(), + exit_code: None, + signal: Some(9), + ..EarlyBootOutput::default() + }; + write_captured(&path, &out).unwrap(); + let body = fs::read_to_string(&path).unwrap(); + assert!(body.contains("exit=signal:9"), "{body}"); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn write_captured_unknown_exit_header() { + let dir = std::env::temp_dir().join(format!( + "microinit-eb-unk-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("early-boot.log"); + let out = EarlyBootOutput { + lines: vec!["x".into()], + captured: true, + source: "embedded".into(), + exit_code: None, + signal: None, + ..EarlyBootOutput::default() + }; + write_captured(&path, &out).unwrap(); + let body = fs::read_to_string(&path).unwrap(); + assert!(body.contains("exit=unknown"), "{body}"); let _ = fs::remove_dir_all(dir); } diff --git a/tests/init_opts_test.rs b/tests/init_opts_test.rs index be7151c..5b05ffa 100644 --- a/tests/init_opts_test.rs +++ b/tests/init_opts_test.rs @@ -26,6 +26,7 @@ fn supervise_opts_disable_machine_shutdown() { ); assert!(opts.skip_early_boot); assert!(!opts.require_early_boot); + assert!(opts.early_boot_logs_path.is_none()); assert!(!opts.spawn_getty); assert!(!opts.attach_ttys); assert_eq!(opts.socket, "/tmp/test.sock"); diff --git a/tests/supervisor_test.rs b/tests/supervisor_test.rs index db19f35..8acff00 100644 --- a/tests/supervisor_test.rs +++ b/tests/supervisor_test.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; -use microinit::config::{Config, LogsConfig, RestartPolicy, ServiceConfig}; +use microinit::config::{Config, EarlyBootConfig, LogsConfig, RestartPolicy, ServiceConfig}; use microinit::console::Console; use microinit::error::Error; use microinit::logs::LogHub; @@ -101,6 +101,7 @@ fn make_sup(services: Vec) -> (Arc, std::path::PathBu dir: None, log_to_files: false, }, + early_boot: EarlyBootConfig::default(), socket: dir.join("sock").to_string_lossy().into(), console: "/dev/null".into(), socket_allow_users: Vec::new(),