tftsr-devops_investigation/src-tauri/src/shell/pty.rs
Shaun Arman 7316339ae2
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
fix(ci): resolve libsodium pkg-config detection across all platforms
## 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>
2026-06-14 04:36:44 -05:00

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)"
);
}
}