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:
2026-05-09 18:11:24 +02:00
parent 852322a708
commit 750b7abcd5
99 changed files with 7879 additions and 73 deletions
+30
View File
@@ -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
}
+47
View File
@@ -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))`.
+78
View File
@@ -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")
}
})
}
+14
View File
@@ -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"`
}
+14
View File
@@ -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)
}
+16
View File
@@ -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
}
+171
View File
@@ -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]
}
+66
View File
@@ -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.
+73
View File
@@ -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)
}
})
}
+105
View File
@@ -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
}
+72
View File
@@ -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)
}
})
}
+2 -1
View File
@@ -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":
+6 -3
View File
@@ -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"
---
+65
View File
@@ -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")
}
}
+7 -3
View File
@@ -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"
---
+108
View File
@@ -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])
}
}
}
+12
View File
@@ -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 "")
}