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:
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user