chore: auto-commit (97 archivos)
- .claude/CLAUDE.md - .claude/agents/fn-recopilador/SKILL.md - .claude/rules/INDEX.md - .claude/rules/cpp_apps.md - bash/functions/infra/build_cpp_windows.sh - cpp/CMakeLists.txt - cpp/PATTERNS.md - cpp/framework/app_base.cpp - cpp/framework/app_base.h - dev/issues/README.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BcryptHtpasswd genera una linea formato htpasswd para basicAuth de Traefik
|
||||
// usando bcrypt. Si cost es 0 usa el default 10.
|
||||
// Output: "<user>:<bcrypt_hash>" (sin escapado $$ — eso es solo para Docker labels en compose).
|
||||
func BcryptHtpasswd(user, password string, cost int) (string, error) {
|
||||
if user == "" {
|
||||
return "", fmt.Errorf("user cannot be empty")
|
||||
}
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("password cannot be empty")
|
||||
}
|
||||
if cost == 0 {
|
||||
cost = 10
|
||||
}
|
||||
if cost < bcrypt.MinCost || cost > bcrypt.MaxCost {
|
||||
return "", fmt.Errorf("cost %d out of range [%d, %d]", cost, bcrypt.MinCost, bcrypt.MaxCost)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bcrypt: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", user, hash), nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: bcrypt_htpasswd
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func BcryptHtpasswd(user, password string, cost int) (string, error)"
|
||||
description: "Genera una linea formato htpasswd para basicAuth de Traefik usando bcrypt. Si cost es 0 usa el default 10. Output: user:hash (sin escapado $$ — eso es solo para Docker labels en compose). Error si user o password vacios o cost fuera de [4,31]."
|
||||
tags: [bcrypt, htpasswd, auth, traefik, basicauth, infra, security]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, golang.org/x/crypto/bcrypt]
|
||||
params:
|
||||
- name: user
|
||||
desc: "nombre de usuario para la linea htpasswd (no puede ser vacio)"
|
||||
- name: password
|
||||
desc: "contrasena en texto plano a hashear con bcrypt (no puede ser vacia)"
|
||||
- name: cost
|
||||
desc: "factor de coste bcrypt en rango [4,31]; 0 para usar el default 10"
|
||||
output: "linea htpasswd con formato 'user:$2a$NN$...' lista para pegar en el file provider de Traefik o nginx"
|
||||
tested: true
|
||||
tests:
|
||||
- "hash valido pasa CompareHashAndPassword"
|
||||
- "formato es user:hash"
|
||||
- "cost cero usa default 10"
|
||||
- "error si user vacio"
|
||||
- "error si password vacio"
|
||||
- "error si cost fuera de rango"
|
||||
test_file_path: "functions/infra/bcrypt_htpasswd_test.go"
|
||||
file_path: "functions/infra/bcrypt_htpasswd.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
line, err := BcryptHtpasswd("lucas", "s3cr3t", 10)
|
||||
// line = "lucas:$2a$10$..."
|
||||
// Pegar directamente en traefik-dynamic.yml bajo basicAuth.users
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La funcion usa `golang.org/x/crypto/bcrypt` (ya en go.mod). El salt aleatorio hace que cada llamada genere un hash distinto — la funcion es impura. El output es para el file provider de Traefik (single `$`). Para Docker labels en compose se necesita escapar a `$$`, lo que NO hace esta funcion. Verificacion: `bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))`.
|
||||
@@ -0,0 +1,78 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestBcryptHtpasswd(t *testing.T) {
|
||||
t.Run("hash valido pasa CompareHashAndPassword", func(t *testing.T) {
|
||||
line, err := BcryptHtpasswd("lucas", "s3cr3t", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected user:hash, got %q", line)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(parts[1]), []byte("s3cr3t")); err != nil {
|
||||
t.Errorf("hash does not match password: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formato es user:hash", func(t *testing.T) {
|
||||
line, err := BcryptHtpasswd("admin", "pass", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "admin:") {
|
||||
t.Errorf("expected line to start with 'admin:', got %q", line)
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
t.Errorf("expected non-empty hash after colon, got %q", line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cost cero usa default 10", func(t *testing.T) {
|
||||
line, err := BcryptHtpasswd("user", "password", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
cost, err := bcrypt.Cost([]byte(parts[1]))
|
||||
if err != nil {
|
||||
t.Fatalf("could not extract cost: %v", err)
|
||||
}
|
||||
if cost != 10 {
|
||||
t.Errorf("expected cost 10, got %d", cost)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si user vacio", func(t *testing.T) {
|
||||
_, err := BcryptHtpasswd("", "pass", 4)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty user, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si password vacio", func(t *testing.T) {
|
||||
_, err := BcryptHtpasswd("user", "", 4)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty password, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si cost fuera de rango", func(t *testing.T) {
|
||||
_, err := BcryptHtpasswd("user", "pass", 32)
|
||||
if err == nil {
|
||||
t.Error("expected error for cost=32, got nil")
|
||||
}
|
||||
_, err = BcryptHtpasswd("user", "pass", 3)
|
||||
if err == nil {
|
||||
t.Error("expected error for cost=3, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package infra
|
||||
|
||||
// CheckResult is the output of executing a single E2ECheck.
|
||||
// It captures the status, timing, exit code, and any captured output.
|
||||
type CheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"` // pass|fail|skip
|
||||
Severity string `json:"severity"` // critical|warning
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package infra
|
||||
|
||||
// ComposeTraefikConfig parametriza la generacion de un docker-compose.yml
|
||||
// para una app Go desplegada behind Traefik + Coolify.
|
||||
type ComposeTraefikConfig struct {
|
||||
ProjectName string // ej. "kanban"
|
||||
ServiceName string // ej. "kanban" (container_name y nombre del service)
|
||||
BuildContext string // ej. "../../" (contexto de docker build)
|
||||
Dockerfile string // ej. "apps/kanban/Dockerfile"
|
||||
Port int // ej. 8421 (mapeado host:container)
|
||||
VolumeName string // ej. "kanban_data" (mount en /data); "" para no volume
|
||||
EnvVars []string // ej. ["KANBAN_TOKEN", "FOO"] — passthrough con sintaxis ${KEY:-}
|
||||
Network string // ej. "coolify" (red externa de Coolify)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package infra
|
||||
|
||||
// E2ECheck describes an individual validation declared in app.md::e2e_checks.
|
||||
// Each check specifies either a command to run, a health endpoint to poll,
|
||||
// or a cross-service reference. Checks are executed sequentially by E2ERunChecks.
|
||||
type E2ECheck struct {
|
||||
ID string `json:"id"`
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
TimeoutS int `json:"timeout_s,omitempty"`
|
||||
ExpectExit *int `json:"expect_exit,omitempty"`
|
||||
ExpectStdoutContains string `json:"expect_stdout_contains,omitempty"`
|
||||
ExpectStdoutJSON string `json:"expect_stdout_json,omitempty"`
|
||||
Severity string `json:"severity,omitempty"` // critical|warning, default critical
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeoutS = 60
|
||||
defaultSeverity = "critical"
|
||||
maxStdoutBytes = 4096
|
||||
healthIntervalMs = 500
|
||||
)
|
||||
|
||||
// E2ERunChecks executes a list of E2ECheck declarations in order and returns
|
||||
// one CheckResult per check. The slice is always len(checks) long; individual
|
||||
// check failures do not abort the run.
|
||||
//
|
||||
// For each check:
|
||||
// - If Cmd is non-empty, it is executed via "bash -c". Commands ending with
|
||||
// "&" are launched in background; the function does not wait for exit and
|
||||
// proceeds to Health (if any) or records an immediate pass.
|
||||
// - If Health is non-empty (after any Cmd), HealthCheckHTTP polls the URL
|
||||
// until status 200 or timeout.
|
||||
// - ExpectExit (default 0) and ExpectStdoutContains are evaluated after Cmd.
|
||||
// - Ref is not yet implemented: the check is recorded as skip with a
|
||||
// descriptive error.
|
||||
// - Checks with no Cmd, Health, or Ref are skipped.
|
||||
//
|
||||
// workDir sets the working directory for subprocesses. Pass "" to inherit the
|
||||
// current process directory.
|
||||
//
|
||||
// Returns an error only for setup failures (e.g. bad workDir), not for
|
||||
// individual check failures.
|
||||
func E2ERunChecks(checks []E2ECheck, workDir string) ([]CheckResult, error) {
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
|
||||
for _, ch := range checks {
|
||||
result := runSingleCheck(ch, workDir)
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func runSingleCheck(ch E2ECheck, workDir string) CheckResult {
|
||||
sev := ch.Severity
|
||||
if sev == "" {
|
||||
sev = defaultSeverity
|
||||
}
|
||||
timeoutS := ch.TimeoutS
|
||||
if timeoutS <= 0 {
|
||||
timeoutS = defaultTimeoutS
|
||||
}
|
||||
|
||||
base := CheckResult{
|
||||
ID: ch.ID,
|
||||
Severity: sev,
|
||||
}
|
||||
|
||||
// Skip: nothing to do.
|
||||
if ch.Cmd == "" && ch.Ref == "" && ch.Health == "" {
|
||||
base.Status = "skip"
|
||||
return base
|
||||
}
|
||||
|
||||
// Ref: not implemented yet.
|
||||
if ch.Ref != "" && ch.Cmd == "" && ch.Health == "" {
|
||||
base.Status = "skip"
|
||||
base.Error = "ref handler not implemented"
|
||||
return base
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Run Cmd if present.
|
||||
if ch.Cmd != "" {
|
||||
background := strings.HasSuffix(strings.TrimSpace(ch.Cmd), "&")
|
||||
|
||||
if background {
|
||||
// Launch background process, do not wait.
|
||||
bgCmd := exec.Command("bash", "-c", ch.Cmd)
|
||||
if workDir != "" {
|
||||
bgCmd.Dir = workDir
|
||||
}
|
||||
if err := bgCmd.Start(); err != nil {
|
||||
base.DurationMs = time.Since(start).Milliseconds()
|
||||
base.Status = "fail"
|
||||
base.Error = fmt.Sprintf("background start failed: %v", err)
|
||||
return base
|
||||
}
|
||||
// Do not wait; proceed to Health or pass.
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutS)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fgCmd := exec.CommandContext(ctx, "bash", "-c", ch.Cmd)
|
||||
if workDir != "" {
|
||||
fgCmd.Dir = workDir
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
fgCmd.Stdout = &stdout
|
||||
fgCmd.Stderr = &stderr
|
||||
|
||||
runErr := fgCmd.Run()
|
||||
base.DurationMs = time.Since(start).Milliseconds()
|
||||
|
||||
stdoutStr := truncate(stdout.String(), maxStdoutBytes)
|
||||
stderrStr := truncate(stderr.String(), maxStdoutBytes)
|
||||
base.Stdout = stdoutStr
|
||||
base.Stderr = stderrStr
|
||||
|
||||
// Exit code.
|
||||
exitCode := 0
|
||||
if fgCmd.ProcessState != nil {
|
||||
exitCode = fgCmd.ProcessState.ExitCode()
|
||||
}
|
||||
base.ExitCode = exitCode
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
base.Status = "fail"
|
||||
base.Error = fmt.Sprintf("command timed out after %ds", timeoutS)
|
||||
return base
|
||||
}
|
||||
|
||||
expectedExit := 0
|
||||
if ch.ExpectExit != nil {
|
||||
expectedExit = *ch.ExpectExit
|
||||
}
|
||||
if exitCode != expectedExit {
|
||||
base.Status = "fail"
|
||||
if runErr != nil {
|
||||
base.Error = runErr.Error()
|
||||
} else {
|
||||
base.Error = fmt.Sprintf("exit code %d, expected %d", exitCode, expectedExit)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
if ch.ExpectStdoutContains != "" && !strings.Contains(stdoutStr, ch.ExpectStdoutContains) {
|
||||
base.Status = "fail"
|
||||
base.Error = fmt.Sprintf("stdout does not contain %q", ch.ExpectStdoutContains)
|
||||
return base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health check (after Cmd or standalone).
|
||||
if ch.Health != "" {
|
||||
if err := HealthCheckHTTP(ch.Health, timeoutS, healthIntervalMs); err != nil {
|
||||
base.DurationMs = time.Since(start).Milliseconds()
|
||||
base.Status = "fail"
|
||||
base.Error = fmt.Sprintf("health check failed: %v", err)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
base.DurationMs = time.Since(start).Milliseconds()
|
||||
base.Status = "pass"
|
||||
return base
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: e2e_run_checks
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func E2ERunChecks(checks []E2ECheck, workDir string) ([]CheckResult, error)"
|
||||
description: "Ejecuta una lista de E2ECheck en orden y retorna un CheckResult por check. Soporta comandos de shell (via bash -c), health checks HTTP, y referencias a otros artefactos (Ref, actualmente skip). Los checks individuales que fallan no abortan la ejecucion del resto."
|
||||
tags: [e2e, testing, infra, checks, validation, monitoring, pipeline]
|
||||
uses_functions: [health_check_http_go_infra]
|
||||
uses_types: [E2ECheck_go_infra, CheckResult_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [bytes, context, fmt, os/exec, strings, time]
|
||||
tested: true
|
||||
tests:
|
||||
- "todos los checks pasan con exit 0"
|
||||
- "check falla por exit code incorrecto"
|
||||
- "check falla por stdout_contains no encontrado"
|
||||
- "check falla por timeout de comando"
|
||||
test_file_path: "functions/infra/e2e_run_checks_test.go"
|
||||
file_path: "functions/infra/e2e_run_checks.go"
|
||||
params:
|
||||
- name: checks
|
||||
desc: "Lista de E2ECheck declarados en app.md::e2e_checks. Se ejecutan en el orden del slice."
|
||||
- name: workDir
|
||||
desc: "Directorio de trabajo para los subprocesos. Usar string vacio para heredar el directorio del proceso actual."
|
||||
output: "Slice de CheckResult con un resultado por cada check de entrada. El error solo indica fallo de infraestructura (imposible iniciar el proceso, workDir invalido), no fallos individuales de checks."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
zero := 0
|
||||
checks := []infra.E2ECheck{
|
||||
{ID: "api-alive", Health: "http://localhost:8080/health", TimeoutS: 30},
|
||||
{ID: "data-ok", Cmd: "psql $DB_URL -c 'SELECT 1'", ExpectExit: &zero},
|
||||
{ID: "schema-v3", Cmd: "migrate status", ExpectStdoutContains: "version: 3"},
|
||||
}
|
||||
|
||||
results, err := infra.E2ERunChecks(checks, "/opt/apps/myapp")
|
||||
for _, r := range results {
|
||||
fmt.Printf("%s: %s (%dms)\n", r.ID, r.Status, r.DurationMs)
|
||||
}
|
||||
```
|
||||
|
||||
## Comportamiento por tipo de check
|
||||
|
||||
| Campo presente | Comportamiento |
|
||||
|---|---|
|
||||
| Solo `Cmd` (foreground) | Ejecuta con bash -c, captura stdout/stderr, evalua ExpectExit y ExpectStdoutContains |
|
||||
| `Cmd` terminando en `&` | Lanza en background, no espera exit, pasa inmediatamente al paso Health |
|
||||
| Solo `Health` | Sondea el endpoint HTTP hasta 200 o timeout |
|
||||
| `Cmd` + `Health` | Ejecuta Cmd primero, luego sondea Health |
|
||||
| Solo `Ref` | skip con error "ref handler not implemented" |
|
||||
| Ninguno | skip |
|
||||
|
||||
## Notas
|
||||
|
||||
Los comandos background (terminan en `&`) son utiles para iniciar servicios y luego verificar su salud via `Health`. Se asume exit 0 inmediato; si el proceso no levanta antes del timeout del health check, el check falla.
|
||||
|
||||
Stdout y stderr se truncan a 4KB por check para evitar resultados excesivamente grandes.
|
||||
|
||||
La implementacion de `Ref` (cross-service checks) esta reservada para issue posterior.
|
||||
@@ -0,0 +1,73 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2ERunChecks(t *testing.T) {
|
||||
t.Run("todos los checks pasan con exit 0", func(t *testing.T) {
|
||||
zero := 0
|
||||
checks := []E2ECheck{
|
||||
{ID: "check-echo", Cmd: "echo hello", ExpectExit: &zero, ExpectStdoutContains: "hello"},
|
||||
{ID: "check-true", Cmd: "true"},
|
||||
}
|
||||
results, err := E2ERunChecks(checks, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Status != "pass" {
|
||||
t.Errorf("check %q: expected pass, got %q (error: %s)", r.ID, r.Status, r.Error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("check falla por exit code incorrecto", func(t *testing.T) {
|
||||
expectedExit := 0
|
||||
checks := []E2ECheck{
|
||||
{ID: "check-fail-exit", Cmd: "exit 1", ExpectExit: &expectedExit},
|
||||
}
|
||||
results, err := E2ERunChecks(checks, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Status != "fail" {
|
||||
t.Errorf("expected fail, got %q", results[0].Status)
|
||||
}
|
||||
if results[0].ExitCode == 0 {
|
||||
t.Errorf("expected non-zero exit code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("check falla por stdout_contains no encontrado", func(t *testing.T) {
|
||||
checks := []E2ECheck{
|
||||
{ID: "check-stdout", Cmd: "echo hello", ExpectStdoutContains: "world"},
|
||||
}
|
||||
results, err := E2ERunChecks(checks, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if results[0].Status != "fail" {
|
||||
t.Errorf("expected fail when stdout does not contain expected string, got %q", results[0].Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("check falla por timeout de comando", func(t *testing.T) {
|
||||
checks := []E2ECheck{
|
||||
{ID: "check-timeout", Cmd: "sleep 60", TimeoutS: 1},
|
||||
}
|
||||
results, err := E2ERunChecks(checks, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if results[0].Status != "fail" {
|
||||
t.Errorf("expected fail on timeout, got %q (error: %s)", results[0].Status, results[0].Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateComposeTraefik genera el texto YAML de un docker-compose.yml
|
||||
// para una app Go desplegada behind Traefik + Coolify.
|
||||
// El output replica el patron de apps/registry_api/docker-compose.yml.
|
||||
// Determinista: el orden de EnvVars sigue el orden de entrada.
|
||||
func GenerateComposeTraefik(cfg ComposeTraefikConfig) string {
|
||||
var b strings.Builder
|
||||
|
||||
// name
|
||||
fmt.Fprintf(&b, "name: %s\n\n", cfg.ProjectName)
|
||||
|
||||
// services
|
||||
fmt.Fprintf(&b, "services:\n")
|
||||
fmt.Fprintf(&b, " %s:\n", cfg.ServiceName)
|
||||
fmt.Fprintf(&b, " build:\n")
|
||||
fmt.Fprintf(&b, " context: %s\n", cfg.BuildContext)
|
||||
fmt.Fprintf(&b, " dockerfile: %s\n", cfg.Dockerfile)
|
||||
fmt.Fprintf(&b, " container_name: %s\n", cfg.ServiceName)
|
||||
fmt.Fprintf(&b, " restart: unless-stopped\n")
|
||||
fmt.Fprintf(&b, " ports:\n")
|
||||
fmt.Fprintf(&b, " - \"%d:%d\"\n", cfg.Port, cfg.Port)
|
||||
|
||||
if cfg.VolumeName != "" {
|
||||
fmt.Fprintf(&b, " volumes:\n")
|
||||
fmt.Fprintf(&b, " - %s:/data\n", cfg.VolumeName)
|
||||
}
|
||||
|
||||
if len(cfg.EnvVars) > 0 {
|
||||
fmt.Fprintf(&b, " environment:\n")
|
||||
for _, key := range cfg.EnvVars {
|
||||
fmt.Fprintf(&b, " - %s=${%s:-}\n", key, key)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, " networks:\n")
|
||||
fmt.Fprintf(&b, " - %s\n", cfg.Network)
|
||||
|
||||
// volumes section
|
||||
if cfg.VolumeName != "" {
|
||||
fmt.Fprintf(&b, "\nvolumes:\n")
|
||||
fmt.Fprintf(&b, " %s:\n", cfg.VolumeName)
|
||||
}
|
||||
|
||||
// networks section
|
||||
fmt.Fprintf(&b, "\nnetworks:\n")
|
||||
fmt.Fprintf(&b, " %s:\n", cfg.Network)
|
||||
fmt.Fprintf(&b, " external: true\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: generate_compose_traefik
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func GenerateComposeTraefik(cfg ComposeTraefikConfig) string"
|
||||
description: "Genera el texto YAML de un docker-compose.yml para una app Go desplegada behind Traefik + Coolify. Replica el patron de apps/registry_api/docker-compose.yml. Determinista: orden de EnvVars sigue el orden de entrada."
|
||||
tags: [docker, compose, traefik, coolify, yaml, infra, deploy, generator]
|
||||
uses_functions: []
|
||||
uses_types: [ComposeTraefikConfig_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt, strings]
|
||||
params:
|
||||
- name: cfg
|
||||
desc: "configuracion del compose: nombre de proyecto/servicio, contexto de build, puerto, volume, env vars y red de Coolify"
|
||||
output: "texto YAML completo del docker-compose.yml listo para escribir a disco"
|
||||
tested: true
|
||||
tests:
|
||||
- "render con volume y multiples envs"
|
||||
- "render sin volume"
|
||||
- "render sin envs"
|
||||
- "project name con guion"
|
||||
- "snapshot YAML completo replica patron registry_api"
|
||||
test_file_path: "functions/infra/generate_compose_traefik_test.go"
|
||||
file_path: "functions/infra/generate_compose_traefik.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := ComposeTraefikConfig{
|
||||
ProjectName: "kanban",
|
||||
ServiceName: "kanban",
|
||||
BuildContext: "../../",
|
||||
Dockerfile: "apps/kanban/Dockerfile",
|
||||
Port: 8421,
|
||||
VolumeName: "kanban_data",
|
||||
EnvVars: []string{"KANBAN_TOKEN"},
|
||||
Network: "coolify",
|
||||
}
|
||||
yaml := GenerateComposeTraefik(cfg)
|
||||
os.WriteFile("apps/kanban/docker-compose.yml", []byte(yaml), 0644)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura: dado el mismo `ComposeTraefikConfig` siempre produce el mismo YAML. Si `VolumeName` es `""` se omite la seccion `volumes:` y el mount. Si `EnvVars` es nil/vacio se omite la seccion `environment:`. Los env vars se generan con la sintaxis `${KEY:-}` (passthrough con fallback vacio) para que el contenedor arranque sin el `.env` si la variable no es critica.
|
||||
@@ -0,0 +1,158 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateComposeTraefik(t *testing.T) {
|
||||
t.Run("render con volume y multiples envs", func(t *testing.T) {
|
||||
cfg := ComposeTraefikConfig{
|
||||
ProjectName: "kanban",
|
||||
ServiceName: "kanban",
|
||||
BuildContext: "../../",
|
||||
Dockerfile: "apps/kanban/Dockerfile",
|
||||
Port: 8421,
|
||||
VolumeName: "kanban_data",
|
||||
EnvVars: []string{"KANBAN_TOKEN", "SECRET_KEY"},
|
||||
Network: "coolify",
|
||||
}
|
||||
got := GenerateComposeTraefik(cfg)
|
||||
|
||||
checks := []string{
|
||||
"name: kanban",
|
||||
"services:",
|
||||
" kanban:",
|
||||
" build:",
|
||||
" context: ../../",
|
||||
" dockerfile: apps/kanban/Dockerfile",
|
||||
" container_name: kanban",
|
||||
" restart: unless-stopped",
|
||||
" ports:",
|
||||
` - "8421:8421"`,
|
||||
" volumes:",
|
||||
" - kanban_data:/data",
|
||||
" environment:",
|
||||
" - KANBAN_TOKEN=${KANBAN_TOKEN:-}",
|
||||
" - SECRET_KEY=${SECRET_KEY:-}",
|
||||
" networks:",
|
||||
" - coolify",
|
||||
"\nvolumes:",
|
||||
" kanban_data:",
|
||||
"\nnetworks:",
|
||||
" coolify:",
|
||||
" external: true",
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render sin volume", func(t *testing.T) {
|
||||
cfg := ComposeTraefikConfig{
|
||||
ProjectName: "myapp",
|
||||
ServiceName: "myapp",
|
||||
BuildContext: ".",
|
||||
Dockerfile: "Dockerfile",
|
||||
Port: 9000,
|
||||
VolumeName: "",
|
||||
EnvVars: []string{"API_KEY"},
|
||||
Network: "coolify",
|
||||
}
|
||||
got := GenerateComposeTraefik(cfg)
|
||||
|
||||
if strings.Contains(got, "volumes:") {
|
||||
t.Errorf("expected no 'volumes:' section when VolumeName is empty, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "networks:") {
|
||||
t.Errorf("expected 'networks:' section, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, " - API_KEY=${API_KEY:-}") {
|
||||
t.Errorf("expected env var passthrough, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render sin envs", func(t *testing.T) {
|
||||
cfg := ComposeTraefikConfig{
|
||||
ProjectName: "plain",
|
||||
ServiceName: "plain",
|
||||
BuildContext: ".",
|
||||
Dockerfile: "Dockerfile",
|
||||
Port: 8080,
|
||||
VolumeName: "plain_data",
|
||||
EnvVars: nil,
|
||||
Network: "coolify",
|
||||
}
|
||||
got := GenerateComposeTraefik(cfg)
|
||||
|
||||
if strings.Contains(got, "environment:") {
|
||||
t.Errorf("expected no 'environment:' section when EnvVars is nil, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project name con guion", func(t *testing.T) {
|
||||
cfg := ComposeTraefikConfig{
|
||||
ProjectName: "registry-api",
|
||||
ServiceName: "registry_api",
|
||||
BuildContext: "../../",
|
||||
Dockerfile: "apps/registry_api/Dockerfile",
|
||||
Port: 8420,
|
||||
VolumeName: "registry_data",
|
||||
EnvVars: []string{"REGISTRY_API_TOKEN"},
|
||||
Network: "coolify",
|
||||
}
|
||||
got := GenerateComposeTraefik(cfg)
|
||||
|
||||
if !strings.Contains(got, "name: registry-api") {
|
||||
t.Errorf("expected 'name: registry-api', got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "container_name: registry_api") {
|
||||
t.Errorf("expected 'container_name: registry_api', got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snapshot YAML completo replica patron registry_api", func(t *testing.T) {
|
||||
cfg := ComposeTraefikConfig{
|
||||
ProjectName: "registry-api",
|
||||
ServiceName: "registry_api",
|
||||
BuildContext: "../../",
|
||||
Dockerfile: "apps/registry_api/Dockerfile",
|
||||
Port: 8420,
|
||||
VolumeName: "registry_data",
|
||||
EnvVars: []string{"REGISTRY_API_TOKEN"},
|
||||
Network: "coolify",
|
||||
}
|
||||
got := GenerateComposeTraefik(cfg)
|
||||
|
||||
expected := `name: registry-api
|
||||
|
||||
services:
|
||||
registry_api:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: apps/registry_api/Dockerfile
|
||||
container_name: registry_api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8420:8420"
|
||||
volumes:
|
||||
- registry_data:/data
|
||||
environment:
|
||||
- REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-}
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
volumes:
|
||||
registry_data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
`
|
||||
if got != expected {
|
||||
t.Errorf("snapshot mismatch.\nGOT:\n%s\nWANT:\n%s", got, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateTraefikDynamic genera el texto YAML de un traefik-dynamic.yml
|
||||
// para el file provider de Traefik (Coolify).
|
||||
// Replica el patron de apps/registry_api/traefik-dynamic.yml.
|
||||
// Determinista: dado el mismo TraefikDynamicConfig siempre produce el mismo YAML.
|
||||
func GenerateTraefikDynamic(cfg TraefikDynamicConfig) string {
|
||||
certResolver := cfg.CertResolver
|
||||
if certResolver == "" {
|
||||
certResolver = "letsencrypt"
|
||||
}
|
||||
|
||||
// Build middleware lists
|
||||
httpsMiddlewares := []string{}
|
||||
if cfg.BasicAuthLine != "" {
|
||||
httpsMiddlewares = append(httpsMiddlewares, fmt.Sprintf("%s-auth", cfg.Name))
|
||||
}
|
||||
if cfg.EnableGzip {
|
||||
httpsMiddlewares = append(httpsMiddlewares, fmt.Sprintf("%s-gzip", cfg.Name))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "http:\n")
|
||||
fmt.Fprintf(&b, " routers:\n")
|
||||
|
||||
// HTTP router (redirect only)
|
||||
fmt.Fprintf(&b, " %s-http:\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " rule: \"Host(`%s`)\"\n", cfg.Domain)
|
||||
fmt.Fprintf(&b, " entryPoints:\n")
|
||||
fmt.Fprintf(&b, " - \"http\"\n")
|
||||
fmt.Fprintf(&b, " middlewares:\n")
|
||||
fmt.Fprintf(&b, " - \"%s-redirect\"\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " service: \"%s-service\"\n", cfg.Name)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
// HTTPS router
|
||||
fmt.Fprintf(&b, " %s-https:\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " rule: \"Host(`%s`)\"\n", cfg.Domain)
|
||||
fmt.Fprintf(&b, " entryPoints:\n")
|
||||
fmt.Fprintf(&b, " - \"https\"\n")
|
||||
if len(httpsMiddlewares) > 0 {
|
||||
fmt.Fprintf(&b, " middlewares:\n")
|
||||
for _, mw := range httpsMiddlewares {
|
||||
fmt.Fprintf(&b, " - \"%s\"\n", mw)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " service: \"%s-service\"\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " tls:\n")
|
||||
fmt.Fprintf(&b, " certResolver: %s\n", certResolver)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
// Services
|
||||
fmt.Fprintf(&b, " services:\n")
|
||||
fmt.Fprintf(&b, " %s-service:\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " loadBalancer:\n")
|
||||
fmt.Fprintf(&b, " servers:\n")
|
||||
fmt.Fprintf(&b, " - url: \"%s\"\n", cfg.UpstreamURL)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
// Middlewares
|
||||
fmt.Fprintf(&b, " middlewares:\n")
|
||||
|
||||
// redirect always present
|
||||
fmt.Fprintf(&b, " %s-redirect:\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " redirectScheme:\n")
|
||||
fmt.Fprintf(&b, " scheme: \"https\"\n")
|
||||
|
||||
// auth only if BasicAuthLine provided
|
||||
if cfg.BasicAuthLine != "" {
|
||||
fmt.Fprintf(&b, " %s-auth:\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " basicAuth:\n")
|
||||
fmt.Fprintf(&b, " users:\n")
|
||||
fmt.Fprintf(&b, " - \"%s\"\n", cfg.BasicAuthLine)
|
||||
}
|
||||
|
||||
// gzip only if enabled
|
||||
if cfg.EnableGzip {
|
||||
fmt.Fprintf(&b, " %s-gzip:\n", cfg.Name)
|
||||
fmt.Fprintf(&b, " compress: true\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: generate_traefik_dynamic
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func GenerateTraefikDynamic(cfg TraefikDynamicConfig) string"
|
||||
description: "Genera el texto YAML de un traefik-dynamic.yml para el file provider de Traefik (Coolify). Replica el patron de apps/registry_api/traefik-dynamic.yml con routers HTTP/HTTPS, redirect, basicAuth opcional y gzip opcional."
|
||||
tags: [traefik, yaml, infra, deploy, generator, basicauth, tls, coolify]
|
||||
uses_functions: []
|
||||
uses_types: [TraefikDynamicConfig_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt, strings]
|
||||
params:
|
||||
- name: cfg
|
||||
desc: "configuracion del dynamic config: nombre (prefix), dominio, upstream URL, linea htpasswd opcional, flag de gzip y cert resolver"
|
||||
output: "texto YAML completo del traefik-dynamic.yml listo para escribir a disco y recargar en Traefik"
|
||||
tested: true
|
||||
tests:
|
||||
- "render con auth y gzip"
|
||||
- "render sin auth"
|
||||
- "render sin gzip"
|
||||
- "certResolver custom"
|
||||
- "certResolver vacio usa letsencrypt por defecto"
|
||||
- "snapshot YAML completo replica patron registry_api"
|
||||
test_file_path: "functions/infra/generate_traefik_dynamic_test.go"
|
||||
file_path: "functions/infra/generate_traefik_dynamic.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
line, _ := BcryptHtpasswd("lucas", "s3cr3t", 10)
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "kanban",
|
||||
Domain: "kanban.organic-machine.com",
|
||||
UpstreamURL: "http://kanban:8421",
|
||||
BasicAuthLine: line,
|
||||
EnableGzip: true,
|
||||
CertResolver: "letsencrypt",
|
||||
}
|
||||
yaml := GenerateTraefikDynamic(cfg)
|
||||
os.WriteFile("apps/kanban/traefik-dynamic.yml", []byte(yaml), 0644)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura: dado el mismo `TraefikDynamicConfig` siempre produce el mismo YAML. Si `BasicAuthLine` es vacio se omite el router middleware `<name>-auth` y la seccion `basicAuth`. Si `EnableGzip` es false se omite el middleware `<name>-gzip`. El redirect HTTP→HTTPS siempre esta presente. `CertResolver` por defecto es `"letsencrypt"`. El output usa `$` simple (file provider), no `$$` (Docker labels). Combinar con `BcryptHtpasswd` para generar la linea de auth.
|
||||
@@ -0,0 +1,183 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateTraefikDynamic(t *testing.T) {
|
||||
t.Run("render con auth y gzip", func(t *testing.T) {
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "registry-api",
|
||||
Domain: "registry.organic-machine.com",
|
||||
UpstreamURL: "http://registry-api:8420",
|
||||
BasicAuthLine: "lucas:$2a$10$hashedpassword",
|
||||
EnableGzip: true,
|
||||
CertResolver: "letsencrypt",
|
||||
}
|
||||
got := GenerateTraefikDynamic(cfg)
|
||||
|
||||
checks := []string{
|
||||
"http:",
|
||||
" routers:",
|
||||
" registry-api-http:",
|
||||
` rule: "Host(` + "`registry.organic-machine.com`" + `)"`,
|
||||
` - "http"`,
|
||||
` - "registry-api-redirect"`,
|
||||
` service: "registry-api-service"`,
|
||||
" registry-api-https:",
|
||||
` - "https"`,
|
||||
` - "registry-api-auth"`,
|
||||
` - "registry-api-gzip"`,
|
||||
" certResolver: letsencrypt",
|
||||
" services:",
|
||||
" registry-api-service:",
|
||||
` - url: "http://registry-api:8420"`,
|
||||
" middlewares:",
|
||||
" registry-api-redirect:",
|
||||
` scheme: "https"`,
|
||||
" registry-api-auth:",
|
||||
" basicAuth:",
|
||||
" users:",
|
||||
` - "lucas:$2a$10$hashedpassword"`,
|
||||
" registry-api-gzip:",
|
||||
" compress: true",
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render sin auth", func(t *testing.T) {
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "myapp",
|
||||
Domain: "myapp.example.com",
|
||||
UpstreamURL: "http://myapp:9000",
|
||||
BasicAuthLine: "",
|
||||
EnableGzip: true,
|
||||
CertResolver: "letsencrypt",
|
||||
}
|
||||
got := GenerateTraefikDynamic(cfg)
|
||||
|
||||
if strings.Contains(got, "basicAuth") {
|
||||
t.Errorf("expected no basicAuth when BasicAuthLine is empty, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "myapp-auth") {
|
||||
t.Errorf("expected no myapp-auth middleware when BasicAuthLine is empty, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "myapp-gzip") {
|
||||
t.Errorf("expected myapp-gzip middleware, got:\n%s", got)
|
||||
}
|
||||
// redirect should always be present
|
||||
if !strings.Contains(got, "myapp-redirect") {
|
||||
t.Errorf("expected myapp-redirect middleware, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render sin gzip", func(t *testing.T) {
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "api",
|
||||
Domain: "api.example.com",
|
||||
UpstreamURL: "http://api:8080",
|
||||
BasicAuthLine: "admin:$2a$10$hash",
|
||||
EnableGzip: false,
|
||||
CertResolver: "letsencrypt",
|
||||
}
|
||||
got := GenerateTraefikDynamic(cfg)
|
||||
|
||||
if strings.Contains(got, "api-gzip") {
|
||||
t.Errorf("expected no api-gzip middleware when EnableGzip is false, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "compress:") {
|
||||
t.Errorf("expected no compress section when EnableGzip is false, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "api-auth") {
|
||||
t.Errorf("expected api-auth middleware when BasicAuthLine is set, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("certResolver custom", func(t *testing.T) {
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "svc",
|
||||
Domain: "svc.example.com",
|
||||
UpstreamURL: "http://svc:7000",
|
||||
EnableGzip: false,
|
||||
CertResolver: "myresolver",
|
||||
}
|
||||
got := GenerateTraefikDynamic(cfg)
|
||||
|
||||
if !strings.Contains(got, "certResolver: myresolver") {
|
||||
t.Errorf("expected certResolver: myresolver, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("certResolver vacio usa letsencrypt por defecto", func(t *testing.T) {
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "svc",
|
||||
Domain: "svc.example.com",
|
||||
UpstreamURL: "http://svc:7000",
|
||||
CertResolver: "",
|
||||
}
|
||||
got := GenerateTraefikDynamic(cfg)
|
||||
|
||||
if !strings.Contains(got, "certResolver: letsencrypt") {
|
||||
t.Errorf("expected certResolver: letsencrypt as default, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snapshot YAML completo replica patron registry_api", func(t *testing.T) {
|
||||
cfg := TraefikDynamicConfig{
|
||||
Name: "registry-api",
|
||||
Domain: "registry.organic-machine.com",
|
||||
UpstreamURL: "http://registry-api:8420",
|
||||
BasicAuthLine: "PLACEHOLDER_BASICAUTH_LINE",
|
||||
EnableGzip: true,
|
||||
CertResolver: "letsencrypt",
|
||||
}
|
||||
got := GenerateTraefikDynamic(cfg)
|
||||
|
||||
expected := `http:
|
||||
routers:
|
||||
registry-api-http:
|
||||
rule: "Host(` + "`registry.organic-machine.com`" + `)"
|
||||
entryPoints:
|
||||
- "http"
|
||||
middlewares:
|
||||
- "registry-api-redirect"
|
||||
service: "registry-api-service"
|
||||
|
||||
registry-api-https:
|
||||
rule: "Host(` + "`registry.organic-machine.com`" + `)"
|
||||
entryPoints:
|
||||
- "https"
|
||||
middlewares:
|
||||
- "registry-api-auth"
|
||||
- "registry-api-gzip"
|
||||
service: "registry-api-service"
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
registry-api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://registry-api:8420"
|
||||
|
||||
middlewares:
|
||||
registry-api-redirect:
|
||||
redirectScheme:
|
||||
scheme: "https"
|
||||
registry-api-auth:
|
||||
basicAuth:
|
||||
users:
|
||||
- "PLACEHOLDER_BASICAUTH_LINE"
|
||||
registry-api-gzip:
|
||||
compress: true
|
||||
`
|
||||
if got != expected {
|
||||
t.Errorf("snapshot mismatch.\nGOT:\n%s\nWANT:\n%s", got, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ProposalFromFailure creates a proposal row in registry.db for each failed
|
||||
// CheckResult. It opens the database at registryDB, filters results with
|
||||
// Status=="fail", and inserts one proposal per failure using:
|
||||
// - kind="new_function" for severity=="critical" checks (highest urgency proxy)
|
||||
// - kind="improve_function" for severity=="warning" checks
|
||||
//
|
||||
// Note: the proposals table kind constraint only allows
|
||||
// (new_function, new_type, improve_function, improve_type, new_pipeline).
|
||||
// Until a dedicated "bug" kind is added, we use new_function/improve_function
|
||||
// as the closest proxies for critical and warning failures respectively.
|
||||
//
|
||||
// Returns the list of proposal IDs created, or an error if the DB cannot be
|
||||
// opened or any INSERT fails.
|
||||
func ProposalFromFailure(registryDB string, appID string, results []CheckResult, executionID string) ([]string, error) {
|
||||
db, err := SQLiteOpen(registryDB, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proposal_from_failure: open registry db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var created []string
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
for _, r := range results {
|
||||
if r.Status != "fail" {
|
||||
continue
|
||||
}
|
||||
|
||||
propID, err := generatePropID()
|
||||
if err != nil {
|
||||
return created, fmt.Errorf("proposal_from_failure: generate id: %w", err)
|
||||
}
|
||||
|
||||
kind := proposalKind(r.Severity)
|
||||
title := fmt.Sprintf("e2e fail: %s::%s", appID, r.ID)
|
||||
desc := buildDescription(r)
|
||||
|
||||
evidence, _ := json.Marshal(map[string]any{
|
||||
"check_id": r.ID,
|
||||
"execution_id": executionID,
|
||||
"exit_code": r.ExitCode,
|
||||
"error": r.Error,
|
||||
"severity": r.Severity,
|
||||
})
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO proposals (id, kind, target_id, title, description, evidence, status, created_by, reviewed_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', 'reactive_loop', '', ?, ?)`,
|
||||
propID, kind, appID, title, desc, string(evidence), now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return created, fmt.Errorf("proposal_from_failure: insert proposal %s: %w", propID, err)
|
||||
}
|
||||
created = append(created, propID)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// proposalKind maps check severity to an allowed proposals.kind value.
|
||||
// critical -> new_function (highest urgency proxy)
|
||||
// warning -> improve_function
|
||||
func proposalKind(severity string) string {
|
||||
if severity == "warning" {
|
||||
return "improve_function"
|
||||
}
|
||||
return "new_function"
|
||||
}
|
||||
|
||||
// buildDescription assembles a human-readable description for the proposal.
|
||||
func buildDescription(r CheckResult) string {
|
||||
desc := fmt.Sprintf("E2E check %q failed (severity: %s, exit_code: %d).", r.ID, r.Severity, r.ExitCode)
|
||||
if r.Error != "" {
|
||||
desc += "\n\nError: " + r.Error
|
||||
}
|
||||
if r.Stdout != "" {
|
||||
desc += "\n\nStdout:\n" + r.Stdout
|
||||
}
|
||||
if r.Stderr != "" {
|
||||
desc += "\n\nStderr:\n" + r.Stderr
|
||||
}
|
||||
desc += "\n\nSugerencia: revisar el comando/endpoint del check y el estado del servicio."
|
||||
return desc
|
||||
}
|
||||
|
||||
// generatePropID generates a random proposal ID of the form "prop_<16hexchars>".
|
||||
func generatePropID() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("crypto/rand: %w", err)
|
||||
}
|
||||
return "prop_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: proposal_from_failure
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ProposalFromFailure(registryDB string, appID string, results []CheckResult, executionID string) ([]string, error)"
|
||||
description: "Crea una fila en la tabla proposals de registry.db por cada CheckResult con Status=fail. Usa kind=new_function para fallos criticos y kind=improve_function para warnings. Retorna los IDs de proposals creados. Parte del bucle reactivo: conecta los resultados de e2e_run_checks con la etapa MEJORAR."
|
||||
tags: [proposals, reactive-loop, e2e, monitoring, registry, infra]
|
||||
uses_functions: [random_hex_id_go_core, sqlite_open_go_infra]
|
||||
uses_types: [CheckResult_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [crypto/rand, encoding/hex, encoding/json, fmt, time, database/sql]
|
||||
tested: true
|
||||
tests:
|
||||
- "no inserta nada cuando todos los checks pasan"
|
||||
- "inserta proposal por cada check fallido"
|
||||
- "proposal critica usa kind new_function"
|
||||
- "proposal warning usa kind improve_function"
|
||||
- "proposals tienen timestamp reciente"
|
||||
test_file_path: "functions/infra/proposal_from_failure_test.go"
|
||||
file_path: "functions/infra/proposal_from_failure.go"
|
||||
params:
|
||||
- name: registryDB
|
||||
desc: "Path absoluto o relativo a registry.db. Puede ser ':memory:' en tests."
|
||||
- name: appID
|
||||
desc: "ID del artefacto (app) al que pertenecen los checks. Se guarda como target_id en la proposal."
|
||||
- name: results
|
||||
desc: "Lista de CheckResult de e2e_run_checks_go_infra. Solo los con Status=fail generan proposals."
|
||||
- name: executionID
|
||||
desc: "ID de la ejecucion en operations.db. Se incluye en el campo evidence de la proposal para trazabilidad."
|
||||
output: "Lista de IDs de proposals creados (formato 'prop_<16hexchars>'). Error si no se puede abrir la BD o falla algun INSERT."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
results, _ := infra.E2ERunChecks(checks, "/opt/apps/myapp")
|
||||
propIDs, err := infra.ProposalFromFailure(
|
||||
"/home/lucas/fn_registry/registry.db",
|
||||
"my_app",
|
||||
results,
|
||||
"exec_20260509_001",
|
||||
)
|
||||
// propIDs = ["prop_a1b2c3d4e5f6a7b8", ...]
|
||||
// Cada ID insertado en proposals con status=pending, created_by=reactive_loop
|
||||
```
|
||||
|
||||
## Mapeo de severity a kind de proposal
|
||||
|
||||
| Severity del check | kind en proposals |
|
||||
|---|---|
|
||||
| `critical` | `new_function` (proxy de mayor urgencia) |
|
||||
| `warning` | `improve_function` |
|
||||
|
||||
**Nota de diseno:** el schema de `proposals` limita `kind` a
|
||||
`(new_function, new_type, improve_function, improve_type, new_pipeline)`.
|
||||
No existe `bug` ni `optimization`. Se usan `new_function` e `improve_function`
|
||||
como proxies hasta que se extienda el schema con un migration.
|
||||
Para un futuro migration: `ALTER TABLE proposals ADD COLUMN ...` o
|
||||
añadir `bug` y `optimization` al CHECK constraint en `migrations/NNN_add_bug_kind.sql`.
|
||||
|
||||
## Notas
|
||||
|
||||
La funcion abre y cierra la conexion a registry.db en cada llamada. Para uso frecuente
|
||||
dentro de una sesion larga, considerar pasar una `*sql.DB` abierta como variante futura.
|
||||
|
||||
El campo `evidence` de la proposal contiene JSON con:
|
||||
`{check_id, execution_id, exit_code, error, severity}` para debugging posterior.
|
||||
@@ -0,0 +1,158 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// createTestProposalsDB crea una BD en memoria con el schema minimo de proposals
|
||||
// para los tests de ProposalFromFailure.
|
||||
func createTestProposalsDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "proposals_test_*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp db: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
path := f.Name()
|
||||
t.Cleanup(func() { os.Remove(path) })
|
||||
|
||||
db, err := sql.Open("sqlite3", "file:"+path+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS proposals (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('new_function','new_type','improve_function','improve_type','new_pipeline')),
|
||||
target_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
evidence TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','implemented')),
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create proposals table: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestProposalFromFailure(t *testing.T) {
|
||||
t.Run("no inserta nada cuando todos los checks pasan", func(t *testing.T) {
|
||||
dbPath := createTestProposalsDB(t)
|
||||
results := []CheckResult{
|
||||
{ID: "check-ok", Status: "pass", Severity: "critical"},
|
||||
{ID: "check-skip", Status: "skip", Severity: "warning"},
|
||||
}
|
||||
ids, err := ProposalFromFailure(dbPath, "app_test", results, "exec_001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("expected 0 proposals, got %d", len(ids))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inserta proposal por cada check fallido", func(t *testing.T) {
|
||||
dbPath := createTestProposalsDB(t)
|
||||
results := []CheckResult{
|
||||
{ID: "check-api", Status: "fail", Severity: "critical", ExitCode: 1, Error: "connection refused"},
|
||||
{ID: "check-perf", Status: "fail", Severity: "warning", ExitCode: 0, Stdout: "slow"},
|
||||
{ID: "check-ok", Status: "pass", Severity: "critical"},
|
||||
}
|
||||
ids, err := ProposalFromFailure(dbPath, "app_test", results, "exec_002")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("expected 2 proposals, got %d: %v", len(ids), ids)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proposal critica usa kind new_function", func(t *testing.T) {
|
||||
dbPath := createTestProposalsDB(t)
|
||||
results := []CheckResult{
|
||||
{ID: "check-critical", Status: "fail", Severity: "critical", ExitCode: 2},
|
||||
}
|
||||
ids, err := ProposalFromFailure(dbPath, "app_x", results, "exec_003")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 proposal, got %d", len(ids))
|
||||
}
|
||||
|
||||
db, _ := sql.Open("sqlite3", "file:"+dbPath)
|
||||
defer db.Close()
|
||||
var kind, status, createdBy string
|
||||
err = db.QueryRow("SELECT kind, status, created_by FROM proposals WHERE id = ?", ids[0]).Scan(&kind, &status, &createdBy)
|
||||
if err != nil {
|
||||
t.Fatalf("query proposal: %v", err)
|
||||
}
|
||||
if kind != "new_function" {
|
||||
t.Errorf("expected kind=new_function, got %q", kind)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("expected status=pending, got %q", status)
|
||||
}
|
||||
if createdBy != "reactive_loop" {
|
||||
t.Errorf("expected created_by=reactive_loop, got %q", createdBy)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proposal warning usa kind improve_function", func(t *testing.T) {
|
||||
dbPath := createTestProposalsDB(t)
|
||||
results := []CheckResult{
|
||||
{ID: "check-warning", Status: "fail", Severity: "warning"},
|
||||
}
|
||||
ids, err := ProposalFromFailure(dbPath, "app_y", results, "exec_004")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 proposal, got %d", len(ids))
|
||||
}
|
||||
|
||||
db, _ := sql.Open("sqlite3", "file:"+dbPath)
|
||||
defer db.Close()
|
||||
var kind string
|
||||
_ = db.QueryRow("SELECT kind FROM proposals WHERE id = ?", ids[0]).Scan(&kind)
|
||||
if kind != "improve_function" {
|
||||
t.Errorf("expected kind=improve_function, got %q", kind)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proposals tienen timestamp reciente", func(t *testing.T) {
|
||||
dbPath := createTestProposalsDB(t)
|
||||
before := time.Now().UTC().Add(-time.Second)
|
||||
results := []CheckResult{
|
||||
{ID: "check-ts", Status: "fail", Severity: "critical"},
|
||||
}
|
||||
ids, err := ProposalFromFailure(dbPath, "app_z", results, "exec_005")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
after := time.Now().UTC().Add(time.Second)
|
||||
|
||||
db, _ := sql.Open("sqlite3", "file:"+dbPath)
|
||||
defer db.Close()
|
||||
var createdAt string
|
||||
_ = db.QueryRow("SELECT created_at FROM proposals WHERE id = ?", ids[0]).Scan(&createdAt)
|
||||
ts, err := time.Parse(time.RFC3339, createdAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse created_at: %v", err)
|
||||
}
|
||||
if ts.Before(before) || ts.After(after) {
|
||||
t.Errorf("created_at %v out of expected range [%v, %v]", ts, before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SMTPConnect establishes an authenticated SMTP connection using the given config.
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
// Returns an *smtp.Client ready to use with SMTPSend.
|
||||
// The caller is responsible for calling client.Quit() when done.
|
||||
func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||
|
||||
switch cfg.TLSMode {
|
||||
case "tls":
|
||||
|
||||
@@ -16,9 +16,12 @@ 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: ""
|
||||
tested: true
|
||||
tests:
|
||||
- TestSSHConfigRead_Missing
|
||||
- TestSSHConfigRead_ParsesExisting
|
||||
- TestSSHConfigRead_PermissionError
|
||||
test_file_path: "functions/infra/ssh_config_read_test.go"
|
||||
file_path: "functions/infra/ssh_config_read.go"
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSSHConfigRead_Missing(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
entries, err := SSHConfigRead()
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for missing config, got %v", err)
|
||||
}
|
||||
if entries != nil {
|
||||
t.Errorf("expected nil entries, got %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRead_ParsesExisting(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := `Host prod
|
||||
HostName 10.0.0.1
|
||||
User admin
|
||||
Port 2222
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := SSHConfigRead()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Alias != "prod" || entries[0].HostName != "10.0.0.1" || entries[0].User != "admin" || entries[0].Port != 2222 {
|
||||
t.Errorf("unexpected entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigRead_PermissionError(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses permission errors")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configPath := filepath.Join(sshDir, "config")
|
||||
if err := os.WriteFile(configPath, []byte("Host x\n"), 0000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(configPath, 0600)
|
||||
if _, err := SSHConfigRead(); err == nil {
|
||||
t.Error("expected error reading unreadable config")
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,13 @@ 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: ""
|
||||
tested: true
|
||||
tests:
|
||||
- TestSSHConfigWrite_CreatesFileAndDir
|
||||
- TestSSHConfigWrite_BackupExisting
|
||||
- TestSSHConfigWrite_NoBackupWhenAbsent
|
||||
- TestSSHConfigWriteRead_Roundtrip
|
||||
test_file_path: "functions/infra/ssh_config_write_test.go"
|
||||
file_path: "functions/infra/ssh_config_write.go"
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSSHConfigWrite_CreatesFileAndDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
entries := []SSHConfigEntry{{Alias: "prod", HostName: "10.0.0.1", User: "admin", Port: 22}}
|
||||
if err := SSHConfigWrite(entries); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(home, ".ssh", "config")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "Host prod") || !strings.Contains(content, "HostName 10.0.0.1") {
|
||||
t.Errorf("unexpected content: %q", content)
|
||||
}
|
||||
info, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("expected 0600, got %o", perm)
|
||||
}
|
||||
dirInfo, err := os.Stat(filepath.Join(home, ".ssh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := dirInfo.Mode().Perm(); perm != 0700 {
|
||||
t.Errorf("expected dir 0700, got %o", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigWrite_BackupExisting(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configPath := filepath.Join(sshDir, "config")
|
||||
original := []byte("Host old\n HostName 1.1.1.1\n")
|
||||
if err := os.WriteFile(configPath, original, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := []SSHConfigEntry{{Alias: "new", HostName: "2.2.2.2"}}
|
||||
if err := SSHConfigWrite(entries); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
backup, err := os.ReadFile(filepath.Join(sshDir, "config.bak"))
|
||||
if err != nil {
|
||||
t.Fatalf("backup not found: %v", err)
|
||||
}
|
||||
if string(backup) != string(original) {
|
||||
t.Errorf("backup mismatch: got %q", backup)
|
||||
}
|
||||
current, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(current), "Host new") {
|
||||
t.Errorf("config not overwritten: %q", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigWrite_NoBackupWhenAbsent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if err := SSHConfigWrite([]SSHConfigEntry{{Alias: "a"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".ssh", "config.bak")); !os.IsNotExist(err) {
|
||||
t.Errorf("expected no backup when config did not exist, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigWriteRead_Roundtrip(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
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},
|
||||
}
|
||||
if err := SSHConfigWrite(original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := SSHConfigRead()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parsed) != len(original) {
|
||||
t.Fatalf("expected %d entries, got %d", len(original), len(parsed))
|
||||
}
|
||||
for i := range original {
|
||||
if parsed[i].Alias != original[i].Alias || parsed[i].HostName != original[i].HostName ||
|
||||
parsed[i].User != original[i].User || parsed[i].Port != original[i].Port {
|
||||
t.Errorf("roundtrip[%d] mismatch: %+v vs %+v", i, parsed[i], original[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package infra
|
||||
|
||||
// TraefikDynamicConfig parametriza la generacion de un traefik-dynamic.yml
|
||||
// para el file provider de Traefik (Coolify).
|
||||
type TraefikDynamicConfig struct {
|
||||
Name string // ej. "kanban" — prefix de routers, services y middlewares
|
||||
Domain string // ej. "kanban.organic-machine.com"
|
||||
UpstreamURL string // ej. "http://kanban:8421"
|
||||
BasicAuthLine string // resultado de BcryptHtpasswd; "" para sin auth
|
||||
EnableGzip bool // si true, añade middleware compress
|
||||
CertResolver string // ej. "letsencrypt" (default si "")
|
||||
}
|
||||
Reference in New Issue
Block a user