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