Add coordinator-owned interactive session terminals
This commit is contained in:
parent
3b76b59bdf
commit
51ff63a3aa
9 changed files with 2078 additions and 15 deletions
|
|
@ -38,7 +38,7 @@ nix = { version = "=0.31.3", features = ["fs"] }
|
||||||
rustls = { version = "=0.23.42", default-features = false, features = ["aws_lc_rs", "std"] }
|
rustls = { version = "=0.23.42", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||||
serde = { version = "=1.0.228", features = ["derive"] }
|
serde = { version = "=1.0.228", features = ["derive"] }
|
||||||
serde_json = "=1.0.150"
|
serde_json = "=1.0.150"
|
||||||
rustix = { version = "=1.1.4", features = ["event", "fs", "mount", "net", "pipe", "process", "thread", "time"] }
|
rustix = { version = "=1.1.4", features = ["event", "fs", "mount", "net", "pipe", "process", "pty", "stdio", "termios", "thread", "time"] }
|
||||||
sha2 = "=0.11.0"
|
sha2 = "=0.11.0"
|
||||||
tar = "=0.4.46"
|
tar = "=0.4.46"
|
||||||
time = "=0.3.53"
|
time = "=0.3.53"
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ pub mod production_selector;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod sandbox_init;
|
pub mod sandbox_init;
|
||||||
pub mod state_anchor;
|
pub mod state_anchor;
|
||||||
|
pub mod terminal;
|
||||||
pub mod waiter;
|
pub mod waiter;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@ fn main() {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if lazaret_host_coordinator::sandbox_init::dispatch_private_sandbox_init(&arguments) {
|
if let Some(result) =
|
||||||
|
lazaret_host_coordinator::sandbox_init::dispatch_private_sandbox_init(&arguments)
|
||||||
|
{
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("sandbox private role failed: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(result) = lazaret_linux::process::dispatch_private_process_role(&arguments) {
|
if let Some(result) = lazaret_linux::process::dispatch_private_process_role(&arguments) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
|
||||||
|
use lazaret_linux::process::{TargetCommand, exec_target};
|
||||||
|
|
||||||
pub const PRIVATE_SANDBOX_INIT_ROLE: &str = "--lazaret-private-sandbox-init-v1";
|
pub const PRIVATE_SANDBOX_INIT_ROLE: &str = "--lazaret-private-sandbox-init-v1";
|
||||||
|
pub const PRIVATE_SESSION_EXEC_ROLE: &str = "--lazaret-private-session-exec-v1";
|
||||||
|
pub const PRIVATE_SESSION_IDENTITY_ROLE: &str = "--lazaret-private-session-identity-v1";
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_private_sandbox_init_role(arguments: &[OsString]) -> bool {
|
pub fn is_private_sandbox_init_role(arguments: &[OsString]) -> bool {
|
||||||
|
|
@ -8,14 +14,77 @@ pub fn is_private_sandbox_init_role(arguments: &[OsString]) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn dispatch_private_sandbox_init(arguments: &[OsString]) -> bool {
|
pub fn dispatch_private_sandbox_init(arguments: &[OsString]) -> Option<io::Result<()>> {
|
||||||
if !is_private_sandbox_init_role(arguments) {
|
if is_private_sandbox_init_role(arguments) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
loop {
|
loop {
|
||||||
std::thread::park();
|
std::thread::park();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if arguments
|
||||||
|
.first()
|
||||||
|
.is_some_and(|argument| argument == PRIVATE_SESSION_EXEC_ROLE)
|
||||||
|
{
|
||||||
|
return Some(run_session_exec(&arguments[1..]));
|
||||||
|
}
|
||||||
|
if arguments
|
||||||
|
.first()
|
||||||
|
.is_some_and(|argument| argument == PRIVATE_SESSION_IDENTITY_ROLE)
|
||||||
|
{
|
||||||
|
return Some(report_session_identity(&arguments[1..]));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_session_exec(arguments: &[OsString]) -> io::Result<()> {
|
||||||
|
let (separator, argv) = arguments
|
||||||
|
.split_first()
|
||||||
|
.filter(|(separator, argv)| {
|
||||||
|
separator == &OsStr::new("--")
|
||||||
|
&& !argv.is_empty()
|
||||||
|
&& !argv[0].is_empty()
|
||||||
|
&& argv
|
||||||
|
.iter()
|
||||||
|
.all(|argument| !argument.as_bytes().contains(&0))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| io::Error::other("session exec requires -- and a nonempty argv"))?;
|
||||||
|
let _ = separator;
|
||||||
|
rustix::process::setsid()?;
|
||||||
|
let target = argv[1..]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.fold(TargetCommand::new(argv[0].clone()), TargetCommand::argument);
|
||||||
|
Err(exec_target(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_session_identity(arguments: &[OsString]) -> io::Result<()> {
|
||||||
|
let pid = match arguments {
|
||||||
|
[pid] => pid
|
||||||
|
.to_str()
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.filter(|pid| *pid > 0)
|
||||||
|
.ok_or_else(|| io::Error::other("session identity PID is invalid"))?,
|
||||||
|
_ => return Err(io::Error::other("session identity requires one PID")),
|
||||||
|
};
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
|
||||||
|
let close = stat
|
||||||
|
.rfind(')')
|
||||||
|
.ok_or_else(|| io::Error::other("guest process stat is malformed"))?;
|
||||||
|
let observed_pid = stat[..close]
|
||||||
|
.split_once(' ')
|
||||||
|
.and_then(|(pid, _)| pid.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| io::Error::other("guest process stat PID is malformed"))?;
|
||||||
|
let fields = stat[close + 1..].split_whitespace().collect::<Vec<_>>();
|
||||||
|
let group = fields
|
||||||
|
.get(2)
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| io::Error::other("guest process stat group is malformed"))?;
|
||||||
|
let start_ticks = fields
|
||||||
|
.get(19)
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.ok_or_else(|| io::Error::other("guest process stat start is malformed"))?;
|
||||||
|
println!("{observed_pid} {start_ticks} {group}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
@ -35,4 +104,25 @@ mod tests {
|
||||||
"--private-sandbox-init"
|
"--private-sandbox-init"
|
||||||
)]));
|
)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_session_roles_are_shape_separated() {
|
||||||
|
assert!(dispatch_private_sandbox_init(&[]).is_none());
|
||||||
|
assert!(
|
||||||
|
dispatch_private_sandbox_init(&[
|
||||||
|
OsString::from(PRIVATE_SESSION_EXEC_ROLE),
|
||||||
|
OsString::from("missing-separator"),
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dispatch_private_sandbox_init(&[
|
||||||
|
OsString::from(PRIVATE_SESSION_IDENTITY_ROLE),
|
||||||
|
OsString::from("not-a-pid"),
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1050
crates/lazaret-host-coordinator/src/terminal.rs
Normal file
1050
crates/lazaret-host-coordinator/src/terminal.rs
Normal file
File diff suppressed because it is too large
Load diff
561
crates/lazaret-host-coordinator/src/terminal/tests.rs
Normal file
561
crates/lazaret-host-coordinator/src/terminal/tests.rs
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io;
|
||||||
|
use std::num::NonZeroU16;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
BootId, BundleDigest, ClientId, ConfigurationFingerprint, HostPath, HostPidLocator,
|
||||||
|
NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1, ProcessStartTimeTicks,
|
||||||
|
SandboxId, SessionAnchorId,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{
|
||||||
|
Domain, DurableDecision, Generation, Participant, SandboxOwnership, Session, TerminalInput,
|
||||||
|
TerminalSignal,
|
||||||
|
};
|
||||||
|
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn bytes(value: u8) -> [u8; 32] {
|
||||||
|
[value; 32]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(value: u64) -> PidfsProcessIdentityV1 {
|
||||||
|
PidfsProcessIdentityV1::new(
|
||||||
|
PidfsProcessAuthorityV1 {
|
||||||
|
boot_id: BootId::new([1; 16]),
|
||||||
|
pidfs_st_dev: PidfsDeviceId::new(value),
|
||||||
|
pidfs_st_ino: PidfsInodeId::new(value + 1),
|
||||||
|
},
|
||||||
|
HostPidLocator::new(u32::try_from(value).unwrap()),
|
||||||
|
ProcessStartTimeTicks::new(value + 2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_file(path: &std::path::Path) -> File {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(path)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_directory: TempDir,
|
||||||
|
store: Arc<HostCoordinatorDomainStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new(session_ids: &[SessionId]) -> Self {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let domain = Domain {
|
||||||
|
domain_id: DomainId::new(bytes(1)),
|
||||||
|
profile_name: "trusted".into(),
|
||||||
|
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
|
||||||
|
};
|
||||||
|
let lock = try_exclusive_flock(lock_file(&directory.path().join("domain.lock"))).unwrap();
|
||||||
|
let store = Arc::new(
|
||||||
|
HostCoordinatorDomainStore::open(lock, &directory.path().join("domain.redb"), &domain)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutGeneration(Generation {
|
||||||
|
domain_id: domain.domain_id,
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
|
||||||
|
coordinator_id: CoordinatorId::new(bytes(3)),
|
||||||
|
network_sidecar_id: NetworkSidecarId::new(bytes(4)),
|
||||||
|
sandbox_id: SandboxId::new(bytes(5)),
|
||||||
|
durable_decision: DurableDecision::None,
|
||||||
|
})])
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutParticipant(
|
||||||
|
Participant::bare_intent(GenerationId::new(7), ParticipantRole::Sandbox),
|
||||||
|
)])
|
||||||
|
.unwrap();
|
||||||
|
let mut sandbox = SandboxOwnership {
|
||||||
|
container_id: Some(ContainerId::new(bytes(9))),
|
||||||
|
runtime_root: Some(HostPath::new(b"runtime".to_vec())),
|
||||||
|
bundle_digest: Some(BundleDigest::new(bytes(10))),
|
||||||
|
host_coordinator: PersistedHostProcessOwnership::committed(process(70)),
|
||||||
|
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||||
|
attached_runsc: PersistedHostProcessOwnership::unassigned(),
|
||||||
|
};
|
||||||
|
let sandbox_snapshot = |state, ownership| {
|
||||||
|
Participant::snapshot(
|
||||||
|
GenerationId::new(7),
|
||||||
|
ParticipantRole::Sandbox,
|
||||||
|
state,
|
||||||
|
Some(ParticipantOwnership::Sandbox(ownership)),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutParticipant(sandbox_snapshot(
|
||||||
|
ParticipantState::Intent,
|
||||||
|
sandbox.clone(),
|
||||||
|
))])
|
||||||
|
.unwrap();
|
||||||
|
sandbox.runtime_supervisor = PersistedHostProcessOwnership::staged(process(71));
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutParticipant(sandbox_snapshot(
|
||||||
|
ParticipantState::Intent,
|
||||||
|
sandbox.clone(),
|
||||||
|
))])
|
||||||
|
.unwrap();
|
||||||
|
sandbox.runtime_supervisor = PersistedHostProcessOwnership::committed(process(71));
|
||||||
|
sandbox.attached_runsc = PersistedHostProcessOwnership::staged(process(72));
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutParticipant(sandbox_snapshot(
|
||||||
|
ParticipantState::Intent,
|
||||||
|
sandbox.clone(),
|
||||||
|
))])
|
||||||
|
.unwrap();
|
||||||
|
sandbox.attached_runsc = PersistedHostProcessOwnership::committed(process(72));
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutParticipant(sandbox_snapshot(
|
||||||
|
ParticipantState::Committed,
|
||||||
|
sandbox,
|
||||||
|
))])
|
||||||
|
.unwrap();
|
||||||
|
for (index, session_id) in session_ids.iter().enumerate() {
|
||||||
|
let mut session = Session {
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
session_id: *session_id,
|
||||||
|
cli_id: ClientId::new(bytes(20 + u8::try_from(index).unwrap())),
|
||||||
|
session_anchor_id: SessionAnchorId::new(bytes(30 + u8::try_from(index).unwrap())),
|
||||||
|
session_phase: SessionPhase::Registering,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutSession(session.clone())])
|
||||||
|
.unwrap();
|
||||||
|
session.session_phase = SessionPhase::Attached;
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutSession(session)])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
_directory: directory,
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MockState {
|
||||||
|
output: VecDeque<Vec<u8>>,
|
||||||
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
|
group_present: bool,
|
||||||
|
identity_mismatch: bool,
|
||||||
|
exec_exited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockProcess {
|
||||||
|
state: Arc<Mutex<MockState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionTerminalProcess for MockProcess {
|
||||||
|
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.log
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("input:{}", bytes.len()));
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(&self, resize: TerminalResize) -> io::Result<()> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.log
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("resize:{}x{}", resize.rows, resize.columns));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let Some(output) = state.output.pop_front() else {
|
||||||
|
return Err(io::ErrorKind::WouldBlock.into());
|
||||||
|
};
|
||||||
|
let length = output.len().min(bytes.len());
|
||||||
|
bytes[..length].copy_from_slice(&output[..length]);
|
||||||
|
state.log.lock().unwrap().push(format!("output:{length}"));
|
||||||
|
Ok(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_exited(&self) -> io::Result<bool> {
|
||||||
|
Ok(self.state.lock().unwrap().exec_exited)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_group(&self, signal: &str) -> Result<(), TerminalError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
if state.identity_mismatch {
|
||||||
|
return Err(TerminalError::GuestIdentityMismatch);
|
||||||
|
}
|
||||||
|
state.log.lock().unwrap().push(format!("signal:{signal}"));
|
||||||
|
if signal == "HUP" {
|
||||||
|
state.group_present = false;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_absent(&self) -> Result<bool, TerminalError> {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
if state.identity_mismatch {
|
||||||
|
return Err(TerminalError::GuestIdentityMismatch);
|
||||||
|
}
|
||||||
|
Ok(!state.group_present)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminate_exec(&self) -> io::Result<()> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.log.lock().unwrap().push("terminate-exec".into());
|
||||||
|
state.exec_exited = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reap_exec(&mut self) -> io::Result<ProcessExit> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.log
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push("reap-exec".into());
|
||||||
|
Ok(ProcessExit {
|
||||||
|
exit_code: Some(17),
|
||||||
|
terminating_signal: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_pty(&mut self) {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state.log.lock().unwrap().push("close-pty".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MockFactory {
|
||||||
|
states: Mutex<Vec<Arc<Mutex<MockState>>>>,
|
||||||
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockFactory {
|
||||||
|
fn state(&self, index: usize) -> Arc<Mutex<MockState>> {
|
||||||
|
Arc::clone(&self.states.lock().unwrap()[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionTerminalFactory for MockFactory {
|
||||||
|
fn acquire(
|
||||||
|
&self,
|
||||||
|
request: &TerminalAcquisition,
|
||||||
|
recorder: &HostCoordinatorTerminalRecorder,
|
||||||
|
) -> Result<(SessionTerminal, Box<dyn SessionTerminalProcess>), TerminalError> {
|
||||||
|
let staged = staged_terminal(
|
||||||
|
request,
|
||||||
|
PtyIdentity {
|
||||||
|
master_fd: FileDescriptor::new(9),
|
||||||
|
device: DeviceId::new(10),
|
||||||
|
inode: InodeId::new(11),
|
||||||
|
},
|
||||||
|
StagedProcessOwnership::v1(
|
||||||
|
request.domain_id,
|
||||||
|
request.generation_id,
|
||||||
|
request.coordinator_id,
|
||||||
|
lazaret_core::records::HostProcessOwnershipSlot::SessionTerminalExecClient(
|
||||||
|
request.session_id,
|
||||||
|
),
|
||||||
|
process(90),
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
recorder.put(staged)?;
|
||||||
|
let guest = GuestProcessIdentity {
|
||||||
|
leader_pid: GuestProcessId::new(41),
|
||||||
|
leader_start_time: GuestProcessStartTimeTicks::new(101),
|
||||||
|
process_group_id: GuestProcessGroupId::new(41),
|
||||||
|
};
|
||||||
|
let running = terminal_snapshot(
|
||||||
|
request,
|
||||||
|
SessionTerminalState::Running,
|
||||||
|
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||||
|
Some(PtyIdentity {
|
||||||
|
master_fd: FileDescriptor::new(9),
|
||||||
|
device: DeviceId::new(10),
|
||||||
|
inode: InodeId::new(11),
|
||||||
|
}),
|
||||||
|
PersistedHostProcessOwnership::committed(process(90)),
|
||||||
|
Some(guest),
|
||||||
|
)?;
|
||||||
|
recorder.put(running.clone())?;
|
||||||
|
let state = Arc::new(Mutex::new(MockState {
|
||||||
|
output: VecDeque::new(),
|
||||||
|
log: Arc::clone(&self.log),
|
||||||
|
group_present: true,
|
||||||
|
identity_mismatch: false,
|
||||||
|
exec_exited: false,
|
||||||
|
}));
|
||||||
|
self.states.lock().unwrap().push(Arc::clone(&state));
|
||||||
|
Ok((running, Box::new(MockProcess { state })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cancellation(Arc<Mutex<Vec<String>>>);
|
||||||
|
|
||||||
|
impl PromptRouteCancellation for Cancellation {
|
||||||
|
fn cancel_for_close(&self, _session_id: SessionId) -> Result<(), TerminalError> {
|
||||||
|
self.0.lock().unwrap().push("cancel-route".into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquisition(session_id: SessionId) -> TerminalAcquisition {
|
||||||
|
TerminalAcquisition {
|
||||||
|
domain_id: DomainId::new(bytes(1)),
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
coordinator_id: CoordinatorId::new(bytes(3)),
|
||||||
|
session_id,
|
||||||
|
container_id: ContainerId::new(bytes(9)),
|
||||||
|
host_coordinator: process(70),
|
||||||
|
runsc_program: "runsc".into(),
|
||||||
|
runsc_global_arguments: Vec::new(),
|
||||||
|
internal_pid_file: "/tmp/not-used-by-mock".into(),
|
||||||
|
argv: vec![b"/bin/agent".to_vec()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(ids: &[SessionId]) -> (Fixture, Arc<MockFactory>, SessionTerminalEngine) {
|
||||||
|
let fixture = Fixture::new(ids);
|
||||||
|
let factory = Arc::new(MockFactory::default());
|
||||||
|
let engine = SessionTerminalEngine::new(
|
||||||
|
Arc::clone(&fixture.store),
|
||||||
|
factory.clone(),
|
||||||
|
Arc::new(Cancellation(Arc::clone(&factory.log))),
|
||||||
|
);
|
||||||
|
(fixture, factory, engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(value: u8) -> SessionId {
|
||||||
|
SessionId::new(bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquisition_refuses_authority_outside_the_committed_sandbox() {
|
||||||
|
let session = id(6);
|
||||||
|
let (fixture, _factory, mut engine) = setup(&[session]);
|
||||||
|
let mut request = acquisition(session);
|
||||||
|
request.host_coordinator = process(700);
|
||||||
|
assert!(matches!(
|
||||||
|
engine.acquire(&request),
|
||||||
|
Err(TerminalError::AuthorityMismatch)
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.store
|
||||||
|
.snapshot()
|
||||||
|
.unwrap()
|
||||||
|
.session_terminals
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_and_terminal_signals_use_the_exact_group_map() {
|
||||||
|
let session = id(6);
|
||||||
|
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
engine
|
||||||
|
.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Resize(TerminalResize {
|
||||||
|
session_id: session,
|
||||||
|
rows: NonZeroU16::new(40).unwrap(),
|
||||||
|
columns: NonZeroU16::new(120).unwrap(),
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (kind, expected) in [
|
||||||
|
(TerminalSignalKind::Interrupt, "signal:INT"),
|
||||||
|
(TerminalSignalKind::Quit, "signal:QUIT"),
|
||||||
|
(TerminalSignalKind::Suspend, "signal:TSTP"),
|
||||||
|
] {
|
||||||
|
engine
|
||||||
|
.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Signal(TerminalSignal {
|
||||||
|
session_id: session,
|
||||||
|
signal: kind,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(factory.log.lock().unwrap().last().unwrap(), expected);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
&factory.log.lock().unwrap()[..2],
|
||||||
|
&["resize:40x120", "signal:WINCH"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_guest_identity_refuses_numeric_group_action() {
|
||||||
|
let session = id(6);
|
||||||
|
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
factory.state(0).lock().unwrap().identity_mismatch = true;
|
||||||
|
assert!(matches!(
|
||||||
|
engine.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Signal(TerminalSignal {
|
||||||
|
session_id: session,
|
||||||
|
signal: TerminalSignalKind::Interrupt,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(TerminalError::GuestIdentityMismatch)
|
||||||
|
));
|
||||||
|
assert!(factory.log.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
||||||
|
let first = id(6);
|
||||||
|
let second = id(7);
|
||||||
|
let (_fixture, factory, mut engine) = setup(&[first, second]);
|
||||||
|
engine.acquire(&acquisition(first)).unwrap();
|
||||||
|
engine.acquire(&acquisition(second)).unwrap();
|
||||||
|
factory
|
||||||
|
.state(0)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.output
|
||||||
|
.extend([b"one".to_vec(), b"two".to_vec()]);
|
||||||
|
factory
|
||||||
|
.state(1)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.output
|
||||||
|
.push_back(b"other".to_vec());
|
||||||
|
engine
|
||||||
|
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
||||||
|
.unwrap();
|
||||||
|
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
engine.poll(second, GenerationId::new(7)).unwrap(),
|
||||||
|
vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||||
|
session_id: second,
|
||||||
|
bytes: b"other".to_vec(),
|
||||||
|
})]
|
||||||
|
);
|
||||||
|
engine
|
||||||
|
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||||
|
.unwrap();
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
||||||
|
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
||||||
|
panic!("expected one merged output message");
|
||||||
|
};
|
||||||
|
merged.extend_from_slice(&output.bytes);
|
||||||
|
}
|
||||||
|
assert_eq!(merged, b"onetwo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
||||||
|
let session = id(6);
|
||||||
|
let (fixture, factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
factory
|
||||||
|
.state(0)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.output
|
||||||
|
.extend((0..10).map(|_| vec![b'x'; MAX_OUTPUT_BYTES]));
|
||||||
|
let messages = engine
|
||||||
|
.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||||
|
session_id: session,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = messages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| match message {
|
||||||
|
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
|
||||||
|
SessionTerminalMessage::Exit(_) => None,
|
||||||
|
})
|
||||||
|
.sum::<usize>();
|
||||||
|
assert_eq!(bytes, MAX_DRAIN_BYTES);
|
||||||
|
assert!(
|
||||||
|
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&factory.log.lock().unwrap()[..2],
|
||||||
|
&["cancel-route", "signal:HUP"]
|
||||||
|
);
|
||||||
|
assert!(!engine.contains(session));
|
||||||
|
assert!(matches!(
|
||||||
|
engine.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Input(TerminalInput {
|
||||||
|
session_id: session,
|
||||||
|
bytes: b"reattach".to_vec(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(TerminalError::UnknownSession)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||||
|
SessionTerminalState::Clean
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
||||||
|
for loss in [
|
||||||
|
TerminalLoss::ClientDisconnected,
|
||||||
|
TerminalLoss::PtyLost,
|
||||||
|
TerminalLoss::ExecClientLost,
|
||||||
|
] {
|
||||||
|
let session = id(6);
|
||||||
|
let (_fixture, _factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
let messages = engine.lose(session, GenerationId::new(7), loss).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
messages.is_empty(),
|
||||||
|
loss == TerminalLoss::ClientDisconnected
|
||||||
|
);
|
||||||
|
assert!(!engine.contains(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = id(6);
|
||||||
|
let (fixture, _factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
assert!(
|
||||||
|
engine
|
||||||
|
.lose(
|
||||||
|
session,
|
||||||
|
GenerationId::new(7),
|
||||||
|
TerminalLoss::HostCoordinatorLost,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(!engine.contains(session));
|
||||||
|
assert_eq!(
|
||||||
|
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||||
|
SessionTerminalState::Running
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use std::os::unix::fs::symlink;
|
||||||
use std::os::unix::net::{SocketAddr, UnixListener, UnixStream};
|
use std::os::unix::net::{SocketAddr, UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -14,18 +15,24 @@ use lazaret_core::configuration::{
|
||||||
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
|
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
|
||||||
};
|
};
|
||||||
use lazaret_core::identity::{
|
use lazaret_core::identity::{
|
||||||
BundleDigest, ContainerId, CoordinatorId, GenerationId, HostPath, NetworkSidecarId, SandboxId,
|
BundleDigest, ClientId, ContainerId, CoordinatorId, GenerationId, HostPath, NetworkSidecarId,
|
||||||
|
SandboxId, SessionAnchorId, SessionId,
|
||||||
};
|
};
|
||||||
use lazaret_core::network::{DnsQueryExpectation, ReachabilityExpectation, derive_ipv4_policy};
|
use lazaret_core::network::{DnsQueryExpectation, ReachabilityExpectation, derive_ipv4_policy};
|
||||||
use lazaret_core::records::{
|
use lazaret_core::records::{
|
||||||
Domain, DurableDecision, Generation, HostProcessOwnershipSlot, IpAddress, ParticipantRole,
|
Domain, DurableDecision, Generation, HostProcessOwnershipSlot, IpAddress, ParticipantRole,
|
||||||
ParticipantState, ProcessOwnershipCommitAck,
|
ParticipantState, ProcessOwnershipCommitAck, Session, SessionPhase, SessionTerminalEvent,
|
||||||
|
SessionTerminalMessage, TerminalResize, TerminalSignal, TerminalSignalKind,
|
||||||
};
|
};
|
||||||
use lazaret_host_coordinator::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
|
use lazaret_host_coordinator::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
|
||||||
use lazaret_host_coordinator::network::NetworkCoordinator;
|
use lazaret_host_coordinator::network::NetworkCoordinator;
|
||||||
use lazaret_host_coordinator::runtime::{
|
use lazaret_host_coordinator::runtime::{
|
||||||
AwaitingAttachedRunsc, CommittedSandboxRuntime, SandboxCoordinator,
|
AwaitingAttachedRunsc, CommittedSandboxRuntime, SandboxCoordinator,
|
||||||
};
|
};
|
||||||
|
use lazaret_host_coordinator::terminal::{
|
||||||
|
NoActivePrompt, RunscSessionTerminalFactory, SessionTerminalEngine, TerminalAcquisition,
|
||||||
|
TerminalRoute,
|
||||||
|
};
|
||||||
use lazaret_linux::filesystem::try_exclusive_flock;
|
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||||
use lazaret_linux::platform::{PlatformObservation, check_platform};
|
use lazaret_linux::platform::{PlatformObservation, check_platform};
|
||||||
use lazaret_linux::process::{
|
use lazaret_linux::process::{
|
||||||
|
|
@ -48,6 +55,7 @@ const HOST_COORDINATOR_DRIVER: &str = "--runtime-composition-host-coordinator";
|
||||||
const SANDBOX_NETWORK_PROBE: &str = "--sandbox-network-probe";
|
const SANDBOX_NETWORK_PROBE: &str = "--sandbox-network-probe";
|
||||||
const NETWORK_MATRIX: &str = "--network-matrix";
|
const NETWORK_MATRIX: &str = "--network-matrix";
|
||||||
const HANDOFF_MATRIX: &str = "--handoff-matrix";
|
const HANDOFF_MATRIX: &str = "--handoff-matrix";
|
||||||
|
const SESSION_TERMINAL_MATRIX: &str = "--session-terminal-matrix";
|
||||||
const BENCHMARK_DRIVER: &str = "--runtime-composition-benchmark-command";
|
const BENCHMARK_DRIVER: &str = "--runtime-composition-benchmark-command";
|
||||||
const BENCHMARK_GUEST: &str = "--runtime-composition-benchmark-guest";
|
const BENCHMARK_GUEST: &str = "--runtime-composition-benchmark-guest";
|
||||||
const BENCHMARK_COMPLETE: &str = "benchmark-complete";
|
const BENCHMARK_COMPLETE: &str = "benchmark-complete";
|
||||||
|
|
@ -61,6 +69,11 @@ fn main() -> ExitCode {
|
||||||
if let Some(result) = dispatch_private_runtime_role(&arguments) {
|
if let Some(result) = dispatch_private_runtime_role(&arguments) {
|
||||||
return exit(result);
|
return exit(result);
|
||||||
}
|
}
|
||||||
|
if let Some(result) =
|
||||||
|
lazaret_host_coordinator::sandbox_init::dispatch_private_sandbox_init(&arguments)
|
||||||
|
{
|
||||||
|
return exit(result);
|
||||||
|
}
|
||||||
if arguments.first().and_then(|argument| argument.to_str()) == Some(BENCHMARK_GUEST) {
|
if arguments.first().and_then(|argument| argument.to_str()) == Some(BENCHMARK_GUEST) {
|
||||||
return exit(run_benchmark_guest(&arguments[1..]));
|
return exit(run_benchmark_guest(&arguments[1..]));
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +118,22 @@ fn main() -> ExitCode {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if arguments.first().and_then(|argument| argument.to_str()) == Some(SESSION_TERMINAL_MATRIX) {
|
||||||
|
return match run_session_terminal_matrix() {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("PASS production Session Terminal matrix");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(MatrixError::Skip(reason)) => {
|
||||||
|
println!("SKIP production Session Terminal matrix: {reason}");
|
||||||
|
ExitCode::from(SKIP)
|
||||||
|
}
|
||||||
|
Err(MatrixError::Failure(error)) => {
|
||||||
|
eprintln!("production Session Terminal matrix failed: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
match run_matrix() {
|
match run_matrix() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
println!("PASS foreground runtime composition and causal-loss matrix");
|
println!("PASS foreground runtime composition and causal-loss matrix");
|
||||||
|
|
@ -241,6 +270,202 @@ fn run_network_matrix() -> Result<(), MatrixError> {
|
||||||
run_network_cases()
|
run_network_cases()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn run_session_terminal_matrix() -> Result<(), MatrixError> {
|
||||||
|
require_matrix_prerequisites()?;
|
||||||
|
let fixture = Fixture::new()?;
|
||||||
|
let runtime_domain = host_path(
|
||||||
|
fixture
|
||||||
|
.configuration
|
||||||
|
.runtime_domain_root()
|
||||||
|
.canonical_label(),
|
||||||
|
);
|
||||||
|
fs::copy(
|
||||||
|
env!("CARGO_BIN_EXE_lazaret-host-coordinator"),
|
||||||
|
runtime_domain
|
||||||
|
.join("sandbox-control")
|
||||||
|
.join(hex(ContainerId::new([6; 32]).as_bytes()))
|
||||||
|
.join("bin/lazaret-sandbox-init"),
|
||||||
|
)?;
|
||||||
|
let runtime = fixture.launch_committed()?;
|
||||||
|
let session_id = SessionId::new([31; 32]);
|
||||||
|
let mut session = Session {
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
session_id,
|
||||||
|
cli_id: ClientId::new([32; 32]),
|
||||||
|
session_anchor_id: SessionAnchorId::new([33; 32]),
|
||||||
|
session_phase: SessionPhase::Registering,
|
||||||
|
};
|
||||||
|
fixture
|
||||||
|
.store
|
||||||
|
.apply(&[DomainStoreMutation::PutSession(session.clone())])
|
||||||
|
.map_err(failure)?;
|
||||||
|
session.session_phase = SessionPhase::Attached;
|
||||||
|
fixture
|
||||||
|
.store
|
||||||
|
.apply(&[DomainStoreMutation::PutSession(session)])
|
||||||
|
.map_err(failure)?;
|
||||||
|
let command = fixture.bundle.command();
|
||||||
|
let run = command
|
||||||
|
.arguments()
|
||||||
|
.iter()
|
||||||
|
.position(|argument| argument == "run")
|
||||||
|
.ok_or_else(|| failure("production runsc command has no run action"))?;
|
||||||
|
let workspace = host_path(fixture.configuration.workspace().canonical_label());
|
||||||
|
let count = workspace.join("session-count");
|
||||||
|
let finish = workspace.join("session-finish");
|
||||||
|
let signals = workspace.join("session-signals");
|
||||||
|
let line = "x".repeat(1024);
|
||||||
|
let script = format!(
|
||||||
|
"trap 'echo INT >> /workspace/session-signals' INT; \
|
||||||
|
trap 'echo QUIT >> /workspace/session-signals' QUIT; \
|
||||||
|
trap 'echo TSTP >> /workspace/session-signals' TSTP; \
|
||||||
|
trap 'echo WINCH >> /workspace/session-signals' WINCH; \
|
||||||
|
n=0; while test ! -e /workspace/session-finish; do \
|
||||||
|
echo '{line}'; n=$((n+1)); echo $n > /workspace/session-count; done; exit 23"
|
||||||
|
);
|
||||||
|
let request = TerminalAcquisition {
|
||||||
|
domain_id: fixture.configuration.domain_id(),
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
coordinator_id: CoordinatorId::new([3; 32]),
|
||||||
|
session_id,
|
||||||
|
container_id: ContainerId::new([6; 32]),
|
||||||
|
host_coordinator: current_process_identity()?,
|
||||||
|
runsc_program: command.program().to_owned(),
|
||||||
|
runsc_global_arguments: command.arguments()[..run].to_vec(),
|
||||||
|
internal_pid_file: runtime_domain.join("session-internal.pid"),
|
||||||
|
argv: vec![b"/bin/sh".to_vec(), b"-c".to_vec(), script.into_bytes()],
|
||||||
|
};
|
||||||
|
let mut engine = SessionTerminalEngine::new(
|
||||||
|
Arc::clone(&fixture.store),
|
||||||
|
Arc::new(RunscSessionTerminalFactory),
|
||||||
|
Arc::new(NoActivePrompt),
|
||||||
|
);
|
||||||
|
engine.acquire(&request).map_err(failure)?;
|
||||||
|
wait_for_path(&count)?;
|
||||||
|
let running = fixture
|
||||||
|
.store
|
||||||
|
.snapshot()
|
||||||
|
.map_err(failure)?
|
||||||
|
.session_terminals
|
||||||
|
.into_iter()
|
||||||
|
.find(|terminal| terminal.session_id() == session_id)
|
||||||
|
.ok_or_else(|| failure("running Session Terminal record is absent"))?;
|
||||||
|
let guest = running
|
||||||
|
.guest_process()
|
||||||
|
.ok_or_else(|| failure("running Session Terminal guest identity is absent"))?;
|
||||||
|
if guest.leader_pid.get() != guest.process_group_id.get() {
|
||||||
|
return Err(failure("guest leader does not own its process group"));
|
||||||
|
}
|
||||||
|
engine
|
||||||
|
.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Resize(TerminalResize {
|
||||||
|
session_id,
|
||||||
|
rows: std::num::NonZeroU16::new(41).unwrap(),
|
||||||
|
columns: std::num::NonZeroU16::new(121).unwrap(),
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map_err(failure)?;
|
||||||
|
for signal in [
|
||||||
|
TerminalSignalKind::Interrupt,
|
||||||
|
TerminalSignalKind::Quit,
|
||||||
|
TerminalSignalKind::Suspend,
|
||||||
|
] {
|
||||||
|
engine
|
||||||
|
.handle(
|
||||||
|
GenerationId::new(7),
|
||||||
|
SessionTerminalEvent::Signal(TerminalSignal { session_id, signal }),
|
||||||
|
)
|
||||||
|
.map_err(failure)?;
|
||||||
|
}
|
||||||
|
wait_for_signal_lines(&signals, 4)?;
|
||||||
|
engine
|
||||||
|
.set_route(session_id, GenerationId::new(7), TerminalRoute::Prompt)
|
||||||
|
.map_err(failure)?;
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let before = fs::read_to_string(&count)?;
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let after = fs::read_to_string(&count)?;
|
||||||
|
if before != after {
|
||||||
|
return Err(failure("prompt route did not apply PTY backpressure"));
|
||||||
|
}
|
||||||
|
engine
|
||||||
|
.set_route(session_id, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||||
|
.map_err(failure)?;
|
||||||
|
let mut output = 0_usize;
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
for message in engine
|
||||||
|
.poll(session_id, GenerationId::new(7))
|
||||||
|
.map_err(failure)?
|
||||||
|
{
|
||||||
|
if let SessionTerminalMessage::Output(chunk) = message {
|
||||||
|
output += chunk.bytes.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fs::read_to_string(&count).ok().as_deref() != Some(after.as_str()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
if output == 0 || fs::read_to_string(&count).ok().as_deref() == Some(after.as_str()) {
|
||||||
|
return Err(failure("sandbox route did not resume ordered PTY output"));
|
||||||
|
}
|
||||||
|
fs::write(&finish, b"finish")?;
|
||||||
|
let mut exit = None;
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
for message in engine
|
||||||
|
.poll(session_id, GenerationId::new(7))
|
||||||
|
.map_err(failure)?
|
||||||
|
{
|
||||||
|
if let SessionTerminalMessage::Exit(outcome) = message {
|
||||||
|
exit = Some(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exit.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
if exit.and_then(lazaret_core::records::SessionExit::exit_code) != Some(23)
|
||||||
|
|| engine.contains(session_id)
|
||||||
|
{
|
||||||
|
return Err(failure(
|
||||||
|
"Session Terminal exit or no-reattachment cleanup failed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
runtime
|
||||||
|
.stop(&SandboxCoordinator::new(&fixture.store))
|
||||||
|
.map_err(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_path(path: &Path) -> Result<(), MatrixError> {
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
if path.is_file() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(failure("Session Terminal guest did not become ready"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_signal_lines(path: &Path, expected: usize) -> Result<(), MatrixError> {
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
if fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|value| value.lines().count() >= expected)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(failure(
|
||||||
|
"Session Terminal guest did not observe exact signals",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn run_handoff_matrix() -> Result<(), MatrixError> {
|
fn run_handoff_matrix() -> Result<(), MatrixError> {
|
||||||
require_matrix_prerequisites()?;
|
require_matrix_prerequisites()?;
|
||||||
replaced_handoff_authority_refuses_target_work()?;
|
replaced_handoff_authority_refuses_target_work()?;
|
||||||
|
|
@ -575,7 +800,7 @@ fn assert_activity_stable(path: &Path) -> Result<(), MatrixError> {
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
_tree: Option<TempDir>,
|
_tree: Option<TempDir>,
|
||||||
configuration: ResolvedDomainConfiguration,
|
configuration: ResolvedDomainConfiguration,
|
||||||
store: HostCoordinatorDomainStore,
|
store: Arc<HostCoordinatorDomainStore>,
|
||||||
bundle: SandboxBundle,
|
bundle: SandboxBundle,
|
||||||
activity: PathBuf,
|
activity: PathBuf,
|
||||||
benchmark_results: Option<PathBuf>,
|
benchmark_results: Option<PathBuf>,
|
||||||
|
|
@ -778,9 +1003,10 @@ impl Fixture {
|
||||||
};
|
};
|
||||||
let persistent_domain = host_path(configuration.persistent_domain_root().canonical_label());
|
let persistent_domain = host_path(configuration.persistent_domain_root().canonical_label());
|
||||||
let lock = try_exclusive_flock(lock_file(&persistent_domain.join("domain.lock")))?;
|
let lock = try_exclusive_flock(lock_file(&persistent_domain.join("domain.lock")))?;
|
||||||
let store =
|
let store = Arc::new(
|
||||||
HostCoordinatorDomainStore::open(lock, &persistent_domain.join("domain.redb"), &domain)
|
HostCoordinatorDomainStore::open(lock, &persistent_domain.join("domain.redb"), &domain)
|
||||||
.map_err(failure)?;
|
.map_err(failure)?,
|
||||||
|
);
|
||||||
store
|
store
|
||||||
.apply(&[DomainStoreMutation::PutGeneration(Generation {
|
.apply(&[DomainStoreMutation::PutGeneration(Generation {
|
||||||
domain_id: configuration.domain_id(),
|
domain_id: configuration.domain_id(),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ pub struct TargetCommand {
|
||||||
arguments: Vec<OsString>,
|
arguments: Vec<OsString>,
|
||||||
parent_edge: ParentEdge,
|
parent_edge: ParentEdge,
|
||||||
executable: Option<OwnedFd>,
|
executable: Option<OwnedFd>,
|
||||||
|
stdin: Option<File>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
|
@ -47,6 +48,7 @@ impl TargetCommand {
|
||||||
arguments: Vec::new(),
|
arguments: Vec::new(),
|
||||||
parent_edge: ParentEdge::ParentDeathKill,
|
parent_edge: ParentEdge::ParentDeathKill,
|
||||||
executable: None,
|
executable: None,
|
||||||
|
stdin: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +66,7 @@ impl TargetCommand {
|
||||||
arguments: arguments.into_iter().collect(),
|
arguments: arguments.into_iter().collect(),
|
||||||
parent_edge: ParentEdge::ParentDeathKill,
|
parent_edge: ParentEdge::ParentDeathKill,
|
||||||
executable: Some(executable),
|
executable: Some(executable),
|
||||||
|
stdin: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +88,16 @@ impl TargetCommand {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches the target's standard input after the private wrapper consumes its barrier.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the descriptor cannot be inherited by the wrapper.
|
||||||
|
pub fn with_target_stdin(mut self, stdin: File) -> io::Result<Self> {
|
||||||
|
fcntl_setfd(stdin.as_fd(), FdFlags::empty())?;
|
||||||
|
self.stdin = Some(stdin);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn parent_edge(&self) -> ParentEdge {
|
pub const fn parent_edge(&self) -> ParentEdge {
|
||||||
self.parent_edge
|
self.parent_edge
|
||||||
|
|
@ -109,6 +122,18 @@ impl TargetCommand {
|
||||||
ParentEdge::ParentDeathKill => "kill",
|
ParentEdge::ParentDeathKill => "kill",
|
||||||
ParentEdge::LifetimePipe => "lifetime-pipe",
|
ParentEdge::LifetimePipe => "lifetime-pipe",
|
||||||
})
|
})
|
||||||
|
.args(
|
||||||
|
self.stdin
|
||||||
|
.as_ref()
|
||||||
|
.map(|stdin| {
|
||||||
|
[
|
||||||
|
OsString::from("--target-stdin-fd"),
|
||||||
|
OsString::from(stdin.as_raw_fd().to_string()),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.flatten(),
|
||||||
|
)
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(&self.program)
|
.arg(&self.program)
|
||||||
.args(&self.arguments);
|
.args(&self.arguments);
|
||||||
|
|
@ -126,6 +151,7 @@ pub enum ChildStandardIoPolicy {
|
||||||
Inherit,
|
Inherit,
|
||||||
PrivateControlWithLifetimePipe,
|
PrivateControlWithLifetimePipe,
|
||||||
Silenced,
|
Silenced,
|
||||||
|
Terminal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChildStandardIo {
|
impl ChildStandardIo {
|
||||||
|
|
@ -159,6 +185,17 @@ impl ChildStandardIo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the output descriptor cannot be duplicated for merged terminal I/O.
|
||||||
|
pub fn terminal(output: File) -> io::Result<Self> {
|
||||||
|
let error = output.try_clone()?;
|
||||||
|
Ok(Self {
|
||||||
|
stdout: Some(Stdio::from(output)),
|
||||||
|
stderr: Some(Stdio::from(error)),
|
||||||
|
policy: ChildStandardIoPolicy::Terminal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn policy(&self) -> ChildStandardIoPolicy {
|
pub const fn policy(&self) -> ChildStandardIoPolicy {
|
||||||
self.policy
|
self.policy
|
||||||
|
|
@ -296,7 +333,9 @@ fn require_parent_edge_standard_io(
|
||||||
(ParentEdge::LifetimePipe, ChildStandardIoPolicy::PrivateControlWithLifetimePipe)
|
(ParentEdge::LifetimePipe, ChildStandardIoPolicy::PrivateControlWithLifetimePipe)
|
||||||
| (
|
| (
|
||||||
ParentEdge::ParentDeathKill,
|
ParentEdge::ParentDeathKill,
|
||||||
ChildStandardIoPolicy::Inherit | ChildStandardIoPolicy::Silenced,
|
ChildStandardIoPolicy::Inherit
|
||||||
|
| ChildStandardIoPolicy::Silenced
|
||||||
|
| ChildStandardIoPolicy::Terminal,
|
||||||
) => Ok(()),
|
) => Ok(()),
|
||||||
(ParentEdge::LifetimePipe, _) => Err(io::Error::other(
|
(ParentEdge::LifetimePipe, _) => Err(io::Error::other(
|
||||||
"lifetime-pipe parent edge requires private control stdout and lifetime-pipe stderr",
|
"lifetime-pipe parent edge requires private control stdout and lifetime-pipe stderr",
|
||||||
|
|
@ -778,6 +817,9 @@ fn run_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
|
||||||
require_lifetime_pipe_and_recheck(expected_parent)?;
|
require_lifetime_pipe_and_recheck(expected_parent)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(stdin) = target.stdin.as_ref() {
|
||||||
|
rustix::stdio::dup2_stdin(stdin)?;
|
||||||
|
}
|
||||||
Err(exec_target(target))
|
Err(exec_target(target))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1245,9 +1287,22 @@ pub fn parse_wrapper_arguments(
|
||||||
Some("lifetime-pipe") => ParentEdge::LifetimePipe,
|
Some("lifetime-pipe") => ParentEdge::LifetimePipe,
|
||||||
_ => return Err(io::Error::other("wrapper parent action is invalid")),
|
_ => return Err(io::Error::other("wrapper parent action is invalid")),
|
||||||
};
|
};
|
||||||
|
let next = arguments.next();
|
||||||
|
let stdin = if next.as_deref() == Some(OsStr::new("--target-stdin-fd")) {
|
||||||
|
let descriptor = arguments
|
||||||
|
.next()
|
||||||
|
.and_then(|value| value.to_str().and_then(|value| value.parse::<i32>().ok()))
|
||||||
|
.filter(|descriptor| *descriptor > 2)
|
||||||
|
.ok_or_else(|| io::Error::other("wrapper target stdin descriptor is invalid"))?;
|
||||||
if arguments.next().as_deref() != Some(OsStr::new("--")) {
|
if arguments.next().as_deref() != Some(OsStr::new("--")) {
|
||||||
return Err(io::Error::other("wrapper target separator is missing"));
|
return Err(io::Error::other("wrapper target separator is missing"));
|
||||||
}
|
}
|
||||||
|
Some(File::open(format!("/proc/self/fd/{descriptor}"))?)
|
||||||
|
} else if next.as_deref() == Some(OsStr::new("--")) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
return Err(io::Error::other("wrapper target separator is missing"));
|
||||||
|
};
|
||||||
let program = arguments
|
let program = arguments
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| io::Error::other("wrapper target is missing"))?;
|
.ok_or_else(|| io::Error::other("wrapper target is missing"))?;
|
||||||
|
|
@ -1256,6 +1311,7 @@ pub fn parse_wrapper_arguments(
|
||||||
arguments: arguments.collect(),
|
arguments: arguments.collect(),
|
||||||
parent_edge,
|
parent_edge,
|
||||||
executable: None,
|
executable: None,
|
||||||
|
stdin,
|
||||||
};
|
};
|
||||||
let boot = parse_boot_hex(&boot)?;
|
let boot = parse_boot_hex(&boot)?;
|
||||||
Ok((
|
Ok((
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,11 @@ use lazaret_linux::process::{
|
||||||
append_parent_identity, child_broker, current_process_identity, dispatch_private_process_role,
|
append_parent_identity, child_broker, current_process_identity, dispatch_private_process_role,
|
||||||
verify_direct_child_pidfd,
|
verify_direct_child_pidfd,
|
||||||
};
|
};
|
||||||
|
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||||
use rustix::pipe::{PipeFlags, pipe_with};
|
use rustix::pipe::{PipeFlags, pipe_with};
|
||||||
use rustix::process::{PidfdFlags, pidfd_open};
|
use rustix::process::{PidfdFlags, pidfd_open};
|
||||||
|
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
||||||
|
use rustix::termios::{Winsize, tcgetwinsize, tcsetwinsize};
|
||||||
|
|
||||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
|
@ -46,6 +49,7 @@ fn exit(result: std::io::Result<()>) -> ExitCode {
|
||||||
fn run_all() -> std::io::Result<()> {
|
fn run_all() -> std::io::Result<()> {
|
||||||
successful_acquisition()?;
|
successful_acquisition()?;
|
||||||
transient_captured_child_preserves_streams_and_exit()?;
|
transient_captured_child_preserves_streams_and_exit()?;
|
||||||
|
terminal_child_restores_slave_stdin_and_merges_output()?;
|
||||||
acknowledged_transition_precedes_exact_wrapper_release()?;
|
acknowledged_transition_precedes_exact_wrapper_release()?;
|
||||||
failed_acknowledged_transition_refuses_exact_wrapper_release()?;
|
failed_acknowledged_transition_refuses_exact_wrapper_release()?;
|
||||||
lifetime_pipe_descriptor_contract()?;
|
lifetime_pipe_descriptor_contract()?;
|
||||||
|
|
@ -57,6 +61,75 @@ fn run_all() -> std::io::Result<()> {
|
||||||
concurrent_acquisitions_serialize()
|
concurrent_acquisitions_serialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn terminal_child_restores_slave_stdin_and_merges_output() -> std::io::Result<()> {
|
||||||
|
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)?;
|
||||||
|
grantpt(&master)?;
|
||||||
|
unlockpt(&master)?;
|
||||||
|
let slave = ioctl_tiocgptpeer(
|
||||||
|
&master,
|
||||||
|
OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC,
|
||||||
|
)?;
|
||||||
|
tcsetwinsize(
|
||||||
|
&master,
|
||||||
|
Winsize {
|
||||||
|
ws_row: 44,
|
||||||
|
ws_col: 132,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if tcgetwinsize(&master)?.ws_row != 44 || tcgetwinsize(&master)?.ws_col != 132 {
|
||||||
|
return Err(std::io::Error::other(
|
||||||
|
"PTY resize did not update the master",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let flags = fcntl_getfl(&master)?;
|
||||||
|
fcntl_setfl(&master, flags | OFlags::NONBLOCK)?;
|
||||||
|
let slave = File::from(slave);
|
||||||
|
let target = TargetCommand::new("/bin/sh")
|
||||||
|
.argument("-c")
|
||||||
|
.argument("read value; printf 'out:%s\\n' \"$value\"; printf 'err\\n' >&2")
|
||||||
|
.with_target_stdin(slave.try_clone()?)?;
|
||||||
|
let expected_parent = current_process_identity()?;
|
||||||
|
let ownership = ProcessOwnershipContext {
|
||||||
|
domain_id: DomainId::new([41; 32]),
|
||||||
|
generation_id: GenerationId::new(42),
|
||||||
|
coordinator_id: CoordinatorId::new([43; 32]),
|
||||||
|
slot: HostProcessOwnershipSlot::SessionTerminalExecClient(
|
||||||
|
lazaret_core::identity::SessionId::new([44; 32]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let process = child_broker().acquire_with_stdio(
|
||||||
|
expected_parent,
|
||||||
|
ownership,
|
||||||
|
&target,
|
||||||
|
ChildStandardIo::terminal(slave)?,
|
||||||
|
|_| Ok(()),
|
||||||
|
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
|
||||||
|
)?;
|
||||||
|
let mut master = File::from(master);
|
||||||
|
master.write_all(b"hello\n")?;
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
while !process.has_exited()? {
|
||||||
|
let _ = read_available(&mut master, &mut merged);
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
let _ = read_available(&mut master, &mut merged);
|
||||||
|
let exit = child_broker().reap_status(process)?;
|
||||||
|
let text = String::from_utf8_lossy(&merged);
|
||||||
|
let out = text.find("out:hello");
|
||||||
|
let err = text.find("err");
|
||||||
|
if !matches!((out, err), (Some(out), Some(err)) if out < err)
|
||||||
|
|| exit.exit_code != Some(0)
|
||||||
|
|| exit.terminating_signal.is_some()
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::other(format!(
|
||||||
|
"terminal stdio or merged ordering failed: {text:?}, {exit:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn transient_captured_child_preserves_streams_and_exit() -> std::io::Result<()> {
|
fn transient_captured_child_preserves_streams_and_exit() -> std::io::Result<()> {
|
||||||
let target = TargetCommand::new("/bin/sh")
|
let target = TargetCommand::new("/bin/sh")
|
||||||
.argument("-c")
|
.argument("-c")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue