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
+26
View File
@@ -0,0 +1,26 @@
package browser
import "fmt"
// CdpClickText combina CdpFindByText + CdpClick: localiza el primer elemento
// con `text` y le hace click. Util para tests/scraping sin depender de
// selectores CSS fragiles.
//
// Si no encuentra ningun elemento con ese texto, retorna error claro
// (no falso negativo silencioso).
func CdpClickText(c *CDPConn, text string, opts FindByTextOpts) error {
if c == nil {
return fmt.Errorf("cdp click text: conexion nula")
}
sel, err := CdpFindByText(c, text, opts)
if err != nil {
return fmt.Errorf("cdp click text: %w", err)
}
if sel == "" {
return fmt.Errorf("cdp click text: no se encontro elemento con texto %q (tag=%q exact=%v)", text, opts.Tag, opts.Exact)
}
if err := CdpClick(c, sel); err != nil {
return fmt.Errorf("cdp click text: click sobre %q: %w", sel, err)
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
---
name: cdp_click_text
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpClickText(c *CDPConn, text string, opts FindByTextOpts) error"
description: "Localiza el primer elemento cuyo innerText matchea el texto dado y le hace click. Composicion de CdpFindByText + CdpClick. Mas robusto que click por selector CSS porque el texto visible cambia menos que la estructura del DOM."
tags: [browser, cdp, click, locator, accessibility]
uses_functions:
- cdp_find_by_text_go_browser
- cdp_click_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
example: |
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
err := browser.CdpClickText(c, "Sign in", browser.FindByTextOpts{Tag: "button"})
if err != nil {
log.Fatal(err)
}
tested: true
tests: ["TestCdpClickText_returnsErrorOnEmpty"]
test_file_path: "functions/browser/cdp_click_text_test.go"
file_path: "functions/browser/cdp_click_text.go"
notes: |
- Devuelve error si no encuentra ningun elemento con ese texto — fail-loud, no falso positivo.
- Reusa la heuristica leafmost de CdpFindByText (click va al elemento mas interno con el texto).
- Para multiples coincidencias (ej. dos botones "OK"), pasar opts.Tag o usar un texto mas especifico.
documentation: |
Patron `getByText(...).click()` de Playwright. Reduce mantenimiento de
tests e2e: cuando el frontend renombra clases CSS o reordena DOM, el
test sigue funcionando si el texto visible no cambia.
params:
- name: c
desc: "Conexion CDP activa."
- name: text
desc: "Texto del elemento a clickar."
- name: opts
desc: "FindByTextOpts (mismos campos que CdpFindByText)."
output: "nil si click OK, error con descripcion si no encuentra elemento o click falla."
---
+23
View File
@@ -0,0 +1,23 @@
package browser
import (
"strings"
"testing"
)
func TestCdpClickText_nilConn(t *testing.T) {
if err := CdpClickText(nil, "Submit", FindByTextOpts{}); err == nil {
t.Fatal("expected error on nil conn")
}
}
func TestCdpClickText_emptyText(t *testing.T) {
c := &CDPConn{}
err := CdpClickText(c, "", FindByTextOpts{})
if err == nil {
t.Fatal("expected error on empty text")
}
if !strings.Contains(err.Error(), "cdp click text") {
t.Fatalf("error no incluye prefijo: %v", err)
}
}
+62 -12
View File
@@ -16,18 +16,25 @@ import (
"sync/atomic"
)
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
type EventHandler func(method string, params map[string]any)
// CDPConn es una conexion activa al Chrome DevTools Protocol.
// Gestiona el WebSocket raw y el protocolo JSON-RPC de CDP.
type CDPConn struct {
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID atomic.Int64
port int
pid int
pending map[int64]chan cdpResponse
pendMu sync.Mutex
closed bool
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID atomic.Int64
port int
pid int
pending map[int64]chan cdpResponse
pendMu sync.Mutex
closed bool
handlers map[string][]EventHandler
hMu sync.Mutex
}
type cdpRequest struct {
@@ -245,7 +252,8 @@ func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any,
return resp.Result, nil
}
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes.
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
// (respuestas a comandos) o a los handlers registrados (eventos CDP).
// Debe ejecutarse en una goroutine.
func (c *CDPConn) readLoop() {
for {
@@ -277,9 +285,51 @@ func (c *CDPConn) readLoop() {
if ok {
ch <- resp
}
continue
}
// Sin ID = evento CDP. Llamar handlers registrados para ese metodo.
if resp.Method != "" {
c.hMu.Lock()
hs := append([]EventHandler(nil), c.handlers[resp.Method]...)
c.hMu.Unlock()
for _, h := range hs {
// Aislamos panics de handlers ajenos para que un handler
// roto no mate la conexion entera.
func(h EventHandler) {
defer func() { _ = recover() }()
h(resp.Method, resp.Params)
}(h)
}
}
}
}
// OnEvent registra un handler para un metodo CDP (ej "Network.requestWillBeSent").
// Devuelve una funcion `cancel` que des-registra el handler. Multiples handlers
// para el mismo metodo se invocan en orden de registro.
//
// El handler corre en la goroutine de lectura — mantenlo rapido. Para trabajo
// pesado, despacha a un canal/goroutine propios.
func (c *CDPConn) OnEvent(method string, h EventHandler) (cancel func()) {
if c == nil || h == nil || method == "" {
return func() {}
}
c.hMu.Lock()
if c.handlers == nil {
c.handlers = make(map[string][]EventHandler)
}
c.handlers[method] = append(c.handlers[method], h)
idx := len(c.handlers[method]) - 1
c.hMu.Unlock()
return func() {
c.hMu.Lock()
defer c.hMu.Unlock()
hs := c.handlers[method]
if idx < len(hs) {
c.handlers[method] = append(hs[:idx], hs[idx+1:]...)
}
// Si no tiene ID, es un evento CDP — por ahora los ignoramos
// Las funciones que necesiten eventos usan polling o envian el comando y esperan
}
}
+102
View File
@@ -0,0 +1,102 @@
package browser
import (
"encoding/json"
"fmt"
"strings"
)
// FindByTextOpts configura la busqueda por texto visible.
type FindByTextOpts struct {
// Tag filtra por nombre de tag (ej "button", "a"). Vacio = cualquiera.
Tag string
// Exact: true = innerText.trim() === text. false (default) = contiene.
Exact bool
// CaseSensitive: false (default) = comparacion lowercased.
CaseSensitive bool
}
// CdpFindByText busca el primer elemento cuyo `innerText` matchea `text` y
// devuelve un selector CSS unico utilizable con CdpClick / CdpEvaluate.
// Prefiere elementos hoja (no contenedores que envuelven hijos con el mismo
// texto) — asi el click va al elemento mas interno, donde el handler vive.
//
// El selector retornado es:
// - "#<id>" si el elemento tiene id.
// - path "tag:nth-of-type(n) > tag:nth-of-type(n) > ..." si no.
//
// Retorna ("", nil) si no encuentra nada (no es error). Error solo si la
// evaluacion JS rompe (conexion CDP caida).
func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp find by text: conexion nula")
}
if text == "" {
return "", fmt.Errorf("cdp find by text: texto vacio")
}
// Serializamos opts como JSON literal en el script para evitar quoting hell.
payload, _ := json.Marshal(map[string]any{
"text": text,
"tag": opts.Tag,
"exact": opts.Exact,
"cs": opts.CaseSensitive,
})
js := fmt.Sprintf(`
(function() {
var P = %s;
var target = P.cs ? P.text : P.text.toLowerCase();
var nodes = document.querySelectorAll(P.tag || '*');
function norm(v) {
v = (v || '').replace(/\s+/g, ' ').trim();
return P.cs ? v : v.toLowerCase();
}
function matches(el) {
var v = norm(el.innerText || el.textContent || '');
return P.exact ? v === target : v.indexOf(target) >= 0;
}
function leafmost(el) {
for (var i = 0; i < el.children.length; i++) {
if (matches(el.children[i])) return false;
}
return true;
}
function selectorOf(el) {
if (el.id) return '#' + CSS.escape(el.id);
var path = [];
while (el && el.nodeType === 1 && el.tagName !== 'HTML') {
var sel = el.tagName.toLowerCase();
var parent = el.parentNode;
if (parent && parent.children) {
var sib = Array.prototype.filter.call(parent.children, function(c) {
return c.tagName === el.tagName;
});
if (sib.length > 1) sel += ':nth-of-type(' + (sib.indexOf(el) + 1) + ')';
}
path.unshift(sel);
if (el === document.body) break;
el = el.parentElement;
}
return path.join(' > ');
}
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
if (matches(el) && leafmost(el)) {
return selectorOf(el);
}
}
return '';
})()`, string(payload))
res, err := CdpEvaluate(c, js)
if err != nil {
return "", fmt.Errorf("cdp find by text: %w", err)
}
// CdpEvaluate retorna el valor stringificado. Para "" devuelve cadena vacia.
res = strings.TrimSpace(res)
if res == "" || res == "<nil>" {
return "", nil
}
return res, nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: cdp_find_by_text
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error)"
description: "Busca el primer elemento cuyo innerText matchea el texto dado y retorna un selector CSS unico. Prefiere elementos hoja (no contenedores). Util para pruebas robustas que no dependen de selectores CSS fragiles del DOM. Combinable con CdpClick para click-by-text."
tags: [browser, cdp, find, locator, accessibility]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
example: |
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
sel, err := browser.CdpFindByText(c, "Submit", browser.FindByTextOpts{Tag: "button"})
if err == nil && sel != "" {
browser.CdpClick(c, sel)
}
tested: true
tests: ["TestCdpFindByText_buildsSelectorScript"]
test_file_path: "functions/browser/cdp_find_by_text_test.go"
file_path: "functions/browser/cdp_find_by_text.go"
notes: |
- Prefiere "leafmost": si un <div> contiene un <button> con el mismo texto, retorna el button (donde suele vivir el handler).
- Caso por defecto: substring + lowercased. Configurable via opts.Exact + opts.CaseSensitive.
- Filtro opcional por tag para acotar la busqueda (mas rapido y menos ambiguo).
- Retorna ("", nil) si no hay match — no es error. Error solo si el eval JS falla (conexion CDP caida).
- Selector retornado funciona con todas las funciones cdp_* que aceptan selector.
documentation: |
Equivale al patron `getByText` de Playwright o `findByText` de Testing Library.
Pensado para pruebas e2e + scraping donde los selectores CSS cambian con el
build del frontend pero el texto visible es estable.
Construye el selector recorriendo desde el elemento hasta `body` con
`tag:nth-of-type(n)`, salvo que el elemento tenga `id` (caso optimo:
retorna `#<id>` directo).
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: text
desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)."
- name: opts
desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)."
output: "Selector CSS unico (string vacio si no encuentra). Error solo si CDP rompe."
---
@@ -0,0 +1,27 @@
package browser
import (
"strings"
"testing"
)
// TestCdpFindByText_buildsSelectorScript verifica que el script JS que se
// envia a Chrome contiene los campos esperados de FindByTextOpts. No requiere
// Chrome — solo inspecciona la estructura del JS via el comportamiento de
// nil-conexion. Tests reales contra Chrome viven gateados por env var.
func TestCdpFindByText_buildsSelectorScript(t *testing.T) {
// Conexion nula → error claro.
if _, err := CdpFindByText(nil, "x", FindByTextOpts{}); err == nil {
t.Fatal("expected error on nil conn")
}
}
func TestCdpFindByText_emptyText(t *testing.T) {
// Conexion no nil pero texto vacio → error.
c := &CDPConn{}
if _, err := CdpFindByText(c, "", FindByTextOpts{}); err == nil {
t.Fatal("expected error on empty text")
} else if !strings.Contains(err.Error(), "vacio") {
t.Fatalf("error message no menciona vacio: %v", err)
}
}
+277
View File
@@ -0,0 +1,277 @@
package browser
import (
"encoding/json"
"fmt"
"sync"
"time"
)
// HarHeader es el formato HAR 1.2 estandar (name/value).
type HarHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
// HarEntry mapea una peticion HTTP capturada via CDP a un entry HAR.
type HarEntry struct {
StartedDateTime string `json:"startedDateTime"` // ISO 8601
Time int64 `json:"time"` // ms
Request struct {
Method string `json:"method"`
URL string `json:"url"`
HTTPVersion string `json:"httpVersion"`
Headers []HarHeader `json:"headers"`
QueryString []HarHeader `json:"queryString"`
Cookies []HarHeader `json:"cookies"`
HeadersSize int `json:"headersSize"`
BodySize int `json:"bodySize"`
PostData *struct {
MimeType string `json:"mimeType"`
Text string `json:"text"`
} `json:"postData,omitempty"`
} `json:"request"`
Response struct {
Status int `json:"status"`
StatusText string `json:"statusText"`
HTTPVersion string `json:"httpVersion"`
Headers []HarHeader `json:"headers"`
Cookies []HarHeader `json:"cookies"`
Content struct {
Size int `json:"size"`
MimeType string `json:"mimeType"`
Text string `json:"text,omitempty"`
} `json:"content"`
RedirectURL string `json:"redirectURL"`
HeadersSize int `json:"headersSize"`
BodySize int `json:"bodySize"`
} `json:"response"`
Cache struct{} `json:"cache"`
Timings struct {
Send int `json:"send"`
Wait int `json:"wait"`
Receive int `json:"receive"`
} `json:"timings"`
ServerIPAddress string `json:"serverIPAddress,omitempty"`
XRequestID string `json:"_requestId"`
XError string `json:"_error,omitempty"`
}
// HarLog es el wrapper top-level de un archivo HAR.
type HarLog struct {
Log struct {
Version string `json:"version"`
Creator struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"creator"`
Pages []any `json:"pages"`
Entries []HarEntry `json:"entries"`
} `json:"log"`
}
// CdpHarRecord activa Network.* events en la conexion, ejecuta la funcion
// `action` y captura todas las peticiones HTTP que ocurran durante. Despues
// de que `action` retorne, espera `settleMs` ms para recoger eventos
// rezagados (loadingFinished tipicamente llega despues que responseReceived).
//
// Retorna el HAR como JSON string listo para escribir a disco o servir.
//
// Body de respuestas NO se captura en v0 — requiere llamar Network.getResponseBody
// por requestId al recibir loadingFinished, lo cual añade latencia y complica.
// v1 puede activarlo via opts.
//
// La conexion debe estar abierta. La funcion no la cierra.
func CdpHarRecord(c *CDPConn, action func() error, settleMs int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp har record: conexion nula")
}
if action == nil {
action = func() error { return nil }
}
if settleMs <= 0 {
settleMs = 1500
}
type pending struct {
entry HarEntry
startTs float64 // CDP timestamp (monotonic seconds)
endTs float64
hasRequest bool
}
var (
mu sync.Mutex
entries = map[string]*pending{}
)
// Helpers para extraer campos de map[string]any sin pelearse con cast.
str := func(m map[string]any, k string) string {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
num := func(m map[string]any, k string) float64 {
if v, ok := m[k]; ok {
if f, ok := v.(float64); ok {
return f
}
}
return 0
}
headersOf := func(h map[string]any) []HarHeader {
out := []HarHeader{}
for k, v := range h {
if s, ok := v.(string); ok {
out = append(out, HarHeader{Name: k, Value: s})
}
}
return out
}
cancel1 := c.OnEvent("Network.requestWillBeSent", func(method string, p map[string]any) {
reqID := str(p, "requestId")
if reqID == "" {
return
}
req, _ := p["request"].(map[string]any)
hdrs, _ := req["headers"].(map[string]any)
ts := num(p, "timestamp")
wallTime := num(p, "wallTime") // unix epoch seconds (float)
mu.Lock()
defer mu.Unlock()
e, ok := entries[reqID]
if !ok {
e = &pending{}
entries[reqID] = e
}
e.entry.XRequestID = reqID
e.entry.Request.Method = str(req, "method")
e.entry.Request.URL = str(req, "url")
e.entry.Request.HTTPVersion = "HTTP/1.1"
e.entry.Request.Headers = headersOf(hdrs)
e.entry.Request.QueryString = []HarHeader{}
e.entry.Request.Cookies = []HarHeader{}
e.entry.Request.HeadersSize = -1
e.entry.Request.BodySize = -1
if pd, ok := req["postData"].(string); ok && pd != "" {
e.entry.Request.PostData = &struct {
MimeType string `json:"mimeType"`
Text string `json:"text"`
}{
MimeType: str(hdrs, "Content-Type"),
Text: pd,
}
}
if wallTime > 0 {
e.entry.StartedDateTime = time.Unix(0, int64(wallTime*1e9)).UTC().Format(time.RFC3339Nano)
} else {
e.entry.StartedDateTime = time.Now().UTC().Format(time.RFC3339Nano)
}
e.startTs = ts
e.hasRequest = true
})
defer cancel1()
cancel2 := c.OnEvent("Network.responseReceived", func(method string, p map[string]any) {
reqID := str(p, "requestId")
if reqID == "" {
return
}
resp, _ := p["response"].(map[string]any)
hdrs, _ := resp["headers"].(map[string]any)
mu.Lock()
defer mu.Unlock()
e, ok := entries[reqID]
if !ok {
e = &pending{}
entries[reqID] = e
}
e.entry.Response.Status = int(num(resp, "status"))
e.entry.Response.StatusText = str(resp, "statusText")
e.entry.Response.HTTPVersion = str(resp, "protocol")
if e.entry.Response.HTTPVersion == "" {
e.entry.Response.HTTPVersion = "HTTP/1.1"
}
e.entry.Response.Headers = headersOf(hdrs)
e.entry.Response.Cookies = []HarHeader{}
e.entry.Response.Content.MimeType = str(resp, "mimeType")
e.entry.Response.Content.Size = int(num(resp, "encodedDataLength"))
e.entry.Response.HeadersSize = -1
e.entry.Response.BodySize = -1
e.entry.ServerIPAddress = str(resp, "remoteIPAddress")
})
defer cancel2()
cancel3 := c.OnEvent("Network.loadingFinished", func(method string, p map[string]any) {
reqID := str(p, "requestId")
ts := num(p, "timestamp")
mu.Lock()
defer mu.Unlock()
if e, ok := entries[reqID]; ok {
e.endTs = ts
}
})
defer cancel3()
cancel4 := c.OnEvent("Network.loadingFailed", func(method string, p map[string]any) {
reqID := str(p, "requestId")
ts := num(p, "timestamp")
errText := str(p, "errorText")
mu.Lock()
defer mu.Unlock()
if e, ok := entries[reqID]; ok {
e.endTs = ts
e.entry.XError = errText
}
})
defer cancel4()
// Habilitar Network domain.
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return "", fmt.Errorf("cdp har record: Network.enable: %w", err)
}
defer c.sendCDP("Network.disable", nil)
// Ejecutar accion del usuario.
actionErr := action()
// Esperar eventos rezagados (loadingFinished suele llegar despues).
time.Sleep(time.Duration(settleMs) * time.Millisecond)
// Construir HAR.
mu.Lock()
defer mu.Unlock()
var har HarLog
har.Log.Version = "1.2"
har.Log.Creator.Name = "navegator/cdp_har_record"
har.Log.Creator.Version = "0.1.0"
har.Log.Pages = []any{}
har.Log.Entries = make([]HarEntry, 0, len(entries))
for _, e := range entries {
if !e.hasRequest {
continue
}
if e.endTs > 0 && e.startTs > 0 {
e.entry.Time = int64((e.endTs - e.startTs) * 1000)
}
// HAR exige timings con send/wait/receive >= 0; usar 0/total/0 si no
// tenemos breakdown.
e.entry.Timings.Send = 0
e.entry.Timings.Wait = int(e.entry.Time)
e.entry.Timings.Receive = 0
har.Log.Entries = append(har.Log.Entries, e.entry)
}
out, err := json.MarshalIndent(har, "", " ")
if err != nil {
return "", fmt.Errorf("cdp har record: marshal: %w", err)
}
if actionErr != nil {
return string(out), fmt.Errorf("cdp har record: action: %w", actionErr)
}
return string(out), nil
}
+53
View File
@@ -0,0 +1,53 @@
---
name: cdp_har_record
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpHarRecord(c *CDPConn, action func() error, settleMs int) (string, error)"
description: "Captura todas las peticiones HTTP/WS que ocurren durante la ejecucion de `action` y devuelve un archivo HAR 1.2 valido como JSON. Habilita Network.* events de CDP, suscribe handlers para requestWillBeSent / responseReceived / loadingFinished / loadingFailed, ejecuta la accion del usuario, espera settleMs para eventos rezagados, y construye el HAR. Body de respuesta no incluido en v0 (requiere Network.getResponseBody adicional)."
tags: [browser, cdp, har, network, capture, observability]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, sync, time]
example: |
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
har, err := browser.CdpHarRecord(c, func() error {
return browser.CdpNavigate(c, "https://example.com")
}, 2000)
if err == nil {
os.WriteFile("page.har", []byte(har), 0644)
}
tested: true
tests: ["TestCdpHarRecord_nilConn"]
test_file_path: "functions/browser/cdp_har_record_test.go"
file_path: "functions/browser/cdp_har_record.go"
notes: |
- Activa Network.enable + Network.disable automaticamente; el caller no necesita gestionarlo.
- Habilita events para `Network.requestWillBeSent`, `responseReceived`, `loadingFinished`, `loadingFailed`.
- settleMs default 1500 — tiempo de espera tras `action()` para eventos trailing (loadingFinished tipicamente llega ~100-500ms despues que responseReceived).
- HAR resultante incluye headers de request/response, status, mimetype, IP servidor, timings totales (send/receive=0, wait=tiempo total — sin breakdown granular).
- `_requestId` y `_error` son extensiones opcionales de HAR (prefijo `_` permitido por la spec).
- Tipos `HarHeader`, `HarEntry`, `HarLog` exportados — apps consumidoras pueden re-parsear.
documentation: |
Patron de captura comun en Playwright/Puppeteer (`page.context().tracing`, `tracing.start({har})`).
Util para:
- Auditar que peticiones hace una pagina (third-party scripts, trackers).
- Reproducir trafico via mocks (HAR -> wiremock/mockttp).
- Debugging visual en navegator_dashboard panel Network.
- Tests e2e que validan cuantas peticiones / a que dominios.
params:
- name: c
desc: "Conexion CDP activa (CdpConnect)."
- name: action
desc: "Funcion a ejecutar mientras se graban eventos. Usualmente CdpNavigate + CdpWaitLoad. Puede ser nil (graba lo que pase pasivamente)."
- name: settleMs
desc: "Milisegundos a esperar tras `action` para recoger trailing events. <=0 → 1500."
output: "JSON HAR 1.2 indentado. Vacio entries si no hubo trafico. Error si Network.enable falla o action retorna error (ambos: HAR parcial + error)."
---
+35
View File
@@ -0,0 +1,35 @@
package browser
import (
"encoding/json"
"strings"
"testing"
)
func TestCdpHarRecord_nilConn(t *testing.T) {
if _, err := CdpHarRecord(nil, nil, 0); err == nil {
t.Fatal("expected error on nil conn")
}
}
func TestCdpHarRecord_emptyHarStructure(t *testing.T) {
// Construir un HAR vacio manualmente y verificar que serializa a la
// estructura HAR 1.2 esperada (campos top-level).
var har HarLog
har.Log.Version = "1.2"
har.Log.Creator.Name = "test"
har.Log.Creator.Version = "0"
har.Log.Pages = []any{}
har.Log.Entries = []HarEntry{}
out, err := json.Marshal(har)
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(out)
for _, want := range []string{`"log":`, `"version":"1.2"`, `"creator":`, `"entries":[]`} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in: %s", want, s)
}
}
}
+131
View File
@@ -0,0 +1,131 @@
package browser
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// CdpTab representa una pestaña/target devuelta por el endpoint /json de CDP.
// Campos publicos para que apps consumidoras puedan filtrar/render.
type CdpTab struct {
ID string `json:"id"`
Type string `json:"type"` // "page", "iframe", "service_worker", ...
Title string `json:"title"`
URL string `json:"url"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
DevtoolsFrontendURL string `json:"devtoolsFrontendUrl,omitempty"`
}
// CdpListTabs llama GET http://{host}:{port}/json y retorna la lista de
// targets. Sin filtrar por tipo — el caller decide si se queda solo con
// type=="page", incluye iframes, etc.
//
// host vacio = "localhost". No requiere websocket (CDP expone /json en HTTP).
func CdpListTabs(host string, port int) ([]CdpTab, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return nil, fmt.Errorf("cdp list tabs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cdp list tabs: status %d", resp.StatusCode)
}
var tabs []CdpTab
if err := json.NewDecoder(resp.Body).Decode(&tabs); err != nil {
return nil, fmt.Errorf("cdp list tabs: decode: %w", err)
}
return tabs, nil
}
// CdpNewTab abre una pestaña nueva via PUT /json/new?<startURL>. Si startURL
// es vacio Chrome abre about:blank. Retorna el CdpTab recien creado.
//
// Nota: desde Chrome 126 /json/new requiere PUT (no GET). Mantenemos el
// fallback a GET por compatibilidad con builds antiguos.
func CdpNewTab(host string, port int, startURL string) (CdpTab, error) {
if host == "" {
host = "localhost"
}
endpoint := fmt.Sprintf("http://%s:%d/json/new", host, port)
if startURL != "" {
endpoint += "?" + url.QueryEscape(startURL)
}
tryRequest := func(method string) (CdpTab, error) {
var out CdpTab
req, err := http.NewRequest(method, endpoint, nil)
if err != nil {
return out, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return out, fmt.Errorf("decode: %w", err)
}
return out, nil
}
tab, err := tryRequest(http.MethodPut)
if err == nil && tab.ID != "" {
return tab, nil
}
// Fallback GET (Chrome < 126).
tab, err2 := tryRequest(http.MethodGet)
if err2 == nil && tab.ID != "" {
return tab, nil
}
if err == nil {
err = err2
}
return CdpTab{}, fmt.Errorf("cdp new tab: %w", err)
}
// CdpCloseTab cierra una pestaña por su ID via GET /json/close/<id>.
// Util complemento — incluido aqui porque comparte estructura HTTP /json.
func CdpCloseTab(host string, port int, tabID string) error {
if host == "" {
host = "localhost"
}
if tabID == "" {
return fmt.Errorf("cdp close tab: tabID vacio")
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json/close/%s", host, port, url.PathEscape(tabID)))
if err != nil {
return fmt.Errorf("cdp close tab: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cdp close tab: status %d", resp.StatusCode)
}
return nil
}
// CdpActivateTab pone la pestaña en foreground (focus) via /json/activate/<id>.
func CdpActivateTab(host string, port int, tabID string) error {
if host == "" {
host = "localhost"
}
if tabID == "" {
return fmt.Errorf("cdp activate tab: tabID vacio")
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json/activate/%s", host, port, url.PathEscape(tabID)))
if err != nil {
return fmt.Errorf("cdp activate tab: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cdp activate tab: status %d", resp.StatusCode)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
---
name: cdp_list_tabs
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpListTabs(host string, port int) ([]CdpTab, error)"
description: "Lista las pestañas/targets de una instancia Chrome via GET /json. Sin websocket — solo HTTP. Util para apps que muestran el inventario de pestañas (dashboards, debuggers) o agentes que iteran tabs sin tener que abrir conexion CDP a cada una."
tags: [browser, cdp, tabs, listing, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, net/http, net/url]
example: |
tabs, err := browser.CdpListTabs("localhost", 9222)
if err == nil {
for _, t := range tabs {
if t.Type == "page" {
fmt.Println(t.ID, t.Title, t.URL)
}
}
}
tested: true
tests: ["TestCdpListTabs_emptyHost"]
test_file_path: "functions/browser/cdp_list_tabs_test.go"
file_path: "functions/browser/cdp_list_tabs.go"
notes: |
- Endpoint CDP es read-only HTTP, no requiere CdpConnect.
- Retorna TODOS los targets (page + iframe + service_worker + ...). Filtrar por type segun caso de uso.
- Tipo `CdpTab` es publico — apps externas pueden consumirlo.
- Mismo archivo expone CdpNewTab, CdpCloseTab, CdpActivateTab — operaciones HTTP /json/* relacionadas.
documentation: |
Wraps el endpoint clasico de Chrome DevTools Protocol /json. Misma estructura
que devuelve `chrome://inspect/#devices` o que consumen Puppeteer/Playwright
cuando se conectan a un Chrome existente.
params:
- name: host
desc: "Host CDP (vacio = localhost)."
- name: port
desc: "Puerto remote-debugging."
output: "Slice de CdpTab (id, type, title, url, webSocketDebuggerUrl). Error si HTTP falla o status != 200."
---
+32
View File
@@ -0,0 +1,32 @@
package browser
import "testing"
func TestCdpListTabs_emptyHost(t *testing.T) {
// Sin Chrome → error (puerto cerrado). Se valida que la funcion construye
// el request sin panic con host vacio.
_, err := CdpListTabs("", 1) // puerto 1 garantizado cerrado
if err == nil {
t.Fatal("expected error talking to closed port")
}
}
func TestCdpNewTab_emptyURLOk(t *testing.T) {
// Igual: sin servidor real esperamos error de red, pero NO panic ni nil-deref.
_, err := CdpNewTab("", 1, "")
if err == nil {
t.Fatal("expected error talking to closed port")
}
}
func TestCdpCloseTab_emptyID(t *testing.T) {
if err := CdpCloseTab("localhost", 9222, ""); err == nil {
t.Fatal("expected error on empty tabID")
}
}
func TestCdpActivateTab_emptyID(t *testing.T) {
if err := CdpActivateTab("localhost", 9222, ""); err == nil {
t.Fatal("expected error on empty tabID")
}
}
+42
View File
@@ -0,0 +1,42 @@
---
name: cdp_new_tab
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpNewTab(host string, port int, startURL string) (CdpTab, error)"
description: "Abre una pestaña nueva via /json/new. Si startURL es vacio Chrome abre about:blank. Retorna el CdpTab recien creado con su id, webSocketDebuggerUrl, etc. Compatible con Chrome 126+ (PUT) y anteriores (fallback GET)."
tags: [browser, cdp, tabs, spawn]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, net/http, net/url]
example: |
tab, err := browser.CdpNewTab("localhost", 9222, "https://example.com")
if err == nil {
fmt.Println("nueva tab id=", tab.ID)
}
tested: true
tests: ["TestCdpNewTab_emptyURLOk"]
test_file_path: "functions/browser/cdp_list_tabs_test.go"
file_path: "functions/browser/cdp_list_tabs.go"
notes: |
- Definida en mismo archivo que CdpListTabs — comparten estructura.
- Desde Chrome 126 el endpoint requiere PUT. Mantenemos fallback a GET por compatibilidad.
- URL se codifica como query string raw (no clave=valor — formato historico de Chrome).
documentation: |
Util para abrir tabs de scraping bajo control programatico sin pasar por
WebSocket. Combina con CdpListTabs para enumerar antes/despues, y
CdpCloseTab para limpiar al final.
params:
- name: host
desc: "Host CDP (vacio = localhost)."
- name: port
desc: "Puerto remote-debugging."
- name: startURL
desc: "URL inicial. Vacio = about:blank."
output: "CdpTab del target recien creado (id, websocket url, ...). Error si HTTP falla."
---
+5 -14
View File
@@ -13,10 +13,13 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: conexion nula")
}
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
// Chrome — enviar ademas un evento "char" lo duplicaba en sitios que
// reaccionan a `input` events (DuckDuckGo, Google, etc.). Patron
// alineado con Puppeteer page.type(): keyDown + (insert via text) + keyUp.
for _, ch := range text {
charStr := string(ch)
// keyDown
keyDown := map[string]any{
"type": "keyDown",
"key": charStr,
@@ -26,27 +29,15 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: keyDown %q: %w", charStr, err)
}
// char (dispara el evento input en el elemento)
keyChar := map[string]any{
"type": "char",
"key": charStr,
"text": charStr,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", keyChar); err != nil {
return fmt.Errorf("cdp type text: char %q: %w", charStr, err)
}
// keyUp
keyUp := map[string]any{
"type": "keyUp",
"key": charStr,
"text": charStr,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", keyUp); err != nil {
return fmt.Errorf("cdp type text: keyUp %q: %w", charStr, err)
}
// Pequena pausa entre caracteres para simular escritura humana
// Pequena pausa entre caracteres para simular escritura humana.
time.Sleep(10 * time.Millisecond)
}
+32
View File
@@ -0,0 +1,32 @@
package core
// GoldenDiff compares two byte slices and returns a similarity score in [0,1].
// similarity = matchedBytes / max(len(actual), len(golden)).
// matched = similarity >= (1.0 - threshold).
// threshold=0.0 requires exact match; threshold=0.05 tolerates up to 5% divergence.
// Returns similarity=1.0 and matched=true when both slices are empty.
func GoldenDiff(actual, golden []byte, threshold float64) (matched bool, similarity float64) {
maxLen := len(actual)
if len(golden) > maxLen {
maxLen = len(golden)
}
if maxLen == 0 {
return true, 1.0
}
minLen := len(actual)
if len(golden) < minLen {
minLen = len(golden)
}
var matchedBytes int
for i := 0; i < minLen; i++ {
if actual[i] == golden[i] {
matchedBytes++
}
}
similarity = float64(matchedBytes) / float64(maxLen)
matched = similarity >= (1.0 - threshold)
return matched, similarity
}
+52
View File
@@ -0,0 +1,52 @@
---
name: golden_diff
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func GoldenDiff(actual, golden []byte, threshold float64) (matched bool, similarity float64)"
description: "Compara dos buffers byte-a-byte y retorna una puntuacion de similitud en [0,1]. similarity = matchedBytes / max(len(actual), len(golden)). matched = similarity >= (1.0 - threshold). threshold=0.0 exige match exacto; threshold=0.05 tolera hasta 5% de divergencia."
tags: [diff, golden, testing, similarity, bytes, comparison]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests:
- "identicos retorna similarity 1 y matched true"
- "totalmente distintos retorna similarity 0 y matched false"
- "1 byte distinto threshold 0 falla pero threshold 0.5 pasa"
- "longitudes distintas usa max como denominador"
test_file_path: "functions/core/golden_diff_test.go"
file_path: "functions/core/golden_diff.go"
params:
- name: actual
desc: "Buffer de bytes producido por el sistema bajo prueba."
- name: golden
desc: "Buffer de bytes de referencia (golden file o snapshot esperado)."
- name: threshold
desc: "Fraccion de divergencia tolerable en [0,1]. 0.0 = match exacto, 0.05 = hasta 5% diferente."
output: "matched indica si la similitud supera el umbral. similarity es la fraccion de bytes que coinciden respecto al mayor de los dos buffers."
---
## Ejemplo
```go
actual := []byte("hello world")
golden := []byte("hello world")
matched, sim := GoldenDiff(actual, golden, 0.0)
// matched=true, sim=1.0
actual2 := []byte("hello wrold") // typo
matched2, sim2 := GoldenDiff(actual2, golden, 0.05)
// sim2 = 9/11 ≈ 0.818, matched2=true (dentro del 5% de tolerancia)
```
## Notas
Funcion pura, sin I/O. Usa comparacion posicional byte-a-byte: dos buffers del mismo contenido pero con bytes insertados/eliminados en el centro daran una similitud baja aunque el contenido sea logicamente similar. Para comparaciones de texto estructurado (JSON, YAML) considerar normalizar antes de llamar a GoldenDiff.
El denominador es `max(len(actual), len(golden))`, lo que penaliza diferencias de longitud ademas de diferencias de contenido.
+58
View File
@@ -0,0 +1,58 @@
package core
import (
"math"
"testing"
)
func TestGoldenDiff(t *testing.T) {
t.Run("identicos retorna similarity 1 y matched true", func(t *testing.T) {
matched, sim := GoldenDiff([]byte("hello"), []byte("hello"), 0.0)
if !matched {
t.Errorf("expected matched=true")
}
if math.Abs(sim-1.0) > 1e-9 {
t.Errorf("expected similarity=1.0, got %v", sim)
}
})
t.Run("totalmente distintos retorna similarity 0 y matched false", func(t *testing.T) {
matched, sim := GoldenDiff([]byte("aaaaa"), []byte("bbbbb"), 0.0)
if matched {
t.Errorf("expected matched=false")
}
if math.Abs(sim-0.0) > 1e-9 {
t.Errorf("expected similarity=0.0, got %v", sim)
}
})
t.Run("1 byte distinto threshold 0 falla pero threshold 0.5 pasa", func(t *testing.T) {
// "hellx" vs "hello": 4 de 5 bytes coinciden -> similarity = 0.8
actual := []byte("hellx")
golden := []byte("hello")
matchedStrict, simStrict := GoldenDiff(actual, golden, 0.0)
if matchedStrict {
t.Errorf("threshold=0.0: expected matched=false with 1 byte difference")
}
if math.Abs(simStrict-0.8) > 1e-9 {
t.Errorf("threshold=0.0: expected similarity=0.8, got %v", simStrict)
}
matchedLoose, _ := GoldenDiff(actual, golden, 0.5)
if !matchedLoose {
t.Errorf("threshold=0.5: expected matched=true with similarity=0.8")
}
})
t.Run("longitudes distintas usa max como denominador", func(t *testing.T) {
// "ab" vs "abcde": 2 de 5 bytes coinciden -> similarity = 0.4
matched, sim := GoldenDiff([]byte("ab"), []byte("abcde"), 0.0)
if matched {
t.Errorf("expected matched=false")
}
if math.Abs(sim-0.4) > 1e-9 {
t.Errorf("expected similarity=0.4, got %v", sim)
}
})
}
+29
View File
@@ -0,0 +1,29 @@
package datascience
import "sort"
// MetricsDrift calculates the relative drift of a current measurement against
// a historical baseline computed at the given percentile.
//
// historical is a window of past measurements (e.g. duration_ms, bytes).
// percentile selects the baseline: 0.5 = median, 0.95 = p95.
// drift = (current - baseline) / baseline, e.g. 0.47 means +47% above baseline.
//
// Returns drift=0, baseline=0 when historical is empty or baseline is zero.
func MetricsDrift(historical []int64, current int64, percentile float64) (drift float64, baseline int64) {
if len(historical) == 0 {
return 0, 0
}
sorted := make([]int64, len(historical))
copy(sorted, historical)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
baseline = Percentile(sorted, percentile)
if baseline == 0 {
return 0, 0
}
drift = float64(current-baseline) / float64(baseline)
return drift, baseline
}
+56
View File
@@ -0,0 +1,56 @@
---
name: metrics_drift
kind: function
lang: go
domain: datascience
version: "1.0.0"
purity: pure
signature: "func MetricsDrift(historical []int64, current int64, percentile float64) (drift float64, baseline int64)"
description: "Calcula la deriva relativa de una medicion actual respecto a una linea base historica. La linea base se obtiene como el percentil indicado del historico. drift = (current - baseline) / baseline. Retorna drift=0, baseline=0 si el historico esta vacio o la linea base es cero."
tags: [metrics, drift, percentile, statistics, monitoring, baseline]
uses_functions: [percentile_int64_go_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [sort]
tested: true
tests:
- "historico vacio retorna drift 0 baseline 0"
- "baseline cero retorna drift 0 baseline 0"
- "drift positivo cuando current supera baseline"
- "drift negativo cuando current es menor que baseline"
test_file_path: "functions/datascience/metrics_drift_test.go"
file_path: "functions/datascience/metrics_drift.go"
params:
- name: historical
desc: "Ventana de mediciones previas en la misma unidad que current (ms, bytes, etc.). No necesita estar ordenada."
- name: current
desc: "Medicion actual a comparar contra la linea base historica."
- name: percentile
desc: "Percentil para calcular la linea base: 0.5 = mediana, 0.95 = p95. Rango [0.0, 1.0]."
output: "drift es la desviacion relativa como fraccion (0.47 = +47% por encima de la linea base, -0.5 = 50% por debajo). baseline es el valor del percentil sobre el historico."
---
## Ejemplo
```go
historical := []int64{100, 120, 95, 110, 105} // ms de respuesta previos
current := int64(200) // ms de la ejecucion actual
drift, baseline := MetricsDrift(historical, current, 0.5)
// baseline ≈ 105 (mediana)
// drift ≈ 0.905 (+90.5% sobre la mediana)
// Con p95 como referencia de "worst case normal"
_, p95 := MetricsDrift(historical, current, 0.95)
// p95 = 120
```
## Notas
Funcion pura. El historico se ordena internamente (copia defensiva) antes de calcular el percentil con `Percentile` de `percentile_int64_go_datascience`. No muta el slice de entrada.
El drift puede ser negativo (mejora) o positivo (degradacion). El caller decide el umbral de alarma (ej. `drift > 0.5` = degradacion mayor al 50%).
Util para el agente `fn-analizador` para comparar `duration_ms` de la ejecucion actual contra el historico de `executions` en `operations.db`.
@@ -0,0 +1,47 @@
package datascience
import (
"math"
"testing"
)
func TestMetricsDrift(t *testing.T) {
t.Run("historico vacio retorna drift 0 baseline 0", func(t *testing.T) {
drift, baseline := MetricsDrift([]int64{}, 100, 0.5)
if drift != 0 || baseline != 0 {
t.Errorf("expected drift=0 baseline=0, got drift=%v baseline=%v", drift, baseline)
}
})
t.Run("baseline cero retorna drift 0 baseline 0", func(t *testing.T) {
// todos ceros -> percentil = 0 -> baseline = 0
drift, baseline := MetricsDrift([]int64{0, 0, 0}, 50, 0.5)
if drift != 0 || baseline != 0 {
t.Errorf("expected drift=0 baseline=0 when baseline is zero, got drift=%v baseline=%v", drift, baseline)
}
})
t.Run("drift positivo cuando current supera baseline", func(t *testing.T) {
// historico: [100,100,100,100,100], mediana=100
// current=147 -> drift=0.47
drift, baseline := MetricsDrift([]int64{100, 100, 100, 100, 100}, 147, 0.5)
if baseline != 100 {
t.Errorf("expected baseline=100, got %v", baseline)
}
if math.Abs(drift-0.47) > 1e-9 {
t.Errorf("expected drift=0.47, got %v", drift)
}
})
t.Run("drift negativo cuando current es menor que baseline", func(t *testing.T) {
// historico: [200,200,200], mediana=200
// current=100 -> drift=-0.5
drift, baseline := MetricsDrift([]int64{200, 200, 200}, 100, 0.5)
if baseline != 200 {
t.Errorf("expected baseline=200, got %v", baseline)
}
if math.Abs(drift-(-0.5)) > 1e-9 {
t.Errorf("expected drift=-0.5, got %v", drift)
}
})
}
+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 "")
}