use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use bollard::container::LogOutput;
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecOptions, StartExecResults};
use futures_util::Stream;
use tokio::io::AsyncWrite;
use super::{DockerClient, describe};
const AUTO_SHELL: &str = "command -v bash >/dev/null 2>&1 && exec bash || exec sh";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TermOp {
Open {
session: u64,
container: String,
shell: String,
cols: u16,
rows: u16,
},
Input {
session: u64,
bytes: Vec<u8>,
},
Resize {
session: u64,
cols: u16,
rows: u16,
},
Close {
session: u64,
},
}
pub fn next_session() -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
pub type ShellOutput =
Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>;
pub struct Shell {
pub exec_id: String,
pub output: ShellOutput,
pub input: Pin<Box<dyn AsyncWrite + Send>>,
}
pub fn exit_message(code: Option<i64>) -> Option<String> {
match code {
Some(0) => None,
Some(126 | 127) => Some(
"This container has no shell, or not the one asked for. Images built \
\"distroless\" or FROM scratch can't be opened this way."
.into(),
),
Some(code) => Some(format!("The shell exited with code {code}.")),
None => Some("The connection to the shell was lost.".into()),
}
}
impl DockerClient {
pub async fn open_shell(
&self,
container: &str,
shell: &str,
cols: u16,
rows: u16,
) -> Result<Shell, String> {
let cmd = if shell.trim().is_empty() {
vec!["/bin/sh".into(), "-c".into(), AUTO_SHELL.into()]
} else {
shell.split_whitespace().map(str::to_owned).collect()
};
let exec = self
.docker
.create_exec(
container,
CreateExecOptions {
cmd: Some(cmd),
env: Some(vec!["TERM=xterm-256color".into()]),
tty: Some(true),
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
},
)
.await
.map_err(describe)?;
let started = self
.docker
.start_exec(
&exec.id,
Some(StartExecOptions {
tty: true,
..Default::default()
}),
)
.await
.map_err(describe)?;
let StartExecResults::Attached { output, input } = started else {
return Err("The shell didn't attach".into());
};
let _ = self.resize_shell(&exec.id, cols, rows).await;
Ok(Shell {
exec_id: exec.id,
output,
input,
})
}
pub async fn resize_shell(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
self.docker
.resize_exec(
exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(describe)
}
pub async fn shell_exit_code(&self, exec_id: &str) -> Option<i64> {
self.docker.inspect_exec(exec_id).await.ok()?.exit_code
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explains_exits() {
assert_eq!(exit_message(Some(0)), None);
assert!(exit_message(Some(127)).unwrap().contains("no shell"));
assert!(exit_message(Some(3)).unwrap().contains("code 3"));
}
#[test]
fn sessions_are_unique() {
assert_ne!(next_session(), next_session());
}
#[tokio::test]
#[ignore]
async fn live_daemon_runs_an_interactive_shell() {
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
let client = DockerClient::connect().unwrap();
let Some(image) = client.list_images().await.unwrap().into_iter().next() else {
return;
};
let spec = crate::docker::RunSpec {
image: image.id,
name: "ferrodock-terminal".into(),
command: "sleep 300".into(),
..Default::default()
};
let opts = || bollard::query_parameters::RemoveContainerOptions {
force: true,
..Default::default()
};
let _ = client
.docker
.remove_container(&spec.name, Some(opts()))
.await;
let id = client.run(&spec).await.unwrap();
let mut sh = client.open_shell(&id, "", 100, 30).await.unwrap();
let mut screen = vt100::Parser::new(30, 100, 0);
async fn step(sh: &mut Shell, screen: &mut vt100::Parser, keys: &[u8], want: &str) -> bool {
sh.input.write_all(keys).await.unwrap();
sh.input.flush().await.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
while !screen.screen().contents().contains(want) {
match tokio::time::timeout_at(deadline, sh.output.next()).await {
Ok(Some(Ok(out))) => screen.process(&out.into_bytes()),
_ => return false,
}
}
true
}
let math = step(&mut sh, &mut screen, b"echo $((6*7))\r", "42").await;
let size = step(&mut sh, &mut screen, b"stty size\r", "30 100").await;
let top = step(&mut sh, &mut screen, b"top\r", "PID").await;
let quit_top = step(&mut sh, &mut screen, b"q", "PID").await
&& step(&mut sh, &mut screen, b"echo after-$((1+1))\r", "after-2").await;
let vi = step(&mut sh, &mut screen, b"vi /etc/ferrodock\r", "~").await;
let typed = step(&mut sh, &mut screen, b"ihello vi\x1b", "hello vi").await;
let quit_vi = step(&mut sh, &mut screen, b":q!\r", "~").await
&& step(&mut sh, &mut screen, b"echo back-$((2+2))\r", "back-4").await;
sh.input.write_all(b"exit\r").await.unwrap();
while sh.output.next().await.is_some() {}
let code = client.shell_exit_code(&sh.exec_id).await;
let mut missing = client
.open_shell(&id, "/no/such/shell", 80, 24)
.await
.unwrap();
while missing.output.next().await.is_some() {}
let missing_code = client.shell_exit_code(&missing.exec_id).await;
client
.docker
.remove_container(&id, Some(opts()))
.await
.unwrap();
assert!(math, "echo works");
assert!(size, "the shell sees the size we set");
assert!(top && quit_top, "top runs and quits");
assert!(vi && typed && quit_vi, "vi opens, takes text and quits");
assert_eq!(exit_message(code), None);
assert!(exit_message(missing_code).unwrap().contains("no shell"));
}
}