fetch_ml/tests/fixtures/ssh_server.go
Jeremie Fraeys e08feae6ab
chore: migrate scripts from docker-compose v1 to v2
- Update all scripts to use 'docker compose' instead of 'docker-compose'
- Fix compose file paths after consolidation (test.yml, prod.yml)
- Update cleanup.sh to handle --profile debug and --profile smoke
- Update test fixtures to reference consolidated compose files
2026-03-04 13:22:26 -05:00

205 lines
5.1 KiB
Go

// Package tests provides test infrastructure for TUI SSH testing
package tests
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// SSHTestServer manages an SSH test server container for TUI testing
type SSHTestServer struct {
Signer ssh.Signer
t *testing.T
Host string
User string
Container string
PrivateKey []byte
Port int
}
// NewSSHTestServer creates and starts an SSH test server in a Docker container
func NewSSHTestServer(t *testing.T) *SSHTestServer {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to resolve caller path")
}
// Navigate to repo root
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
// Ensure SSH keys exist
keysDir := filepath.Join(repoRoot, "deployments", "test_keys")
privateKeyPath := filepath.Join(keysDir, "test_key")
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
t.Skipf("SSH test keys not found at %s. Run: ./scripts/testing/gen-ssh-test-keys.sh", keysDir)
}
// Read private key
privateKey, err := os.ReadFile(privateKeyPath)
if err != nil {
t.Fatalf("failed to read private key: %v", err)
}
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
t.Fatalf("failed to parse private key: %v", err)
}
server := &SSHTestServer{
Host: "localhost",
Port: 2222,
User: "test",
PrivateKey: privateKey,
Signer: signer,
Container: "ml-ssh-test",
t: t,
}
// Verify container is running
if err := server.waitForContainer(); err != nil {
t.Skipf("SSH test container not available: %v. Run: docker compose -f deployments/docker-compose.prod.yml --profile smoke up -d ssh-test-server", err)
}
// Verify SSH connectivity
if err := server.waitForSSH(); err != nil {
t.Fatalf("SSH server not reachable: %v", err)
}
return server
}
// waitForContainer checks if the SSH container is running
func (s *SSHTestServer) waitForContainer() error {
cmd := exec.Command("docker", "ps", "-q", "-f", fmt.Sprintf("name=%s", s.Container))
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to check container status: %w", err)
}
if len(output) == 0 {
return fmt.Errorf("container %s is not running", s.Container)
}
return nil
}
// waitForSSH waits for the SSH server to accept connections
func (s *SSHTestServer) waitForSSH() error {
config := &ssh.ClientConfig{
User: s.User,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(s.Signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // Test only
Timeout: 5 * time.Second,
}
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
// Retry with backoff
for i := 0; i < 10; i++ {
client, err := ssh.Dial("tcp", addr, config)
if err == nil {
client.Close()
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("SSH server not available after retries")
}
// NewClient creates a new SSH client connection
func (s *SSHTestServer) NewClient() (*ssh.Client, error) {
config := &ssh.ClientConfig{
User: s.User,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(s.Signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // Test only
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
return ssh.Dial("tcp", addr, config)
}
// Stop stops the SSH test server container
func (s *SSHTestServer) Stop() {
// Container is managed by docker-compose, so we don't stop it here
// This allows multiple tests to reuse the same container
}
// Cleanup stops and removes the SSH test container
func (s *SSHTestServer) Cleanup() {
cmd := exec.Command("docker", "stop", s.Container)
_ = cmd.Run()
cmd = exec.Command("docker", "rm", s.Container)
_ = cmd.Run()
}
// Exec runs a command on the SSH server and returns output
func (s *SSHTestServer) Exec(cmd string) (string, error) {
client, err := s.NewClient()
if err != nil {
return "", fmt.Errorf("failed to connect: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
if err != nil {
return string(output), fmt.Errorf("command failed: %w", err)
}
return string(output), nil
}
// ExecWithPTY runs a command with PTY allocation for interactive TUI testing
func (s *SSHTestServer) ExecWithPTY(cmd string, term string, width, height int) (*ssh.Session, error) {
client, err := s.NewClient()
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
session, err := client.NewSession()
if err != nil {
client.Close()
return nil, fmt.Errorf("failed to create session: %w", err)
}
// Request PTY
modes := ssh.TerminalModes{
ssh.ECHO: 0,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty(term, width, height, modes); err != nil {
session.Close()
client.Close()
return nil, fmt.Errorf("failed to request pty: %w", err)
}
if err := session.Start(cmd); err != nil {
session.Close()
client.Close()
return nil, fmt.Errorf("failed to start command: %w", err)
}
return session, nil
}