feat: funciones SSH para infra — conn, check, exec, download, upload, tunnel

Conjunto completo de funciones SSH para operaciones remotas: conexión,
verificación de host, ejecución de comandos, transferencia de archivos
(upload/download) y gestión de túneles. Incluye tipo SSHConn y tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 22:03:41 +02:00
parent 2b123e0c87
commit 560cbf280e
19 changed files with 729 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// SSHCheck verifica la conectividad SSH ejecutando un comando noop en el host remoto.
// Retorna nil si la conexion fue exitosa, o error con el detalle del fallo.
func SSHCheck(conn SSHConn) error {
args := conn.sshArgs()
args = append(args, "-o", "ConnectTimeout=5")
args = append(args, "-o", "BatchMode=yes")
args = append(args, conn.destination(), "exit", "0")
out, err := exec.Command("ssh", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ssh check %s: %s", conn.destination(), strings.TrimSpace(string(out)))
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
---
name: ssh_check
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHCheck(conn SSHConn) error"
description: "Verifica conectividad SSH ejecutando un comando noop en el host remoto. Timeout de 5 segundos."
tags: [ssh, connection, check, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["conecta a organic-machine", "falla con host inexistente"]
test_file_path: "functions/infra/ssh_check_test.go"
file_path: "functions/infra/ssh_check.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
if err := SSHCheck(conn); err != nil {
log.Fatal("no se puede conectar:", err)
}
```
## Notas
Usa BatchMode=yes para fallar inmediatamente si requiere input interactivo (password prompt). ConnectTimeout=5 para no bloquear si el host no responde. StrictHostKeyChecking=accept-new acepta hosts nuevos automaticamente.
+40
View File
@@ -0,0 +1,40 @@
package infra
import (
"testing"
)
func sshTestConn() SSHConn {
return SSHConn{
Host: "organic-machine.com",
User: "ubuntu",
KeyPath: "/home/lucas/.ssh/organic-machine",
}
}
func skipIfNoSSH(t *testing.T) SSHConn {
t.Helper()
conn := sshTestConn()
if err := SSHCheck(conn); err != nil {
t.Skipf("SSH no disponible: %v", err)
}
return conn
}
func TestSSHCheck(t *testing.T) {
t.Run("conecta a organic-machine", func(t *testing.T) {
conn := sshTestConn()
err := SSHCheck(conn)
if err != nil {
t.Skipf("SSH no disponible: %v", err)
}
})
t.Run("falla con host inexistente", func(t *testing.T) {
conn := SSHConn{Host: "192.0.2.1", Port: 22, User: "nobody"}
err := SSHCheck(conn)
if err == nil {
t.Error("esperaba error con host inexistente")
}
})
}
+49
View File
@@ -0,0 +1,49 @@
package infra
import "fmt"
// SSHConn parametros de conexion SSH reutilizables.
type SSHConn struct {
Host string // Hostname o IP del servidor remoto
Port int // Puerto SSH (0 usa el default 22)
User string // Usuario remoto
KeyPath string // Ruta a clave privada (vacio usa ssh-agent o default)
}
// sshArgs construye los argumentos comunes de ssh/scp a partir de SSHConn.
func (c SSHConn) sshArgs() []string {
var args []string
port := c.Port
if port == 0 {
port = 22
}
args = append(args, "-o", "StrictHostKeyChecking=accept-new")
args = append(args, "-p", fmt.Sprintf("%d", port))
if c.KeyPath != "" {
args = append(args, "-i", c.KeyPath)
}
return args
}
// scpArgs construye los argumentos comunes de scp a partir de SSHConn.
func (c SSHConn) scpArgs() []string {
var args []string
port := c.Port
if port == 0 {
port = 22
}
args = append(args, "-o", "StrictHostKeyChecking=accept-new")
args = append(args, "-P", fmt.Sprintf("%d", port))
if c.KeyPath != "" {
args = append(args, "-i", c.KeyPath)
}
return args
}
// destination retorna user@host.
func (c SSHConn) destination() string {
if c.User != "" {
return c.User + "@" + c.Host
}
return c.Host
}
+73
View File
@@ -0,0 +1,73 @@
package infra
import (
"testing"
)
func TestSSHConnDestination(t *testing.T) {
tests := []struct {
name string
conn SSHConn
want string
}{
{"con user", SSHConn{Host: "example.com", User: "deploy"}, "deploy@example.com"},
{"sin user", SSHConn{Host: "example.com"}, "example.com"},
{"con IP", SSHConn{Host: "10.0.0.1", User: "root"}, "root@10.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.conn.destination()
if got != tt.want {
t.Errorf("destination() = %q, want %q", got, tt.want)
}
})
}
}
func TestSSHConnSSHArgs(t *testing.T) {
t.Run("puerto default", func(t *testing.T) {
conn := SSHConn{Host: "example.com", User: "deploy"}
args := conn.sshArgs()
assertContains(t, args, "-p", "22")
})
t.Run("puerto custom", func(t *testing.T) {
conn := SSHConn{Host: "example.com", Port: 2222}
args := conn.sshArgs()
assertContains(t, args, "-p", "2222")
})
t.Run("con key path", func(t *testing.T) {
conn := SSHConn{Host: "example.com", KeyPath: "/home/user/.ssh/id_ed25519"}
args := conn.sshArgs()
assertContains(t, args, "-i", "/home/user/.ssh/id_ed25519")
})
t.Run("sin key path no incluye -i", func(t *testing.T) {
conn := SSHConn{Host: "example.com"}
args := conn.sshArgs()
for _, a := range args {
if a == "-i" {
t.Error("sshArgs() no debe incluir -i si KeyPath esta vacio")
}
}
})
}
func TestSSHConnSCPArgs(t *testing.T) {
t.Run("usa -P mayuscula para puerto", func(t *testing.T) {
conn := SSHConn{Host: "example.com", Port: 2222}
args := conn.scpArgs()
assertContains(t, args, "-P", "2222")
})
}
func assertContains(t *testing.T, args []string, flag, value string) {
t.Helper()
for i, a := range args {
if a == flag && i+1 < len(args) && args[i+1] == value {
return
}
}
t.Errorf("args %v no contiene %s %s", args, flag, value)
}
+20
View File
@@ -0,0 +1,20 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// SSHDownload descarga un archivo del host remoto al filesystem local via scp.
func SSHDownload(conn SSHConn, remotePath, localPath string) error {
args := conn.scpArgs()
src := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
args = append(args, src, localPath)
out, err := exec.Command("scp", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("scp download from %s: %s", conn.destination(), strings.TrimSpace(string(out)))
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
---
name: ssh_download
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHDownload(conn SSHConn, remotePath, localPath string) error"
description: "Descarga un archivo del host remoto al filesystem local via scp."
tags: [ssh, scp, download, file, transfer, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["upload y download roundtrip"]
test_file_path: "functions/infra/ssh_transfer_test.go"
file_path: "functions/infra/ssh_download.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
err := SSHDownload(conn, "/var/log/app.log", "./app.log")
```
## Notas
Descarga un archivo remoto al path local indicado. Para descargar directorios, usar SSHExec con tar/rsync como alternativa.
+39
View File
@@ -0,0 +1,39 @@
package infra
import (
"os/exec"
)
// SSHExec ejecuta un comando en el host remoto via SSH y retorna stdout, stderr y exit code.
func SSHExec(conn SSHConn, command string) (string, string, int, error) {
args := conn.sshArgs()
args = append(args, "-o", "BatchMode=yes")
args = append(args, conn.destination(), command)
cmd := exec.Command("ssh", args...)
var stdout, stderr []byte
cmd.Stdout = writerFunc(func(p []byte) (int, error) {
stdout = append(stdout, p...)
return len(p), nil
})
cmd.Stderr = writerFunc(func(p []byte) (int, error) {
stderr = append(stderr, p...)
return len(p), nil
})
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return "", "", -1, err
}
}
return string(stdout), string(stderr), exitCode, nil
}
// writerFunc adapta una funcion a io.Writer.
type writerFunc func([]byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
+36
View File
@@ -0,0 +1,36 @@
---
name: ssh_exec
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHExec(conn SSHConn, command string) (string, string, int, error)"
description: "Ejecuta un comando en el host remoto via SSH. Retorna stdout, stderr y exit code separados."
tags: [ssh, exec, remote, command]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os/exec]
tested: true
tests: ["echo simple", "captura stderr", "exit code no cero", "comando multilinea"]
test_file_path: "functions/infra/ssh_exec_test.go"
file_path: "functions/infra/ssh_exec.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
stdout, stderr, code, err := SSHExec(conn, "df -h /")
if err != nil {
log.Fatal(err)
}
fmt.Printf("exit=%d\nstdout:\n%s\nstderr:\n%s\n", code, stdout, stderr)
```
## Notas
Retorna stdout y stderr como strings separados mas el exit code. Si el comando remoto falla (exit code != 0), NO retorna error — el exit code indica el fallo. Solo retorna error si ssh no pudo conectar o ejecutar. Usa BatchMode=yes para evitar prompts interactivos.
+60
View File
@@ -0,0 +1,60 @@
package infra
import (
"strings"
"testing"
)
func TestSSHExec(t *testing.T) {
conn := skipIfNoSSH(t)
t.Run("echo simple", func(t *testing.T) {
stdout, stderr, code, err := SSHExec(conn, "echo hello")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0; stderr: %s", code, stderr)
}
if strings.TrimSpace(stdout) != "hello" {
t.Errorf("stdout = %q, want %q", strings.TrimSpace(stdout), "hello")
}
})
t.Run("captura stderr", func(t *testing.T) {
_, stderr, code, err := SSHExec(conn, "echo error >&2")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(stderr, "error") {
t.Errorf("stderr = %q, want contener 'error'", stderr)
}
})
t.Run("exit code no cero", func(t *testing.T) {
_, _, code, err := SSHExec(conn, "exit 42")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 42 {
t.Errorf("exit code = %d, want 42", code)
}
})
t.Run("comando multilinea", func(t *testing.T) {
stdout, _, code, err := SSHExec(conn, "hostname && uname -s")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) < 2 {
t.Errorf("esperaba al menos 2 lineas, got %d: %q", len(lines), stdout)
}
})
}
+60
View File
@@ -0,0 +1,60 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSSHUploadDownload(t *testing.T) {
conn := skipIfNoSSH(t)
t.Run("upload y download roundtrip", func(t *testing.T) {
// Crear archivo temporal local
tmpDir := t.TempDir()
localFile := filepath.Join(tmpDir, "test_upload.txt")
content := "fn_registry ssh test: upload roundtrip"
if err := os.WriteFile(localFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
remotePath := "/tmp/fn_registry_ssh_test.txt"
// Upload
err := SSHUpload(conn, localFile, remotePath)
if err != nil {
t.Fatalf("SSHUpload: %v", err)
}
// Verificar que existe en remoto
stdout, _, code, err := SSHExec(conn, "cat "+remotePath)
if err != nil {
t.Fatalf("SSHExec cat: %v", err)
}
if code != 0 {
t.Fatalf("cat remoto fallo con code %d", code)
}
if strings.TrimSpace(stdout) != content {
t.Errorf("contenido remoto = %q, want %q", strings.TrimSpace(stdout), content)
}
// Download
downloadFile := filepath.Join(tmpDir, "test_download.txt")
err = SSHDownload(conn, remotePath, downloadFile)
if err != nil {
t.Fatalf("SSHDownload: %v", err)
}
got, err := os.ReadFile(downloadFile)
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("downloaded = %q, want %q", string(got), content)
}
// Limpiar remoto
SSHExec(conn, "rm -f "+remotePath)
})
}
+21
View File
@@ -0,0 +1,21 @@
package infra
import (
"fmt"
"os"
"syscall"
)
// SSHTunnelClose cierra un tunel SSH enviando SIGTERM al proceso por PID.
func SSHTunnelClose(pid int) error {
proc, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("ssh tunnel close: process %d not found: %w", pid, err)
}
err = proc.Signal(syscall.SIGTERM)
if err != nil {
return fmt.Errorf("ssh tunnel close: cannot signal PID %d: %w", pid, err)
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
---
name: ssh_tunnel_close
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHTunnelClose(pid int) error"
description: "Cierra un tunel SSH enviando SIGTERM al proceso por PID."
tags: [ssh, tunnel, close, remote]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, syscall]
tested: true
tests: ["abre tunel y lo cierra"]
test_file_path: "functions/infra/ssh_tunnel_test.go"
file_path: "functions/infra/ssh_tunnel_close.go"
---
## Ejemplo
```go
// Abrir tunel
pid, _ := SSHTunnelOpen(conn, 5432, "db-server", 5432)
// ... usar el tunel ...
// Cerrar
err := SSHTunnelClose(pid)
```
## Notas
Envia SIGTERM (cierre limpio) al proceso ssh. El PID viene de SSHTunnelOpen. Si el proceso ya termino, retorna error.
+46
View File
@@ -0,0 +1,46 @@
package infra
import (
"fmt"
"os/exec"
"strings"
"time"
)
// SSHTunnelOpen abre un tunel SSH (local port forwarding) en background.
// Mapea localPort en la maquina local a remoteHost:remotePort a traves del servidor SSH.
// Retorna el PID del proceso ssh para cerrarlo despues con SSHTunnelClose.
func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error) {
if remoteHost == "" {
remoteHost = "localhost"
}
forward := fmt.Sprintf("%d:%s:%d", localPort, remoteHost, remotePort)
args := conn.sshArgs()
args = append(args, "-o", "BatchMode=yes")
args = append(args, "-o", "ExitOnForwardFailure=yes")
args = append(args, "-N", "-f", "-L", forward, conn.destination())
cmd := exec.Command("ssh", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return 0, fmt.Errorf("ssh tunnel %s: %s", forward, strings.TrimSpace(string(out)))
}
// ssh -f se detach, buscar el PID del proceso
time.Sleep(200 * time.Millisecond)
pidCmd := exec.Command("sh", "-c",
fmt.Sprintf("ps aux | grep '[s]sh.*-L %s' | awk '{print $2}' | head -1", forward))
pidOut, err := pidCmd.Output()
if err != nil || strings.TrimSpace(string(pidOut)) == "" {
return 0, fmt.Errorf("ssh tunnel started but could not find PID")
}
var pid int
_, err = fmt.Sscanf(strings.TrimSpace(string(pidOut)), "%d", &pid)
if err != nil {
return 0, fmt.Errorf("ssh tunnel: invalid PID %q", strings.TrimSpace(string(pidOut)))
}
return pid, nil
}
+39
View File
@@ -0,0 +1,39 @@
---
name: ssh_tunnel_open
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error)"
description: "Abre un tunel SSH (local port forwarding) en background. Retorna el PID del proceso para cerrarlo despues."
tags: [ssh, tunnel, port-forwarding, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings, time]
tested: true
tests: ["abre tunel y lo cierra"]
test_file_path: "functions/infra/ssh_tunnel_test.go"
file_path: "functions/infra/ssh_tunnel_open.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "bastion.example.com", User: "deploy"}
// Tunel: localhost:5432 -> db-server:5432 via bastion
pid, err := SSHTunnelOpen(conn, 5432, "db-server", 5432)
if err != nil {
log.Fatal(err)
}
fmt.Println("tunnel PID:", pid)
// Usar localhost:5432 para conectar a la BD remota
// Cerrar con SSHTunnelClose(pid)
```
## Notas
Usa ssh -N -f -L para crear el tunel en background. ExitOnForwardFailure=yes falla inmediatamente si el puerto local esta ocupado. remoteHost vacio se interpreta como "localhost" (el servidor SSH mismo). El PID se obtiene buscando el proceso ssh en la tabla de procesos.
+49
View File
@@ -0,0 +1,49 @@
package infra
import (
"fmt"
"net"
"testing"
"time"
)
func TestSSHTunnelOpenClose(t *testing.T) {
conn := skipIfNoSSH(t)
t.Run("abre tunel y lo cierra", func(t *testing.T) {
// Usar puerto alto aleatorio para evitar conflictos
localPort := 19876
// Tunel a localhost:22 del remoto (el propio sshd)
pid, err := SSHTunnelOpen(conn, localPort, "localhost", 22)
if err != nil {
t.Fatalf("SSHTunnelOpen: %v", err)
}
if pid <= 0 {
t.Fatalf("PID invalido: %d", pid)
}
// Verificar que el puerto local esta escuchando
time.Sleep(300 * time.Millisecond)
c, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 2*time.Second)
if err != nil {
// Limpiar antes de fallar
SSHTunnelClose(pid)
t.Fatalf("no se puede conectar al tunel en localhost:%d: %v", localPort, err)
}
c.Close()
// Cerrar tunel
err = SSHTunnelClose(pid)
if err != nil {
t.Errorf("SSHTunnelClose: %v", err)
}
// Verificar que el puerto ya no escucha
time.Sleep(300 * time.Millisecond)
c, err = net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 1*time.Second)
if err == nil {
c.Close()
t.Error("el tunel sigue abierto despues de SSHTunnelClose")
}
})
}
+20
View File
@@ -0,0 +1,20 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// SSHUpload sube un archivo local al host remoto via scp.
func SSHUpload(conn SSHConn, localPath, remotePath string) error {
args := conn.scpArgs()
dest := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
args = append(args, localPath, dest)
out, err := exec.Command("scp", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("scp upload to %s: %s", conn.destination(), strings.TrimSpace(string(out)))
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
---
name: ssh_upload
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHUpload(conn SSHConn, localPath, remotePath string) error"
description: "Sube un archivo local al host remoto via scp."
tags: [ssh, scp, upload, file, transfer, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["upload y download roundtrip"]
test_file_path: "functions/infra/ssh_transfer_test.go"
file_path: "functions/infra/ssh_upload.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy", KeyPath: "~/.ssh/id_ed25519"}
err := SSHUpload(conn, "./config.yaml", "/home/deploy/app/config.yaml")
```
## Notas
Usa scp con -P para el puerto (distinto a ssh que usa -p). Para subir directorios, usar SSHExec con tar/rsync como alternativa.
+22
View File
@@ -0,0 +1,22 @@
---
name: ssh_conn
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type SSHConn struct {
Host string // Hostname o IP del servidor remoto
Port int // Puerto SSH (0 usa el default 22)
User string // Usuario remoto
KeyPath string // Ruta a clave privada (vacio usa ssh-agent o default)
}
description: "Parametros de conexion SSH reutilizables. Contiene host, puerto, usuario y ruta a clave privada."
tags: [ssh, connection, remote, infra]
uses_types: []
file_path: "functions/infra/ssh_conn.go"
---
## Notas
Tipo producto — todos los campos siempre presentes. Port=0 se interpreta como puerto 22 por defecto. KeyPath vacio delega la autenticacion a ssh-agent o la clave default (~/.ssh/id_rsa). Incluye metodos helper (sshArgs, scpArgs, destination) que las funciones SSH del registry consumen internamente.