FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
|
||||
use fds_burn::cli::{BurnCommand, FormatCommand};
|
||||
use fds_common::Bay;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(multicall = true)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub applet: Applet,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Applet {
|
||||
#[command(version, about = "FDS/OS system and cartridge control")]
|
||||
Fds(FdsArgs),
|
||||
/// Inspect a bay, image, or cartridge manifest.
|
||||
#[command(version)]
|
||||
FdsInspect {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
args: InspectArgs,
|
||||
},
|
||||
/// Unmount a cartridge before removal.
|
||||
#[command(version)]
|
||||
FdsEject {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
args: BayArgs,
|
||||
},
|
||||
/// Prepare native shutdown or inspect its status.
|
||||
#[command(version)]
|
||||
FdsPower {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
args: PowerArgs,
|
||||
},
|
||||
}
|
||||
impl Cli {
|
||||
pub fn into_command(self) -> (Option<Action>, bool) {
|
||||
match self.applet {
|
||||
Applet::Fds(args) => (args.command, args.json),
|
||||
Applet::FdsInspect { json, args } => (Some(Action::Inspect(args)), json),
|
||||
Applet::FdsEject { json, args } => (Some(Action::Eject(args)), json),
|
||||
Applet::FdsPower { json, args } => (Some(Action::Power(args)), json),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct FdsArgs {
|
||||
/// Emit machine-readable JSON where supported.
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Action>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Action {
|
||||
/// Show the system and tool identity.
|
||||
Info,
|
||||
/// Show all twelve bays.
|
||||
Bays,
|
||||
/// Show one bay and its cartridge state.
|
||||
#[command(visible_alias = "cartridge")]
|
||||
Bay(BayArgs),
|
||||
/// Unmount a cartridge before removal.
|
||||
Eject(BayArgs),
|
||||
/// Select writable DATA.
|
||||
Data {
|
||||
#[command(subcommand)]
|
||||
command: DataCommand,
|
||||
},
|
||||
/// Start a managed DATA job or PROGRAM executable.
|
||||
Run {
|
||||
#[arg(value_parser = fds_burn::client::bay)]
|
||||
bay: Bay,
|
||||
/// Executable and its arguments; separate them from FDS options with --.
|
||||
#[arg(last = true, required = true, num_args = 1.., value_name = "EXECUTABLE_AND_ARGS")]
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
/// Show desktop and network state.
|
||||
Profiles,
|
||||
/// Activate a desktop or return to the console.
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
command: ProfileCommand,
|
||||
},
|
||||
/// Enable or stop DHCP networking.
|
||||
Network {
|
||||
#[arg(value_enum)]
|
||||
state: Switch,
|
||||
},
|
||||
/// Refresh cartridge inventory.
|
||||
Rescan,
|
||||
/// Show USB paths for bay calibration.
|
||||
Topology,
|
||||
/// Inspect a bay, image, or manifest file.
|
||||
Inspect(InspectArgs),
|
||||
/// Preview and confirm a cartridge write, or manage an existing operation.
|
||||
Burn {
|
||||
#[command(subcommand)]
|
||||
command: BurnCommand,
|
||||
},
|
||||
/// Create a cartridge and preview a confirmed write.
|
||||
Format {
|
||||
#[command(subcommand)]
|
||||
command: FormatCommand,
|
||||
},
|
||||
/// Manage persistent machine settings and saved diagnostics.
|
||||
Machine {
|
||||
#[command(subcommand)]
|
||||
command: Option<MachineCommand>,
|
||||
},
|
||||
/// Check DATA or confirm conservative repair from the root recovery console.
|
||||
Recovery {
|
||||
#[command(subcommand)]
|
||||
command: Option<RecoveryCommand>,
|
||||
},
|
||||
/// Verify DATA and request native shutdown.
|
||||
Poweroff,
|
||||
/// Verify DATA and request a reboot.
|
||||
Reboot,
|
||||
/// Show, resume, or request native shutdown preparation.
|
||||
Power(PowerArgs),
|
||||
/// Show measured boot events.
|
||||
BootProfile,
|
||||
/// Show the tool version.
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct BayArgs {
|
||||
#[arg(value_parser = fds_burn::client::bay)]
|
||||
pub bay: Bay,
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum DataCommand {
|
||||
/// Select a DATA cartridge for /data.
|
||||
Use(BayArgs),
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ProfileCommand {
|
||||
/// Activate a profile.
|
||||
Activate {
|
||||
#[arg(value_parser = ["windowmaker", "cli"])]
|
||||
name: String,
|
||||
},
|
||||
/// Return to the console.
|
||||
Deactivate,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum Switch {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InspectArgs {
|
||||
pub target: Option<PathBuf>,
|
||||
pub command: Option<InspectCommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(subcommand_negates_reqs = true)]
|
||||
struct InspectParser {
|
||||
/// A bay number (1–12 or BAY1–BAY12), or a manifest file path.
|
||||
#[arg(required = true, value_name = "BAY_OR_MANIFEST")]
|
||||
target: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<InspectCommand>,
|
||||
}
|
||||
|
||||
impl TryFrom<InspectParser> for InspectArgs {
|
||||
type Error = clap::Error;
|
||||
fn try_from(parsed: InspectParser) -> Result<Self, Self::Error> {
|
||||
// Clap's args_conflicts_with_subcommands also conflicts with global
|
||||
// --json. Check only these two typed alternatives during conversion.
|
||||
if parsed.target.is_some() && parsed.command.is_some() {
|
||||
return Err(clap::Error::raw(
|
||||
clap::error::ErrorKind::ArgumentConflict,
|
||||
"Choose a bay/manifest target or the image subcommand, not both",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
target: parsed.target,
|
||||
command: parsed.command,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl clap::FromArgMatches for InspectArgs {
|
||||
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
|
||||
InspectParser::from_arg_matches(matches)?.try_into()
|
||||
}
|
||||
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
|
||||
let mut parsed = InspectParser {
|
||||
target: self.target.clone(),
|
||||
command: self.command.clone(),
|
||||
};
|
||||
parsed.update_from_arg_matches(matches)?;
|
||||
*self = parsed.try_into()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Args for InspectArgs {
|
||||
fn augment_args(command: clap::Command) -> clap::Command {
|
||||
InspectParser::augment_args(command)
|
||||
}
|
||||
fn augment_args_for_update(command: clap::Command) -> clap::Command {
|
||||
InspectParser::augment_args_for_update(command)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Subcommand)]
|
||||
pub enum InspectCommand {
|
||||
/// Validate a regular cartridge image and calculate its SHA-256 digest.
|
||||
Image { image: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct PowerArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<PowerCommand>,
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum PowerCommand {
|
||||
/// Show preparation status or its blocking error (the default).
|
||||
Status,
|
||||
/// Resume operations before native shutdown starts.
|
||||
Resume,
|
||||
/// Verify DATA and request native shutdown.
|
||||
Poweroff,
|
||||
/// Verify DATA and request a reboot.
|
||||
Reboot,
|
||||
#[command(long_flag = "shutdown-hook", hide = true)]
|
||||
ShutdownHook,
|
||||
#[command(long_flag = "hold-shutdown", hide = true)]
|
||||
HoldShutdown,
|
||||
#[command(long_flag = "record-final", hide = true)]
|
||||
RecordFinal {
|
||||
#[arg(value_parser = ["failed"])]
|
||||
outcome: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum RecoveryCommand {
|
||||
/// Unmount and check DATA without repairs.
|
||||
Check(BayArgs),
|
||||
/// Preview repair, or confirm the exact token returned by the preview.
|
||||
Repair {
|
||||
#[arg(value_parser = fds_burn::client::bay)]
|
||||
bay: Bay,
|
||||
#[arg(long, value_name = "TOKEN")]
|
||||
confirm: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum MachineCommand {
|
||||
/// Show the active machine identity and settings source.
|
||||
Status,
|
||||
/// Export this boot's active settings into a new directory.
|
||||
Export { new_directory: PathBuf },
|
||||
/// Validate machine.toml, bays.toml and hardware-catalog.toml.
|
||||
Validate { directory: PathBuf },
|
||||
/// Validate and pack settings into a new JSON file.
|
||||
Pack {
|
||||
directory: PathBuf,
|
||||
new_json_file: PathBuf,
|
||||
},
|
||||
/// Install settings for the next boot (root recovery console only).
|
||||
Install { directory: PathBuf },
|
||||
/// Save an explicit diagnostic of up to 16 MiB (root only).
|
||||
Store { name: String, file: PathBuf },
|
||||
/// Retrieve a saved diagnostic into a new file (root only).
|
||||
Fetch { name: String, new_file: PathBuf },
|
||||
#[command(long_flag = "load", hide = true)]
|
||||
Load,
|
||||
}
|
||||
|
||||
pub fn help(subcommand: Option<&str>) -> std::io::Result<()> {
|
||||
let mut root = Cli::command();
|
||||
root.build();
|
||||
let fds = root.find_subcommand_mut("fds").expect("fds applet");
|
||||
let command = match subcommand {
|
||||
Some(name) => fds.find_subcommand_mut(name).expect("known subcommand"),
|
||||
None => fds,
|
||||
};
|
||||
command.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_tree_and_installed_aliases() {
|
||||
Cli::command().debug_assert();
|
||||
for arguments in [
|
||||
vec!["fds", "--json", "inspect", "BAY12"],
|
||||
vec!["/usr/bin/fds-inspect", "BAY12", "--json"],
|
||||
vec!["fds-inspect", "image", "card.img"],
|
||||
vec!["fds-eject", "--json", "12"],
|
||||
vec!["fds-power", "status", "--json"],
|
||||
vec!["fds", "cartridge", "12"],
|
||||
vec![
|
||||
"fds",
|
||||
"recovery",
|
||||
"repair",
|
||||
"BAY1",
|
||||
"--confirm",
|
||||
"exact phrase",
|
||||
],
|
||||
vec!["fds", "machine", "pack", "settings", "new.json"],
|
||||
vec!["fds", "machine", "--load"],
|
||||
vec!["fds", "power", "--shutdown-hook"],
|
||||
vec!["fds-power", "--hold-shutdown"],
|
||||
vec!["fds-power", "--record-final", "failed"],
|
||||
] {
|
||||
assert!(Cli::try_parse_from(&arguments).is_ok(), "{arguments:?}");
|
||||
}
|
||||
for name in ["fds", "fds-inspect", "fds-eject", "fds-power"] {
|
||||
let error = Cli::try_parse_from([name, "--help"]).unwrap_err();
|
||||
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
|
||||
assert!(error.to_string().contains(&format!("Usage: {name}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_preserves_every_child_argument_after_separator() {
|
||||
let (command, json) = Cli::try_parse_from([
|
||||
"fds", "run", "BAY2", "--", "/bin/app", "--json", "--help", "-x", "", "--", "a b",
|
||||
])
|
||||
.unwrap()
|
||||
.into_command();
|
||||
assert!(!json);
|
||||
let Some(Action::Run { bay, arguments }) = command else {
|
||||
panic!("run command")
|
||||
};
|
||||
assert_eq!(bay, "2".parse::<Bay>().unwrap());
|
||||
assert_eq!(
|
||||
arguments,
|
||||
["/bin/app", "--json", "--help", "-x", "", "--", "a b"]
|
||||
);
|
||||
let (_, json) = Cli::try_parse_from(["fds", "run", "--json", "2", "--", "/bin/app"])
|
||||
.unwrap()
|
||||
.into_command();
|
||||
assert!(json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_image_accepts_global_options_in_every_position() {
|
||||
for arguments in [
|
||||
vec!["fds", "--json", "inspect", "image", "card.img"],
|
||||
vec!["fds", "inspect", "--json", "image", "card.img"],
|
||||
vec!["fds", "inspect", "image", "card.img", "--json"],
|
||||
vec!["fds-inspect", "--json", "image", "card.img"],
|
||||
vec!["fds-inspect", "image", "--json", "card.img"],
|
||||
] {
|
||||
let (command, json) = Cli::try_parse_from(&arguments).unwrap().into_command();
|
||||
assert!(json);
|
||||
assert!(matches!(
|
||||
command,
|
||||
Some(Action::Inspect(InspectArgs {
|
||||
command: Some(InspectCommand::Image { .. }),
|
||||
target: None,
|
||||
}))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_conflicting_requests_before_execution() {
|
||||
for arguments in [
|
||||
vec!["fds", "eject", "0"],
|
||||
vec!["fds-eject", "13"],
|
||||
vec!["fds", "run", "1", "/bin/app"],
|
||||
vec!["fds", "run", "1", "--"],
|
||||
vec!["fds", "inspect", "image"],
|
||||
vec!["fds-inspect", "1", "image", "card.img"],
|
||||
vec!["fds", "recovery", "check", "1", "--confirm", "token"],
|
||||
vec!["fds", "recovery", "repair", "1", "--confirm"],
|
||||
vec![
|
||||
"fds",
|
||||
"recovery",
|
||||
"repair",
|
||||
"1",
|
||||
"--confirm",
|
||||
"one",
|
||||
"--confirm",
|
||||
"two",
|
||||
],
|
||||
vec!["fds", "power", "--shutdown-hook", "--record-final"],
|
||||
vec!["fds", "power", "--record-final", "success"],
|
||||
vec!["fds", "machine", "--load", "settings"],
|
||||
vec!["fds", "network", "maybe"],
|
||||
vec!["fds", "profile", "activate", "unknown"],
|
||||
vec!["fds", "unknown"],
|
||||
] {
|
||||
assert!(Cli::try_parse_from(&arguments).is_err(), "{arguments:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Internal NVMe access is explicit and isolated in this process's mount namespace.
|
||||
use fds_common::{
|
||||
Error, Result,
|
||||
machine::{self, Config},
|
||||
read_text, sysfs,
|
||||
};
|
||||
use std::{
|
||||
ffi::CString,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read, Write},
|
||||
os::{
|
||||
fd::AsRawFd,
|
||||
unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const RUNTIME: &str = "/run/fds/machine";
|
||||
const MOUNT: &str = "/run/fds/machine/internal";
|
||||
|
||||
fn c(text: &str) -> Result<CString> {
|
||||
CString::new(text).map_err(|_| Error("NUL in mount argument".into()))
|
||||
}
|
||||
fn checked(value: libc::c_int, what: &str) -> Result<()> {
|
||||
if value < 0 {
|
||||
Err(Error(format!("{what}: {}", io::Error::last_os_error())))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn root() -> Result<()> {
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return Err(Error("Machine storage operations require root".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn prepare() -> Result<File> {
|
||||
root()?;
|
||||
fs::create_dir_all(RUNTIME)?;
|
||||
fs::set_permissions(RUNTIME, fs::Permissions::from_mode(0o755))?;
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(format!("{RUNTIME}/lock"))?;
|
||||
checked(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
|
||||
"Another machine storage operation is active",
|
||||
)?;
|
||||
Ok(lock)
|
||||
}
|
||||
fn parent(part: &sysfs::BlockPartition) -> Result<PathBuf> {
|
||||
let path = fs::canonicalize(format!("/sys/dev/block/{}:{}", part.major, part.minor))?;
|
||||
path.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| Error("Partition has no parent disk".into()))
|
||||
}
|
||||
fn select() -> Result<(sysfs::BlockPartition, PathBuf, String)> {
|
||||
let parts = sysfs::partitions(Path::new("/sys"))?;
|
||||
let mut candidates = Vec::new();
|
||||
for part in parts.iter().filter(|p| p.partition_name == "FDS_INTERNAL") {
|
||||
let disk = parent(part)?;
|
||||
let buses: Vec<_> = disk
|
||||
.ancestors()
|
||||
.filter_map(|path| fs::read_link(path.join("subsystem")).ok())
|
||||
.filter_map(|path| path.file_name().map(|s| s.to_owned()))
|
||||
.collect();
|
||||
if !buses.iter().any(|b| b == "nvme") || buses.iter().any(|b| b == "usb") {
|
||||
continue;
|
||||
}
|
||||
if read_text(&disk.join("removable"), 32)?.trim() != "0" {
|
||||
continue;
|
||||
}
|
||||
let siblings: Vec<_> = parts
|
||||
.iter()
|
||||
.filter(|p| parent(p).ok().as_ref() == Some(&disk))
|
||||
.collect();
|
||||
if siblings.len() != 3 {
|
||||
return Err(Error(
|
||||
"Internal NVMe must contain exactly FDS_BOOT, FDS_RECOVERY and FDS_INTERNAL".into(),
|
||||
));
|
||||
}
|
||||
for (number, label) in [(1, "FDS_BOOT"), (2, "FDS_RECOVERY"), (3, "FDS_INTERNAL")] {
|
||||
let matches: Vec<_> = siblings
|
||||
.iter()
|
||||
.filter(|p| p.partition_name == label)
|
||||
.collect();
|
||||
if matches.len() != 1
|
||||
|| read_text(
|
||||
&PathBuf::from(format!(
|
||||
"/sys/dev/block/{}:{}/partition",
|
||||
matches[0].major, matches[0].minor
|
||||
)),
|
||||
32,
|
||||
)?
|
||||
.trim()
|
||||
!= number.to_string()
|
||||
{
|
||||
return Err(Error("Invalid internal NVMe partition layout".into()));
|
||||
}
|
||||
}
|
||||
let sequence = read_text(&disk.join("diskseq"), 32)?.trim().to_owned();
|
||||
sequence
|
||||
.parse::<u64>()
|
||||
.map_err(|_| Error("Invalid internal disk sequence".into()))?;
|
||||
candidates.push((part.clone(), disk, sequence));
|
||||
}
|
||||
match candidates.len() {
|
||||
0 => Err(Error(
|
||||
"No complete internal NVMe layout found; image defaults remain available".into(),
|
||||
)),
|
||||
1 => Ok(candidates.pop().unwrap()),
|
||||
_ => Err(Error(
|
||||
"Multiple internal NVMe layouts found; refusing to choose one".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
struct Internal {
|
||||
file: File,
|
||||
disk: PathBuf,
|
||||
sequence: String,
|
||||
mounted: bool,
|
||||
}
|
||||
impl Internal {
|
||||
fn open(writable: bool) -> Result<Self> {
|
||||
let (part, disk, sequence) = select()?;
|
||||
let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?;
|
||||
if mounts.lines().any(|line| {
|
||||
line.split_whitespace().nth(2) == Some(&format!("{}:{}", part.major, part.minor))
|
||||
}) {
|
||||
return Err(Error(
|
||||
"Internal settings are already mounted; unmount them before continuing".into(),
|
||||
));
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(writable)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&part.device)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.file_type().is_block_device()
|
||||
|| libc::major(meta.rdev()) != part.major
|
||||
|| libc::minor(meta.rdev()) != part.minor
|
||||
{
|
||||
return Err(Error("Internal partition identity changed".into()));
|
||||
}
|
||||
let mut header = [0u8; 1024];
|
||||
file.read_exact_at(&mut header, 1024)?;
|
||||
let word = |at| u16::from_le_bytes([header[at], header[at + 1]]);
|
||||
let features = u32::from_le_bytes(header[96..100].try_into().unwrap());
|
||||
if word(56) != 0xef53
|
||||
|| word(58) != 1
|
||||
|| features & 4 != 0
|
||||
|| &header[120..136] != b"FDS_INTERNAL\0\0\0\0"
|
||||
{
|
||||
return Err(Error("Internal ext4 is unclean, damaged or incorrectly labeled; offline filesystem maintenance is required".into()));
|
||||
}
|
||||
checked(
|
||||
unsafe { libc::unshare(libc::CLONE_NEWNS) },
|
||||
"Isolate internal storage mounts",
|
||||
)?;
|
||||
checked(
|
||||
unsafe {
|
||||
libc::mount(
|
||||
std::ptr::null(),
|
||||
c("/")?.as_ptr(),
|
||||
std::ptr::null(),
|
||||
libc::MS_REC | libc::MS_PRIVATE,
|
||||
std::ptr::null(),
|
||||
)
|
||||
},
|
||||
"Make storage mount private",
|
||||
)?;
|
||||
fs::create_dir_all(MOUNT)?;
|
||||
fs::set_permissions(MOUNT, fs::Permissions::from_mode(0o700))?;
|
||||
let mut internal = Self {
|
||||
file,
|
||||
disk,
|
||||
sequence,
|
||||
mounted: false,
|
||||
};
|
||||
internal.identity()?;
|
||||
let options = c(if writable {
|
||||
"errors=remount-ro"
|
||||
} else {
|
||||
"noload"
|
||||
})?;
|
||||
checked(
|
||||
unsafe {
|
||||
libc::mount(
|
||||
c(&format!("/proc/self/fd/{}", internal.file.as_raw_fd()))?.as_ptr(),
|
||||
c(MOUNT)?.as_ptr(),
|
||||
c("ext4")?.as_ptr(),
|
||||
libc::MS_NOSUID
|
||||
| libc::MS_NODEV
|
||||
| libc::MS_NOEXEC
|
||||
| if writable { 0 } else { libc::MS_RDONLY },
|
||||
options.as_ptr().cast(),
|
||||
)
|
||||
},
|
||||
"Mount internal settings",
|
||||
)?;
|
||||
internal.mounted = true;
|
||||
internal.identity()?;
|
||||
Ok(internal)
|
||||
}
|
||||
fn identity(&self) -> Result<()> {
|
||||
if read_text(&self.disk.join("diskseq"), 32)?.trim() != self.sequence {
|
||||
return Err(Error("Internal NVMe changed during the operation".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn close(mut self, writable: bool) -> Result<()> {
|
||||
self.identity()?;
|
||||
if writable {
|
||||
let directory = File::open(MOUNT)?;
|
||||
checked(
|
||||
unsafe { libc::syncfs(directory.as_raw_fd()) },
|
||||
"Flush internal settings filesystem",
|
||||
)?;
|
||||
}
|
||||
checked(
|
||||
unsafe { libc::umount(c(MOUNT)?.as_ptr()) },
|
||||
"Unmount internal settings",
|
||||
)?;
|
||||
self.mounted = false;
|
||||
if writable {
|
||||
self.file.sync_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for Internal {
|
||||
fn drop(&mut self) {
|
||||
if self.mounted {
|
||||
// No lazy unmount. The private namespace is also destroyed on exit.
|
||||
if let Ok(path) = c(MOUNT) {
|
||||
unsafe {
|
||||
libc::umount(path.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn atomic(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| Error("Missing parent directory".into()))?;
|
||||
let temporary = path.with_extension(format!("next-{}", std::process::id()));
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(mode)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&temporary)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, path)?;
|
||||
File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn emulator_config() -> Result<Option<Config>> {
|
||||
if !read_text(Path::new("/proc/cmdline"), 65536)?
|
||||
.split_whitespace()
|
||||
.any(|s| s == "fds.emulator=1")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let compatible = fs::read("/proc/device-tree/compatible")?;
|
||||
if !compatible
|
||||
.split(|b| *b == 0)
|
||||
.any(|s| s == b"linux,dummy-virt")
|
||||
{
|
||||
return Err(Error(
|
||||
"fds.emulator=1 requires the QEMU virt machine".into(),
|
||||
));
|
||||
}
|
||||
let controller = fs::canonicalize("/sys/bus/pci/devices/0000:00:05.0")?;
|
||||
if read_text(&controller.join("vendor"), 64)?.trim() != "0x1b36"
|
||||
|| read_text(&controller.join("device"), 64)?.trim() != "0x000d"
|
||||
{
|
||||
return Err(Error(
|
||||
"Emulator requires QEMU xHCI at PCI address 00:05.0".into(),
|
||||
));
|
||||
}
|
||||
let identity = controller
|
||||
.strip_prefix("/sys/devices")
|
||||
.map_err(|_| Error("Unexpected emulator controller path".into()))?
|
||||
.to_string_lossy();
|
||||
let mut config = Config::fallback()?;
|
||||
config.name = "FDS QEMU workstation emulator".into();
|
||||
config.bays.clear();
|
||||
for protocol in ["usb2", "usb3"] {
|
||||
config.bays.push_str(&format!(
|
||||
"[{protocol}]\nhub = \"{identity}:{protocol}\"\n[{protocol}.ports]\n"
|
||||
));
|
||||
for n in 1..=12 {
|
||||
config.bays.push_str(&format!("{n} = {n}\n"));
|
||||
}
|
||||
}
|
||||
config.validate()?;
|
||||
Ok(Some(config))
|
||||
}
|
||||
pub fn load() -> Result<()> {
|
||||
let _lock = prepare()?;
|
||||
if Path::new(machine::SNAPSHOT).exists() && Path::new(machine::STATUS).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let attempt = || -> Result<(Config, String)> {
|
||||
let internal = Internal::open(false)?;
|
||||
let config = Config::parse(&machine::trusted_text(
|
||||
Path::new(&format!("{MOUNT}/config/machine.json")),
|
||||
machine::MAX_BUNDLE,
|
||||
)?)?;
|
||||
let sequence = internal.sequence.clone();
|
||||
internal.close(false)?;
|
||||
Ok((config, sequence))
|
||||
};
|
||||
let emulated = emulator_config()?;
|
||||
let (config, status) = if let Some(config) = emulated {
|
||||
let status = serde_json::json!({"source":"qemu_emulator", "name":config.name, "disk_sequence":null, "error":null});
|
||||
(config, status)
|
||||
} else {
|
||||
match attempt() {
|
||||
Ok((config, sequence)) => {
|
||||
let status = serde_json::json!({"source":"internal_nvme", "name":config.name, "disk_sequence":sequence, "error":null});
|
||||
(config, status)
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("FDS machine settings: {error}");
|
||||
let config = Config::fallback()?;
|
||||
let status = serde_json::json!({"source":"image_defaults", "name":config.name, "disk_sequence":null, "error":error.to_string()});
|
||||
(config, status)
|
||||
}
|
||||
}
|
||||
};
|
||||
atomic(
|
||||
Path::new(machine::STATUS),
|
||||
&serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?,
|
||||
0o644,
|
||||
)?;
|
||||
atomic(Path::new(machine::SNAPSHOT), &config.json()?, 0o644)?;
|
||||
Ok(())
|
||||
}
|
||||
fn install(directory: &Path) -> Result<()> {
|
||||
root()?;
|
||||
if !fds_common::recovery_mode()? {
|
||||
return Err(Error("Installing machine settings requires the recovery console; changes take effect at the next boot".into()));
|
||||
}
|
||||
let config = Config::directory(directory)?;
|
||||
let _lock = prepare()?;
|
||||
let internal = Internal::open(true)?;
|
||||
let current = PathBuf::from(format!("{MOUNT}/config/machine.json"));
|
||||
// Validate ownership and preserve the previous bytes, even when its JSON is
|
||||
// damaged: recovery must be able to replace invalid settings with valid ones.
|
||||
let previous = machine::trusted_text(¤t, machine::MAX_BUNDLE)?;
|
||||
atomic(
|
||||
¤t.with_file_name("previous.json"),
|
||||
previous.as_bytes(),
|
||||
0o600,
|
||||
)?;
|
||||
atomic(¤t, &config.json()?, 0o600)?;
|
||||
internal.close(true)?;
|
||||
println!(
|
||||
"Saved machine settings for {}. Reboot to activate them; the current bay mapping is unchanged.",
|
||||
config.name
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn diagnostic_name(name: &str) -> Result<()> {
|
||||
if name.is_empty()
|
||||
|| name.len() > 80
|
||||
|| name.starts_with('.')
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||
{
|
||||
return Err(Error("Diagnostic name must be 1..80 letters, digits, dots, hyphens or underscores and must not start with a dot".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn store(name: &str, input: &Path) -> Result<()> {
|
||||
root()?;
|
||||
diagnostic_name(name)?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(input)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(Error("Diagnostic input must be a regular file".into()));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() > 16 * 1024 * 1024 {
|
||||
return Err(Error("Diagnostic input exceeds 16 MiB".into()));
|
||||
}
|
||||
let _lock = prepare()?;
|
||||
let internal = Internal::open(true)?;
|
||||
let directory = PathBuf::from(format!("{MOUNT}/diagnostics"));
|
||||
let meta = fs::symlink_metadata(&directory)?;
|
||||
if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o077 != 0 {
|
||||
return Err(Error("Untrusted diagnostics directory".into()));
|
||||
}
|
||||
let output = directory.join(name);
|
||||
if output.symlink_metadata().is_ok() {
|
||||
return Err(Error("Diagnostic already exists; choose a new name".into()));
|
||||
}
|
||||
atomic(&output, &bytes, 0o600)?;
|
||||
internal.close(true)?;
|
||||
println!("Saved diagnostics/{name} on internal NVMe; storage is flushed and unmounted.");
|
||||
Ok(())
|
||||
}
|
||||
pub fn run(command: crate::cli::MachineCommand, json: bool) -> Result<()> {
|
||||
use crate::cli::MachineCommand;
|
||||
match command {
|
||||
MachineCommand::Status => {
|
||||
let text = read_text(Path::new(machine::STATUS), 16384)?;
|
||||
if json {
|
||||
println!("{text}");
|
||||
} else {
|
||||
let status: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|e| Error(e.to_string()))?;
|
||||
println!(
|
||||
"MACHINE {}\nSETTINGS {}",
|
||||
status["name"].as_str().unwrap_or("unknown"),
|
||||
status["source"].as_str().unwrap_or("unknown")
|
||||
);
|
||||
if let Some(error) = status["error"].as_str() {
|
||||
println!("DETAIL {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
MachineCommand::Validate { directory } => {
|
||||
let config = Config::directory(&directory)?;
|
||||
println!("Valid machine settings: {}", config.name);
|
||||
}
|
||||
MachineCommand::Pack {
|
||||
directory,
|
||||
new_json_file: output,
|
||||
} => {
|
||||
let bytes = Config::directory(&directory)?.json()?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&output)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
println!("Validated machine settings: {}", output.display());
|
||||
}
|
||||
MachineCommand::Load => load()?,
|
||||
MachineCommand::Install { directory } => install(&directory)?,
|
||||
MachineCommand::Store { name, file: input } => store(&name, &input)?,
|
||||
MachineCommand::Export {
|
||||
new_directory: directory,
|
||||
} => {
|
||||
let config = Config::active()?;
|
||||
let path = &directory;
|
||||
fs::create_dir(path)?;
|
||||
fs::write(
|
||||
path.join("machine.toml"),
|
||||
format!(
|
||||
"format = 1\nname = {}\n",
|
||||
serde_json::to_string(&config.name).map_err(|e| Error(e.to_string()))?
|
||||
),
|
||||
)?;
|
||||
fs::write(path.join("bays.toml"), config.bays)?;
|
||||
fs::write(path.join("hardware-catalog.toml"), config.hardware_catalog)?;
|
||||
println!(
|
||||
"Exported this boot's machine settings to {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
MachineCommand::Fetch {
|
||||
name,
|
||||
new_file: output,
|
||||
} => {
|
||||
diagnostic_name(&name)?;
|
||||
let _lock = prepare()?;
|
||||
let internal = Internal::open(false)?;
|
||||
let path = format!("{MOUNT}/diagnostics/{name}");
|
||||
let input = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(path)?;
|
||||
let meta = input.metadata()?;
|
||||
if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
|
||||
return Err(Error("Untrusted diagnostic file".into()));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
input.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() > 16 * 1024 * 1024 {
|
||||
return Err(Error("Diagnostic exceeds 16 MiB".into()));
|
||||
}
|
||||
internal.close(false)?;
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(output)?;
|
||||
output.write_all(&bytes)?;
|
||||
output.sync_all()?;
|
||||
println!("Retrieved diagnostics/{name}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
mod cli;
|
||||
mod machine;
|
||||
mod power;
|
||||
mod recovery;
|
||||
use clap::Parser;
|
||||
use cli::{
|
||||
Action, DataCommand, InspectCommand, PowerCommand, ProfileCommand, RecoveryCommand, Switch,
|
||||
};
|
||||
use fds_common::{
|
||||
Error, Result, VERSION,
|
||||
control::{self, Request},
|
||||
manifest::Manifest,
|
||||
read_text, trace,
|
||||
};
|
||||
use std::{path::Path, process::ExitCode};
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let (command, json) = cli::Cli::parse().into_command();
|
||||
let Some(command) = command else {
|
||||
return Ok(cli::help(None)?);
|
||||
};
|
||||
match command {
|
||||
Action::Machine {
|
||||
command: Some(command),
|
||||
} => machine::run(command, json)?,
|
||||
Action::Machine { command: None } => cli::help(Some("machine"))?,
|
||||
Action::Recovery { command: None } => cli::help(Some("recovery"))?,
|
||||
Action::Recovery {
|
||||
command: Some(RecoveryCommand::Check(args)),
|
||||
} => recovery::run(args.bay, false, None, json)?,
|
||||
Action::Recovery {
|
||||
command: Some(RecoveryCommand::Repair { bay, confirm }),
|
||||
} => recovery::run(bay, true, confirm, json)?,
|
||||
Action::Poweroff => power::client(Request::Poweroff, json)?,
|
||||
Action::Reboot => power::client(Request::Reboot, json)?,
|
||||
Action::Power(args) => match args.command.unwrap_or(PowerCommand::Status) {
|
||||
PowerCommand::Poweroff => power::client(Request::Poweroff, json)?,
|
||||
PowerCommand::Reboot => power::client(Request::Reboot, json)?,
|
||||
PowerCommand::Status => power::client(Request::PowerStatus, json)?,
|
||||
PowerCommand::Resume => power::client(Request::PowerResume, json)?,
|
||||
PowerCommand::ShutdownHook => power::shutdown_hook()?,
|
||||
PowerCommand::HoldShutdown => power::hold_shutdown()?,
|
||||
PowerCommand::RecordFinal { outcome } => power::record_final(outcome.is_some())?,
|
||||
},
|
||||
Action::Burn { command } => fds_burn::client::burn(command, json)?,
|
||||
Action::Format { command } => fds_burn::client::format(command, json)?,
|
||||
Action::Inspect(args) => {
|
||||
if let Some(InspectCommand::Image { image: path }) = args.command {
|
||||
let file = std::fs::File::open(path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(Error("Image inspection requires a regular file".into()));
|
||||
}
|
||||
let mut info = fds_burn::image::inspect(&file, file.metadata()?.len())?;
|
||||
info.sha256 = Some(fds_burn::image::digest(&file, info.bytes, |_| Ok(()))?);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
|
||||
);
|
||||
} else {
|
||||
let path = args
|
||||
.target
|
||||
.expect("Clap requires a target or image subcommand");
|
||||
if let Some(bay) = path
|
||||
.to_str()
|
||||
.and_then(|value| fds_burn::client::bay(value).ok())
|
||||
{
|
||||
fds_burn::client::inspect_bay(bay, json)?;
|
||||
} else {
|
||||
inspect_manifest(&path, json)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::Profiles => cartridge(Request::Profiles, json)?,
|
||||
Action::Profile { command } => cartridge(
|
||||
Request::Profile {
|
||||
profile: match command {
|
||||
ProfileCommand::Activate { name } => name,
|
||||
ProfileCommand::Deactivate => "cli".into(),
|
||||
},
|
||||
},
|
||||
json,
|
||||
)?,
|
||||
Action::Network { state } => cartridge(
|
||||
Request::Network {
|
||||
enabled: matches!(state, Switch::On),
|
||||
},
|
||||
json,
|
||||
)?,
|
||||
Action::Bays => cartridge(Request::Bays, json)?,
|
||||
Action::Bay(args) => cartridge(Request::Bay { bay: args.bay }, json)?,
|
||||
Action::Eject(args) => cartridge(Request::Eject { bay: args.bay }, json)?,
|
||||
Action::Rescan => cartridge(Request::Rescan, json)?,
|
||||
Action::Data {
|
||||
command: DataCommand::Use(args),
|
||||
} => cartridge(Request::DataUse { bay: args.bay }, json)?,
|
||||
Action::Run { bay, arguments } => cartridge(Request::Run { bay, arguments }, json)?,
|
||||
Action::Topology => cartridge(Request::Topology, json)?,
|
||||
Action::Version => println!("fds {VERSION}"),
|
||||
Action::BootProfile => trace::load(Path::new(trace::RUNTIME))?.print(json)?,
|
||||
Action::Info => info(json)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn info(json: bool) -> Result<()> {
|
||||
let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)?
|
||||
.trim()
|
||||
.to_owned();
|
||||
let pid1 = std::fs::read_link("/proc/1/exe")
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
read_text(Path::new("/proc/1/comm"), 4096)
|
||||
.map(|s| s.trim().to_owned())
|
||||
.unwrap_or_else(|_| "unavailable".into())
|
||||
});
|
||||
let target = if cfg!(all(
|
||||
target_arch = "aarch64",
|
||||
target_env = "musl",
|
||||
target_feature = "crt-static"
|
||||
)) {
|
||||
"aarch64 static-musl"
|
||||
} else {
|
||||
"host test build"
|
||||
};
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"fds_version": VERSION, "target": target, "kernel": kernel, "pid1": pid1})
|
||||
);
|
||||
} else {
|
||||
println!("FDS/OS {VERSION}\nTOOLS {target}\nKERNEL {kernel}\nINIT {pid1}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn inspect_manifest(path: &Path, json: bool) -> Result<()> {
|
||||
let manifest = Manifest::load(path)?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&manifest).map_err(|e| Error(e.to_string()))?
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}\n{} {}\nID {}\nMEDIA {}",
|
||||
manifest.cartridge.class.label(),
|
||||
manifest.cartridge.name,
|
||||
manifest.cartridge.version,
|
||||
manifest.cartridge.id,
|
||||
if manifest.media.writable {
|
||||
"WRITABLE"
|
||||
} else {
|
||||
"READ-ONLY"
|
||||
}
|
||||
);
|
||||
if let Some(activation) = manifest.activation {
|
||||
println!("PROFILE {}", activation.profile);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn cartridge(request: Request, json: bool) -> Result<()> {
|
||||
let debug = matches!(request, Request::Topology);
|
||||
let response = control::request(&request)?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&response).map_err(|e| Error(e.to_string()))?
|
||||
);
|
||||
} else {
|
||||
if let Some(pid) = response.started_pid {
|
||||
println!("STARTED {pid} Output: /run/log/cartridged/current");
|
||||
}
|
||||
if let Some(p) = response.profiles {
|
||||
println!(
|
||||
"PROFILES cli, windowmaker\nDESKTOP {} {}\nNETWORK {}",
|
||||
p.desktop,
|
||||
if p.desktop == "windowmaker" {
|
||||
if p.ready_ns.is_some() {
|
||||
"READY"
|
||||
} else {
|
||||
"STARTING"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
},
|
||||
if p.network.is_empty() {
|
||||
"OFF".into()
|
||||
} else {
|
||||
p.network.join(", ")
|
||||
}
|
||||
);
|
||||
if let Some(bay) = p.environment_bay {
|
||||
println!("ENVIRONMENT BAY {bay}");
|
||||
}
|
||||
if let (Some(start), Some(end)) = (p.activation_ns, p.ready_ns) {
|
||||
println!(
|
||||
"ACTIVATION TO READY {:.3} ms",
|
||||
end.saturating_sub(start) as f64 / 1_000_000.0
|
||||
);
|
||||
}
|
||||
if let Some(error) = p.error {
|
||||
println!("ERROR {error}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
for bay in response.bays {
|
||||
println!(
|
||||
"BAY {} {}{}",
|
||||
bay.bay,
|
||||
bay.state.to_uppercase().replace('_', " "),
|
||||
bay.name
|
||||
.as_ref()
|
||||
.map(|n| format!(" {n}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
if let Some(detail) = bay.detail {
|
||||
println!(" {detail}");
|
||||
}
|
||||
if let Some(manifest) = bay.manifest {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
manifest.cartridge.class.label(),
|
||||
manifest.cartridge.id,
|
||||
manifest.cartridge.version
|
||||
);
|
||||
}
|
||||
if let Some(catalogue) = bay.software {
|
||||
for software in catalogue.software {
|
||||
println!(
|
||||
" SOFTWARE {} {} (partition {})",
|
||||
software.id, software.version, software.partition
|
||||
);
|
||||
for command in software.commands.keys() {
|
||||
println!(" fds run {} -- {}:{}", bay.bay, software.id, command);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mount) = bay.mount {
|
||||
println!(" MOUNT {mount}");
|
||||
}
|
||||
if bay.consumers > 0 {
|
||||
println!(" MANAGED PROCESSES {}", bay.consumers);
|
||||
}
|
||||
if debug {
|
||||
for device in bay.devices {
|
||||
println!(
|
||||
" {} USB {}:{}",
|
||||
device.topology, device.vendor, device.product
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if debug {
|
||||
for device in response.unmapped {
|
||||
println!(
|
||||
"UNMAPPED {} USB {}:{}",
|
||||
device.topology, device.vendor, device.product
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("fds: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use fds_common::{
|
||||
Error, Result,
|
||||
control::{self, PowerEvent, PowerState, Request},
|
||||
trace,
|
||||
};
|
||||
use std::{
|
||||
ffi::CString,
|
||||
fs, io,
|
||||
os::{
|
||||
fd::{AsRawFd, FromRawFd, OwnedFd},
|
||||
unix::fs::PermissionsExt,
|
||||
},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
const DIRECTORY: &str = "/run/fds/power";
|
||||
const RECORD: &str = "/run/fds/power/state.json";
|
||||
pub fn client(request: Request, json: bool) -> Result<()> {
|
||||
let power = control::request(&request)?
|
||||
.power
|
||||
.ok_or_else(|| Error("Missing shutdown state".into()))?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&power).map_err(|e| Error(e.to_string()))?
|
||||
);
|
||||
} else {
|
||||
println!("POWER STATE: {}", power.phase);
|
||||
if let Some(action) = &power.action {
|
||||
println!("ACTION: {action}");
|
||||
}
|
||||
if power.native_pending {
|
||||
println!("Native s6 shutdown has been requested.");
|
||||
}
|
||||
if let Some(error) = &power.error {
|
||||
println!(
|
||||
"BLOCKED: {error}\nResolve the problem and retry, or use fds power resume before native shutdown starts."
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn root() -> Result<()> {
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
Err(Error("This native shutdown helper requires root".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn hold_shutdown() -> Result<()> {
|
||||
root()?;
|
||||
// Last-resort fail-closed path used only if the state watcher itself fails.
|
||||
// A blocked signal wait consumes no CPU and does not advance native init.
|
||||
let mut mask = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
libc::sigfillset(&mut mask);
|
||||
if libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
loop {
|
||||
libc::pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn state() -> Result<PowerState> {
|
||||
serde_json::from_str(&fds_common::read_text(Path::new(RECORD), 32 * 1024)?)
|
||||
.map_err(|e| Error(format!("Invalid shutdown record: {e}")))
|
||||
}
|
||||
pub fn shutdown_hook() -> Result<()> {
|
||||
root()?;
|
||||
fs::create_dir_all(DIRECTORY)?;
|
||||
fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?;
|
||||
let fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let path = CString::new(DIRECTORY).unwrap();
|
||||
if unsafe {
|
||||
libc::inotify_add_watch(
|
||||
fd.as_raw_fd(),
|
||||
path.as_ptr(),
|
||||
libc::IN_CLOSE_WRITE | libc::IN_MOVED_TO,
|
||||
)
|
||||
} < 0
|
||||
{
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
// Record the irrevocable native request even if the cartridge daemon is
|
||||
// currently restarting. Its next startup must keep operations frozen.
|
||||
fs::write(
|
||||
Path::new(DIRECTORY).join("native-pending"),
|
||||
b"native shutdown requested\n",
|
||||
)?;
|
||||
if let Err(error) = control::request(&Request::PowerPrepare) {
|
||||
eprintln!(
|
||||
"Native shutdown is waiting: {error}. Resolve the problem and retry fds poweroff; DATA is not declared SAFE."
|
||||
);
|
||||
}
|
||||
loop {
|
||||
if state().is_ok_and(|s| s.phase == "prepared" && s.native_pending) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut bytes = [0u8; 4096];
|
||||
let count = unsafe { libc::read(fd.as_raw_fd(), bytes.as_mut_ptr().cast(), bytes.len()) };
|
||||
if count < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
if count <= 0 {
|
||||
return Err(Error("Shutdown state watcher failed".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn record_final(failed: bool) -> Result<()> {
|
||||
root()?;
|
||||
let mut record = state()?;
|
||||
if record.phase != "prepared" {
|
||||
return Err(Error("Shutdown preparation was not completed".into()));
|
||||
}
|
||||
record.events.push(PowerEvent {
|
||||
phase: if failed {
|
||||
"service_stop_failed"
|
||||
} else {
|
||||
"services_stopped"
|
||||
}
|
||||
.into(),
|
||||
at_ns: trace::now()?,
|
||||
});
|
||||
println!(
|
||||
"FDS_SHUTDOWN_FINAL {}",
|
||||
serde_json::to_string(&record).map_err(|e| Error(e.to_string()))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use fds_common::{
|
||||
Bay, Error, Result,
|
||||
control::{self, Request},
|
||||
};
|
||||
|
||||
pub fn run(bay: Bay, repair: bool, confirmation: Option<String>, json: bool) -> Result<()> {
|
||||
let response = control::request(&Request::RecoveryData {
|
||||
bay,
|
||||
repair,
|
||||
confirmation,
|
||||
})?;
|
||||
let report = response
|
||||
.recovery
|
||||
.ok_or_else(|| Error("Missing recovery response".into()))?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&report).map_err(|e| Error(e.to_string()))?
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"BAY {} {} {} bytes\nSERIAL {}",
|
||||
report.disk.bay,
|
||||
report.disk.model,
|
||||
report.disk.bytes,
|
||||
report.disk.serial.as_deref().unwrap_or("unavailable")
|
||||
);
|
||||
if let Some(token) = report.confirmation {
|
||||
println!(
|
||||
"Repair preview: no filesystem changes made.\nAfter reviewing this device, run:\nfds recovery repair {bay} --confirm '{token}'"
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}\nSAFE TO REMOVE",
|
||||
if report.repaired {
|
||||
"DATA REPAIRED AND VERIFIED"
|
||||
} else {
|
||||
"DATA CHECK PASSED"
|
||||
}
|
||||
);
|
||||
}
|
||||
if let Some(log) = report.log {
|
||||
println!("LOG {log}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user