From ab7317b0c0178f2f42d60016a9beed18780fd56e Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 12 Apr 2026 13:54:43 +0200 Subject: [PATCH] feat: add Go SSH config management functions and type 7 funciones Go del dominio infra para gestion programatica de ~/.ssh/config: ssh_config_parse (parser de bloques Host/Match), ssh_config_read (lectura del archivo), ssh_config_find (busqueda por host), ssh_config_add_entry y ssh_config_remove_entry (CRUD), ssh_config_render (serializacion a texto), ssh_config_write (escritura atomica). Incluye tipo SshConfigEntry (product type) y tests unitarios del parser. --- functions/infra/ssh_config_add_entry.go | 16 ++ functions/infra/ssh_config_add_entry.md | 38 +++ functions/infra/ssh_config_entry.go | 21 ++ functions/infra/ssh_config_find.go | 12 + functions/infra/ssh_config_find.md | 41 +++ functions/infra/ssh_config_parse.go | 84 +++++++ functions/infra/ssh_config_parse.md | 43 ++++ functions/infra/ssh_config_parse_test.go | 279 +++++++++++++++++++++ functions/infra/ssh_config_read.go | 21 ++ functions/infra/ssh_config_read.md | 39 +++ functions/infra/ssh_config_remove_entry.go | 22 ++ functions/infra/ssh_config_remove_entry.md | 37 +++ functions/infra/ssh_config_render.go | 42 ++++ functions/infra/ssh_config_render.md | 39 +++ functions/infra/ssh_config_write.go | 33 +++ functions/infra/ssh_config_write.md | 41 +++ types/infra/ssh_config_entry.md | 26 ++ 17 files changed, 834 insertions(+) create mode 100644 functions/infra/ssh_config_add_entry.go create mode 100644 functions/infra/ssh_config_add_entry.md create mode 100644 functions/infra/ssh_config_entry.go create mode 100644 functions/infra/ssh_config_find.go create mode 100644 functions/infra/ssh_config_find.md create mode 100644 functions/infra/ssh_config_parse.go create mode 100644 functions/infra/ssh_config_parse.md create mode 100644 functions/infra/ssh_config_parse_test.go create mode 100644 functions/infra/ssh_config_read.go create mode 100644 functions/infra/ssh_config_read.md create mode 100644 functions/infra/ssh_config_remove_entry.go create mode 100644 functions/infra/ssh_config_remove_entry.md create mode 100644 functions/infra/ssh_config_render.go create mode 100644 functions/infra/ssh_config_render.md create mode 100644 functions/infra/ssh_config_write.go create mode 100644 functions/infra/ssh_config_write.md create mode 100644 types/infra/ssh_config_entry.md diff --git a/functions/infra/ssh_config_add_entry.go b/functions/infra/ssh_config_add_entry.go new file mode 100644 index 00000000..879bf98c --- /dev/null +++ b/functions/infra/ssh_config_add_entry.go @@ -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 +} diff --git a/functions/infra/ssh_config_add_entry.md b/functions/infra/ssh_config_add_entry.md new file mode 100644 index 00000000..0d6952f9 --- /dev/null +++ b/functions/infra/ssh_config_add_entry.md @@ -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. diff --git a/functions/infra/ssh_config_entry.go b/functions/infra/ssh_config_entry.go new file mode 100644 index 00000000..63ce8bd9 --- /dev/null +++ b/functions/infra/ssh_config_entry.go @@ -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, + } +} diff --git a/functions/infra/ssh_config_find.go b/functions/infra/ssh_config_find.go new file mode 100644 index 00000000..28b2723f --- /dev/null +++ b/functions/infra/ssh_config_find.go @@ -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 +} diff --git a/functions/infra/ssh_config_find.md b/functions/infra/ssh_config_find.md new file mode 100644 index 00000000..78225e28 --- /dev/null +++ b/functions/infra/ssh_config_find.md @@ -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. diff --git a/functions/infra/ssh_config_parse.go b/functions/infra/ssh_config_parse.go new file mode 100644 index 00000000..79137d93 --- /dev/null +++ b/functions/infra/ssh_config_parse.go @@ -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]) +} diff --git a/functions/infra/ssh_config_parse.md b/functions/infra/ssh_config_parse.md new file mode 100644 index 00000000..0019c9c2 --- /dev/null +++ b/functions/infra/ssh_config_parse.md @@ -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`. diff --git a/functions/infra/ssh_config_parse_test.go b/functions/infra/ssh_config_parse_test.go new file mode 100644 index 00000000..7223b482 --- /dev/null +++ b/functions/infra/ssh_config_parse_test.go @@ -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) + } + } +} diff --git a/functions/infra/ssh_config_read.go b/functions/infra/ssh_config_read.go new file mode 100644 index 00000000..7cf29c7b --- /dev/null +++ b/functions/infra/ssh_config_read.go @@ -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 +} diff --git a/functions/infra/ssh_config_read.md b/functions/infra/ssh_config_read.md new file mode 100644 index 00000000..93af85bd --- /dev/null +++ b/functions/infra/ssh_config_read.md @@ -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. diff --git a/functions/infra/ssh_config_remove_entry.go b/functions/infra/ssh_config_remove_entry.go new file mode 100644 index 00000000..cf163956 --- /dev/null +++ b/functions/infra/ssh_config_remove_entry.go @@ -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 +} diff --git a/functions/infra/ssh_config_remove_entry.md b/functions/infra/ssh_config_remove_entry.md new file mode 100644 index 00000000..9ce6457c --- /dev/null +++ b/functions/infra/ssh_config_remove_entry.md @@ -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. diff --git a/functions/infra/ssh_config_render.go b/functions/infra/ssh_config_render.go new file mode 100644 index 00000000..16eafd0b --- /dev/null +++ b/functions/infra/ssh_config_render.go @@ -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() +} diff --git a/functions/infra/ssh_config_render.md b/functions/infra/ssh_config_render.md new file mode 100644 index 00000000..52d38f94 --- /dev/null +++ b/functions/infra/ssh_config_render.md @@ -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. diff --git a/functions/infra/ssh_config_write.go b/functions/infra/ssh_config_write.go new file mode 100644 index 00000000..c056d769 --- /dev/null +++ b/functions/infra/ssh_config_write.go @@ -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 +} diff --git a/functions/infra/ssh_config_write.md b/functions/infra/ssh_config_write.md new file mode 100644 index 00000000..7f36c3a7 --- /dev/null +++ b/functions/infra/ssh_config_write.md @@ -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). diff --git a/types/infra/ssh_config_entry.md b/types/infra/ssh_config_entry.md new file mode 100644 index 00000000..7d5777b3 --- /dev/null +++ b/types/infra/ssh_config_entry.md @@ -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.).