Some checks failed
Release Beta / autotag (push) Successful in 39s
Release Beta / changelog (push) Successful in 1m26s
Test / frontend-tests (push) Successful in 1m55s
Test / frontend-typecheck (push) Successful in 2m8s
Release Beta / build-macos-arm64 (push) Successful in 4m8s
Release Beta / build-linux-amd64 (push) Failing after 4m39s
Release Beta / build-windows-amd64 (push) Failing after 4m52s
Release Beta / build-linux-arm64 (push) Failing after 5m22s
Test / rust-clippy (push) Has been cancelled
Test / rust-tests (push) Has been cancelled
Test / rust-fmt-check (push) Has been cancelled
## Problem All three CI build platforms (linux-amd64, windows-amd64, linux-arm64) were failing with libsodium detection errors in release-beta.yml: - Linux: "libsodium not found via pkg-config or vcpkg" - Windows: "SODIUM_LIB_DIR is incompatible with SODIUM_USE_PKG_CONFIG" ## Root Cause The libsodium-sys-stable crate requires explicit environment configuration: - Linux needs SODIUM_USE_PKG_CONFIG=1 to find libsodium-dev packages - Windows needs SODIUM_LIB_DIR pointing to pre-built libs OR pkg-config (not both) - Cross-compilation requires complete PKG_CONFIG_PATH for arch-specific .pc files ## Solution ### release-beta.yml fixes: 1. **linux-amd64**: Added SODIUM_USE_PKG_CONFIG=1 2. **windows-amd64**: - Set SODIUM_LIB_DIR=/usr/x86_64-w64-mingw32/lib (was "") - Added SODIUM_USE_PKG_CONFIG=no (explicit disable) - Standardized SODIUM_STATIC=1 (was "yes") 3. **linux-arm64**: - Added SODIUM_USE_PKG_CONFIG=1 - Extended PKG_CONFIG_PATH to include /usr/aarch64-linux-gnu/lib/pkgconfig ### auto-tag.yml fixes: - **linux-arm64**: Extended PKG_CONFIG_PATH (same as release-beta.yml) ## Additional Fix Fixed flaky test `shell::pty::tests::test_is_alive` by adding retry logic for process reaping to handle OS timing variations (macOS was timing out). ## Validation ✅ Local build: cargo check passed ✅ Rust tests: 416 passed, 6 ignored ✅ Frontend tests: 386 passed (45 files) ✅ Linting: cargo clippy + eslint passed ⏳ CI validation: pending push to beta branch Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
343 lines
10 KiB
Rust
343 lines
10 KiB
Rust
// PTY Management for Interactive Shell Sessions
|
|
//
|
|
// This module provides pseudo-terminal (PTY) support for kubectl exec/attach operations.
|
|
// It uses the portable-pty crate for cross-platform PTY functionality.
|
|
//
|
|
// Key features:
|
|
// - Spawns kubectl exec/attach in a PTY for full interactivity
|
|
// - Bidirectional I/O streaming (stdin/stdout/stderr)
|
|
// - Proper terminal control (SIGWINCH, raw mode, etc.)
|
|
// - Clean session lifecycle management
|
|
|
|
use anyhow::{Context, Result};
|
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
|
use std::io::{Read, Write};
|
|
use tracing::{debug, warn};
|
|
|
|
/// PTY session handle with I/O streams
|
|
pub struct PtySession {
|
|
/// PTY pair (master + child)
|
|
pair: portable_pty::PtyPair,
|
|
/// Child process handle
|
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
|
}
|
|
|
|
impl PtySession {
|
|
/// Spawn a new PTY session with the given command and arguments
|
|
pub fn spawn(command: &str, args: Vec<String>, env: Vec<(String, String)>) -> Result<Self> {
|
|
let pty_system = native_pty_system();
|
|
|
|
// Create PTY with default size (80x24)
|
|
let pair = pty_system
|
|
.openpty(PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.context("Failed to open PTY")?;
|
|
|
|
// Build command
|
|
let mut cmd = CommandBuilder::new(command);
|
|
cmd.args(args);
|
|
for (key, value) in env {
|
|
cmd.env(key, value);
|
|
}
|
|
|
|
// Spawn child process
|
|
let child = pair
|
|
.slave
|
|
.spawn_command(cmd)
|
|
.context("Failed to spawn command in PTY")?;
|
|
|
|
debug!(
|
|
"PTY session spawned: {} (PID: {:?})",
|
|
command,
|
|
child.process_id()
|
|
);
|
|
|
|
Ok(Self { pair, child })
|
|
}
|
|
|
|
/// Spawn kubectl exec session
|
|
pub fn spawn_kubectl_exec(
|
|
kubectl_path: &str,
|
|
namespace: &str,
|
|
pod: &str,
|
|
container: Option<&str>,
|
|
kubeconfig_path: Option<&str>,
|
|
) -> Result<Self> {
|
|
let mut args = vec![
|
|
"exec".to_string(),
|
|
"-i".to_string(),
|
|
"-t".to_string(),
|
|
"-n".to_string(),
|
|
namespace.to_string(),
|
|
pod.to_string(),
|
|
];
|
|
|
|
if let Some(c) = container {
|
|
args.push("-c".to_string());
|
|
args.push(c.to_string());
|
|
}
|
|
|
|
// Use FreeLens-style shell fallback command.
|
|
// We deliberately omit `clear` from the chain: when a container image
|
|
// lacks `clear` (or `tput`), running it would print a non-fatal but
|
|
// confusing error to the user. The frontend terminal is responsible
|
|
// for clearing on connect.
|
|
args.push("--".to_string());
|
|
args.push("sh".to_string());
|
|
args.push("-c".to_string());
|
|
args.push("bash || ash || sh".to_string());
|
|
|
|
let mut env = Vec::new();
|
|
if let Some(kubeconfig) = kubeconfig_path {
|
|
env.push(("KUBECONFIG".to_string(), kubeconfig.to_string()));
|
|
}
|
|
|
|
Self::spawn(kubectl_path, args, env)
|
|
}
|
|
|
|
/// Spawn kubectl attach session
|
|
pub fn spawn_kubectl_attach(
|
|
kubectl_path: &str,
|
|
namespace: &str,
|
|
pod: &str,
|
|
container: Option<&str>,
|
|
kubeconfig_path: Option<&str>,
|
|
) -> Result<Self> {
|
|
let mut args = vec![
|
|
"attach".to_string(),
|
|
"-i".to_string(),
|
|
"-t".to_string(),
|
|
"-n".to_string(),
|
|
namespace.to_string(),
|
|
pod.to_string(),
|
|
];
|
|
|
|
if let Some(c) = container {
|
|
args.push("-c".to_string());
|
|
args.push(c.to_string());
|
|
}
|
|
|
|
let mut env = Vec::new();
|
|
if let Some(kubeconfig) = kubeconfig_path {
|
|
env.push(("KUBECONFIG".to_string(), kubeconfig.to_string()));
|
|
}
|
|
|
|
Self::spawn(kubectl_path, args, env)
|
|
}
|
|
|
|
/// Write data to PTY stdin
|
|
pub fn write(&mut self, data: &[u8]) -> Result<()> {
|
|
let mut writer = self
|
|
.pair
|
|
.master
|
|
.take_writer()
|
|
.context("PTY writer unavailable")?;
|
|
writer
|
|
.write_all(data)
|
|
.context("Failed to write to PTY stdin")?;
|
|
writer.flush().context("Failed to flush PTY stdin")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read available data from PTY stdout/stderr (non-blocking)
|
|
pub fn read(&mut self) -> Result<Vec<u8>> {
|
|
let mut reader = self
|
|
.pair
|
|
.master
|
|
.try_clone_reader()
|
|
.context("PTY reader unavailable")?;
|
|
let mut buffer = vec![0u8; 4096];
|
|
|
|
// Non-blocking read with timeout
|
|
match reader.read(&mut buffer) {
|
|
Ok(n) if n > 0 => {
|
|
buffer.truncate(n);
|
|
Ok(buffer)
|
|
}
|
|
Ok(_) => Ok(Vec::new()), // No data available
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(Vec::new()),
|
|
Err(e) => Err(e).context("Failed to read from PTY"),
|
|
}
|
|
}
|
|
|
|
/// Resize the PTY
|
|
pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
|
|
self.pair
|
|
.master
|
|
.resize(PtySize {
|
|
rows,
|
|
cols,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.context("Failed to resize PTY")
|
|
}
|
|
|
|
/// Check if the child process is still alive
|
|
pub fn is_alive(&mut self) -> bool {
|
|
match self.child.try_wait() {
|
|
Ok(Some(_)) => false, // Process exited
|
|
Ok(None) => true, // Still running
|
|
Err(_) => false, // Error checking status
|
|
}
|
|
}
|
|
|
|
/// Kill the child process
|
|
pub fn kill(&mut self) -> Result<()> {
|
|
self.child
|
|
.kill()
|
|
.context("Failed to kill PTY child process")
|
|
}
|
|
|
|
/// Wait for the child process to exit
|
|
pub fn wait(&mut self) -> Result<portable_pty::ExitStatus> {
|
|
self.child
|
|
.wait()
|
|
.context("Failed to wait for PTY child process")
|
|
}
|
|
}
|
|
|
|
impl Drop for PtySession {
|
|
fn drop(&mut self) {
|
|
// Best-effort cleanup. Log kill failures rather than swallowing them so
|
|
// operators can detect leaked child processes during diagnostics.
|
|
if self.is_alive() {
|
|
if let Err(e) = self.kill() {
|
|
warn!("PTY session Drop: failed to kill child process: {e:#}");
|
|
}
|
|
}
|
|
debug!("PTY session dropped");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_spawn_simple_command() {
|
|
// Spawn a simple echo command
|
|
let result = PtySession::spawn("echo", vec!["hello".to_string()], vec![]);
|
|
assert!(result.is_ok(), "Failed to spawn PTY session");
|
|
|
|
let mut session = result.unwrap();
|
|
|
|
// Wait a bit for command to execute
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
// Read output
|
|
let output = session.read().unwrap();
|
|
let output_str = String::from_utf8_lossy(&output);
|
|
|
|
// Should contain "hello"
|
|
assert!(
|
|
output_str.contains("hello") || output_str.is_empty(),
|
|
"Expected output to contain 'hello' or be empty (timing issue)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_write_and_read() {
|
|
// Spawn cat command (echoes stdin to stdout)
|
|
let result = PtySession::spawn("cat", vec![], vec![]);
|
|
assert!(result.is_ok(), "Failed to spawn PTY session");
|
|
|
|
let mut session = result.unwrap();
|
|
|
|
// Write data
|
|
let test_data = b"test input\n";
|
|
assert!(session.write(test_data).is_ok(), "Failed to write to PTY");
|
|
|
|
// Wait a bit for data to echo back
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
// Read output
|
|
let output = session.read().unwrap();
|
|
|
|
// Kill the session
|
|
assert!(session.kill().is_ok(), "Failed to kill PTY session");
|
|
|
|
// Output should contain our test data (cat echoes it back)
|
|
let output_str = String::from_utf8_lossy(&output);
|
|
assert!(
|
|
output_str.contains("test input") || output_str.is_empty(),
|
|
"Expected output to contain 'test input' or be empty (timing issue)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_alive() {
|
|
let mut session = PtySession::spawn("sleep", vec!["0.1".to_string()], vec![]).unwrap();
|
|
|
|
// Should be alive initially
|
|
assert!(session.is_alive(), "Session should be alive");
|
|
|
|
// Wait for process to exit with retry logic to handle OS timing variations
|
|
let mut retries = 10;
|
|
while retries > 0 && session.is_alive() {
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
retries -= 1;
|
|
}
|
|
|
|
// Should be dead now
|
|
assert!(
|
|
!session.is_alive(),
|
|
"Session should be dead after sleep completed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kill() {
|
|
let mut session = PtySession::spawn("sleep", vec!["10".to_string()], vec![]).unwrap();
|
|
|
|
assert!(session.is_alive(), "Session should be alive");
|
|
|
|
// Kill it
|
|
assert!(session.kill().is_ok(), "Failed to kill session");
|
|
|
|
// Wait a bit
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
// Should be dead
|
|
assert!(!session.is_alive(), "Session should be dead after kill");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resize() {
|
|
let session = PtySession::spawn("cat", vec![], vec![]).unwrap();
|
|
|
|
// Resize should succeed
|
|
assert!(session.resize(40, 120).is_ok(), "Failed to resize PTY");
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_variables() {
|
|
// Spawn a command that prints an environment variable
|
|
let result = PtySession::spawn(
|
|
"sh",
|
|
vec!["-c".to_string(), "echo $TEST_VAR".to_string()],
|
|
vec![("TEST_VAR".to_string(), "test_value".to_string())],
|
|
);
|
|
assert!(result.is_ok(), "Failed to spawn PTY session with env");
|
|
|
|
let mut session = result.unwrap();
|
|
|
|
// Wait for command to execute
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
// Read output
|
|
let output = session.read().unwrap();
|
|
let output_str = String::from_utf8_lossy(&output);
|
|
|
|
// Should contain our test value
|
|
assert!(
|
|
output_str.contains("test_value") || output_str.is_empty(),
|
|
"Expected output to contain 'test_value' or be empty (timing issue)"
|
|
);
|
|
}
|
|
}
|