Organize the public CLI with clap
This commit is contained in:
parent
e7dd95aea6
commit
d08d931b5f
8 changed files with 445 additions and 255 deletions
115
Cargo.lock
generated
115
Cargo.lock
generated
|
|
@ -26,6 +26,56 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
|
|
@ -224,6 +274,46 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
|
|
@ -242,6 +332,12 @@ dependencies = [
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
|
|
@ -1097,6 +1193,12 @@ version = "2.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
|
|
@ -1208,6 +1310,7 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
|
||||||
name = "lazaret-client"
|
name = "lazaret-client"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"lazaret-core",
|
"lazaret-core",
|
||||||
|
|
@ -1621,6 +1724,12 @@ version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -2742,6 +2851,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ license = "Apache-2.0"
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "=1.0.103"
|
anyhow = "=1.0.103"
|
||||||
bstr = "=1.13.0"
|
bstr = "=1.13.0"
|
||||||
|
clap = { version = "=4.6.2", features = ["derive"] }
|
||||||
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
|
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
|
||||||
flate2 = "=1.1.9"
|
flate2 = "=1.1.9"
|
||||||
fuser = { version = "=0.17.0", default-features = false, features = ["abi-7-31"] }
|
fuser = { version = "=0.17.0", default-features = false, features = ["abi-7-31"] }
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ name = "lazaret"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
getrandom.workspace = true
|
getrandom.workspace = true
|
||||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||||
|
|
|
||||||
182
crates/lazaret-client/src/cli.rs
Normal file
182
crates/lazaret-client/src/cli.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
use lazaret_host_coordinator::domain_store::StateResetScopeV1;
|
||||||
|
|
||||||
|
use crate::ClientCommand;
|
||||||
|
use crate::state::{StateCommand, StateOperation};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "lazaret",
|
||||||
|
version,
|
||||||
|
about = "Run development tools in an isolated agent sandbox",
|
||||||
|
long_about = "Run development tools in an isolated gVisor sandbox while keeping workspace editing, persistent tool state, controlled network access, and developer-approved host actions available."
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: PublicCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum PublicCommand {
|
||||||
|
/// Run a command in the sandbox for a profile and workspace.
|
||||||
|
Run(RunArguments),
|
||||||
|
/// Inspect, verify, or reset persistent sandbox state.
|
||||||
|
State {
|
||||||
|
#[command(subcommand)]
|
||||||
|
operation: StateArguments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
struct RunArguments {
|
||||||
|
#[command(flatten)]
|
||||||
|
domain: DomainArguments,
|
||||||
|
/// Command and arguments to run inside the sandbox.
|
||||||
|
#[arg(last = true, required = true, num_args = 1.., value_name = "COMMAND")]
|
||||||
|
command: Vec<OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
struct DomainArguments {
|
||||||
|
/// Agent profile to use from the Lazaret configuration.
|
||||||
|
#[arg(long, value_name = "NAME")]
|
||||||
|
profile: String,
|
||||||
|
/// Project directory to share with the sandbox.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
workspace: Option<PathBuf>,
|
||||||
|
/// Lazaret configuration file to load instead of the user default.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum StateArguments {
|
||||||
|
/// List configured persistent sandbox paths without reading file contents.
|
||||||
|
List(StateReadArguments),
|
||||||
|
/// Show structural changes in persistent sandbox paths without file contents.
|
||||||
|
Inspect(StateReadArguments),
|
||||||
|
/// Verify persistent sandbox state while the domain is inactive.
|
||||||
|
Verify(StateReadArguments),
|
||||||
|
/// Discard persistent sandbox state for selected configured paths.
|
||||||
|
Reset(StateResetArguments),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
struct StateReadArguments {
|
||||||
|
#[command(flatten)]
|
||||||
|
domain: DomainArguments,
|
||||||
|
/// Write a stable machine-readable JSON report.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
struct StateResetArguments {
|
||||||
|
#[command(flatten)]
|
||||||
|
domain: DomainArguments,
|
||||||
|
/// Write a stable machine-readable JSON report.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
/// Reset without asking for interactive confirmation.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
#[command(flatten)]
|
||||||
|
scope: ResetScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
#[group(id = "scope", required = true, multiple = false)]
|
||||||
|
struct ResetScope {
|
||||||
|
/// Reset every configured persistent sandbox path in the domain.
|
||||||
|
#[arg(long)]
|
||||||
|
all: bool,
|
||||||
|
/// Reset one exact configured container path, such as `/usr/local`.
|
||||||
|
#[arg(long, value_name = "CONTAINER_PATH")]
|
||||||
|
path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Command {
|
||||||
|
Run(ClientCommand),
|
||||||
|
State(StateCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the public command tree while preserving non-UTF-8 host paths and command arguments.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns clap's structured usage or help result.
|
||||||
|
pub fn parse(
|
||||||
|
arguments: impl IntoIterator<Item = OsString>,
|
||||||
|
current_workspace: PathBuf,
|
||||||
|
) -> Result<Command, clap::Error> {
|
||||||
|
let cli = Cli::try_parse_from(std::iter::once(OsString::from("lazaret")).chain(arguments))?;
|
||||||
|
Ok(match cli.command {
|
||||||
|
PublicCommand::Run(arguments) => Command::Run(ClientCommand {
|
||||||
|
profile: arguments.domain.profile,
|
||||||
|
workspace: arguments.domain.workspace.unwrap_or(current_workspace),
|
||||||
|
configuration: arguments.domain.config,
|
||||||
|
argv: arguments
|
||||||
|
.command
|
||||||
|
.into_iter()
|
||||||
|
.map(std::os::unix::ffi::OsStringExt::into_vec)
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
PublicCommand::State { operation } => {
|
||||||
|
Command::State(state_command(operation, current_workspace))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_command(operation: StateArguments, current_workspace: PathBuf) -> StateCommand {
|
||||||
|
let (operation, domain, machine, yes, scope) = match operation {
|
||||||
|
StateArguments::List(arguments) => (
|
||||||
|
StateOperation::List,
|
||||||
|
arguments.domain,
|
||||||
|
arguments.json,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
StateArguments::Inspect(arguments) => (
|
||||||
|
StateOperation::Inspect,
|
||||||
|
arguments.domain,
|
||||||
|
arguments.json,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
StateArguments::Verify(arguments) => (
|
||||||
|
StateOperation::Verify,
|
||||||
|
arguments.domain,
|
||||||
|
arguments.json,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
StateArguments::Reset(arguments) => {
|
||||||
|
let scope = match arguments.scope.path {
|
||||||
|
Some(path) => StateResetScopeV1::Selected(path.into_bytes()),
|
||||||
|
None => StateResetScopeV1::All,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
StateOperation::Reset,
|
||||||
|
arguments.domain,
|
||||||
|
arguments.json,
|
||||||
|
arguments.yes,
|
||||||
|
Some(scope),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
StateCommand {
|
||||||
|
operation,
|
||||||
|
context: ClientCommand {
|
||||||
|
profile: domain.profile,
|
||||||
|
workspace: domain.workspace.unwrap_or(current_workspace),
|
||||||
|
configuration: domain.config,
|
||||||
|
argv: Vec::new(),
|
||||||
|
},
|
||||||
|
machine,
|
||||||
|
yes,
|
||||||
|
scope,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
|
@ -22,6 +20,7 @@ use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
||||||
|
|
||||||
|
pub mod cli;
|
||||||
pub mod coordinator_backend;
|
pub mod coordinator_backend;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
@ -34,82 +33,6 @@ pub struct ClientCommand {
|
||||||
pub argv: Vec<Vec<u8>>,
|
pub argv: Vec<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct ArgumentError(&'static str);
|
|
||||||
|
|
||||||
impl fmt::Display for ArgumentError {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str(self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for ArgumentError {}
|
|
||||||
|
|
||||||
/// Parses `--profile`, optional host paths, and the exact command following `--`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns a concise usage error for missing values, unknown options, or a missing command.
|
|
||||||
pub fn parse_command_line(
|
|
||||||
arguments: impl IntoIterator<Item = OsString>,
|
|
||||||
current_workspace: PathBuf,
|
|
||||||
) -> Result<ClientCommand, ArgumentError> {
|
|
||||||
let mut arguments = arguments.into_iter();
|
|
||||||
let mut profile = None;
|
|
||||||
let mut workspace = None;
|
|
||||||
let mut configuration = None;
|
|
||||||
let mut argv = Vec::new();
|
|
||||||
while let Some(argument) = arguments.next() {
|
|
||||||
match argument.to_str() {
|
|
||||||
Some("--profile") => profile = Some(required_utf8(&mut arguments, "--profile")?),
|
|
||||||
Some("--workspace") => {
|
|
||||||
workspace = Some(PathBuf::from(required_value(
|
|
||||||
&mut arguments,
|
|
||||||
"--workspace",
|
|
||||||
)?));
|
|
||||||
}
|
|
||||||
Some("--config") => {
|
|
||||||
configuration = Some(PathBuf::from(required_value(&mut arguments, "--config")?));
|
|
||||||
}
|
|
||||||
Some("--") => {
|
|
||||||
argv.extend(arguments.map(OsString::into_vec));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) | None => return Err(ArgumentError("unknown option before `--`")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let profile = profile.ok_or(ArgumentError("`--profile <name>` is required"))?;
|
|
||||||
if argv.is_empty() {
|
|
||||||
return Err(ArgumentError("a command is required after `--`"));
|
|
||||||
}
|
|
||||||
Ok(ClientCommand {
|
|
||||||
profile,
|
|
||||||
workspace: workspace.unwrap_or(current_workspace),
|
|
||||||
configuration,
|
|
||||||
argv,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn required_value(
|
|
||||||
arguments: &mut impl Iterator<Item = OsString>,
|
|
||||||
option: &'static str,
|
|
||||||
) -> Result<OsString, ArgumentError> {
|
|
||||||
arguments.next().ok_or(match option {
|
|
||||||
"--profile" => ArgumentError("`--profile` requires a value"),
|
|
||||||
"--workspace" => ArgumentError("`--workspace` requires a path"),
|
|
||||||
"--config" => ArgumentError("`--config` requires a path"),
|
|
||||||
_ => ArgumentError("option requires a value"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn required_utf8(
|
|
||||||
arguments: &mut impl Iterator<Item = OsString>,
|
|
||||||
option: &'static str,
|
|
||||||
) -> Result<String, ArgumentError> {
|
|
||||||
required_value(arguments, option)?
|
|
||||||
.into_string()
|
|
||||||
.map_err(|_| ArgumentError("profile name must be UTF-8"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum DomainStatus<E> {
|
pub enum DomainStatus<E> {
|
||||||
Absent,
|
Absent,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,15 @@ fn main() {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if arguments.first().and_then(|value| value.to_str()) == Some("state") {
|
let current_workspace = std::env::current_dir().unwrap_or_else(|error| {
|
||||||
match lazaret_client::state::run_command(&arguments[1..]) {
|
eprintln!("cannot determine current workspace: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
let command = lazaret_client::cli::parse(arguments, current_workspace)
|
||||||
|
.unwrap_or_else(|error| error.exit());
|
||||||
|
match command {
|
||||||
|
lazaret_client::cli::Command::State(command) => {
|
||||||
|
match lazaret_client::state::run_command(command) {
|
||||||
Ok(status) => std::process::exit(status),
|
Ok(status) => std::process::exit(status),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("Lazaret state maintenance failed: {error}");
|
eprintln!("Lazaret state maintenance failed: {error}");
|
||||||
|
|
@ -16,20 +23,11 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let current_workspace = match std::env::current_dir() {
|
lazaret_client::cli::Command::Run(command) => run(&command),
|
||||||
Ok(path) => path,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("cannot determine current workspace: {error}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
let command = match lazaret_client::parse_command_line(arguments, current_workspace) {
|
|
||||||
Ok(command) => command,
|
fn run(command: &lazaret_client::ClientCommand) {
|
||||||
Err(error) => {
|
|
||||||
eprintln!("invalid command line: {error}");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut backend = match lazaret_client::coordinator_backend::CoordinatorClientBackend::new() {
|
let mut backend = match lazaret_client::coordinator_backend::CoordinatorClientBackend::new() {
|
||||||
Ok(backend) => backend,
|
Ok(backend) => backend,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -39,7 +37,7 @@ fn main() {
|
||||||
};
|
};
|
||||||
match lazaret_client::run(
|
match lazaret_client::run(
|
||||||
&mut backend,
|
&mut backend,
|
||||||
&command,
|
command,
|
||||||
&mut std::io::stdout(),
|
&mut std::io::stdout(),
|
||||||
&mut std::io::stderr(),
|
&mut std::io::stderr(),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::AsFd;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use lazaret_core::records::Domain;
|
use lazaret_core::records::Domain;
|
||||||
use lazaret_host_coordinator::domain_store::{HostCoordinatorDomainStore, StateResetScopeV1};
|
use lazaret_host_coordinator::domain_store::{HostCoordinatorDomainStore, StateResetScopeV1};
|
||||||
|
|
@ -19,19 +17,20 @@ use crate::coordinator_backend::{load_configuration, open_private_domain_lock};
|
||||||
const OUTPUT_SCHEMA: &str = "lazaret-state-output-v1";
|
const OUTPUT_SCHEMA: &str = "lazaret-state-output-v1";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum Operation {
|
pub enum StateOperation {
|
||||||
List,
|
List,
|
||||||
Inspect,
|
Inspect,
|
||||||
Verify,
|
Verify,
|
||||||
Reset,
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StateCommand {
|
#[derive(Debug)]
|
||||||
operation: Operation,
|
pub struct StateCommand {
|
||||||
context: ClientCommand,
|
pub(crate) operation: StateOperation,
|
||||||
machine: bool,
|
pub(crate) context: ClientCommand,
|
||||||
yes: bool,
|
pub(crate) machine: bool,
|
||||||
scope: Option<StateResetScopeV1>,
|
pub(crate) yes: bool,
|
||||||
|
pub(crate) scope: Option<StateResetScopeV1>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs one state-maintenance command using resolved configuration and exclusive Domain authority.
|
/// Runs one state-maintenance command using resolved configuration and exclusive Domain authority.
|
||||||
|
|
@ -39,8 +38,7 @@ struct StateCommand {
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Refuses invalid arguments, active Domains, arbitrary targets, authority changes, malformed
|
/// Refuses invalid arguments, active Domains, arbitrary targets, authority changes, malformed
|
||||||
/// durable state, structural failures, and reset without confirmation.
|
/// durable state, structural failures, and reset without confirmation.
|
||||||
pub fn run_command(arguments: &[OsString]) -> Result<i32, Box<dyn std::error::Error>> {
|
pub fn run_command(command: StateCommand) -> Result<i32, Box<dyn std::error::Error>> {
|
||||||
let command = parse(arguments)?;
|
|
||||||
let configuration = load_configuration(&command.context)?;
|
let configuration = load_configuration(&command.context)?;
|
||||||
if let Some(StateResetScopeV1::Selected(path)) = &command.scope
|
if let Some(StateResetScopeV1::Selected(path)) = &command.scope
|
||||||
&& !configuration
|
&& !configuration
|
||||||
|
|
@ -78,10 +76,10 @@ pub fn run_command(arguments: &[OsString]) -> Result<i32, Box<dyn std::error::Er
|
||||||
};
|
};
|
||||||
let store = HostCoordinatorDomainStore::open_file(lock, database.into(), &expected)?;
|
let store = HostCoordinatorDomainStore::open_file(lock, database.into(), &expected)?;
|
||||||
let report = match command.operation {
|
let report = match command.operation {
|
||||||
Operation::List | Operation::Inspect | Operation::Verify => {
|
StateOperation::List | StateOperation::Inspect | StateOperation::Verify => {
|
||||||
inspect_domain_state(&configuration, &store)?
|
inspect_domain_state(&configuration, &store)?
|
||||||
}
|
}
|
||||||
Operation::Reset => {
|
StateOperation::Reset => {
|
||||||
let scope = command.scope.ok_or("reset scope is missing")?;
|
let scope = command.scope.ok_or("reset scope is missing")?;
|
||||||
if !command.yes {
|
if !command.yes {
|
||||||
confirm(&configuration, &scope)?;
|
confirm(&configuration, &scope)?;
|
||||||
|
|
@ -93,97 +91,6 @@ pub fn run_command(arguments: &[OsString]) -> Result<i32, Box<dyn std::error::Er
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse(arguments: &[OsString]) -> Result<StateCommand, &'static str> {
|
|
||||||
let operation = match arguments.first().and_then(|value| value.to_str()) {
|
|
||||||
Some("list") => Operation::List,
|
|
||||||
Some("inspect") => Operation::Inspect,
|
|
||||||
Some("verify") => Operation::Verify,
|
|
||||||
Some("reset") => Operation::Reset,
|
|
||||||
_ => return Err("expected `state list|inspect|verify|reset`"),
|
|
||||||
};
|
|
||||||
let mut profile = None;
|
|
||||||
let mut workspace = None;
|
|
||||||
let mut configuration = None;
|
|
||||||
let mut machine = false;
|
|
||||||
let mut yes = false;
|
|
||||||
let mut scope = None;
|
|
||||||
let mut index = 1;
|
|
||||||
while index < arguments.len() {
|
|
||||||
let option = arguments[index].to_str().ok_or("options must be UTF-8")?;
|
|
||||||
index += 1;
|
|
||||||
match option {
|
|
||||||
"--profile" => profile = Some(value(arguments, &mut index, "--profile")?),
|
|
||||||
"--workspace" => workspace = Some(PathBuf::from(value_os(arguments, &mut index)?)),
|
|
||||||
"--config" => configuration = Some(PathBuf::from(value_os(arguments, &mut index)?)),
|
|
||||||
"--json" => machine = true,
|
|
||||||
"--yes" => yes = true,
|
|
||||||
"--all" => set_scope(&mut scope, StateResetScopeV1::All)?,
|
|
||||||
"--path" => {
|
|
||||||
let path = value(arguments, &mut index, "--path")?;
|
|
||||||
set_scope(&mut scope, StateResetScopeV1::Selected(path.into_bytes()))?;
|
|
||||||
}
|
|
||||||
_ => return Err("unknown state-maintenance option"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let profile = profile.ok_or("`--profile <name>` is required")?;
|
|
||||||
let workspace = match workspace {
|
|
||||||
Some(workspace) => workspace,
|
|
||||||
None => std::env::current_dir().map_err(|_| "cannot determine current workspace")?,
|
|
||||||
};
|
|
||||||
if operation == Operation::Reset && scope.is_none() {
|
|
||||||
return Err("reset requires exactly one of `--all` or `--path <container-path>`");
|
|
||||||
}
|
|
||||||
if operation != Operation::Reset && (scope.is_some() || yes) {
|
|
||||||
return Err("reset scope and confirmation options require `state reset`");
|
|
||||||
}
|
|
||||||
Ok(StateCommand {
|
|
||||||
operation,
|
|
||||||
context: ClientCommand {
|
|
||||||
profile,
|
|
||||||
workspace,
|
|
||||||
configuration,
|
|
||||||
argv: Vec::new(),
|
|
||||||
},
|
|
||||||
machine,
|
|
||||||
yes,
|
|
||||||
scope,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value(
|
|
||||||
arguments: &[OsString],
|
|
||||||
index: &mut usize,
|
|
||||||
option: &'static str,
|
|
||||||
) -> Result<String, &'static str> {
|
|
||||||
value_os(arguments, index)?
|
|
||||||
.into_string()
|
|
||||||
.map_err(|_| match option {
|
|
||||||
"--profile" => "profile name must be UTF-8",
|
|
||||||
"--path" => "container path must be UTF-8",
|
|
||||||
_ => "option value must be UTF-8",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_os(arguments: &[OsString], index: &mut usize) -> Result<OsString, &'static str> {
|
|
||||||
let value = arguments
|
|
||||||
.get(*index)
|
|
||||||
.ok_or("option requires a value")?
|
|
||||||
.clone();
|
|
||||||
*index += 1;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_scope(
|
|
||||||
current: &mut Option<StateResetScopeV1>,
|
|
||||||
scope: StateResetScopeV1,
|
|
||||||
) -> Result<(), &'static str> {
|
|
||||||
if current.replace(scope).is_some() {
|
|
||||||
Err("reset accepts exactly one scope")
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirm(
|
fn confirm(
|
||||||
configuration: &lazaret_core::configuration::ResolvedDomainConfiguration,
|
configuration: &lazaret_core::configuration::ResolvedDomainConfiguration,
|
||||||
scope: &StateResetScopeV1,
|
scope: &StateResetScopeV1,
|
||||||
|
|
@ -211,7 +118,7 @@ fn confirm(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_report(report: &DomainStateReport, operation: Operation, machine: bool) {
|
fn write_report(report: &DomainStateReport, operation: StateOperation, machine: bool) {
|
||||||
if machine {
|
if machine {
|
||||||
let anchors = report
|
let anchors = report
|
||||||
.anchors
|
.anchors
|
||||||
|
|
@ -252,7 +159,7 @@ fn write_report(report: &DomainStateReport, operation: Operation, machine: bool)
|
||||||
anchor.container_path,
|
anchor.container_path,
|
||||||
anchor.changes.len()
|
anchor.changes.len()
|
||||||
);
|
);
|
||||||
if matches!(operation, Operation::Inspect | Operation::Verify) {
|
if matches!(operation, StateOperation::Inspect | StateOperation::Verify) {
|
||||||
for entry in &anchor.changes {
|
for entry in &anchor.changes {
|
||||||
println!(
|
println!(
|
||||||
" {} {} mode {:o} uid {} gid {} size {}",
|
" {} {} mode {:o} uid {} gid {} size {}",
|
||||||
|
|
@ -270,12 +177,12 @@ fn write_report(report: &DomainStateReport, operation: Operation, machine: bool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn operation_name(operation: Operation) -> &'static str {
|
const fn operation_name(operation: StateOperation) -> &'static str {
|
||||||
match operation {
|
match operation {
|
||||||
Operation::List => "list",
|
StateOperation::List => "list",
|
||||||
Operation::Inspect => "inspect",
|
StateOperation::Inspect => "inspect",
|
||||||
Operation::Verify => "verify",
|
StateOperation::Verify => "verify",
|
||||||
Operation::Reset => "reset",
|
StateOperation::Reset => "reset",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,43 +202,3 @@ fn hex(bytes: &[u8]) -> String {
|
||||||
output
|
output
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parser_requires_explicit_reset_scope_and_rejects_arbitrary_options() {
|
|
||||||
assert!(parse(&["reset", "--profile", "default"].map(OsString::from)).is_err());
|
|
||||||
assert!(
|
|
||||||
parse(
|
|
||||||
&["reset", "--profile", "default", "--all", "--path", "/usr",].map(OsString::from)
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(parse(&["inspect", "--profile", "default", "--yes"].map(OsString::from)).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parser_preserves_only_container_scope_not_a_host_upper_path() {
|
|
||||||
let command = parse(
|
|
||||||
&[
|
|
||||||
"reset",
|
|
||||||
"--profile",
|
|
||||||
"default",
|
|
||||||
"--path",
|
|
||||||
"/usr",
|
|
||||||
"--yes",
|
|
||||||
"--json",
|
|
||||||
]
|
|
||||||
.map(OsString::from),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
command.scope,
|
|
||||||
Some(StateResetScopeV1::Selected(b"/usr".to_vec()))
|
|
||||||
);
|
|
||||||
assert!(command.yes);
|
|
||||||
assert!(command.machine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ use std::collections::VecDeque;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use lazaret_client::{
|
use lazaret_client::{
|
||||||
BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, DomainStatus,
|
BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, DomainStatus, run,
|
||||||
parse_command_line, run,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
|
@ -234,8 +234,9 @@ fn unsupported_host_and_configuration_errors_keep_their_cause() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_line_requires_an_explicit_profile_and_preserves_command_bytes() {
|
fn command_line_requires_an_explicit_profile_and_preserves_command_bytes() {
|
||||||
let parsed = parse_command_line(
|
let parsed = lazaret_client::cli::parse(
|
||||||
[
|
[
|
||||||
|
"run",
|
||||||
"--profile",
|
"--profile",
|
||||||
"agent",
|
"agent",
|
||||||
"--workspace",
|
"--workspace",
|
||||||
|
|
@ -250,6 +251,9 @@ fn command_line_requires_an_explicit_profile_and_preserves_command_bytes() {
|
||||||
PathBuf::from("/current"),
|
PathBuf::from("/current"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let lazaret_client::cli::Command::Run(parsed) = parsed else {
|
||||||
|
panic!("expected run command");
|
||||||
|
};
|
||||||
assert_eq!(parsed.profile, "agent");
|
assert_eq!(parsed.profile, "agent");
|
||||||
assert_eq!(parsed.workspace, PathBuf::from("/project"));
|
assert_eq!(parsed.workspace, PathBuf::from("/project"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -259,8 +263,8 @@ fn command_line_requires_an_explicit_profile_and_preserves_command_bytes() {
|
||||||
assert_eq!(parsed.argv, [b"tool".to_vec(), b"argument".to_vec()]);
|
assert_eq!(parsed.argv, [b"tool".to_vec(), b"argument".to_vec()]);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
parse_command_line(
|
lazaret_client::cli::parse(
|
||||||
["--", "tool"].map(OsString::from),
|
["run", "--", "tool"].map(OsString::from),
|
||||||
PathBuf::from("/current")
|
PathBuf::from("/current")
|
||||||
)
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
|
|
@ -268,3 +272,102 @@ fn command_line_requires_an_explicit_profile_and_preserves_command_bytes() {
|
||||||
.contains("profile")
|
.contains("profile")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_line_preserves_non_utf8_paths_and_command_arguments() {
|
||||||
|
let workspace = OsString::from_vec(b"/project-\xff".to_vec());
|
||||||
|
let argument = OsString::from_vec(b"argument-\xfe".to_vec());
|
||||||
|
let parsed = lazaret_client::cli::parse(
|
||||||
|
[
|
||||||
|
OsString::from("run"),
|
||||||
|
OsString::from("--profile"),
|
||||||
|
OsString::from("agent"),
|
||||||
|
OsString::from("--workspace"),
|
||||||
|
workspace.clone(),
|
||||||
|
OsString::from("--"),
|
||||||
|
OsString::from("tool"),
|
||||||
|
argument,
|
||||||
|
],
|
||||||
|
PathBuf::from("/current"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let lazaret_client::cli::Command::Run(parsed) = parsed else {
|
||||||
|
panic!("expected run command");
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.workspace, PathBuf::from(workspace));
|
||||||
|
assert_eq!(parsed.argv, [b"tool".to_vec(), b"argument-\xfe".to_vec()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_help_explains_commands_and_options() {
|
||||||
|
for (arguments, expected) in [
|
||||||
|
(
|
||||||
|
&["--help"][..],
|
||||||
|
&[
|
||||||
|
"Run development tools in an isolated gVisor sandbox",
|
||||||
|
"run",
|
||||||
|
"state",
|
||||||
|
][..],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["run", "--help"],
|
||||||
|
&[
|
||||||
|
"Agent profile to use",
|
||||||
|
"Project directory to share",
|
||||||
|
"Command and arguments to run",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["state", "--help"],
|
||||||
|
&["list", "inspect", "verify", "reset"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["state", "reset", "--help"],
|
||||||
|
&[
|
||||||
|
"Reset every configured persistent sandbox path",
|
||||||
|
"Reset one exact configured container path",
|
||||||
|
"interactive confirmation",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let error = lazaret_client::cli::parse(
|
||||||
|
arguments.iter().map(OsString::from),
|
||||||
|
PathBuf::from("/current"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
|
||||||
|
let help = error.to_string();
|
||||||
|
for text in expected {
|
||||||
|
assert!(
|
||||||
|
help.contains(text),
|
||||||
|
"{arguments:?} did not explain {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clap_rejects_missing_profile_command_and_ambiguous_reset_scope() {
|
||||||
|
let workspace = PathBuf::from("/current");
|
||||||
|
for arguments in [
|
||||||
|
vec!["run", "--", "tool"],
|
||||||
|
vec!["run", "--profile", "agent"],
|
||||||
|
vec!["state", "reset", "--profile", "agent"],
|
||||||
|
vec![
|
||||||
|
"state",
|
||||||
|
"reset",
|
||||||
|
"--profile",
|
||||||
|
"agent",
|
||||||
|
"--all",
|
||||||
|
"--path",
|
||||||
|
"/usr",
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
let error = lazaret_client::cli::parse(
|
||||||
|
arguments.into_iter().map(OsString::from),
|
||||||
|
workspace.clone(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.exit_code(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue