fix(fn-run): propagar stdout/stderr de bash functions library-style #1
@@ -0,0 +1,16 @@
|
||||
package infra
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SSHConfigAddEntry añade un nuevo entry a la lista. Retorna error si el alias
|
||||
// ya existe. No muta el slice original.
|
||||
func SSHConfigAddEntry(entries []SSHConfigEntry, entry SSHConfigEntry) ([]SSHConfigEntry, error) {
|
||||
for _, e := range entries {
|
||||
if e.Alias == entry.Alias {
|
||||
return nil, fmt.Errorf("alias %q already exists", entry.Alias)
|
||||
}
|
||||
}
|
||||
result := make([]SSHConfigEntry, len(entries), len(entries)+1)
|
||||
copy(result, entries)
|
||||
return append(result, entry), nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: ssh_config_add_entry
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SSHConfigAddEntry(entries []SSHConfigEntry, entry SSHConfigEntry) ([]SSHConfigEntry, error)"
|
||||
description: "Añade un nuevo SSHConfigEntry a la lista. Error si el alias ya existe."
|
||||
tags: [ssh, config, add, remote]
|
||||
uses_functions: []
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt]
|
||||
params:
|
||||
- name: entries
|
||||
desc: "lista actual de SSHConfigEntry"
|
||||
- name: entry
|
||||
desc: "nuevo entry a añadir con alias unico"
|
||||
output: "nueva lista con el entry añadido al final"
|
||||
tested: true
|
||||
tests: ["añade entry a lista vacia", "añade entry a lista existente", "error si alias duplicado"]
|
||||
test_file_path: "functions/infra/ssh_config_parse_test.go"
|
||||
file_path: "functions/infra/ssh_config_add_entry.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
newEntry := SSHConfigEntry{Alias: "staging", HostName: "10.0.0.2", User: "deploy"}
|
||||
updated, err := SSHConfigAddEntry(entries, newEntry)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
No muta el slice original — crea una copia nueva. La validacion de unicidad es por alias exacto.
|
||||
@@ -0,0 +1,21 @@
|
||||
package infra
|
||||
|
||||
// SSHConfigEntry representa un bloque Host en ~/.ssh/config.
|
||||
type SSHConfigEntry struct {
|
||||
Alias string // Nombre del host (lo que va despues de "Host")
|
||||
HostName string // IP o hostname real del servidor
|
||||
User string // Usuario remoto
|
||||
Port int // Puerto SSH (0 = no especificado, usa default 22)
|
||||
IdentityFile string // Ruta a clave privada
|
||||
Options map[string]string // Opciones SSH adicionales (ForwardAgent, ProxyJump, etc.)
|
||||
}
|
||||
|
||||
// ToSSHConn convierte un SSHConfigEntry a SSHConn para usar con las funciones SSH del registry.
|
||||
func (e SSHConfigEntry) ToSSHConn() SSHConn {
|
||||
return SSHConn{
|
||||
Host: e.HostName,
|
||||
Port: e.Port,
|
||||
User: e.User,
|
||||
KeyPath: e.IdentityFile,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package infra
|
||||
|
||||
// SSHConfigFind busca un entry por alias en la lista. Retorna el entry y true
|
||||
// si lo encuentra, o un SSHConfigEntry vacio y false si no existe.
|
||||
func SSHConfigFind(entries []SSHConfigEntry, alias string) (SSHConfigEntry, bool) {
|
||||
for _, e := range entries {
|
||||
if e.Alias == alias {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return SSHConfigEntry{}, false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: ssh_config_find
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SSHConfigFind(entries []SSHConfigEntry, alias string) (SSHConfigEntry, bool)"
|
||||
description: "Busca un entry por alias en la lista de SSHConfigEntry."
|
||||
tags: [ssh, config, search, remote]
|
||||
uses_functions: []
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: entries
|
||||
desc: "lista de SSHConfigEntry donde buscar"
|
||||
- name: alias
|
||||
desc: "nombre del host a buscar (case-sensitive)"
|
||||
output: "el SSHConfigEntry encontrado y true, o entry vacio y false si no existe"
|
||||
tested: true
|
||||
tests: ["encuentra entry existente", "retorna false para alias inexistente"]
|
||||
test_file_path: "functions/infra/ssh_config_parse_test.go"
|
||||
file_path: "functions/infra/ssh_config_find.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
entry, ok := SSHConfigFind(entries, "myserver")
|
||||
if ok {
|
||||
conn := entry.ToSSHConn()
|
||||
SSHCheck(conn)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Busqueda lineal por coincidencia exacta del alias. Para pocos entries (tipico en un SSH config personal) es suficiente.
|
||||
@@ -0,0 +1,84 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHConfigParse parsea el contenido de un archivo ~/.ssh/config y retorna
|
||||
// una lista de SSHConfigEntry. Ignora comentarios y lineas vacias.
|
||||
// Los bloques Host con wildcards (* o ?) se ignoran.
|
||||
func SSHConfigParse(content string) []SSHConfigEntry {
|
||||
var entries []SSHConfigEntry
|
||||
var current *SSHConfigEntry
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
key, value := splitDirective(line)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(key, "Host") {
|
||||
if current != nil && current.Alias != "" {
|
||||
entries = append(entries, *current)
|
||||
}
|
||||
if strings.ContainsAny(value, "*?") {
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
current = &SSHConfigEntry{
|
||||
Alias: value,
|
||||
Options: make(map[string]string),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(key) {
|
||||
case "hostname":
|
||||
current.HostName = value
|
||||
case "user":
|
||||
current.User = value
|
||||
case "port":
|
||||
if p, err := strconv.Atoi(value); err == nil {
|
||||
current.Port = p
|
||||
}
|
||||
case "identityfile":
|
||||
current.IdentityFile = value
|
||||
default:
|
||||
current.Options[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if current != nil && current.Alias != "" {
|
||||
entries = append(entries, *current)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// splitDirective separa una linea "Key Value" o "Key=Value" en clave y valor.
|
||||
func splitDirective(line string) (string, string) {
|
||||
// Intentar separar por =
|
||||
if idx := strings.IndexByte(line, '='); idx >= 0 {
|
||||
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
// Separar por primer espacio/tab
|
||||
fields := strings.SplitN(line, " ", 2)
|
||||
if len(fields) < 2 {
|
||||
fields = strings.SplitN(line, "\t", 2)
|
||||
}
|
||||
if len(fields) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(fields[0]), strings.TrimSpace(fields[1])
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: ssh_config_parse
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SSHConfigParse(content string) []SSHConfigEntry"
|
||||
description: "Parsea el contenido de un archivo ~/.ssh/config y retorna una lista de SSHConfigEntry."
|
||||
tags: [ssh, config, parser, remote]
|
||||
uses_functions: []
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [bufio, strconv, strings]
|
||||
params:
|
||||
- name: content
|
||||
desc: "texto completo del archivo ~/.ssh/config"
|
||||
output: "lista de SSHConfigEntry, uno por cada bloque Host (sin wildcards)"
|
||||
tested: true
|
||||
tests: ["parsea config con multiples hosts", "ignora comentarios y lineas vacias", "ignora hosts con wildcards", "parsea opciones extra en Options map"]
|
||||
test_file_path: "functions/infra/ssh_config_parse_test.go"
|
||||
file_path: "functions/infra/ssh_config_parse.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
content := `Host myserver
|
||||
HostName 192.168.1.100
|
||||
User deploy
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519`
|
||||
|
||||
entries := SSHConfigParse(content)
|
||||
// entries[0].Alias == "myserver"
|
||||
// entries[0].HostName == "192.168.1.100"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ignora bloques `Host *` y `Host 192.168.?.*` (wildcards). Soporta directivas con separador espacio o `=`. Las directivas no reconocidas se almacenan en el map `Options`.
|
||||
@@ -0,0 +1,279 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSSHConfigParse_MultipleHosts(t *testing.T) {
|
||||
content := `
|
||||
Host prod
|
||||
HostName 10.0.0.1
|
||||
User admin
|
||||
Port 22
|
||||
IdentityFile ~/.ssh/id_prod
|
||||
|
||||
Host staging
|
||||
HostName 10.0.0.2
|
||||
User deploy
|
||||
Port 2222
|
||||
`
|
||||
entries := SSHConfigParse(content)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Alias != "prod" {
|
||||
t.Errorf("expected alias prod, got %s", entries[0].Alias)
|
||||
}
|
||||
if entries[0].HostName != "10.0.0.1" {
|
||||
t.Errorf("expected hostname 10.0.0.1, got %s", entries[0].HostName)
|
||||
}
|
||||
if entries[0].User != "admin" {
|
||||
t.Errorf("expected user admin, got %s", entries[0].User)
|
||||
}
|
||||
if entries[0].Port != 22 {
|
||||
t.Errorf("expected port 22, got %d", entries[0].Port)
|
||||
}
|
||||
if entries[0].IdentityFile != "~/.ssh/id_prod" {
|
||||
t.Errorf("expected identity file ~/.ssh/id_prod, got %s", entries[0].IdentityFile)
|
||||
}
|
||||
if entries[1].Alias != "staging" {
|
||||
t.Errorf("expected alias staging, got %s", entries[1].Alias)
|
||||
}
|
||||
if entries[1].Port != 2222 {
|
||||
t.Errorf("expected port 2222, got %d", entries[1].Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigParse_IgnoresCommentsAndBlanks(t *testing.T) {
|
||||
content := `
|
||||
# This is a comment
|
||||
Host myserver
|
||||
HostName 192.168.1.1
|
||||
# inline comment
|
||||
User root
|
||||
`
|
||||
entries := SSHConfigParse(content)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].User != "root" {
|
||||
t.Errorf("expected user root, got %s", entries[0].User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigParse_IgnoresWildcards(t *testing.T) {
|
||||
content := `
|
||||
Host *
|
||||
ServerAliveInterval 60
|
||||
|
||||
Host prod
|
||||
HostName 10.0.0.1
|
||||
|
||||
Host 192.168.*
|
||||
User local
|
||||
`
|
||||
entries := SSHConfigParse(content)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry (wildcards ignored), got %d", len(entries))
|
||||
}
|
||||
if entries[0].Alias != "prod" {
|
||||
t.Errorf("expected alias prod, got %s", entries[0].Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigParse_ExtraOptions(t *testing.T) {
|
||||
content := `Host jump
|
||||
HostName bastion.example.com
|
||||
User ops
|
||||
ForwardAgent yes
|
||||
ProxyJump none
|
||||
`
|
||||
entries := SSHConfigParse(content)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Options["ForwardAgent"] != "yes" {
|
||||
t.Errorf("expected ForwardAgent=yes, got %s", entries[0].Options["ForwardAgent"])
|
||||
}
|
||||
if entries[0].Options["ProxyJump"] != "none" {
|
||||
t.Errorf("expected ProxyJump=none, got %s", entries[0].Options["ProxyJump"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRender_FullEntry(t *testing.T) {
|
||||
entries := []SSHConfigEntry{{
|
||||
Alias: "prod",
|
||||
HostName: "10.0.0.1",
|
||||
User: "admin",
|
||||
Port: 2222,
|
||||
IdentityFile: "~/.ssh/id_prod",
|
||||
}}
|
||||
result := SSHConfigRender(entries)
|
||||
if !strings.Contains(result, "Host prod") {
|
||||
t.Error("missing Host directive")
|
||||
}
|
||||
if !strings.Contains(result, "HostName 10.0.0.1") {
|
||||
t.Error("missing HostName")
|
||||
}
|
||||
if !strings.Contains(result, "User admin") {
|
||||
t.Error("missing User")
|
||||
}
|
||||
if !strings.Contains(result, "Port 2222") {
|
||||
t.Error("missing Port")
|
||||
}
|
||||
if !strings.Contains(result, "IdentityFile ~/.ssh/id_prod") {
|
||||
t.Error("missing IdentityFile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRender_MinimalEntry(t *testing.T) {
|
||||
entries := []SSHConfigEntry{{Alias: "local"}}
|
||||
result := SSHConfigRender(entries)
|
||||
if result != "Host local\n" {
|
||||
t.Errorf("expected minimal render, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRender_MultipleEntries(t *testing.T) {
|
||||
entries := []SSHConfigEntry{
|
||||
{Alias: "a", HostName: "1.1.1.1"},
|
||||
{Alias: "b", HostName: "2.2.2.2"},
|
||||
}
|
||||
result := SSHConfigRender(entries)
|
||||
parts := strings.Split(result, "\n\n")
|
||||
if len(parts) < 2 {
|
||||
t.Error("expected blocks separated by blank line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRender_OptionsSorted(t *testing.T) {
|
||||
entries := []SSHConfigEntry{{
|
||||
Alias: "test",
|
||||
HostName: "1.1.1.1",
|
||||
Options: map[string]string{"ProxyJump": "bastion", "ForwardAgent": "yes"},
|
||||
}}
|
||||
result := SSHConfigRender(entries)
|
||||
fwIdx := strings.Index(result, "ForwardAgent")
|
||||
pjIdx := strings.Index(result, "ProxyJump")
|
||||
if fwIdx < 0 || pjIdx < 0 {
|
||||
t.Fatal("missing options in render")
|
||||
}
|
||||
if fwIdx > pjIdx {
|
||||
t.Error("options should be sorted alphabetically")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigFind_Exists(t *testing.T) {
|
||||
entries := []SSHConfigEntry{
|
||||
{Alias: "prod", HostName: "10.0.0.1"},
|
||||
{Alias: "staging", HostName: "10.0.0.2"},
|
||||
}
|
||||
entry, ok := SSHConfigFind(entries, "staging")
|
||||
if !ok {
|
||||
t.Fatal("expected to find staging")
|
||||
}
|
||||
if entry.HostName != "10.0.0.2" {
|
||||
t.Errorf("expected hostname 10.0.0.2, got %s", entry.HostName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigFind_NotExists(t *testing.T) {
|
||||
entries := []SSHConfigEntry{{Alias: "prod"}}
|
||||
_, ok := SSHConfigFind(entries, "nope")
|
||||
if ok {
|
||||
t.Error("expected not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigAddEntry_ToEmpty(t *testing.T) {
|
||||
entry := SSHConfigEntry{Alias: "new", HostName: "1.1.1.1"}
|
||||
result, err := SSHConfigAddEntry(nil, entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].Alias != "new" {
|
||||
t.Errorf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigAddEntry_ToExisting(t *testing.T) {
|
||||
existing := []SSHConfigEntry{{Alias: "old"}}
|
||||
entry := SSHConfigEntry{Alias: "new"}
|
||||
result, err := SSHConfigAddEntry(existing, entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(result))
|
||||
}
|
||||
// Original no mutado
|
||||
if len(existing) != 1 {
|
||||
t.Error("original slice was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigAddEntry_DuplicateAlias(t *testing.T) {
|
||||
existing := []SSHConfigEntry{{Alias: "prod"}}
|
||||
entry := SSHConfigEntry{Alias: "prod"}
|
||||
_, err := SSHConfigAddEntry(existing, entry)
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRemoveEntry_Exists(t *testing.T) {
|
||||
entries := []SSHConfigEntry{
|
||||
{Alias: "a"},
|
||||
{Alias: "b"},
|
||||
{Alias: "c"},
|
||||
}
|
||||
result, err := SSHConfigRemoveEntry(entries, "b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(result))
|
||||
}
|
||||
if result[0].Alias != "a" || result[1].Alias != "c" {
|
||||
t.Errorf("unexpected order: %+v", result)
|
||||
}
|
||||
// Original no mutado
|
||||
if len(entries) != 3 {
|
||||
t.Error("original slice was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRemoveEntry_NotExists(t *testing.T) {
|
||||
entries := []SSHConfigEntry{{Alias: "prod"}}
|
||||
_, err := SSHConfigRemoveEntry(entries, "nope")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigParseRender_Roundtrip(t *testing.T) {
|
||||
original := []SSHConfigEntry{
|
||||
{Alias: "prod", HostName: "10.0.0.1", User: "admin", Port: 22, IdentityFile: "~/.ssh/id_prod"},
|
||||
{Alias: "staging", HostName: "10.0.0.2", User: "deploy", Port: 2222},
|
||||
}
|
||||
text := SSHConfigRender(original)
|
||||
parsed := SSHConfigParse(text)
|
||||
if len(parsed) != len(original) {
|
||||
t.Fatalf("roundtrip: expected %d entries, got %d", len(original), len(parsed))
|
||||
}
|
||||
for i := range original {
|
||||
if parsed[i].Alias != original[i].Alias {
|
||||
t.Errorf("roundtrip[%d]: alias mismatch %s != %s", i, parsed[i].Alias, original[i].Alias)
|
||||
}
|
||||
if parsed[i].HostName != original[i].HostName {
|
||||
t.Errorf("roundtrip[%d]: hostname mismatch", i)
|
||||
}
|
||||
if parsed[i].User != original[i].User {
|
||||
t.Errorf("roundtrip[%d]: user mismatch", i)
|
||||
}
|
||||
if parsed[i].Port != original[i].Port {
|
||||
t.Errorf("roundtrip[%d]: port mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// SSHConfigRead lee y parsea el archivo ~/.ssh/config. Si el archivo no existe,
|
||||
// retorna una lista vacia sin error (config todavia no creado).
|
||||
func SSHConfigRead() ([]SSHConfigEntry, error) {
|
||||
path := filepath.Join(os.Getenv("HOME"), ".ssh", "config")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ssh config read: %w", err)
|
||||
}
|
||||
return SSHConfigParse(string(data)), nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: ssh_config_read
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SSHConfigRead() ([]SSHConfigEntry, error)"
|
||||
description: "Lee y parsea ~/.ssh/config. Retorna lista vacia si el archivo no existe."
|
||||
tags: [ssh, config, read, remote]
|
||||
uses_functions: [ssh_config_parse_go_infra]
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os, path/filepath]
|
||||
params: []
|
||||
output: "lista de SSHConfigEntry parseados del archivo ~/.ssh/config"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/ssh_config_read.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
entries, err := SSHConfigRead()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
fmt.Printf("%s -> %s@%s\n", e.Alias, e.User, e.HostName)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Si `~/.ssh/config` no existe, retorna lista vacia y nil (no es error — el usuario simplemente no tiene config aun). Usa `SSHConfigParse` internamente.
|
||||
@@ -0,0 +1,22 @@
|
||||
package infra
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SSHConfigRemoveEntry elimina el entry con el alias dado. Retorna error si
|
||||
// el alias no existe. No muta el slice original.
|
||||
func SSHConfigRemoveEntry(entries []SSHConfigEntry, alias string) ([]SSHConfigEntry, error) {
|
||||
idx := -1
|
||||
for i, e := range entries {
|
||||
if e.Alias == alias {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
result := make([]SSHConfigEntry, 0, len(entries)-1)
|
||||
result = append(result, entries[:idx]...)
|
||||
result = append(result, entries[idx+1:]...)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: ssh_config_remove_entry
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SSHConfigRemoveEntry(entries []SSHConfigEntry, alias string) ([]SSHConfigEntry, error)"
|
||||
description: "Elimina un entry por alias de la lista. Error si el alias no existe."
|
||||
tags: [ssh, config, remove, remote]
|
||||
uses_functions: []
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt]
|
||||
params:
|
||||
- name: entries
|
||||
desc: "lista actual de SSHConfigEntry"
|
||||
- name: alias
|
||||
desc: "alias del host a eliminar"
|
||||
output: "nueva lista sin el entry eliminado"
|
||||
tested: true
|
||||
tests: ["elimina entry existente", "error si alias no existe"]
|
||||
test_file_path: "functions/infra/ssh_config_parse_test.go"
|
||||
file_path: "functions/infra/ssh_config_remove_entry.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
updated, err := SSHConfigRemoveEntry(entries, "old-server")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
No muta el slice original — crea una copia nueva sin el entry eliminado.
|
||||
@@ -0,0 +1,42 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHConfigRender convierte una lista de SSHConfigEntry al formato texto
|
||||
// de ~/.ssh/config. Cada bloque se separa con una linea en blanco.
|
||||
func SSHConfigRender(entries []SSHConfigEntry) string {
|
||||
var sb strings.Builder
|
||||
for i, e := range entries {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Host %s\n", e.Alias))
|
||||
if e.HostName != "" {
|
||||
sb.WriteString(fmt.Sprintf(" HostName %s\n", e.HostName))
|
||||
}
|
||||
if e.User != "" {
|
||||
sb.WriteString(fmt.Sprintf(" User %s\n", e.User))
|
||||
}
|
||||
if e.Port != 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Port %d\n", e.Port))
|
||||
}
|
||||
if e.IdentityFile != "" {
|
||||
sb.WriteString(fmt.Sprintf(" IdentityFile %s\n", e.IdentityFile))
|
||||
}
|
||||
if len(e.Options) > 0 {
|
||||
keys := make([]string, 0, len(e.Options))
|
||||
for k := range e.Options {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s\n", k, e.Options[k]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: ssh_config_render
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SSHConfigRender(entries []SSHConfigEntry) string"
|
||||
description: "Convierte una lista de SSHConfigEntry al formato texto de ~/.ssh/config."
|
||||
tags: [ssh, config, render, remote]
|
||||
uses_functions: []
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt, sort, strings]
|
||||
params:
|
||||
- name: entries
|
||||
desc: "lista de SSHConfigEntry a renderizar"
|
||||
output: "texto en formato ~/.ssh/config con bloques Host separados por linea en blanco"
|
||||
tested: true
|
||||
tests: ["renderiza entry completo", "renderiza entry minimo solo con alias", "separa bloques con linea en blanco", "ordena opciones extra alfabeticamente"]
|
||||
test_file_path: "functions/infra/ssh_config_parse_test.go"
|
||||
file_path: "functions/infra/ssh_config_render.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
entries := []SSHConfigEntry{{
|
||||
Alias: "prod", HostName: "10.0.0.1", User: "admin", Port: 22,
|
||||
}}
|
||||
text := SSHConfigRender(entries)
|
||||
// "Host prod\n HostName 10.0.0.1\n User admin\n Port 22\n"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Campos vacios o Port=0 se omiten. Las opciones extra se renderizan en orden alfabetico para determinismo.
|
||||
@@ -0,0 +1,33 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// SSHConfigWrite escribe la lista de entries al archivo ~/.ssh/config.
|
||||
// Crea un backup (.config.bak) antes de sobrescribir si el archivo ya existe.
|
||||
// Crea el directorio ~/.ssh si no existe.
|
||||
func SSHConfigWrite(entries []SSHConfigEntry) error {
|
||||
sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
|
||||
configPath := filepath.Join(sshDir, "config")
|
||||
backupPath := filepath.Join(sshDir, "config.bak")
|
||||
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
return fmt.Errorf("ssh config write: create dir: %w", err)
|
||||
}
|
||||
|
||||
// Backup si existe
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := os.WriteFile(backupPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("ssh config write: backup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
content := SSHConfigRender(entries)
|
||||
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("ssh config write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: ssh_config_write
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SSHConfigWrite(entries []SSHConfigEntry) error"
|
||||
description: "Escribe entries a ~/.ssh/config con backup automatico del archivo previo."
|
||||
tags: [ssh, config, write, remote]
|
||||
uses_functions: [ssh_config_render_go_infra]
|
||||
uses_types: [ssh_config_entry_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os, path/filepath]
|
||||
params:
|
||||
- name: entries
|
||||
desc: "lista de SSHConfigEntry a escribir en ~/.ssh/config"
|
||||
output: "nil si la escritura fue exitosa"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/ssh_config_write.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
entries, _ := SSHConfigRead()
|
||||
entries, _ = SSHConfigAddEntry(entries, SSHConfigEntry{
|
||||
Alias: "prod", HostName: "10.0.0.1", User: "deploy",
|
||||
})
|
||||
if err := SSHConfigWrite(entries); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Crea `~/.ssh/` con permisos 0700 si no existe. Hace backup a `~/.ssh/config.bak` antes de sobrescribir. El archivo resultante tiene permisos 0600 (solo lectura/escritura para el owner).
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: ssh_config_entry
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type SSHConfigEntry struct {
|
||||
Alias string
|
||||
HostName string
|
||||
User string
|
||||
Port int
|
||||
IdentityFile string
|
||||
Options map[string]string
|
||||
}
|
||||
description: "Bloque Host de ~/.ssh/config. Contiene alias, hostname, usuario, puerto, clave y opciones extra."
|
||||
tags: [ssh, config, remote, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/ssh_config_entry.go"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto — modela un bloque `Host` del archivo `~/.ssh/config`. Port=0 significa que no se especifica (el SSH client usa 22 por defecto). Options contiene pares clave-valor para directivas SSH adicionales como ForwardAgent, ProxyJump, ServerAliveInterval, etc.
|
||||
|
||||
Incluye metodo `ToSSHConn()` para convertir a `SSHConn` y poder usar las funciones SSH operativas del registry (ssh_exec, ssh_check, etc.).
|
||||
Reference in New Issue
Block a user