mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
239 lines
7.5 KiB
Rust
239 lines
7.5 KiB
Rust
#![cfg(unix)]
|
|
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::process::Command;
|
|
use std::sync::mpsc;
|
|
use std::time::Duration;
|
|
|
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
|
|
|
// No real SSH connection or server is started. Stop at startup after recording setup actions.
|
|
const SSH: &str = r#"#!/bin/sh
|
|
for arg do last=$arg; done
|
|
if [ "$last" = 'command -v herdr' ]; then
|
|
echo /home/remote/.local/bin/herdr
|
|
exit 0
|
|
fi
|
|
case "$last" in
|
|
'tee '*) cat >/dev/null; exit 0 ;;
|
|
esac
|
|
script=$(cat)
|
|
case "$script" in
|
|
*'uname -s'*) uname -s; uname -m ;;
|
|
*'version='*) echo /home/remote/.local/bin/herdr ;;
|
|
*'status client --json'*)
|
|
if [ "$FAKE_INSTALLED" = new ] || [ -f "$FAKE_ROOT/installed" ]; then
|
|
printf '%s\n' "$FAKE_CLIENT_STATUS"
|
|
else
|
|
echo '{"version":"0.8.2","protocol":20}'
|
|
fi ;;
|
|
*'status server --json'*)
|
|
if [ -f "$FAKE_ROOT/stopped" ]; then
|
|
echo '{"running":false}'
|
|
else
|
|
echo '{"running":true,"version":"0.8.2","capabilities":{"live_handoff":true,"detached_server_daemon":true}}'
|
|
fi ;;
|
|
*'server live-handoff'*) echo handoff >>"$FAKE_ROOT/actions"; exit 1 ;;
|
|
*'server stop'*) echo stop >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/stopped" ;;
|
|
*'remote-client-bridge'*) echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;;
|
|
*'mkdir -p'*) printf '/fake/tmp\000/fake/herdr\000' ;;
|
|
*'chmod 755'*) echo install >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/installed" ;;
|
|
*'command -v herdr'*) echo /home/remote/.local/bin/herdr ;;
|
|
*) echo "unexpected fake SSH script: $script" >&2; exit 1 ;;
|
|
esac
|
|
"#;
|
|
|
|
struct SetupResult {
|
|
output: String,
|
|
actions: String,
|
|
prompts: usize,
|
|
}
|
|
|
|
fn setup(installed: &str, answer: &str, handoff: bool) -> SetupResult {
|
|
let root = std::path::PathBuf::from(format!(
|
|
"/var/tmp/herdr-machine-setup-{}-{}-{}-{}",
|
|
std::process::id(),
|
|
installed,
|
|
answer.trim().is_empty(),
|
|
handoff
|
|
));
|
|
let app = if cfg!(debug_assertions) {
|
|
"herdr-dev"
|
|
} else {
|
|
"herdr"
|
|
};
|
|
fs::create_dir_all(root.join("bin")).unwrap();
|
|
fs::create_dir_all(root.join("config").join(app)).unwrap();
|
|
fs::write(root.join("bin/ssh"), SSH).unwrap();
|
|
fs::set_permissions(root.join("bin/ssh"), fs::Permissions::from_mode(0o700)).unwrap();
|
|
fs::write(
|
|
root.join("config").join(app).join("config.toml"),
|
|
"onboarding = false\n[remote]\nmanage_ssh_config = false\n",
|
|
)
|
|
.unwrap();
|
|
let status = Command::new(env!("CARGO_BIN_EXE_herdr"))
|
|
.args(["status", "client", "--json"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(status.status.success());
|
|
|
|
let pair = native_pty_system().openpty(PtySize::default()).unwrap();
|
|
let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
|
if handoff {
|
|
command.args(["--remote", "fake-host", "--handoff"]);
|
|
} else {
|
|
command.args(["machine", "add", "fake-host", "--label", "VPS"]);
|
|
}
|
|
command.env(
|
|
"PATH",
|
|
format!("{}:/usr/bin:/bin", root.join("bin").display()),
|
|
);
|
|
command.env("HOME", &root);
|
|
command.env("XDG_CONFIG_HOME", root.join("config"));
|
|
command.env("XDG_STATE_HOME", root.join("state"));
|
|
command.env("XDG_RUNTIME_DIR", &root);
|
|
command.env("FAKE_ROOT", &root);
|
|
command.env("FAKE_INSTALLED", installed);
|
|
command.env(
|
|
"FAKE_CLIENT_STATUS",
|
|
String::from_utf8(status.stdout).unwrap(),
|
|
);
|
|
for name in [
|
|
"HERDR_ENV",
|
|
"HERDR_SESSION",
|
|
"HERDR_SOCKET_PATH",
|
|
"HERDR_CLIENT_SOCKET_PATH",
|
|
"HERDR_REMOTE_BINARY",
|
|
"HERDR_CONFIG_PATH",
|
|
] {
|
|
command.env_remove(name);
|
|
}
|
|
let mut child = pair.slave.spawn_command(command).unwrap();
|
|
drop(pair.slave);
|
|
let mut reader = pair.master.try_clone_reader().unwrap();
|
|
let mut writer = pair.master.take_writer().unwrap();
|
|
let (tx, rx) = mpsc::channel();
|
|
let reading = std::thread::spawn(move || {
|
|
let mut buffer = [0; 4096];
|
|
while let Ok(len) = reader.read(&mut buffer) {
|
|
if len == 0 || tx.send(buffer[..len].to_vec()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
let mut output = String::new();
|
|
let mut prompts = 0;
|
|
let mut timed_out = false;
|
|
loop {
|
|
match rx.recv_timeout(Duration::from_secs(20)) {
|
|
Ok(bytes) => {
|
|
output.push_str(&String::from_utf8_lossy(&bytes));
|
|
let count = output.matches("[y/N]").count() + output.matches("[Y/n]").count();
|
|
if count > prompts {
|
|
writer
|
|
.write_all(if prompts == 0 {
|
|
answer.as_bytes()
|
|
} else {
|
|
b"n\n"
|
|
})
|
|
.unwrap();
|
|
writer.flush().unwrap();
|
|
prompts = count;
|
|
}
|
|
}
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
timed_out = true;
|
|
child.kill().unwrap();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
child.wait().unwrap();
|
|
drop(writer);
|
|
drop(pair.master);
|
|
reading.join().unwrap();
|
|
let actions = fs::read_to_string(root.join("actions")).unwrap_or_default();
|
|
// Every scenario either cancels or reaches the intentionally failed startup.
|
|
let saved = root
|
|
.join("state")
|
|
.join(app)
|
|
.join("client/endpoints.json")
|
|
.exists();
|
|
fs::remove_dir_all(root).unwrap();
|
|
assert!(
|
|
!saved,
|
|
"failed or cancelled setup saved the machine: {output}"
|
|
);
|
|
assert!(!timed_out, "setup timed out: {output}");
|
|
SetupResult {
|
|
output,
|
|
actions,
|
|
prompts,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn machine_setup_old_install_requires_one_explicit_stop_approval() {
|
|
let result = setup("old", "y\n", false);
|
|
assert_eq!(result.prompts, 1, "{}", result.output);
|
|
assert!(
|
|
result
|
|
.output
|
|
.contains("This stops active remote pane processes"),
|
|
"{}",
|
|
result.output
|
|
);
|
|
assert_eq!(
|
|
result.actions, "install\nstop\nstart\n",
|
|
"{}",
|
|
result.output
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn machine_setup_old_install_enter_cancels_without_remote_changes() {
|
|
let result = setup("old", "\n", false);
|
|
assert!(result.actions.is_empty(), "{}", result.output);
|
|
assert!(
|
|
result.output.contains("machine was not saved"),
|
|
"{}",
|
|
result.output
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn machine_setup_new_install_old_server_enter_does_not_stop_or_handoff() {
|
|
let result = setup("new", "\n", false);
|
|
assert!(result.actions.is_empty(), "{}", result.output);
|
|
assert!(
|
|
result
|
|
.output
|
|
.contains("This stops active remote pane processes"),
|
|
"{}",
|
|
result.output
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn machine_setup_new_install_old_server_approval_stops_then_starts_without_installing() {
|
|
let result = setup("new", "y\n", false);
|
|
assert_eq!(result.prompts, 1, "{}", result.output);
|
|
assert_eq!(result.actions, "stop\nstart\n", "{}", result.output);
|
|
}
|
|
|
|
#[test]
|
|
fn machine_setup_explicit_remote_handoff_remains_available_but_restart_fallback_defaults_to_no() {
|
|
let result = setup("new", "\n", true);
|
|
assert_eq!(result.actions, "handoff\n", "{}", result.output);
|
|
assert!(
|
|
result
|
|
.output
|
|
.contains("This stops active remote pane processes"),
|
|
"{}",
|
|
result.output
|
|
);
|
|
}
|