merge: quick/content-hash-sources-infra-functions — content hash, sources, funciones infra/core/PowerShell y app navegador
This commit is contained in:
@@ -49,6 +49,19 @@ sqlite3 registry.db ".schema"
|
||||
|
||||
**Regla:** Si necesitas saber si algo existe o hay algo similar, haz la consulta FTS5 sobre la BD. No asumas que no existe sin consultar primero.
|
||||
|
||||
### Schema rapido
|
||||
|
||||
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines
|
||||
|
||||
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||
- Enums: `algebraic`(product|sum)
|
||||
|
||||
**FTS5 (columnas buscables):**
|
||||
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code
|
||||
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
|
||||
|
||||
---
|
||||
|
||||
## Estructura
|
||||
|
||||
@@ -14,3 +14,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 08 | [tag_launcher.md](tag_launcher.md) | Tag launcher para Pipeline Launcher TUI |
|
||||
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
|
||||
| 10 | [apps_vs_functions.md](apps_vs_functions.md) | Codigo reutilizable en functions/, no reutilizable en apps/ |
|
||||
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
## Extraccion de funciones desde repos externos (`sources/`)
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Clonar repo en `sources/<nombre>` (gitignored, solo el manifest `sources/sources.yaml` se versiona)
|
||||
2. El agente analiza el repo y propone funciones candidatas
|
||||
3. Las funciones se **copian y adaptan** al formato del registry (.go/.py/.sh/.ts + .md con frontmatter)
|
||||
4. `fn index` las registra. El manifest se actualiza con las funciones extraidas.
|
||||
|
||||
### Filtro de calidad (obligatorio antes de extraer)
|
||||
|
||||
Una funcion externa solo se extrae si cumple TODOS estos criterios:
|
||||
|
||||
- **Firma generica**: no depende de tipos internos del repo origen ni de config hardcodeada
|
||||
- **Sin estado global**: no usa variables globales, singletons, ni init() con side effects
|
||||
- **Dependencias minimas**: solo stdlib o dependencias ya presentes en fn_registry
|
||||
- **Pura si es posible**: si la funcion puede ser pura, debe extraerse como pura
|
||||
- **Sin credenciales**: no contiene secrets, API keys, ni paths absolutos
|
||||
- **Testeable**: la logica debe poder validarse con tests unitarios
|
||||
- **No duplicada**: consultar registry.db con FTS5 antes de extraer para evitar duplicados
|
||||
- **Licencia compatible**: el repo debe tener licencia permisiva (MIT, Apache 2.0, BSD, etc.)
|
||||
|
||||
### Adaptacion al extraer
|
||||
|
||||
- Renombrar a snake_case siguiendo la convencion del registry
|
||||
- Adaptar firma para usar tipos nativos (no tipos internos del repo)
|
||||
- Crear .md con frontmatter completo incluyendo `source_repo`, `source_license`, `source_file`
|
||||
- Actualizar `sources/sources.yaml` con la extraccion
|
||||
|
||||
### Campos de atribucion en frontmatter
|
||||
|
||||
```yaml
|
||||
source_repo: "https://github.com/user/project"
|
||||
source_license: "MIT"
|
||||
source_file: "pkg/original_file.go"
|
||||
```
|
||||
|
||||
Estos campos se indexan en registry.db y permiten consultar:
|
||||
```sql
|
||||
SELECT id, source_repo, source_license FROM functions WHERE source_repo != '';
|
||||
```
|
||||
|
||||
### Lenguajes soportados para extraccion
|
||||
|
||||
Cualquier lenguaje puede analizarse como fuente. El destino depende de la naturaleza de la funcion:
|
||||
- Algoritmos/logica pura → Go (functions/{domain}/) o Python (python/functions/{domain}/)
|
||||
- Scripts/utilidades sistema → Bash (bash/functions/{domain}/)
|
||||
- UI/frontend → TypeScript (frontend/functions/{domain}/)
|
||||
- C/Rust/otros → Traducir a Go o Python, manteniendo la semantica original
|
||||
@@ -37,6 +37,9 @@ python/.venv/
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||
sources/*/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Crea un dashboard en Metabase para monitorear operations de script_navegador."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
|
||||
from metabase.client import metabase_auth
|
||||
from metabase import (
|
||||
metabase_list_databases,
|
||||
metabase_create_card,
|
||||
metabase_create_dashboard,
|
||||
metabase_update_dashboard,
|
||||
metabase_list_dashboards,
|
||||
)
|
||||
from metabase.databases import metabase_add_database
|
||||
|
||||
# --- Config ---
|
||||
METABASE_URL = "http://localhost:3000"
|
||||
EMAIL = "admin@fnregistry.local"
|
||||
PASSWORD = "FnRegistry2024!"
|
||||
|
||||
# Path de operations.db dentro del contenedor Docker
|
||||
# Copiar con: docker exec metabase mkdir -p /data/ops-script-navegador && docker cp apps/script_navegador/operations.db metabase:/data/ops-script-navegador/operations.db
|
||||
OPS_DB_PATH = "/data/ops-script-navegador/operations.db"
|
||||
DB_NAME = "ops-script-navegador"
|
||||
|
||||
CARDS = [
|
||||
# ---- Fila 0: KPIs (h=5) ----
|
||||
{
|
||||
"name": "Total Ejecuciones",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS total FROM executions;",
|
||||
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Ejecuciones Exitosas",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS exitosas FROM executions WHERE status = 'success';",
|
||||
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Ejecuciones Fallidas",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS fallidas FROM executions WHERE status = 'failure';",
|
||||
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Duracion Promedio (ms)",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT ROUND(AVG(duration_ms)) AS avg_ms FROM executions WHERE status = 'success';",
|
||||
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
|
||||
},
|
||||
# ---- Fila 5: Tendencias (h=8) ----
|
||||
{
|
||||
"name": "Ejecuciones por Estado",
|
||||
"display": "pie",
|
||||
"sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status;",
|
||||
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
|
||||
},
|
||||
{
|
||||
"name": "Duracion por Ejecucion (timeline)",
|
||||
"display": "line",
|
||||
"sql": """
|
||||
SELECT
|
||||
started_at,
|
||||
duration_ms,
|
||||
status
|
||||
FROM executions
|
||||
ORDER BY started_at;
|
||||
""",
|
||||
"size_x": 16, "size_y": 8, "col": 8, "row": 5,
|
||||
},
|
||||
# ---- Fila 13: Detalle de pasos (h=9) ----
|
||||
{
|
||||
"name": "Pasos por Script (metricas)",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
id,
|
||||
status,
|
||||
records_in AS pasos_total,
|
||||
records_out AS pasos_exitosos,
|
||||
duration_ms,
|
||||
CASE WHEN error = '' THEN '-' ELSE error END AS error,
|
||||
json_extract(metrics, '$.script_name') AS script,
|
||||
started_at
|
||||
FROM executions
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 20;
|
||||
""",
|
||||
"size_x": 24, "size_y": 9, "col": 0, "row": 13,
|
||||
},
|
||||
# ---- Fila 22: Logs (h=9) ----
|
||||
{
|
||||
"name": "Logs Recientes",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
json_extract(metadata, '$.action') AS action,
|
||||
json_extract(metadata, '$.elapsed_ms') AS elapsed_ms,
|
||||
created_at
|
||||
FROM logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
""",
|
||||
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
|
||||
},
|
||||
]
|
||||
|
||||
DASHBOARD_NAME = "script_navegador Operations"
|
||||
|
||||
|
||||
def main():
|
||||
print("Autenticando en Metabase...")
|
||||
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
|
||||
|
||||
# Buscar si ya existe la database
|
||||
dbs = metabase_list_databases(client)
|
||||
ops_db_id = None
|
||||
for db in dbs:
|
||||
if db.get("name") == DB_NAME:
|
||||
ops_db_id = db["id"]
|
||||
print(f" Database ya existe: {DB_NAME} (id={ops_db_id})")
|
||||
break
|
||||
|
||||
if not ops_db_id:
|
||||
print(f"Registrando {DB_NAME} como datasource SQLite ({OPS_DB_PATH})...")
|
||||
new_db = metabase_add_database(
|
||||
client=client,
|
||||
name=DB_NAME,
|
||||
engine="sqlite",
|
||||
details={"db": OPS_DB_PATH},
|
||||
)
|
||||
ops_db_id = new_db["id"]
|
||||
print(f" Database registrada: id={ops_db_id}")
|
||||
|
||||
# Eliminar dashboard existente si lo hay
|
||||
existing = metabase_list_dashboards(client)
|
||||
for d in existing:
|
||||
if d.get("name") == DASHBOARD_NAME:
|
||||
print(f" Dashboard ya existe (id={d['id']}), recreando...")
|
||||
from metabase import metabase_delete_dashboard
|
||||
metabase_delete_dashboard(client, d["id"])
|
||||
|
||||
# Crear cards
|
||||
print("Creando cards...")
|
||||
created_cards = []
|
||||
for i, card_def in enumerate(CARDS):
|
||||
card = metabase_create_card(
|
||||
client,
|
||||
name=card_def["name"],
|
||||
dataset_query={
|
||||
"database": ops_db_id,
|
||||
"type": "native",
|
||||
"native": {"query": card_def["sql"]},
|
||||
},
|
||||
display=card_def["display"],
|
||||
description=f"script_navegador: {card_def['name']}",
|
||||
)
|
||||
created_cards.append((card, card_def))
|
||||
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
|
||||
|
||||
# Crear dashboard
|
||||
print("Creando dashboard...")
|
||||
dashboard = metabase_create_dashboard(
|
||||
client,
|
||||
name=DASHBOARD_NAME,
|
||||
description="Monitoreo de ejecuciones de script_navegador: KPIs, tendencias, detalle de pasos y logs.",
|
||||
)
|
||||
dash_id = dashboard["id"]
|
||||
print(f" Dashboard creado: id={dash_id}")
|
||||
|
||||
# Agregar cards al dashboard
|
||||
dashcards = []
|
||||
for idx, (card, card_def) in enumerate(created_cards):
|
||||
dashcards.append({
|
||||
"id": -(idx + 1),
|
||||
"card_id": card["id"],
|
||||
"size_x": card_def["size_x"],
|
||||
"size_y": card_def["size_y"],
|
||||
"col": card_def["col"],
|
||||
"row": card_def["row"],
|
||||
})
|
||||
|
||||
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
|
||||
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,7 +11,7 @@ Via variables de entorno:
|
||||
METABASE_URL=http://localhost:3000 \
|
||||
METABASE_ADMIN_EMAIL=admin@example.com \
|
||||
METABASE_ADMIN_PASSWORD=secret \
|
||||
REGISTRY_DB_PATH=/registry.db \
|
||||
REGISTRY_DB_PATH=/data/registry/registry.db \
|
||||
python main.py
|
||||
|
||||
Via argumentos CLI:
|
||||
@@ -357,7 +357,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# Registry DB path (ruta dentro del contenedor Docker)
|
||||
p.add_argument(
|
||||
"--registry-db-path",
|
||||
default=os.environ.get("REGISTRY_DB_PATH", "/registry.db"),
|
||||
default=os.environ.get("REGISTRY_DB_PATH", "/data/registry/registry.db"),
|
||||
dest="registry_db_path",
|
||||
help=(
|
||||
"Ruta al registry.db DENTRO del contenedor Docker "
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
build/
|
||||
*.exe
|
||||
script_navegador
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: script_navegador
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "Ejecutor de scripts de navegador CDP sobre Chrome. Lee pasos desde YAML y los ejecuta en secuencia registrando cada resultado en operations.db."
|
||||
tags: [cdp, chrome, browser, automation, yaml]
|
||||
uses_functions:
|
||||
- chrome_launch_go_infra
|
||||
- cdp_connect_go_infra
|
||||
- cdp_navigate_go_infra
|
||||
- cdp_click_go_infra
|
||||
- cdp_type_text_go_infra
|
||||
- cdp_wait_element_go_infra
|
||||
- cdp_evaluate_go_infra
|
||||
- cdp_get_html_go_infra
|
||||
- cdp_screenshot_go_infra
|
||||
- cdp_close_go_infra
|
||||
uses_types: []
|
||||
framework: ""
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/script_navegador"
|
||||
---
|
||||
|
||||
## Descripcion
|
||||
|
||||
CLI Go que lee un archivo YAML con pasos de navegacion CDP y los ejecuta sobre Chrome, registrando cada paso y su resultado en `operations.db`.
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
# Conectarse a Chrome ya corriendo en puerto 9222
|
||||
go run . --script examples/busqueda_google.yaml
|
||||
|
||||
# Lanzar Chrome nuevo (headless)
|
||||
go run . --script examples/busqueda_google.yaml --launch --headless
|
||||
|
||||
# Puerto personalizado
|
||||
go run . --script examples/busqueda_google.yaml --port 9333
|
||||
```
|
||||
|
||||
## Formato del script YAML
|
||||
|
||||
```yaml
|
||||
name: "nombre_del_script"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://ejemplo.com"
|
||||
|
||||
- action: wait
|
||||
selector: "#elemento"
|
||||
timeout_ms: 5000 # opcional, default 10000
|
||||
|
||||
- action: click
|
||||
selector: "#boton"
|
||||
continue_on_error: true # opcional, default false
|
||||
|
||||
- action: type
|
||||
selector: "input[name=q]" # hace click primero para enfocar
|
||||
text: "texto a escribir"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/captura.png"
|
||||
full_page: false # opcional, default false
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.title"
|
||||
|
||||
- action: get_html
|
||||
# sin parametros adicionales
|
||||
|
||||
- action: sleep
|
||||
ms: 500 # pausa en milisegundos
|
||||
```
|
||||
|
||||
## Acciones soportadas
|
||||
|
||||
| Accion | Parametros obligatorios | Parametros opcionales |
|
||||
|-------------|-------------------------|-------------------------------|
|
||||
| `navigate` | `url` | |
|
||||
| `wait` | `selector` | `timeout_ms` (default 10000) |
|
||||
| `click` | `selector` | `continue_on_error` |
|
||||
| `type` | `selector`, `text` | `continue_on_error` |
|
||||
| `screenshot`| `path` | `full_page`, `continue_on_error` |
|
||||
| `evaluate` | `expr` | `continue_on_error` |
|
||||
| `get_html` | — | `continue_on_error` |
|
||||
| `sleep` | `ms` | |
|
||||
|
||||
## Registro en operations.db
|
||||
|
||||
- **Entity `script_run`**: una por ejecucion del script, con metadata del script y resultado final
|
||||
- **Execution**: una por ejecucion, con `pipeline_id = "script_navegador"`, duration_ms, records_in=pasos totales, records_out=pasos exitosos
|
||||
- **Logs**: un log por cada paso ejecutado con nivel info/error
|
||||
|
||||
## Notas
|
||||
|
||||
- Si Chrome no esta corriendo y no se pasa `--launch`, la conexion falla con error claro
|
||||
- `continue_on_error: true` por paso permite continuar aunque ese paso falle
|
||||
- Flag global `--abort-on-error` (default false) aborta todo el script al primer error
|
||||
- Al terminar (exito o error), siempre se ejecuta `cdp_close` para limpiar recursos
|
||||
- operations.db se inicializa automaticamente si no existe usando `fn ops init`
|
||||
@@ -0,0 +1,28 @@
|
||||
name: "busqueda_google"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://www.google.com"
|
||||
|
||||
- action: wait
|
||||
selector: "textarea[name=q]"
|
||||
timeout_ms: 8000
|
||||
|
||||
- action: type
|
||||
selector: "textarea[name=q]"
|
||||
text: "golang cdp automation"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/busqueda_antes.png"
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.title"
|
||||
|
||||
- action: sleep
|
||||
ms: 500
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.querySelector('textarea[name=q]').value"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/busqueda_despues.png"
|
||||
full_page: false
|
||||
@@ -0,0 +1,20 @@
|
||||
name: "demo_continue_on_error"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://example.com"
|
||||
|
||||
- action: wait
|
||||
selector: "h1"
|
||||
timeout_ms: 5000
|
||||
|
||||
# Este paso fallara porque el selector no existe, pero el script continua
|
||||
- action: click
|
||||
selector: "#boton-que-no-existe"
|
||||
continue_on_error: true
|
||||
|
||||
# Este paso se ejecuta aunque el anterior fallo
|
||||
- action: evaluate
|
||||
expr: "document.title"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/continue_on_error.png"
|
||||
@@ -0,0 +1,16 @@
|
||||
name: "scrape_titulo"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://example.com"
|
||||
|
||||
- action: wait
|
||||
selector: "h1"
|
||||
timeout_ms: 5000
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.querySelector('h1').textContent"
|
||||
|
||||
- action: get_html
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/example_com.png"
|
||||
@@ -0,0 +1,6 @@
|
||||
name: "navegar_youtube"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://www.youtube.com"
|
||||
- action: screenshot
|
||||
path: "/tmp/youtube.png"
|
||||
@@ -0,0 +1,12 @@
|
||||
module script-navegador
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
|
||||
replace fn-registry => /home/lucas/fn_registry
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// generateID genera un UUID v4 simple sin dependencias externas.
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback con timestamp si rand falla (muy improbable)
|
||||
return fmt.Sprintf("fallback-%x", b)
|
||||
}
|
||||
// Ajustar bits para UUID v4
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Flags
|
||||
scriptPath := flag.String("script", "", "Ruta al archivo YAML con el script de navegacion (obligatorio)")
|
||||
port := flag.Int("port", 9222, "Puerto CDP de Chrome")
|
||||
launch := flag.Bool("launch", false, "Lanzar Chrome nuevo en vez de conectarse a uno existente")
|
||||
headless := flag.Bool("headless", false, "Lanzar Chrome en modo headless (requiere --launch)")
|
||||
chromePath := flag.String("chrome-path", "", "Ruta al ejecutable de Chrome (ej: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe')")
|
||||
userDataDir := flag.String("user-data-dir", "", "Directorio de perfil de Chrome (path WSL, se convierte a Windows automaticamente)")
|
||||
keepOpen := flag.Bool("keep-open", false, "No cerrar Chrome al terminar")
|
||||
abortOnError := flag.Bool("abort-on-error", false, "Abortar el script al primer error en cualquier paso")
|
||||
flag.Parse()
|
||||
|
||||
if *scriptPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --script es obligatorio")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := run(*scriptPath, *port, *launch, *headless, *abortOnError, *userDataDir, *keepOpen, *chromePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(scriptPath string, port int, launch, headless, abortOnError bool, userDataDir string, keepOpen bool, chromePath string) error {
|
||||
// 1. Cargar y validar el script YAML
|
||||
script, err := LoadScript(scriptPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cargar script: %w", err)
|
||||
}
|
||||
fmt.Printf("[script_navegador] script: %q (%d pasos)\n", script.Name, len(script.Steps))
|
||||
|
||||
// 2. Inicializar operations.db
|
||||
appDir, err := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
if err != nil {
|
||||
// Fallback al directorio de trabajo
|
||||
appDir, _ = os.Getwd()
|
||||
}
|
||||
// Si estamos corriendo con `go run .`, os.Args[0] es un tmp, usar cwd
|
||||
if cwd, e := os.Getwd(); e == nil {
|
||||
if _, e2 := os.Stat(filepath.Join(cwd, "app.md")); e2 == nil {
|
||||
appDir = cwd
|
||||
}
|
||||
}
|
||||
|
||||
db, err := initOpsDB(appDir)
|
||||
if err != nil {
|
||||
// No es fatal: seguir sin operations.db, solo logear
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo inicializar operations.db: %v\n", err)
|
||||
}
|
||||
if db != nil {
|
||||
defer db.Close()
|
||||
}
|
||||
|
||||
// 3. Lanzar Chrome o conectarse al existente
|
||||
var pid int
|
||||
if launch {
|
||||
// Convertir path WSL a Windows para chrome.exe
|
||||
// Si empieza con / es un path Linux (WSL), convertir. Si empieza con letra:\ ya es Windows.
|
||||
winDataDir := userDataDir
|
||||
if winDataDir != "" && strings.HasPrefix(winDataDir, "/") {
|
||||
out, err := exec.Command("wslpath", "-w", winDataDir).Output()
|
||||
if err == nil {
|
||||
winDataDir = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
fmt.Printf("[chrome] lanzando Chrome en puerto %d (headless=%v, user-data-dir=%q)...\n", port, headless, winDataDir)
|
||||
pid, err = infra.ChromeLaunch(infra.ChromeLaunchOpts{
|
||||
Port: port,
|
||||
Headless: headless,
|
||||
UserDataDir: winDataDir,
|
||||
ChromePath: chromePath,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lanzar Chrome: %w", err)
|
||||
}
|
||||
fmt.Printf("[chrome] Chrome lanzado (pid=%d)\n", pid)
|
||||
} else {
|
||||
fmt.Printf("[chrome] conectando a Chrome en localhost:%d...\n", port)
|
||||
}
|
||||
|
||||
// 4. Conectar CDP (con mirrored networking, localhost es compartido WSL<->Windows)
|
||||
fmt.Printf("[cdp] conectando a localhost:%d...\n", port)
|
||||
conn, err := infra.CdpConnect(port)
|
||||
if err != nil {
|
||||
// Si lanzamos Chrome, matar el proceso antes de salir
|
||||
if pid > 0 {
|
||||
_ = infra.CdpClose(nil, pid)
|
||||
}
|
||||
return fmt.Errorf("conectar CDP en localhost:%d: %w", port, err)
|
||||
}
|
||||
fmt.Printf("[cdp] conexion establecida\n")
|
||||
|
||||
// Asegurar cierre al salir (respetar --keep-open)
|
||||
defer func() {
|
||||
if keepOpen {
|
||||
fmt.Printf("[cdp] cerrando conexion CDP (Chrome sigue abierto, pid=%d, puerto=%d)\n", pid, port)
|
||||
// Solo cerrar la conexion WebSocket, no matar Chrome
|
||||
if err := infra.CdpClose(conn, 0); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar conexion: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[cdp] cerrando conexion y limpiando recursos...\n")
|
||||
if err := infra.CdpClose(conn, pid); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 5. Registrar entities y relations en operations.db
|
||||
var relationID string
|
||||
if db != nil {
|
||||
_, _, _, err := EnsureEntities(db, port, chromePath, userDataDir, script.Name, scriptPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear entities: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[ops] entities registradas\n")
|
||||
}
|
||||
relationID, err = EnsureRelations(db, "chrome_instance", "cdp_session", fmt.Sprintf("script_%s", script.Name))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear relations: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[ops] relations registradas\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Ejecutar el script
|
||||
runner := NewRunner(conn, RunnerOpts{AbortOnError: abortOnError})
|
||||
|
||||
startedAt := time.Now()
|
||||
fmt.Printf("[run] iniciando ejecucion: %s\n", startedAt.Format(time.RFC3339))
|
||||
|
||||
results, runErr := runner.Run(script)
|
||||
endedAt := time.Now()
|
||||
|
||||
// 7. Imprimir resumen de pasos
|
||||
printSummary(script, results, runErr, startedAt, endedAt)
|
||||
|
||||
// 8. Registrar execution y actualizar relation en operations.db
|
||||
if db != nil {
|
||||
execID, err := RecordRun(db, script, relationID, results, runErr, startedAt, endedAt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar ejecucion: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[ops] ejecucion registrada en operations.db (id=%s)\n", execID[:8])
|
||||
}
|
||||
// Registrar cada paso como log
|
||||
for _, r := range results {
|
||||
if logErr := LogStep(db, execID, r); logErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar log step[%d]: %v\n", r.Index, logErr)
|
||||
}
|
||||
}
|
||||
// Actualizar relation status
|
||||
if relationID != "" {
|
||||
UpdateRelationAfterRun(db, relationID, runErr)
|
||||
}
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return runErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSummary imprime un resumen legible de la ejecucion.
|
||||
func printSummary(script *Script, results []StepResult, runErr error, startedAt, endedAt time.Time) {
|
||||
duration := endedAt.Sub(startedAt)
|
||||
success := 0
|
||||
for _, r := range results {
|
||||
if r.Err == nil {
|
||||
success++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n--- Resumen: %q ---\n", script.Name)
|
||||
fmt.Printf("Duracion: %v\n", duration.Round(time.Millisecond))
|
||||
fmt.Printf("Pasos: %d/%d exitosos\n", success, len(results))
|
||||
fmt.Println()
|
||||
|
||||
for _, r := range results {
|
||||
status := "ok"
|
||||
detail := ""
|
||||
if r.Err != nil {
|
||||
status = "ERROR"
|
||||
detail = fmt.Sprintf(" -> %v", r.Err)
|
||||
} else if r.Output != "" {
|
||||
detail = fmt.Sprintf(" -> %q", r.Output)
|
||||
}
|
||||
fmt.Printf(" [%d] %-12s %s (%dms)%s\n",
|
||||
r.Index, r.Action, status, r.Elapsed.Milliseconds(), detail)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
fmt.Printf("\nAbortado: %v\n", runErr)
|
||||
} else {
|
||||
fmt.Printf("\nScript completado.\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
fn_operations "fn-registry/fn_operations"
|
||||
)
|
||||
|
||||
const opsDBName = "operations.db"
|
||||
|
||||
// initOpsDB inicializa o abre operations.db en el directorio de la app.
|
||||
func initOpsDB(appDir string) (*fn_operations.DB, error) {
|
||||
dbPath := filepath.Join(appDir, opsDBName)
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
if err := bootstrapOpsDB(appDir, dbPath); err != nil {
|
||||
return nil, fmt.Errorf("inicializar operations.db: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := fn_operations.Open(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abrir operations.db: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// bootstrapOpsDB intenta crear operations.db usando el CLI fn o directamente.
|
||||
func bootstrapOpsDB(appDir, dbPath string) error {
|
||||
registryRoot := os.Getenv("FN_REGISTRY_ROOT")
|
||||
if registryRoot == "" {
|
||||
registryRoot = filepath.Join(appDir, "..", "..")
|
||||
}
|
||||
|
||||
fnBin := filepath.Join(registryRoot, "fn")
|
||||
if _, err := os.Stat(fnBin); err == nil {
|
||||
cmd := exec.Command(fnBin, "ops", "init", appDir)
|
||||
cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+registryRoot)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fn ops init: %w\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err := fn_operations.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crear operations.db directamente: %w", err)
|
||||
}
|
||||
return db.Close()
|
||||
}
|
||||
|
||||
// --- Entities ---
|
||||
|
||||
// EnsureEntities crea o actualiza las entities del pipeline de navegacion.
|
||||
// Entities:
|
||||
// - chrome_instance: la instancia de Chrome con CDP
|
||||
// - cdp_session: la sesion CDP activa
|
||||
// - script_file: el archivo YAML del script
|
||||
func EnsureEntities(db *fn_operations.DB, port int, chromePath, userDataDir, scriptName, scriptPath string) (chromeID, cdpID, scriptID string, err error) {
|
||||
now := time.Now()
|
||||
|
||||
chromeID = "chrome_instance"
|
||||
cdpID = "cdp_session"
|
||||
scriptID = fmt.Sprintf("script_%s", scriptName)
|
||||
|
||||
// Chrome instance
|
||||
existing, _ := db.GetEntity(chromeID)
|
||||
if existing == nil {
|
||||
err = db.InsertEntity(&fn_operations.Entity{
|
||||
ID: chromeID,
|
||||
Name: "Chrome Windows",
|
||||
TypeRef: "chrome_instance",
|
||||
Status: fn_operations.StatusActive,
|
||||
Description: "Instancia de Chrome con remote debugging habilitado",
|
||||
Domain: "infra",
|
||||
Tags: []string{"chrome", "cdp", "windows"},
|
||||
Source: "script_navegador",
|
||||
Metadata: map[string]any{
|
||||
"port": port,
|
||||
"chrome_path": chromePath,
|
||||
"user_data_dir": userDataDir,
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("insertar entity chrome_instance: %w", err)
|
||||
}
|
||||
} else if existing.Status != fn_operations.StatusActive {
|
||||
existing.Status = fn_operations.StatusActive
|
||||
existing.UpdatedAt = now
|
||||
db.UpdateEntity(existing)
|
||||
}
|
||||
|
||||
// CDP session
|
||||
existing, _ = db.GetEntity(cdpID)
|
||||
if existing == nil {
|
||||
err = db.InsertEntity(&fn_operations.Entity{
|
||||
ID: cdpID,
|
||||
Name: "CDP Session",
|
||||
TypeRef: "cdp_session",
|
||||
Status: fn_operations.StatusActive,
|
||||
Description: "Sesion CDP WebSocket activa contra Chrome",
|
||||
Domain: "infra",
|
||||
Tags: []string{"cdp", "websocket"},
|
||||
Source: "script_navegador",
|
||||
Metadata: map[string]any{
|
||||
"port": port,
|
||||
"protocol": "CDP 1.3",
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("insertar entity cdp_session: %w", err)
|
||||
}
|
||||
} else if existing.Status != fn_operations.StatusActive {
|
||||
existing.Status = fn_operations.StatusActive
|
||||
existing.UpdatedAt = now
|
||||
db.UpdateEntity(existing)
|
||||
}
|
||||
|
||||
// Script
|
||||
existing, _ = db.GetEntity(scriptID)
|
||||
if existing == nil {
|
||||
err = db.InsertEntity(&fn_operations.Entity{
|
||||
ID: scriptID,
|
||||
Name: scriptName,
|
||||
TypeRef: "nav_script",
|
||||
Status: fn_operations.StatusActive,
|
||||
Description: fmt.Sprintf("Script de navegacion: %s", scriptName),
|
||||
Domain: "automation",
|
||||
Tags: []string{"script", "yaml", "navegacion"},
|
||||
Source: scriptPath,
|
||||
Metadata: map[string]any{
|
||||
"script_name": scriptName,
|
||||
"file_path": scriptPath,
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("insertar entity script: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return chromeID, cdpID, scriptID, nil
|
||||
}
|
||||
|
||||
// --- Relations ---
|
||||
|
||||
// EnsureRelations crea las relaciones entre entities si no existen.
|
||||
// Relations:
|
||||
// - chrome_to_cdp: Chrome -> CDP Session (via chrome_launch + cdp_connect)
|
||||
// - cdp_to_script: CDP Session -> Script (via runner)
|
||||
func EnsureRelations(db *fn_operations.DB, chromeID, cdpID, scriptID string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
// chrome -> cdp
|
||||
chromeToCDP := "chrome_to_cdp"
|
||||
existing, _ := db.GetRelation(chromeToCDP)
|
||||
if existing == nil {
|
||||
err := db.InsertRelation(&fn_operations.Relation{
|
||||
ID: chromeToCDP,
|
||||
Name: "chrome_to_cdp",
|
||||
FromEntity: chromeID,
|
||||
ToEntity: cdpID,
|
||||
Via: "cdp_connect_go_infra",
|
||||
Description: "Chrome expone CDP, la app se conecta via WebSocket",
|
||||
Purity: "impure",
|
||||
Direction: fn_operations.DirUnidirectional,
|
||||
Status: fn_operations.RelImplemented,
|
||||
Tags: []string{"cdp", "websocket"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insertar relation chrome_to_cdp: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cdp -> script execution
|
||||
cdpToScript := fmt.Sprintf("cdp_to_%s", scriptID)
|
||||
existing, _ = db.GetRelation(cdpToScript)
|
||||
if existing == nil {
|
||||
startedAt := now
|
||||
err := db.InsertRelation(&fn_operations.Relation{
|
||||
ID: cdpToScript,
|
||||
Name: cdpToScript,
|
||||
FromEntity: cdpID,
|
||||
ToEntity: scriptID,
|
||||
Via: "script_navegador_runner",
|
||||
Description: fmt.Sprintf("CDP ejecuta pasos del script %s", scriptID),
|
||||
Purity: "impure",
|
||||
Direction: fn_operations.DirUnidirectional,
|
||||
Status: fn_operations.RelRunning,
|
||||
StartedAt: &startedAt,
|
||||
Tags: []string{"automation", "pipeline"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insertar relation cdp_to_script: %w", err)
|
||||
}
|
||||
} else {
|
||||
existing.Status = fn_operations.RelRunning
|
||||
existing.UpdatedAt = now
|
||||
db.UpdateRelation(existing)
|
||||
}
|
||||
|
||||
return cdpToScript, nil
|
||||
}
|
||||
|
||||
// UpdateRelationAfterRun actualiza el status de la relation segun el resultado.
|
||||
func UpdateRelationAfterRun(db *fn_operations.DB, relationID string, runErr error) {
|
||||
rel, err := db.GetRelation(relationID)
|
||||
if err != nil || rel == nil {
|
||||
return
|
||||
}
|
||||
if runErr != nil {
|
||||
rel.Status = fn_operations.RelImplemented
|
||||
} else {
|
||||
rel.Status = fn_operations.RelTested
|
||||
}
|
||||
now := time.Now()
|
||||
rel.EndedAt = &now
|
||||
rel.UpdatedAt = now
|
||||
db.UpdateRelation(rel)
|
||||
}
|
||||
|
||||
// --- Executions ---
|
||||
|
||||
// RecordRun registra una ejecucion completa del script en operations.db.
|
||||
func RecordRun(db *fn_operations.DB, script *Script, relationID string, results []StepResult, runErr error, startedAt, endedAt time.Time) (string, error) {
|
||||
totalSteps := int64(len(results))
|
||||
successSteps := int64(0)
|
||||
for _, r := range results {
|
||||
if r.Err == nil {
|
||||
successSteps++
|
||||
}
|
||||
}
|
||||
|
||||
status := fn_operations.ExecSuccess
|
||||
errMsg := ""
|
||||
if runErr != nil {
|
||||
status = fn_operations.ExecFailure
|
||||
errMsg = runErr.Error()
|
||||
} else if successSteps < totalSteps {
|
||||
status = fn_operations.ExecPartial
|
||||
}
|
||||
|
||||
durationMs := endedAt.Sub(startedAt).Milliseconds()
|
||||
|
||||
stepSummary := make([]map[string]any, 0, len(results))
|
||||
for _, r := range results {
|
||||
entry := map[string]any{
|
||||
"index": r.Index,
|
||||
"action": r.Action,
|
||||
"elapsed_ms": r.Elapsed.Milliseconds(),
|
||||
"ok": r.Err == nil,
|
||||
}
|
||||
if r.Output != "" {
|
||||
entry["output"] = r.Output
|
||||
}
|
||||
if r.Err != nil {
|
||||
entry["error"] = r.Err.Error()
|
||||
}
|
||||
stepSummary = append(stepSummary, entry)
|
||||
}
|
||||
|
||||
execID := generateID()
|
||||
execution := &fn_operations.Execution{
|
||||
ID: execID,
|
||||
PipelineID: "script_navegador",
|
||||
RelationID: relationID,
|
||||
Status: status,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: &endedAt,
|
||||
DurationMs: &durationMs,
|
||||
RecordsIn: &totalSteps,
|
||||
RecordsOut: &successSteps,
|
||||
Error: errMsg,
|
||||
Metrics: map[string]any{
|
||||
"script_name": script.Name,
|
||||
"total_steps": totalSteps,
|
||||
"success_steps": successSteps,
|
||||
"steps": stepSummary,
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.InsertExecution(execution); err != nil {
|
||||
return "", fmt.Errorf("insertar execution: %w", err)
|
||||
}
|
||||
|
||||
return execID, nil
|
||||
}
|
||||
|
||||
// --- Logs ---
|
||||
|
||||
// LogStep registra un paso individual como log en operations.db.
|
||||
func LogStep(db *fn_operations.DB, execID string, res StepResult) error {
|
||||
level := fn_operations.LogInfo
|
||||
msg := fmt.Sprintf("step[%d] %s: ok", res.Index, res.Action)
|
||||
if res.Err != nil {
|
||||
level = fn_operations.LogError
|
||||
msg = fmt.Sprintf("step[%d] %s: %v", res.Index, res.Action, res.Err)
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"action": res.Action,
|
||||
"elapsed_ms": res.Elapsed.Milliseconds(),
|
||||
}
|
||||
if res.Output != "" {
|
||||
meta["output"] = res.Output
|
||||
}
|
||||
|
||||
log := &fn_operations.Log{
|
||||
ID: generateID(),
|
||||
Level: level,
|
||||
Source: "script_navegador",
|
||||
ExecutionID: execID,
|
||||
Message: msg,
|
||||
Metadata: meta,
|
||||
}
|
||||
|
||||
return db.InsertLog(log)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// StepResult es el resultado de ejecutar un paso.
|
||||
type StepResult struct {
|
||||
Index int
|
||||
Action string
|
||||
Output string // resultado de evaluate/get_html, path de screenshot, etc.
|
||||
Err error
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// RunnerOpts configura la ejecucion del runner.
|
||||
type RunnerOpts struct {
|
||||
AbortOnError bool
|
||||
}
|
||||
|
||||
// Runner ejecuta los pasos de un Script sobre una conexion CDP activa.
|
||||
type Runner struct {
|
||||
conn *infra.CDPConn
|
||||
opts RunnerOpts
|
||||
}
|
||||
|
||||
// NewRunner crea un Runner con la conexion CDP dada.
|
||||
func NewRunner(conn *infra.CDPConn, opts RunnerOpts) *Runner {
|
||||
return &Runner{conn: conn, opts: opts}
|
||||
}
|
||||
|
||||
// Run ejecuta todos los pasos del script y retorna los resultados de cada paso.
|
||||
// Siempre retorna todos los resultados procesados hasta el momento, incluso si aborta.
|
||||
func (r *Runner) Run(script *Script) ([]StepResult, error) {
|
||||
results := make([]StepResult, 0, len(script.Steps))
|
||||
|
||||
for i, step := range script.Steps {
|
||||
start := time.Now()
|
||||
output, err := r.runStep(step)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
res := StepResult{
|
||||
Index: i,
|
||||
Action: step.Action,
|
||||
Output: output,
|
||||
Err: err,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
results = append(results, res)
|
||||
|
||||
if err != nil {
|
||||
if step.ContinueOnError {
|
||||
// Continuar con el siguiente paso aunque este fallo
|
||||
continue
|
||||
}
|
||||
if r.opts.AbortOnError {
|
||||
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
|
||||
}
|
||||
// Por defecto: abortar si el paso fallo y no tiene continue_on_error
|
||||
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// runStep ejecuta un paso individual y retorna su output y error.
|
||||
func (r *Runner) runStep(step Step) (string, error) {
|
||||
switch step.Action {
|
||||
case "navigate":
|
||||
if err := infra.CdpNavigate(r.conn, step.URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Esperar a que la página cargue completamente
|
||||
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return "", infra.CdpWaitLoad(r.conn, timeout)
|
||||
|
||||
case "wait_load":
|
||||
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return "", infra.CdpWaitLoad(r.conn, timeout)
|
||||
|
||||
case "wait":
|
||||
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return "", infra.CdpWaitElement(r.conn, step.Selector, timeout)
|
||||
|
||||
case "click":
|
||||
return "", infra.CdpClick(r.conn, step.Selector)
|
||||
|
||||
case "type":
|
||||
// Hacer click primero para enfocar el elemento
|
||||
if err := infra.CdpClick(r.conn, step.Selector); err != nil {
|
||||
return "", fmt.Errorf("enfocar elemento para type: %w", err)
|
||||
}
|
||||
return "", infra.CdpTypeText(r.conn, step.Text)
|
||||
|
||||
case "screenshot":
|
||||
opts := infra.CdpScreenshotOpts{
|
||||
FullPage: step.FullPage,
|
||||
Format: "png",
|
||||
}
|
||||
if err := infra.CdpScreenshot(r.conn, step.Path, opts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return step.Path, nil
|
||||
|
||||
case "evaluate":
|
||||
result, err := infra.CdpEvaluate(r.conn, step.Expr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "get_html":
|
||||
html, err := infra.CdpGetHTML(r.conn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Truncar para el log (el HTML puede ser muy largo)
|
||||
if len(html) > 200 {
|
||||
return html[:200] + "...", nil
|
||||
}
|
||||
return html, nil
|
||||
|
||||
case "sleep":
|
||||
time.Sleep(time.Duration(step.Ms) * time.Millisecond)
|
||||
return fmt.Sprintf("slept %dms", step.Ms), nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("accion desconocida: %q", step.Action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Script representa un archivo YAML de pasos de navegacion.
|
||||
type Script struct {
|
||||
Name string `yaml:"name"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
}
|
||||
|
||||
// Step es un paso individual dentro del script.
|
||||
type Step struct {
|
||||
// Comun a todos los pasos
|
||||
Action string `yaml:"action"`
|
||||
ContinueOnError bool `yaml:"continue_on_error"`
|
||||
|
||||
// navigate
|
||||
URL string `yaml:"url"`
|
||||
|
||||
// wait
|
||||
Selector string `yaml:"selector"`
|
||||
TimeoutMs int `yaml:"timeout_ms"`
|
||||
|
||||
// type
|
||||
Text string `yaml:"text"`
|
||||
|
||||
// screenshot
|
||||
Path string `yaml:"path"`
|
||||
FullPage bool `yaml:"full_page"`
|
||||
|
||||
// evaluate
|
||||
Expr string `yaml:"expr"`
|
||||
|
||||
// sleep
|
||||
Ms int `yaml:"ms"`
|
||||
}
|
||||
|
||||
// Validate comprueba que el script tiene los campos minimos correctos.
|
||||
func (s *Script) Validate() error {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("script: campo 'name' obligatorio")
|
||||
}
|
||||
if len(s.Steps) == 0 {
|
||||
return fmt.Errorf("script %q: sin pasos definidos", s.Name)
|
||||
}
|
||||
for i, step := range s.Steps {
|
||||
if err := step.Validate(i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate comprueba que el paso tiene los campos requeridos segun su action.
|
||||
func (s *Step) Validate(idx int) error {
|
||||
prefix := fmt.Sprintf("step[%d] action=%q", idx, s.Action)
|
||||
switch s.Action {
|
||||
case "navigate":
|
||||
if s.URL == "" {
|
||||
return fmt.Errorf("%s: campo 'url' obligatorio", prefix)
|
||||
}
|
||||
case "wait":
|
||||
if s.Selector == "" {
|
||||
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||
}
|
||||
case "click":
|
||||
if s.Selector == "" {
|
||||
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||
}
|
||||
case "type":
|
||||
if s.Selector == "" {
|
||||
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||
}
|
||||
if s.Text == "" {
|
||||
return fmt.Errorf("%s: campo 'text' obligatorio", prefix)
|
||||
}
|
||||
case "screenshot":
|
||||
if s.Path == "" {
|
||||
return fmt.Errorf("%s: campo 'path' obligatorio", prefix)
|
||||
}
|
||||
case "evaluate":
|
||||
if s.Expr == "" {
|
||||
return fmt.Errorf("%s: campo 'expr' obligatorio", prefix)
|
||||
}
|
||||
case "get_html":
|
||||
// sin parametros requeridos
|
||||
case "wait_load":
|
||||
// sin parametros requeridos (timeout_ms opcional)
|
||||
case "sleep":
|
||||
if s.Ms <= 0 {
|
||||
return fmt.Errorf("%s: campo 'ms' debe ser mayor que 0", prefix)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s: accion desconocida (navigate|wait|wait_load|click|type|screenshot|evaluate|get_html|sleep)", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadScript lee y parsea un archivo YAML de script de navegador.
|
||||
func LoadScript(path string) (*Script, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("leer script %q: %w", path, err)
|
||||
}
|
||||
|
||||
var s Script
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parsear script %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := s.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// getWindowsHostIP obtiene la IP del host Windows desde WSL2.
|
||||
// Lee /etc/resolv.conf que WSL2 configura con la IP del host.
|
||||
func getWindowsHostIP() string {
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "nameserver ") {
|
||||
ip := strings.TrimPrefix(line, "nameserver ")
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getWindowsGatewayIP obtiene la IP del gateway (host Windows) desde la tabla de rutas.
|
||||
func getWindowsGatewayIP() string {
|
||||
data, err := os.ReadFile("/proc/net/route")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[1] == "00000000" { // default route
|
||||
hexIP := fields[2]
|
||||
if len(hexIP) == 8 {
|
||||
// /proc/net/route stores IPs as little-endian 32-bit hex
|
||||
// "011017AC" -> bytes [01,10,17,AC] -> IP 172.23.16.1 (reversed)
|
||||
var a, b, c, d uint8
|
||||
fmt.Sscanf(hexIP[0:2], "%02x", &a)
|
||||
fmt.Sscanf(hexIP[2:4], "%02x", &b)
|
||||
fmt.Sscanf(hexIP[4:6], "%02x", &c)
|
||||
fmt.Sscanf(hexIP[6:8], "%02x", &d)
|
||||
return fmt.Sprintf("%d.%d.%d.%d", d, c, b, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// waitForCDP espera a que el puerto CDP esté accesible desde WSL.
|
||||
func waitForCDP(host string, port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("CDP %s no disponible despues de %s", addr, timeout)
|
||||
}
|
||||
|
||||
// startCDPProxy levanta un proxy TCP local que reenvía conexiones al host Windows.
|
||||
// Chrome CDP solo acepta conexiones desde localhost, así que el proxy en WSL
|
||||
// conecta al host Windows vía portproxy/netsh y expone el puerto localmente.
|
||||
// Retorna el puerto local del proxy y una función para cerrarlo.
|
||||
func startCDPProxy(windowsHost string, remotePort, localPort int) (net.Listener, error) {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy listen: %w", err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
client, err := ln.Accept()
|
||||
if err != nil {
|
||||
return // listener cerrado
|
||||
}
|
||||
go proxyConn(client, windowsHost, remotePort)
|
||||
}
|
||||
}()
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func proxyConn(client net.Conn, host string, port int) {
|
||||
defer client.Close()
|
||||
remote, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer remote.Close()
|
||||
go io.Copy(remote, client)
|
||||
io.Copy(client, remote)
|
||||
}
|
||||
@@ -105,6 +105,18 @@ func cmdIndex() {
|
||||
// Flush WAL to main db file so external readers (e.g. Metabase) see changes.
|
||||
db.WalCheckpoint()
|
||||
|
||||
// Sync registry.db to Metabase mount directory if it exists.
|
||||
metabaseCopy := filepath.Join(r, ".metabase-registry", "registry.db")
|
||||
if _, err := os.Stat(filepath.Dir(metabaseCopy)); err == nil {
|
||||
src := filepath.Join(r, dbName)
|
||||
data, err := os.ReadFile(src)
|
||||
if err == nil {
|
||||
if err := os.WriteFile(metabaseCopy, data, 0666); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not sync to metabase: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Indexed %d functions, %d types, %d apps\n", result.Functions, result.Types, result.Apps)
|
||||
for _, e := range result.ValidationErrors {
|
||||
fmt.Fprintf(os.Stderr, " INVALID: %s\n", e)
|
||||
@@ -341,6 +353,11 @@ func printFunction(f *registry.Function) {
|
||||
if f.Code != "" {
|
||||
fmt.Printf("\nCode:\n%s\n", f.Code)
|
||||
}
|
||||
if f.SourceRepo != "" {
|
||||
fmt.Printf("Source repo: %s\n", f.SourceRepo)
|
||||
fmt.Printf("Source license: %s\n", f.SourceLicense)
|
||||
fmt.Printf("Source file: %s\n", f.SourceFile)
|
||||
}
|
||||
if f.Kind == registry.KindComponent {
|
||||
fmt.Printf("Framework: %s\n", f.Framework)
|
||||
if f.HasState != nil {
|
||||
@@ -365,6 +382,11 @@ func printType(t *registry.Type) {
|
||||
if len(t.UsesTypes) > 0 {
|
||||
fmt.Printf("Uses types: %s\n", strings.Join(t.UsesTypes, ", "))
|
||||
}
|
||||
if t.SourceRepo != "" {
|
||||
fmt.Printf("Source repo: %s\n", t.SourceRepo)
|
||||
fmt.Printf("Source license: %s\n", t.SourceLicense)
|
||||
fmt.Printf("Source file: %s\n", t.SourceFile)
|
||||
}
|
||||
if t.Definition != "" {
|
||||
fmt.Printf("\nDefinition:\n%s\n", t.Definition)
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -18,6 +18,10 @@ tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/core/filter_slice.go"
|
||||
# Source attribution (solo para funciones extraidas de repos externos)
|
||||
# source_repo: "https://github.com/user/project"
|
||||
# source_license: "MIT"
|
||||
# source_file: "pkg/utils.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DetectCycle checks if adding a directed edge (from -> to) would create a cycle
|
||||
// in a directed graph stored in a SQLite table.
|
||||
// It performs BFS from toNode following edges where the filter column is non-empty.
|
||||
// If it reaches fromNode, a cycle exists.
|
||||
//
|
||||
// Parameters:
|
||||
// - conn: open *sql.DB connection
|
||||
// - table: table name containing the edges (e.g. "relations")
|
||||
// - fromCol: column name for edge source (e.g. "from_entity")
|
||||
// - toCol: column name for edge destination (e.g. "to_entity")
|
||||
// - filterCol: column name that must be non-empty for causal edges (e.g. "via"); pass "" to consider all edges
|
||||
// - fromNode: source node of the proposed new edge
|
||||
// - toNode: destination node of the proposed new edge
|
||||
func DetectCycle(conn *sql.DB, table, fromCol, toCol, filterCol, fromNode, toNode string) error {
|
||||
if fromNode == "" || toNode == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var query string
|
||||
if filterCol != "" {
|
||||
query = fmt.Sprintf("SELECT %s FROM %s WHERE %s = ? AND %s != ''", toCol, table, fromCol, filterCol)
|
||||
} else {
|
||||
query = fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", toCol, table, fromCol)
|
||||
}
|
||||
|
||||
visited := map[string]bool{}
|
||||
queue := []string{toNode}
|
||||
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
if visited[current] {
|
||||
continue
|
||||
}
|
||||
visited[current] = true
|
||||
|
||||
if current == fromNode {
|
||||
return fmt.Errorf("cycle detected: adding edge %s -> %s would create a cycle", fromNode, toNode)
|
||||
}
|
||||
|
||||
rows, err := conn.Query(query, current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying %s for cycle detection: %w", table, err)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var next string
|
||||
if err := rows.Scan(&next); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if !visited[next] {
|
||||
queue = append(queue, next)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: detect_cycle
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DetectCycle(conn *sql.DB, table, fromCol, toCol, filterCol, fromNode, toNode string) error"
|
||||
description: "Detecta ciclos en un grafo dirigido almacenado en SQLite usando BFS antes de insertar una arista."
|
||||
tags: [graph, cycle, bfs, sqlite, validation]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/core/detect_cycle.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Verificar si agregar A -> B crearia un ciclo en la tabla "relations"
|
||||
err := DetectCycle(db, "relations", "from_entity", "to_entity", "via", "A", "B")
|
||||
if err != nil {
|
||||
// ciclo detectado
|
||||
}
|
||||
|
||||
// Sin filtro — considerar todas las aristas
|
||||
err = DetectCycle(db, "edges", "source", "target", "", "X", "Y")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa BFS desde toNode siguiendo aristas existentes. Si alcanza fromNode, la nueva arista crearia un ciclo. El parametro filterCol permite ignorar aristas semanticas (no causales) — pasar "" para considerar todas.
|
||||
@@ -0,0 +1,7 @@
|
||||
package core
|
||||
|
||||
// GenerateID builds a canonical ID from name, lang, and domain.
|
||||
// Format: {name}_{lang}_{domain}
|
||||
func GenerateID(name, lang, domain string) string {
|
||||
return name + "_" + lang + "_" + domain
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: generate_id
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func GenerateID(name, lang, domain string) string"
|
||||
description: "Genera un ID canonico determinista a partir de nombre, lenguaje y dominio."
|
||||
tags: [id, naming, deterministic]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/core/generate_id.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
id := GenerateID("filter_slice", "go", "core")
|
||||
// id = "filter_slice_go_core"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura sin dependencias. Util para cualquier sistema que necesite IDs compuestos deterministas a partir de componentes con nombre.
|
||||
@@ -0,0 +1,58 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rewriteFieldPattern = regexp.MustCompile(`\b([a-zA-Z_][a-zA-Z0-9_]*)\b`)
|
||||
|
||||
var rewriteSQLKeywords = map[string]bool{
|
||||
"AND": true, "OR": true, "NOT": true, "IS": true, "NULL": true,
|
||||
"IN": true, "BETWEEN": true, "LIKE": true, "GLOB": true,
|
||||
"TRUE": true, "FALSE": true, "CASE": true, "WHEN": true,
|
||||
"THEN": true, "ELSE": true, "END": true, "SELECT": true,
|
||||
"FROM": true, "WHERE": true, "AS": true, "CAST": true,
|
||||
}
|
||||
|
||||
var rewriteSQLFunctions = map[string]bool{
|
||||
"json_extract": true, "datetime": true, "now": true,
|
||||
"abs": true, "avg": true, "count": true, "max": true, "min": true,
|
||||
"sum": true, "total": true, "length": true, "typeof": true,
|
||||
"coalesce": true, "ifnull": true, "nullif": true,
|
||||
"upper": true, "lower": true, "trim": true, "replace": true,
|
||||
"substr": true, "instr": true, "round": true,
|
||||
}
|
||||
|
||||
// RewriteRule transforms a rule expression so that bare field names become
|
||||
// json_extract calls on a given JSON column.
|
||||
// For example, with column "metadata":
|
||||
//
|
||||
// "price > 100 AND status IS NOT NULL"
|
||||
//
|
||||
// becomes:
|
||||
//
|
||||
// "json_extract(metadata, '$.price') > 100 AND json_extract(metadata, '$.status') IS NOT NULL"
|
||||
//
|
||||
// If the rule already contains json_extract, it is returned as-is.
|
||||
// SQL keywords and common SQL functions are preserved.
|
||||
func RewriteRule(rule, jsonColumn string) string {
|
||||
if strings.Contains(rule, "json_extract") {
|
||||
return rule
|
||||
}
|
||||
|
||||
return rewriteFieldPattern.ReplaceAllStringFunc(rule, func(match string) string {
|
||||
upper := strings.ToUpper(match)
|
||||
if rewriteSQLKeywords[upper] {
|
||||
return match
|
||||
}
|
||||
if rewriteSQLFunctions[strings.ToLower(match)] {
|
||||
return match
|
||||
}
|
||||
if match[0] >= '0' && match[0] <= '9' {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf("json_extract(%s, '$.%s')", jsonColumn, match)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: rewrite_rule
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func RewriteRule(rule, jsonColumn string) string"
|
||||
description: "Reescribe campos bare en una expresion SQL a llamadas json_extract sobre una columna JSON de SQLite."
|
||||
tags: [sql, json, sqlite, rewrite, assertion]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["regexp"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/core/rewrite_rule.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
out := RewriteRule("price > 100 AND status IS NOT NULL", "metadata")
|
||||
// out = "json_extract(metadata, '$.price') > 100 AND json_extract(metadata, '$.status') IS NOT NULL"
|
||||
|
||||
// Si ya tiene json_extract, no modifica nada
|
||||
out = RewriteRule("json_extract(data, '$.x') > 0", "data")
|
||||
// out = "json_extract(data, '$.x') > 0"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura. Preserva keywords SQL y funciones SQLite conocidas. Util para construir queries dinamicas sobre columnas JSON en SQLite sin que el usuario tenga que escribir json_extract manualmente.
|
||||
@@ -20,8 +20,11 @@ type cdpTarget struct {
|
||||
|
||||
// cdpGetPageWSURL obtiene el webSocketDebuggerUrl de la primera tab de tipo "page"
|
||||
// via el endpoint /json. Si no hay ninguna, crea una nueva con /json/new.
|
||||
func cdpGetPageWSURL(port int) (string, error) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json", port))
|
||||
func cdpGetPageWSURL(host string, port int) (string, error) {
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp targets: %w", err)
|
||||
}
|
||||
@@ -40,7 +43,7 @@ func cdpGetPageWSURL(port int) (string, error) {
|
||||
}
|
||||
|
||||
// No hay tabs — crear una nueva via /json/new
|
||||
newResp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/new", port))
|
||||
newResp, err := http.Get(fmt.Sprintf("http://%s:%d/json/new", host, port))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp new tab: %w", err)
|
||||
}
|
||||
@@ -61,7 +64,16 @@ func cdpGetPageWSURL(port int) (string, error) {
|
||||
// Si no hay tabs disponibles, crea una nueva via /json/new.
|
||||
// Realiza el handshake WebSocket RFC 6455 sobre TCP puro (sin dependencias externas).
|
||||
func CdpConnect(port int) (*CDPConn, error) {
|
||||
wsURL, err := cdpGetPageWSURL(port)
|
||||
return CdpConnectHost("localhost", port)
|
||||
}
|
||||
|
||||
// CdpConnectHost es como CdpConnect pero permite especificar el host.
|
||||
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
|
||||
func CdpConnectHost(host string, port int) (*CDPConn, error) {
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
wsURL, err := cdpGetPageWSURL(host, port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp connect: %w", err)
|
||||
}
|
||||
@@ -72,20 +84,20 @@ func CdpConnect(port int) (*CDPConn, error) {
|
||||
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
|
||||
}
|
||||
|
||||
host := u.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host = host + ":80"
|
||||
wsHost := u.Host
|
||||
if !strings.Contains(wsHost, ":") {
|
||||
wsHost = wsHost + ":80"
|
||||
}
|
||||
|
||||
// Abrir conexion TCP
|
||||
tcpConn, err := net.Dial("tcp", host)
|
||||
tcpConn, err := net.Dial("tcp", wsHost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", host, err)
|
||||
return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", wsHost, err)
|
||||
}
|
||||
|
||||
// Realizar handshake WebSocket
|
||||
path := u.RequestURI()
|
||||
reader, err := wsHandshake(tcpConn, host, path)
|
||||
reader, err := wsHandshake(tcpConn, wsHost, path)
|
||||
if err != nil {
|
||||
tcpConn.Close()
|
||||
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpWaitLoad espera a que la página actual termine de cargar completamente.
|
||||
// Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta
|
||||
// que sea "complete", o hasta que se agote el timeout.
|
||||
// Retorna error si el timeout se agota o si CdpEvaluate falla (conexion rota).
|
||||
func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp wait load: conexion nula")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
interval := 200 * time.Millisecond
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
result, err := CdpEvaluate(c, "document.readyState")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp wait load: error evaluando readyState: %w", err)
|
||||
}
|
||||
if result == "complete" {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(interval)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: cdp_wait_load
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error"
|
||||
description: "Espera a que la pagina actual termine de cargar completamente. Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta que sea \"complete\", o hasta que se agote el timeout. Retorna error inmediato si CdpEvaluate falla (la conexion puede estar rota)."
|
||||
tags: [chrome, cdp, browser, automation, wait, polling, devtools, readystate, load]
|
||||
uses_functions: [cdp_evaluate_go_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, time]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/cdp_wait_load.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
// Esperar hasta 30 segundos a que la pagina cargue por completo
|
||||
if err := CdpWaitLoad(conn, 30*time.Second); err != nil {
|
||||
log.Fatal("Timeout esperando carga:", err)
|
||||
}
|
||||
|
||||
html, _ := CdpGetHTML(conn)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
A diferencia de `CdpWaitElement`, que ignora errores de `CdpEvaluate` durante el polling (la pagina puede aun no estar lista), `CdpWaitLoad` retorna el error inmediatamente porque un fallo en `document.readyState` indica una conexion rota, no una condicion transitoria.
|
||||
|
||||
Si `timeout <= 0` usa 30s por defecto (mas largo que `CdpWaitElement` porque la carga completa de red puede tardar mas que la aparicion de un elemento DOM).
|
||||
@@ -16,6 +16,8 @@ type ChromeLaunchOpts struct {
|
||||
UserDataDir string
|
||||
// Headless activa el modo headless (--headless=new). Por defecto false.
|
||||
Headless bool
|
||||
// ChromePath es la ruta al ejecutable de Chrome. Si esta vacio, se busca automaticamente.
|
||||
ChromePath string
|
||||
// ExtraArgs permite pasar flags adicionales a Chrome.
|
||||
ExtraArgs []string
|
||||
}
|
||||
@@ -45,9 +47,13 @@ func findChrome() (string, error) {
|
||||
}
|
||||
|
||||
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
|
||||
func waitCDPReady(port int, timeout time.Duration) error {
|
||||
// host puede estar vacio (usa "localhost").
|
||||
func waitCDPReady(host string, port int, timeout time.Duration) error {
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
addr := fmt.Sprintf("localhost:%d", port)
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
@@ -56,7 +62,7 @@ func waitCDPReady(port int, timeout time.Duration) error {
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("chrome: puerto CDP %d no disponible despues de %s", port, timeout)
|
||||
return fmt.Errorf("chrome: puerto CDP %s:%d no disponible despues de %s", host, port, timeout)
|
||||
}
|
||||
|
||||
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
|
||||
@@ -70,10 +76,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
opts.UserDataDir = "/tmp/chrome-cdp-profile"
|
||||
}
|
||||
|
||||
chromePath, err := findChrome()
|
||||
chromePath := opts.ChromePath
|
||||
if chromePath == "" {
|
||||
var err error
|
||||
chromePath, err = findChrome()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{
|
||||
fmt.Sprintf("--remote-debugging-port=%d", opts.Port),
|
||||
@@ -111,11 +121,20 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Esperar a que el puerto CDP este listo
|
||||
if err := waitCDPReady(opts.Port, 15*time.Second); err != nil {
|
||||
// Matar proceso si no arranco correctamente
|
||||
// Si Chrome escucha en 0.0.0.0 (ej: WSL2 -> Windows), el caller se encarga del wait
|
||||
skipWait := false
|
||||
for _, a := range opts.ExtraArgs {
|
||||
if a == "--remote-debugging-address=0.0.0.0" {
|
||||
skipWait = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skipWait {
|
||||
if err := waitCDPReady("localhost", opts.Port, 15*time.Second); err != nil {
|
||||
cmd.Process.Kill()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DeployApp orquesta el deploy completo de una app Go en Docker.
|
||||
// Pasos: genera Dockerfile → lo escribe → build image → run container (detach, port mapping).
|
||||
// Retorna el container ID del contenedor lanzado.
|
||||
func DeployApp(appDir string, imageName string, port int, envVars map[string]string) (string, error) {
|
||||
// 1. Generar Dockerfile (puro)
|
||||
content := GenerateDockerfile(imageName, port, envVars)
|
||||
|
||||
// 2. Escribir Dockerfile a disco
|
||||
_, err := WriteDockerfile(appDir, content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deploy_app: escribir Dockerfile: %w", err)
|
||||
}
|
||||
|
||||
// 3. Build de la imagen Docker
|
||||
tag := imageName + ":latest"
|
||||
_, err = DockerBuildImage(appDir, tag, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deploy_app: build imagen %s: %w", tag, err)
|
||||
}
|
||||
|
||||
// 4. Ejecutar el contenedor en modo detach con port mapping
|
||||
portMapping := fmt.Sprintf("%d:%d", port, port)
|
||||
opts := DockerRunOpts{
|
||||
Name: imageName,
|
||||
Ports: []string{portMapping},
|
||||
Env: envVars,
|
||||
Detach: true,
|
||||
}
|
||||
containerID, err := DockerRunContainer(tag, opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deploy_app: run contenedor %s: %w", imageName, err)
|
||||
}
|
||||
|
||||
return containerID, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: deploy_app
|
||||
kind: pipeline
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DeployApp(appDir string, imageName string, port int, envVars map[string]string) (string, error)"
|
||||
description: "Orquesta el deploy completo de una app Go en Docker. Pasos: genera Dockerfile, lo escribe a disco, construye la imagen y lanza el contenedor en modo detach con port mapping. Retorna el container ID."
|
||||
tags: [docker, deploy, go, pipeline, infra, container]
|
||||
uses_functions: [generate_dockerfile_go_infra, write_dockerfile_go_infra, docker_build_image_go_infra, docker_run_container_go_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/deploy_app.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
containerID, err := DeployApp(
|
||||
"/home/user/apps/myapp",
|
||||
"myapp",
|
||||
8080,
|
||||
map[string]string{
|
||||
"DB_HOST": "postgres",
|
||||
"PORT": "8080",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Contenedor lanzado:", containerID)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline de 4 pasos: generate_dockerfile (pura) → write_dockerfile → docker_build_image → docker_run_container. El nombre del contenedor e imagen coinciden con imageName. El port mapping es simetrico (hostPort == containerPort). Si cualquier paso falla, el pipeline retorna error con contexto del paso fallido. No hace rollback automatico — para limpiar usar stop_app.
|
||||
@@ -0,0 +1,51 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerBuildImage construye una imagen Docker desde un directorio con Dockerfile.
|
||||
// Retorna el image ID de la imagen construida.
|
||||
func DockerBuildImage(contextDir, tag string, buildArgs map[string]string) (string, error) {
|
||||
args := []string{"build", "-t", tag}
|
||||
|
||||
for k, v := range buildArgs {
|
||||
args = append(args, "--build-arg", k+"="+v)
|
||||
}
|
||||
|
||||
args = append(args, contextDir)
|
||||
|
||||
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker build %s: %s", tag, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Extraer el image ID de la ultima linea de output
|
||||
// docker build imprime "Successfully built <id>" o con BuildKit "writing image sha256:<id>"
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if strings.Contains(line, "Successfully built ") {
|
||||
parts := strings.Fields(line)
|
||||
return parts[len(parts)-1], nil
|
||||
}
|
||||
if strings.Contains(line, "sha256:") {
|
||||
// BuildKit: extraer sha256:...
|
||||
idx := strings.Index(line, "sha256:")
|
||||
id := line[idx:]
|
||||
if sp := strings.IndexAny(id, " \t,"); sp != -1 {
|
||||
id = id[:sp]
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Si no encontramos el ID en el output, inspeccionar por tag
|
||||
inspectOut, err2 := exec.Command("docker", "inspect", "--format", "{{.Id}}", tag).Output()
|
||||
if err2 != nil {
|
||||
return tag, nil
|
||||
}
|
||||
return strings.TrimSpace(string(inspectOut)), nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: docker_build_image
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DockerBuildImage(contextDir, tag string, buildArgs map[string]string) (string, error)"
|
||||
description: "Construye una imagen Docker desde un directorio con Dockerfile. Soporta build args opcionales. Retorna el image ID de la imagen construida."
|
||||
tags: [docker, image, build, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os/exec, strings]
|
||||
tested: true
|
||||
tests: ["build sin build args retorna image ID", "build con build args incluye --build-arg", "error si contextDir no existe"]
|
||||
test_file_path: "functions/infra/docker_build_image_test.go"
|
||||
file_path: "functions/infra/docker_build_image.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
imageID, err := DockerBuildImage("./myapp", "myapp:latest", map[string]string{
|
||||
"VERSION": "1.2.3",
|
||||
"ENV": "production",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Built:", imageID)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ejecuta `docker build -t tag contextDir --build-arg key=val ...`. Parsea el image ID del output de docker build, compatible con el builder clasico (mensajes "Successfully built") y BuildKit (sha256). Si no puede parsear el ID del output, hace un `docker inspect` por tag como fallback.
|
||||
@@ -0,0 +1,58 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerBuildImage(t *testing.T) {
|
||||
t.Run("build sin build args retorna image ID", func(t *testing.T) {
|
||||
// Crear un Dockerfile temporal minimo
|
||||
dir := t.TempDir()
|
||||
dockerfile := filepath.Join(dir, "Dockerfile")
|
||||
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0644); err != nil {
|
||||
t.Skip("no se pudo crear Dockerfile temporal:", err)
|
||||
}
|
||||
|
||||
id, err := DockerBuildImage(dir, "test-fn-registry-scratch:latest", nil)
|
||||
if err != nil {
|
||||
// Docker puede no estar disponible en CI — skip en vez de fail
|
||||
t.Skipf("docker build no disponible: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Error("se esperaba un image ID no vacio")
|
||||
}
|
||||
|
||||
// Limpiar
|
||||
_ = DockerRemoveImage("test-fn-registry-scratch:latest", true)
|
||||
})
|
||||
|
||||
t.Run("build con build args incluye --build-arg", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dockerfile := filepath.Join(dir, "Dockerfile")
|
||||
content := "FROM scratch\nARG MY_VERSION\n"
|
||||
if err := os.WriteFile(dockerfile, []byte(content), 0644); err != nil {
|
||||
t.Skip("no se pudo crear Dockerfile:", err)
|
||||
}
|
||||
|
||||
id, err := DockerBuildImage(dir, "test-fn-registry-args:latest", map[string]string{
|
||||
"MY_VERSION": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("docker build no disponible: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Error("se esperaba un image ID no vacio")
|
||||
}
|
||||
|
||||
_ = DockerRemoveImage("test-fn-registry-args:latest", true)
|
||||
})
|
||||
|
||||
t.Run("error si contextDir no existe", func(t *testing.T) {
|
||||
_, err := DockerBuildImage("/ruta/que/no/existe/nunca", "test:latest", nil)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error para directorio inexistente")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerComposeDown baja un stack docker-compose desde el archivo dado.
|
||||
// Si removeVolumes es true elimina también los volumes (-v). Retorna el stdout del comando.
|
||||
func DockerComposeDown(composeFile string, removeVolumes bool) (string, error) {
|
||||
args := []string{"compose", "-f", composeFile, "down"}
|
||||
if removeVolumes {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
|
||||
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker compose down %s: %s", composeFile, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: docker_compose_down
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DockerComposeDown(composeFile string, removeVolumes bool) (string, error)"
|
||||
description: "Baja un stack docker-compose desde el archivo dado. Si removeVolumes es true elimina también los volumes declarados (-v). Retorna el stdout del comando."
|
||||
tags: [docker, compose, down, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os/exec, strings]
|
||||
tested: true
|
||||
tests: ["removeVolumes true agrega flag -v al comando", "error si composeFile no existe"]
|
||||
test_file_path: "functions/infra/docker_compose_down_test.go"
|
||||
file_path: "functions/infra/docker_compose_down.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Bajar stack y limpiar volumes
|
||||
out, err := DockerComposeDown("./docker-compose.yml", true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ejecuta `docker compose -f composeFile down [-v]`. Usa el subcomando `docker compose` (V2). El flag -v elimina volumes nombrados declarados en el compose file, no volumes externos. Retorna stdout + stderr combinados.
|
||||
@@ -0,0 +1,33 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerComposeDown(t *testing.T) {
|
||||
t.Run("removeVolumes true agrega flag -v al comando", func(t *testing.T) {
|
||||
// Verificar que docker compose esta disponible
|
||||
if err := exec.Command("docker", "compose", "version").Run(); err != nil {
|
||||
t.Skip("docker compose no disponible")
|
||||
}
|
||||
|
||||
// Con archivo inexistente debe fallar pero con el mensaje correcto
|
||||
_, err := DockerComposeDown("/ruta/inexistente/docker-compose.yml", true)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error para archivo inexistente")
|
||||
}
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
|
||||
t.Logf("error obtenido: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si composeFile no existe", func(t *testing.T) {
|
||||
_, err := DockerComposeDown("/ruta/que/no/existe/docker-compose.yml", false)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error para archivo inexistente")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerComposeUp levanta un stack docker-compose desde el archivo dado.
|
||||
// Si detach es true ejecuta en background (-d). Retorna el stdout del comando.
|
||||
func DockerComposeUp(composeFile string, detach bool) (string, error) {
|
||||
args := []string{"compose", "-f", composeFile, "up"}
|
||||
if detach {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
|
||||
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker compose up %s: %s", composeFile, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: docker_compose_up
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DockerComposeUp(composeFile string, detach bool) (string, error)"
|
||||
description: "Levanta un stack docker-compose desde el archivo dado. Si detach es true ejecuta en background (-d). Retorna el stdout del comando."
|
||||
tags: [docker, compose, up, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os/exec, strings]
|
||||
tested: true
|
||||
tests: ["detach true agrega flag -d al comando", "error si composeFile no existe"]
|
||||
test_file_path: "functions/infra/docker_compose_up_test.go"
|
||||
file_path: "functions/infra/docker_compose_up.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
out, err := DockerComposeUp("./docker-compose.yml", true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ejecuta `docker compose -f composeFile up [-d]`. Usa el subcomando `docker compose` (V2), no el binario standalone `docker-compose`. Retorna stdout + stderr combinados para facilitar el debugging. En modo no-detach bloquea hasta que el compose termine (util para stacks efimeros).
|
||||
@@ -0,0 +1,34 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerComposeUp(t *testing.T) {
|
||||
t.Run("detach true agrega flag -d al comando", func(t *testing.T) {
|
||||
// Verificar que docker compose esta disponible
|
||||
if err := exec.Command("docker", "compose", "version").Run(); err != nil {
|
||||
t.Skip("docker compose no disponible")
|
||||
}
|
||||
|
||||
// Con archivo inexistente y detach, debe fallar pero con el mensaje correcto
|
||||
_, err := DockerComposeUp("/ruta/inexistente/docker-compose.yml", true)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error para archivo inexistente")
|
||||
}
|
||||
// El error debe mencionar el archivo o compose
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
|
||||
t.Logf("error obtenido: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si composeFile no existe", func(t *testing.T) {
|
||||
_, err := DockerComposeUp("/ruta/que/no/existe/docker-compose.yml", false)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error para archivo inexistente")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerVolumeCreate crea un volume Docker con el nombre dado.
|
||||
// Retorna el nombre del volume creado.
|
||||
func DockerVolumeCreate(name string) (string, error) {
|
||||
out, err := exec.Command("docker", "volume", "create", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker volume create %s: %s", name, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: docker_volume_create
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DockerVolumeCreate(name string) (string, error)"
|
||||
description: "Crea un volume Docker con el nombre dado. Retorna el nombre del volume creado tal como lo confirma Docker."
|
||||
tags: [docker, volume, create, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os/exec, strings]
|
||||
tested: true
|
||||
tests: ["crea volume y retorna nombre", "idempotente si el volume ya existe"]
|
||||
test_file_path: "functions/infra/docker_volume_create_test.go"
|
||||
file_path: "functions/infra/docker_volume_create.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
volumeName, err := DockerVolumeCreate("postgres_data")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Created volume:", volumeName)
|
||||
// Created volume: postgres_data
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ejecuta `docker volume create name`. Docker imprime el nombre del volume creado en stdout. Idempotente si el volume ya existe con el mismo nombre.
|
||||
@@ -0,0 +1,42 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerVolumeCreate(t *testing.T) {
|
||||
t.Run("crea volume y retorna nombre", func(t *testing.T) {
|
||||
name := "fn-registry-test-vol-create"
|
||||
|
||||
got, err := DockerVolumeCreate(name)
|
||||
if err != nil {
|
||||
t.Skipf("docker no disponible: %v", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(got) != name {
|
||||
t.Errorf("got %q, want %q", got, name)
|
||||
}
|
||||
|
||||
// Limpiar
|
||||
_ = DockerVolumeRemove(name, false)
|
||||
})
|
||||
|
||||
t.Run("idempotente si el volume ya existe", func(t *testing.T) {
|
||||
name := "fn-registry-test-vol-idempotent"
|
||||
|
||||
first, err := DockerVolumeCreate(name)
|
||||
if err != nil {
|
||||
t.Skipf("docker no disponible: %v", err)
|
||||
}
|
||||
defer DockerVolumeRemove(name, false) //nolint
|
||||
|
||||
second, err := DockerVolumeCreate(name)
|
||||
if err != nil {
|
||||
t.Errorf("segunda llamada fallo: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Errorf("nombres distintos: %q vs %q", first, second)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerVolumeList lista los volumes Docker disponibles localmente.
|
||||
// Retorna un slice de maps con campos Driver, Name, Scope, Labels, Mountpoint.
|
||||
func DockerVolumeList() ([]map[string]string, error) {
|
||||
out, err := exec.Command("docker", "volume", "ls", "--format", "{{json .}}").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker volume ls: %w", err)
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(string(out))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var volumes []map[string]string
|
||||
for _, line := range splitLines(out) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var raw map[string]string
|
||||
if err := json.Unmarshal(line, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
volumes = append(volumes, raw)
|
||||
}
|
||||
|
||||
return volumes, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: docker_volume_list
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DockerVolumeList() ([]map[string]string, error)"
|
||||
description: "Lista los volumes Docker disponibles localmente. Parsea la salida JSON de docker volume ls. Retorna slice de maps con campos Driver, Name, Scope, Labels, Mountpoint."
|
||||
tags: [docker, volume, list, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, fmt, os/exec, strings]
|
||||
tested: true
|
||||
tests: ["lista vacia retorna nil sin error", "parsea campos Driver y Name correctamente"]
|
||||
test_file_path: "functions/infra/docker_volume_list_test.go"
|
||||
file_path: "functions/infra/docker_volume_list.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
volumes, err := DockerVolumeList()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, v := range volumes {
|
||||
fmt.Printf("Volume: %s (driver: %s)\n", v["Name"], v["Driver"])
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ejecuta `docker volume ls --format {{json .}}` (un JSON por linea). Usa splitLines del paquete infra para iterar lineas. Retorna nil si no hay volumes. Los campos del map dependen de la version de Docker pero siempre incluyen Driver y Name.
|
||||
@@ -0,0 +1,45 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerVolumeList(t *testing.T) {
|
||||
t.Run("lista vacia retorna nil sin error", func(t *testing.T) {
|
||||
// Verificar que la funcion no falla incluso si no hay volumes
|
||||
volumes, err := DockerVolumeList()
|
||||
if err != nil {
|
||||
t.Skipf("docker no disponible: %v", err)
|
||||
}
|
||||
// volumes puede ser nil o no segun el sistema — no es un error
|
||||
_ = volumes
|
||||
})
|
||||
|
||||
t.Run("parsea campos Driver y Name correctamente", func(t *testing.T) {
|
||||
name := "fn-registry-test-vol-list"
|
||||
_, err := DockerVolumeCreate(name)
|
||||
if err != nil {
|
||||
t.Skipf("docker no disponible: %v", err)
|
||||
}
|
||||
defer DockerVolumeRemove(name, false) //nolint
|
||||
|
||||
volumes, err := DockerVolumeList()
|
||||
if err != nil {
|
||||
t.Fatalf("DockerVolumeList: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, v := range volumes {
|
||||
if v["Name"] == name {
|
||||
found = true
|
||||
if v["Driver"] == "" {
|
||||
t.Error("Driver vacio para volume existente")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("volume %q no encontrado en la lista", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerVolumeRemove elimina un volume Docker por nombre.
|
||||
// Si force es true, fuerza la eliminación incluso si está en uso.
|
||||
func DockerVolumeRemove(name string, force bool) error {
|
||||
args := []string{"volume", "rm"}
|
||||
if force {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
args = append(args, name)
|
||||
|
||||
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker volume rm %s: %s", name, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: docker_volume_remove
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func DockerVolumeRemove(name string, force bool) error"
|
||||
description: "Elimina un volume Docker por nombre. Si force es true fuerza la eliminación aunque esté en uso."
|
||||
tags: [docker, volume, remove, delete, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os/exec, strings]
|
||||
tested: true
|
||||
tests: ["error si volume no existe", "force flag incluye -f en el comando"]
|
||||
test_file_path: "functions/infra/docker_volume_remove_test.go"
|
||||
file_path: "functions/infra/docker_volume_remove.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Eliminar volume con fuerza
|
||||
err := DockerVolumeRemove("postgres_data", true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Volume eliminado")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ejecuta `docker volume rm [-f] name`. El flag -f solo esta disponible en versiones recientes de Docker. Sin force, falla si el volume esta siendo usado por un contenedor activo.
|
||||
@@ -0,0 +1,27 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerVolumeRemove(t *testing.T) {
|
||||
t.Run("error si volume no existe", func(t *testing.T) {
|
||||
err := DockerVolumeRemove("fn-registry-vol-que-no-existe-xyz", false)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error para volume inexistente")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force flag incluye -f en el comando", func(t *testing.T) {
|
||||
name := "fn-registry-test-vol-remove"
|
||||
_, err := DockerVolumeCreate(name)
|
||||
if err != nil {
|
||||
t.Skipf("docker no disponible: %v", err)
|
||||
}
|
||||
|
||||
err = DockerVolumeRemove(name, true)
|
||||
if err != nil {
|
||||
t.Errorf("DockerVolumeRemove con force: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateDockerfile genera el texto de un Dockerfile multi-stage para una app Go.
|
||||
// Stage build: golang:1.23-alpine — descarga dependencias y compila.
|
||||
// Stage final: alpine:latest — copia el binario, expone el puerto y lo ejecuta.
|
||||
// Las envVars se inyectan como instrucciones ENV en el stage final.
|
||||
// Funcion pura: no realiza I/O, solo genera texto.
|
||||
func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Stage build
|
||||
sb.WriteString("# Stage build\n")
|
||||
sb.WriteString("FROM golang:1.23-alpine AS builder\n\n")
|
||||
sb.WriteString("WORKDIR /app\n\n")
|
||||
sb.WriteString("COPY go.mod go.sum ./\n")
|
||||
sb.WriteString("RUN go mod download\n\n")
|
||||
sb.WriteString("COPY . .\n")
|
||||
sb.WriteString(fmt.Sprintf("RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags=\"-s -w\" -o %s .\n\n", binaryName))
|
||||
|
||||
// Stage final
|
||||
sb.WriteString("# Stage final\n")
|
||||
sb.WriteString("FROM alpine:latest\n\n")
|
||||
sb.WriteString("RUN apk --no-cache add ca-certificates tzdata\n\n")
|
||||
sb.WriteString("WORKDIR /app\n\n")
|
||||
sb.WriteString(fmt.Sprintf("COPY --from=builder /app/%s .\n\n", binaryName))
|
||||
|
||||
// ENV vars (orden determinista)
|
||||
if len(envVars) > 0 {
|
||||
keys := make([]string, 0, len(envVars))
|
||||
for k := range envVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
sb.WriteString(fmt.Sprintf("ENV %s=%s\n", k, envVars[k]))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if port > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EXPOSE %d\n\n", port))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("ENTRYPOINT [\"./%s\"]\n", binaryName))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: generate_dockerfile
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string"
|
||||
description: "Genera el texto de un Dockerfile multi-stage para una app Go. Stage build con golang:1.23-alpine, stage final con alpine:latest. Incluye ENV vars del map con orden determinista. Funcion pura sin I/O."
|
||||
tags: [docker, dockerfile, go, build, deploy, infra, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt, sort, strings]
|
||||
tested: true
|
||||
tests: ["contiene stage builder con golang:1.23-alpine", "contiene stage final con alpine:latest", "incluye EXPOSE cuando port mayor a cero", "no incluye EXPOSE cuando port es cero", "env vars aparecen ordenadas alfabeticamente", "binaryName aparece en ENTRYPOINT", "env vars vacias no generan instrucciones ENV"]
|
||||
test_file_path: "functions/infra/generate_dockerfile_test.go"
|
||||
file_path: "functions/infra/generate_dockerfile.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
content := GenerateDockerfile("myapp", 8080, map[string]string{
|
||||
"DB_HOST": "localhost",
|
||||
"PORT": "8080",
|
||||
})
|
||||
fmt.Println(content)
|
||||
// FROM golang:1.23-alpine AS builder
|
||||
// ...
|
||||
// EXPOSE 8080
|
||||
// ENTRYPOINT ["./myapp"]
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — no toca el sistema de archivos. Componer con WriteDockerfile para persistir el resultado. Las ENV vars se ordenan alfabeticamente para garantizar Dockerfiles deterministas (mismo input => mismo output exacto). El stage build usa CGO_ENABLED=0 para binarios estáticos compatibles con alpine. Si port <= 0, omite la instruccion EXPOSE.
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateDockerfile(t *testing.T) {
|
||||
t.Run("contiene stage builder con golang:1.23-alpine", func(t *testing.T) {
|
||||
got := GenerateDockerfile("myapp", 8080, nil)
|
||||
if !strings.Contains(got, "FROM golang:1.23-alpine AS builder") {
|
||||
t.Errorf("expected FROM golang:1.23-alpine AS builder, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contiene stage final con alpine:latest", func(t *testing.T) {
|
||||
got := GenerateDockerfile("myapp", 8080, nil)
|
||||
if !strings.Contains(got, "FROM alpine:latest") {
|
||||
t.Errorf("expected FROM alpine:latest, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incluye EXPOSE cuando port mayor a cero", func(t *testing.T) {
|
||||
got := GenerateDockerfile("myapp", 8080, nil)
|
||||
if !strings.Contains(got, "EXPOSE 8080") {
|
||||
t.Errorf("expected EXPOSE 8080, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no incluye EXPOSE cuando port es cero", func(t *testing.T) {
|
||||
got := GenerateDockerfile("myapp", 0, nil)
|
||||
if strings.Contains(got, "EXPOSE") {
|
||||
t.Errorf("expected no EXPOSE directive, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env vars aparecen ordenadas alfabeticamente", func(t *testing.T) {
|
||||
got := GenerateDockerfile("myapp", 8080, map[string]string{
|
||||
"Z_VAR": "z",
|
||||
"A_VAR": "a",
|
||||
"M_VAR": "m",
|
||||
})
|
||||
posA := strings.Index(got, "ENV A_VAR=a")
|
||||
posM := strings.Index(got, "ENV M_VAR=m")
|
||||
posZ := strings.Index(got, "ENV Z_VAR=z")
|
||||
if posA < 0 || posM < 0 || posZ < 0 {
|
||||
t.Fatalf("ENV vars no encontradas en:\n%s", got)
|
||||
}
|
||||
if !(posA < posM && posM < posZ) {
|
||||
t.Errorf("ENV vars no ordenadas: A=%d M=%d Z=%d", posA, posM, posZ)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("binaryName aparece en ENTRYPOINT", func(t *testing.T) {
|
||||
got := GenerateDockerfile("mycli", 9090, nil)
|
||||
if !strings.Contains(got, `ENTRYPOINT ["./mycli"]`) {
|
||||
t.Errorf("expected ENTRYPOINT with mycli, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env vars vacias no generan instrucciones ENV", func(t *testing.T) {
|
||||
got := GenerateDockerfile("myapp", 8080, map[string]string{})
|
||||
if strings.Contains(got, "ENV ") {
|
||||
t.Errorf("expected no ENV directives for empty map, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GoBuildBinary compila un binario Go desde projectDir hacia outputPath.
|
||||
// Si ldflags está vacío usa "-s -w" (strip debug info). Si outputPath está vacío
|
||||
// usa "build/{dirname}" dentro del projectDir. Requiere Go instalado en PATH.
|
||||
func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error {
|
||||
if ldflags == "" {
|
||||
ldflags = "-s -w"
|
||||
}
|
||||
if outputPath == "" {
|
||||
outputPath = filepath.Join(projectDir, "build", filepath.Base(projectDir))
|
||||
}
|
||||
|
||||
// Crear directorio destino si no existe
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return fmt.Errorf("go_build_binary: crear directorio destino %s: %w", filepath.Dir(outputPath), err)
|
||||
}
|
||||
|
||||
args := []string{"build", "-trimpath", "-ldflags=" + ldflags}
|
||||
if tags != "" {
|
||||
args = append(args, "-tags="+tags)
|
||||
}
|
||||
args = append(args, "-o", outputPath, ".")
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = projectDir
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("go build en %s: %s", projectDir, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: go_build_binary
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error"
|
||||
description: "Compila un binario Go desde un directorio de proyecto. Si ldflags está vacío usa -s -w (strip debug). Si outputPath está vacío usa build/{dirname} dentro del projectDir. Ejecuta con CGO_ENABLED=0."
|
||||
tags: [go, build, binary, compile, infra, deploy]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os, os/exec, path/filepath, strings]
|
||||
tested: true
|
||||
tests: ["compila proyecto valido sin error", "outputPath vacio usa build/dirname por defecto", "ldflags vacio usa -s -w por defecto", "error si projectDir no existe"]
|
||||
test_file_path: "functions/infra/go_build_binary_test.go"
|
||||
file_path: "functions/infra/go_build_binary.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Compilar con opciones por defecto
|
||||
err := GoBuildBinary("/home/user/apps/myapp", "", "", "")
|
||||
// genera /home/user/apps/myapp/build/myapp
|
||||
|
||||
// Compilar con output y tags explícitos
|
||||
err = GoBuildBinary("/home/user/apps/myapp", "/tmp/myapp-bin", "-s -w -X main.version=1.0", "fts5")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `CGO_ENABLED=0` para binarios estáticos compatibles con imágenes alpine. El flag `-trimpath` elimina rutas absolutas del binario para reproducibilidad. Los flags `-s -w` reducen el tamaño eliminando información de debug y la tabla de símbolos. Compatible con el flujo deploy_app que genera Dockerfile multi-stage.
|
||||
@@ -0,0 +1,87 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoBuildBinary(t *testing.T) {
|
||||
t.Run("compila proyecto valido sin error", func(t *testing.T) {
|
||||
// Crear un proyecto Go mínimo en un directorio temporal
|
||||
tmpDir := t.TempDir()
|
||||
goModContent := "module testapp\n\ngo 1.21\n"
|
||||
mainContent := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello")
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(tmpDir, "bin", "testapp")
|
||||
err := GoBuildBinary(tmpDir, outputPath, "", "")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verificar que el binario existe
|
||||
if _, statErr := os.Stat(outputPath); os.IsNotExist(statErr) {
|
||||
t.Errorf("binary not found at %s", outputPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outputPath vacio usa build/dirname por defecto", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
goModContent := "module myproject\n\ngo 1.21\n"
|
||||
mainContent := "package main\n\nfunc main() {}\n"
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := GoBuildBinary(tmpDir, "", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(tmpDir, "build", filepath.Base(tmpDir))
|
||||
if _, statErr := os.Stat(expectedPath); os.IsNotExist(statErr) {
|
||||
t.Errorf("expected binary at %s, not found", expectedPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ldflags vacio usa -s -w por defecto", func(t *testing.T) {
|
||||
// Este test verifica que la función no falla con ldflags vacío
|
||||
// (los flags por defecto -s -w son válidos para go build)
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module x\n\ngo 1.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(tmpDir, "out")
|
||||
err := GoBuildBinary(tmpDir, outputPath, "", "")
|
||||
if err != nil {
|
||||
t.Errorf("ldflags vacío debería usar -s -w y compilar sin error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si projectDir no existe", func(t *testing.T) {
|
||||
err := GoBuildBinary("/nonexistent/path/to/project", "/tmp/out", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent projectDir, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthCheckHTTP hace polling HTTP GET a url hasta recibir status 200
|
||||
// o hasta que pasen timeoutSecs segundos. Entre intentos espera intervalMs milisegundos.
|
||||
func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error {
|
||||
deadline := time.Now().Add(time.Duration(timeoutSecs) * time.Second)
|
||||
interval := time.Duration(intervalMs) * time.Millisecond
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(intervalMs)*time.Millisecond + 500*time.Millisecond,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("status %d", resp.StatusCode)
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(interval)
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("health check %s timeout after %ds: %w", url, timeoutSecs, lastErr)
|
||||
}
|
||||
return fmt.Errorf("health check %s timeout after %ds", url, timeoutSecs)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: health_check_http
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error"
|
||||
description: "Hace polling HTTP GET a un endpoint hasta recibir status 200 o hasta agotar el timeout. Útil para esperar que un servicio levante antes de continuar un pipeline."
|
||||
tags: [http, health, check, polling, wait, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, net/http, time]
|
||||
tested: true
|
||||
tests: ["retorna nil cuando el servidor responde 200", "retorna error si el timeout se agota", "respeta el intervalo entre intentos"]
|
||||
test_file_path: "functions/infra/health_check_http_test.go"
|
||||
file_path: "functions/infra/health_check_http.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Esperar hasta 60s a que Metabase levante, polling cada 2s
|
||||
err := HealthCheckHTTP("http://localhost:3000/api/health", 60, 2000)
|
||||
if err != nil {
|
||||
log.Fatal("Servicio no disponible:", err)
|
||||
}
|
||||
fmt.Println("Servicio listo")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa solo net/http de la stdlib, sin dependencias externas. El cliente HTTP tiene timeout de intervalMs + 500ms para no bloquear el loop. Retorna el ultimo error si el timeout expira. No sigue redirects especiales — cualquier respuesta 200 OK es exito.
|
||||
@@ -0,0 +1,63 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthCheckHTTP(t *testing.T) {
|
||||
t.Run("retorna nil cuando el servidor responde 200", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := HealthCheckHTTP(srv.URL, 5, 100)
|
||||
if err != nil {
|
||||
t.Errorf("se esperaba nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna error si el timeout se agota", func(t *testing.T) {
|
||||
// Servidor que nunca responde 200
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := HealthCheckHTTP(srv.URL, 1, 200)
|
||||
if err == nil {
|
||||
t.Error("se esperaba error por timeout")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respeta el intervalo entre intentos", func(t *testing.T) {
|
||||
attempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts >= 3 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
start := time.Now()
|
||||
err := HealthCheckHTTP(srv.URL, 5, 150)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("se esperaba nil despues de 3 intentos, got: %v", err)
|
||||
}
|
||||
// Con 2 intervalos de 150ms debe haber pasado al menos 250ms
|
||||
if elapsed < 250*time.Millisecond {
|
||||
t.Errorf("elapsed %v demasiado rapido, se esperaban al menos 250ms", elapsed)
|
||||
}
|
||||
if attempts < 3 {
|
||||
t.Errorf("se esperaban al menos 3 intentos, got %d", attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StopApp para y elimina el contenedor de una app desplegada.
|
||||
// Si removeImage es true, elimina también la imagen Docker asociada.
|
||||
// containerName debe coincidir con el nombre usado en DeployApp (= imageName).
|
||||
func StopApp(containerName string, removeImage bool) error {
|
||||
// 1. Detener el contenedor (timeout 10s)
|
||||
if err := DockerStopContainer(containerName, 10); err != nil {
|
||||
return fmt.Errorf("stop_app: detener contenedor %s: %w", containerName, err)
|
||||
}
|
||||
|
||||
// 2. Eliminar el contenedor
|
||||
if err := DockerRemoveContainer(containerName, false); err != nil {
|
||||
return fmt.Errorf("stop_app: eliminar contenedor %s: %w", containerName, err)
|
||||
}
|
||||
|
||||
// 3. Eliminar la imagen si se solicita
|
||||
if removeImage {
|
||||
imageName := containerName + ":latest"
|
||||
if err := DockerRemoveImage(imageName, false); err != nil {
|
||||
return fmt.Errorf("stop_app: eliminar imagen %s: %w", imageName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: stop_app
|
||||
kind: pipeline
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func StopApp(containerName string, removeImage bool) error"
|
||||
description: "Para y elimina el contenedor de una app desplegada. Si removeImage es true elimina también la imagen Docker. containerName debe coincidir con el imageName usado en deploy_app."
|
||||
tags: [docker, stop, remove, deploy, pipeline, infra, container]
|
||||
uses_functions: [docker_stop_container_go_infra, docker_remove_container_go_infra, docker_remove_image_go_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/stop_app.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Parar contenedor sin eliminar la imagen (para relanzar rapido)
|
||||
err := StopApp("myapp", false)
|
||||
|
||||
// Parar y limpiar todo
|
||||
err = StopApp("myapp", true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Inverso de deploy_app. El contenedor se detiene con 10 segundos de gracia antes de SIGKILL. La imagen se busca como containerName:latest (convencion de deploy_app). Si solo se quiere parar sin limpiar, usar removeImage=false para conservar la imagen en cache local y acelerar el siguiente deploy.
|
||||
@@ -0,0 +1,27 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WriteDockerfile escribe content en dir/Dockerfile.
|
||||
// Crea el directorio si no existe. Retorna el path absoluto del archivo escrito.
|
||||
func WriteDockerfile(dir, content string) (string, error) {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write_dockerfile: resolver path %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("write_dockerfile: crear directorio %s: %w", absDir, err)
|
||||
}
|
||||
|
||||
dockerfilePath := filepath.Join(absDir, "Dockerfile")
|
||||
if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write_dockerfile: escribir %s: %w", dockerfilePath, err)
|
||||
}
|
||||
|
||||
return dockerfilePath, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: write_dockerfile
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func WriteDockerfile(dir, content string) (string, error)"
|
||||
description: "Escribe content en dir/Dockerfile. Crea el directorio si no existe. Retorna el path absoluto del archivo escrito. Compañera impura de generate_dockerfile."
|
||||
tags: [docker, dockerfile, io, write, deploy, infra]
|
||||
uses_functions: [generate_dockerfile_go_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os, path/filepath]
|
||||
tested: true
|
||||
tests: ["escribe Dockerfile en directorio existente", "crea directorio si no existe", "retorna path absoluto correcto", "error si dir es path invalido"]
|
||||
test_file_path: "functions/infra/write_dockerfile_test.go"
|
||||
file_path: "functions/infra/write_dockerfile.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Patron puro+impuro: generar contenido y luego escribir
|
||||
content := GenerateDockerfile("myapp", 8080, map[string]string{"PORT": "8080"})
|
||||
path, err := WriteDockerfile("/home/user/apps/myapp", content)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Dockerfile escrito en:", path)
|
||||
// /home/user/apps/myapp/Dockerfile
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Patron puro+impuro: generate_dockerfile produce el texto (pura, testeable sin I/O), write_dockerfile lo persiste (impura, efecto secundario aislado). Esto facilita testear la generacion del contenido independientemente de la escritura. Sobreescribe cualquier Dockerfile existente en el directorio.
|
||||
@@ -0,0 +1,78 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteDockerfile(t *testing.T) {
|
||||
t.Run("escribe Dockerfile en directorio existente", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
content := "FROM alpine:latest\nCMD [\"sh\"]\n"
|
||||
|
||||
path, err := WriteDockerfile(tmpDir, content)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot read written file: %v", err)
|
||||
}
|
||||
if string(data) != content {
|
||||
t.Errorf("content mismatch: got %q, want %q", string(data), content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("crea directorio si no existe", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
newDir := filepath.Join(tmpDir, "subdir", "nested")
|
||||
content := "FROM scratch\n"
|
||||
|
||||
path, err := WriteDockerfile(newDir, content)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(newDir); os.IsNotExist(statErr) {
|
||||
t.Error("expected directory to be created")
|
||||
}
|
||||
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
|
||||
t.Errorf("expected Dockerfile at %s", path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna path absoluto correcto", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
content := "FROM ubuntu:22.04\n"
|
||||
|
||||
path, err := WriteDockerfile(tmpDir, content)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Errorf("expected absolute path, got: %s", path)
|
||||
}
|
||||
if !strings.HasSuffix(path, "Dockerfile") {
|
||||
t.Errorf("expected path to end with Dockerfile, got: %s", path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si dir es path invalido", func(t *testing.T) {
|
||||
// Intentar escribir en un path donde el padre es un archivo (no directorio)
|
||||
tmpDir := t.TempDir()
|
||||
// Crear un archivo donde esperamos un directorio
|
||||
blockerPath := filepath.Join(tmpDir, "blocker")
|
||||
if err := os.WriteFile(blockerPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Intentar usar ese archivo como directorio
|
||||
_, err := WriteDockerfile(filepath.Join(blockerPath, "subdir"), "FROM scratch\n")
|
||||
if err == nil {
|
||||
t.Error("expected error when dir path goes through a file, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: win_firewall_add_rule
|
||||
kind: function
|
||||
lang: ps
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "win_firewall_add_rule -Name <string> -Port <int> [-Protocol <string>]"
|
||||
description: "Añade una regla de entrada al firewall de Windows para permitir tráfico en un puerto TCP/UDP. Si ya existe una regla con el mismo nombre, la elimina y la recrea. Requiere privilegios de Administrador."
|
||||
tags: [firewall, windows, netsh, network, infra, security, port, wsl2]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "powershell/functions/infra/win_firewall_add_rule.ps1"
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```powershell
|
||||
# Desde PowerShell (como Administrador)
|
||||
.\win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
|
||||
|
||||
# Con protocolo explícito
|
||||
.\win_firewall_add_rule.ps1 -Name "MyApp-UDP-5000" -Port 5000 -Protocol UDP
|
||||
```
|
||||
|
||||
```bash
|
||||
# Desde WSL2
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
|
||||
```
|
||||
|
||||
## Parametros
|
||||
|
||||
| Parametro | Tipo | Obligatorio | Default | Descripcion |
|
||||
|------------|--------|-------------|---------|--------------------------------------|
|
||||
| `-Name` | string | si | — | Nombre de la regla en el firewall |
|
||||
| `-Port` | int | si | — | Puerto local (1-65535) |
|
||||
| `-Protocol`| string | no | TCP | Protocolo: TCP o UDP |
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere ejecutarse como **Administrador** en Windows. Si se llama desde WSL2, usar `powershell.exe` (que corre en Windows) o `pwsh.exe`.
|
||||
- Si ya existe una regla con el mismo nombre, la elimina primero y la recrea (idempotente respecto al nombre).
|
||||
- La regla creada es `dir=in action=allow` — solo aplica para tráfico entrante.
|
||||
- Caso de uso principal: permitir que WSL2 alcance Chrome CDP en el host Windows cuando Chrome escucha en `127.0.0.1:9222`.
|
||||
- Retorna exit code 0 si tuvo éxito, 1 si hubo error (falta de privilegios, puerto inválido, fallo de netsh).
|
||||
@@ -0,0 +1,56 @@
|
||||
# win_firewall_add_rule.ps1 - Adds a Windows Firewall inbound rule for a TCP/UDP port.
|
||||
# Requires: Administrator privileges
|
||||
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$Port,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Protocol = "TCP"
|
||||
)
|
||||
|
||||
# Verify administrator privileges
|
||||
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate protocol
|
||||
$validProtocols = @("TCP", "UDP")
|
||||
if ($validProtocols -notcontains $Protocol.ToUpper()) {
|
||||
Write-Error "ERROR: Protocol must be TCP or UDP, got '$Protocol'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate port range
|
||||
if ($Port -lt 1 -or $Port -gt 65535) {
|
||||
Write-Error "ERROR: Port must be between 1 and 65535, got '$Port'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Remove existing rule with the same name if it exists
|
||||
$existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Removing existing rule '$Name'..."
|
||||
netsh advfirewall firewall delete rule name="$Name" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "ERROR: Failed to remove existing rule '$Name'."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Add the new inbound rule
|
||||
Write-Host "Adding firewall rule '$Name' for $Protocol port $Port..."
|
||||
netsh advfirewall firewall add rule name="$Name" dir=in action=allow protocol=$Protocol localport=$Port
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "ERROR: Failed to add firewall rule '$Name'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "OK: Firewall rule '$Name' added - $Protocol inbound on port $Port."
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: win_firewall_remove_rule
|
||||
kind: function
|
||||
lang: ps
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "win_firewall_remove_rule -Name <string>"
|
||||
description: "Elimina una regla del firewall de Windows por nombre. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador."
|
||||
tags: [firewall, windows, netsh, network, infra, security, cleanup, wsl2]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "powershell/functions/infra/win_firewall_remove_rule.ps1"
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```powershell
|
||||
# Desde PowerShell (como Administrador)
|
||||
.\win_firewall_remove_rule.ps1 -Name "CDP-9222"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Desde WSL2
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
|
||||
```
|
||||
|
||||
## Parametros
|
||||
|
||||
| Parametro | Tipo | Obligatorio | Descripcion |
|
||||
|-----------|--------|-------------|-----------------------------------|
|
||||
| `-Name` | string | si | Nombre exacto de la regla a eliminar |
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere ejecutarse como **Administrador** en Windows.
|
||||
- Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
|
||||
- Complementa `win_firewall_add_rule` para teardown limpio de reglas temporales.
|
||||
- Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.
|
||||
@@ -0,0 +1,33 @@
|
||||
# win_firewall_remove_rule.ps1 - Removes a Windows Firewall rule by name.
|
||||
# Requires: Administrator privileges
|
||||
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
# Verify administrator privileges
|
||||
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if the rule exists
|
||||
$existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "INFO: Firewall rule '$Name' does not exist - nothing to remove."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Remove the rule
|
||||
Write-Host "Removing firewall rule '$Name'..."
|
||||
netsh advfirewall firewall delete rule name="$Name"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "ERROR: Failed to remove firewall rule '$Name'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "OK: Firewall rule '$Name' removed."
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: win_portproxy_add
|
||||
kind: function
|
||||
lang: ps
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "win_portproxy_add -ListenPort <int> [-ConnectPort <int>] [-ListenAddr <string>] [-ConnectAddr <string>]"
|
||||
description: "Añade una regla de port proxy v4tov4 con netsh para redirigir tráfico desde ListenAddr:ListenPort hacia ConnectAddr:ConnectPort. Si ya existe una regla para el mismo listenaddress:listenport, la elimina y la recrea. Requiere privilegios de Administrador."
|
||||
tags: [portproxy, netsh, windows, network, infra, wsl2, cdp, redirect, proxy]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "powershell/functions/infra/win_portproxy_add.ps1"
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```powershell
|
||||
# Caso típico WSL2 → Chrome CDP: redirigir 0.0.0.0:9222 → 127.0.0.1:9222
|
||||
.\win_portproxy_add.ps1 -ListenPort 9222
|
||||
|
||||
# Con todos los parámetros explícitos
|
||||
.\win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1
|
||||
|
||||
# Redirigir a un puerto diferente
|
||||
.\win_portproxy_add.ps1 -ListenPort 8080 -ConnectPort 3000
|
||||
```
|
||||
|
||||
```bash
|
||||
# Desde WSL2 — habilitar acceso a Chrome CDP en el host Windows
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
|
||||
```
|
||||
|
||||
## Parametros
|
||||
|
||||
| Parametro | Tipo | Obligatorio | Default | Descripcion |
|
||||
|----------------|--------|-------------|---------------|--------------------------------------------------|
|
||||
| `-ListenPort` | int | si | — | Puerto en el que escucha el proxy (1-65535) |
|
||||
| `-ConnectPort` | int | no | =ListenPort | Puerto de destino al que conectar (1-65535) |
|
||||
| `-ListenAddr` | string | no | `0.0.0.0` | Dirección IP en la que escucha el proxy |
|
||||
| `-ConnectAddr` | string | no | `127.0.0.1` | Dirección IP de destino |
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere ejecutarse como **Administrador** en Windows.
|
||||
- Caso de uso principal: WSL2 no puede alcanzar `127.0.0.1` del host Windows directamente. Esta regla hace que el host escuche en `0.0.0.0:PORT` y reenvíe al loopback donde Chrome (u otro proceso) está escuchando.
|
||||
- Idempotente respecto a listenaddress:listenport: si ya existe una regla para esa combinación, la elimina y la recrea.
|
||||
- Combinar con `win_firewall_add_rule` para que el firewall también permita el tráfico entrante.
|
||||
- Retorna exit code 0 si tuvo éxito, 1 si hubo error.
|
||||
|
||||
## Flujo completo WSL2 → Chrome CDP
|
||||
|
||||
```bash
|
||||
# 1. Abrir Puerto en firewall
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
|
||||
|
||||
# 2. Redirigir tráfico de red al loopback
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
|
||||
|
||||
# 3. Arrancar Chrome con CDP en Windows
|
||||
# chrome.exe --remote-debugging-port=9222 --headless
|
||||
|
||||
# 4. Desde WSL2 conectar al host Windows
|
||||
# WSL2_HOST=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}')
|
||||
# curl http://$WSL2_HOST:9222/json
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# win_portproxy_add.ps1 - Adds a netsh portproxy rule (v4tov4) to forward traffic.
|
||||
# Requires: Administrator privileges
|
||||
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
|
||||
# powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ListenPort,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$ConnectPort = 0, # 0 means use ListenPort
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ListenAddr = "0.0.0.0",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ConnectAddr = "127.0.0.1"
|
||||
)
|
||||
|
||||
# Verify administrator privileges
|
||||
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Default ConnectPort to ListenPort if not specified
|
||||
if ($ConnectPort -eq 0) {
|
||||
$ConnectPort = $ListenPort
|
||||
}
|
||||
|
||||
# Validate ports
|
||||
if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
|
||||
Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
|
||||
exit 1
|
||||
}
|
||||
if ($ConnectPort -lt 1 -or $ConnectPort -gt 65535) {
|
||||
Write-Error "ERROR: ConnectPort must be between 1 and 65535, got '$ConnectPort'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Remove existing portproxy for the same listenaddress:listenport if it exists
|
||||
$existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
|
||||
if ($existing) {
|
||||
Write-Host "Removing existing portproxy for ${ListenAddr}:${ListenPort}..."
|
||||
netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "ERROR: Failed to remove existing portproxy for ${ListenAddr}:${ListenPort}."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Add the portproxy rule
|
||||
Write-Host "Adding portproxy: ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}..."
|
||||
netsh interface portproxy add v4tov4 listenaddress=$ListenAddr listenport=$ListenPort connectaddress=$ConnectAddr connectport=$ConnectPort
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "ERROR: Failed to add portproxy ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "OK: Portproxy added - ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: win_portproxy_remove
|
||||
kind: function
|
||||
lang: ps
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "win_portproxy_remove -ListenPort <int> [-ListenAddr <string>]"
|
||||
description: "Elimina una regla de port proxy v4tov4 de netsh identificada por ListenAddr:ListenPort. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador."
|
||||
tags: [portproxy, netsh, windows, network, infra, wsl2, cleanup, proxy]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "powershell/functions/infra/win_portproxy_remove.ps1"
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```powershell
|
||||
# Eliminar portproxy en puerto 9222 (listenaddr por defecto: 0.0.0.0)
|
||||
.\win_portproxy_remove.ps1 -ListenPort 9222
|
||||
|
||||
# Con listenaddr explícito
|
||||
.\win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222
|
||||
```
|
||||
|
||||
```bash
|
||||
# Desde WSL2
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
|
||||
```
|
||||
|
||||
## Parametros
|
||||
|
||||
| Parametro | Tipo | Obligatorio | Default | Descripcion |
|
||||
|---------------|--------|-------------|-------------|------------------------------------------------------|
|
||||
| `-ListenPort` | int | si | — | Puerto de escucha de la regla a eliminar (1-65535) |
|
||||
| `-ListenAddr` | string | no | `0.0.0.0` | Dirección IP de escucha de la regla a eliminar |
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere ejecutarse como **Administrador** en Windows.
|
||||
- Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
|
||||
- Complementa `win_portproxy_add` para teardown limpio.
|
||||
- La regla se identifica por la combinación `listenaddress:listenport` — debe coincidir exactamente con la usada en `win_portproxy_add`.
|
||||
- Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.
|
||||
|
||||
## Teardown completo WSL2 → Chrome CDP
|
||||
|
||||
```bash
|
||||
# 1. Eliminar portproxy
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
|
||||
|
||||
# 2. Eliminar regla de firewall
|
||||
powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# win_portproxy_remove.ps1 - Removes a netsh portproxy v4tov4 rule.
|
||||
# Requires: Administrator privileges
|
||||
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
|
||||
# powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ListenPort,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ListenAddr = "0.0.0.0"
|
||||
)
|
||||
|
||||
# Verify administrator privileges
|
||||
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate port
|
||||
if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
|
||||
Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if the portproxy rule exists
|
||||
$existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
|
||||
if (-not $existing) {
|
||||
Write-Host "INFO: Portproxy for ${ListenAddr}:${ListenPort} does not exist - nothing to remove."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Remove the portproxy rule
|
||||
Write-Host "Removing portproxy for ${ListenAddr}:${ListenPort}..."
|
||||
netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "ERROR: Failed to remove portproxy for ${ListenAddr}:${ListenPort}."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "OK: Portproxy for ${ListenAddr}:${ListenPort} removed."
|
||||
@@ -0,0 +1,101 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timestampRecord holds preserved timestamps and hash for an existing entry.
|
||||
type timestampRecord struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ContentHash string
|
||||
}
|
||||
|
||||
// ComputeFunctionHash computes a deterministic hash of all content fields of a Function
|
||||
// (excluding created_at, updated_at, and content_hash itself).
|
||||
func ComputeFunctionHash(f *Function) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s|%s",
|
||||
f.ID, f.Name, f.Kind, f.Lang, f.Domain, f.Version, f.Purity, f.Signature, f.Description)
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.Tags))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.UsesFunctions))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.UsesTypes))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.Returns))
|
||||
fmt.Fprintf(h, "|%t|%s", f.ReturnsOptional, f.ErrorType)
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.Imports))
|
||||
fmt.Fprintf(h, "|%s|%t", f.Example, f.Tested)
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.Tests))
|
||||
fmt.Fprintf(h, "|%s|%s", f.TestFilePath, f.FilePath)
|
||||
fmt.Fprintf(h, "|%s", marshalProps(f.Props))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.Emits))
|
||||
if f.HasState != nil {
|
||||
fmt.Fprintf(h, "|%t", *f.HasState)
|
||||
}
|
||||
fmt.Fprintf(h, "|%s", f.Framework)
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(f.Variant))
|
||||
fmt.Fprintf(h, "|%s|%s|%s", f.Notes, f.Documentation, f.Code)
|
||||
fmt.Fprintf(h, "|%s|%s|%s", f.SourceRepo, f.SourceLicense, f.SourceFile)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// ComputeTypeHash computes a deterministic hash of all content fields of a Type.
|
||||
func ComputeTypeHash(t *Type) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s",
|
||||
t.ID, t.Name, t.Lang, t.Domain, t.Version, t.Algebraic, t.Definition, t.Description)
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(t.Tags))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(t.UsesTypes))
|
||||
fmt.Fprintf(h, "|%s|%s|%s|%s|%s", t.FilePath, t.Examples, t.Notes, t.Documentation, t.Code)
|
||||
fmt.Fprintf(h, "|%s|%s|%s", t.SourceRepo, t.SourceLicense, t.SourceFile)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// ComputeAppHash computes a deterministic hash of all content fields of an App.
|
||||
func ComputeAppHash(a *App) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s|%s|%s|%s|%s",
|
||||
a.ID, a.Name, a.Lang, a.Domain, a.Description)
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(a.Tags))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesFunctions))
|
||||
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesTypes))
|
||||
fmt.Fprintf(h, "|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// LoadTimestamps reads existing id → {created_at, updated_at, content_hash} from all tables.
|
||||
// Called before Purge so we can preserve dates across reindexing.
|
||||
func (db *DB) LoadTimestamps() (funcs, types, apps map[string]timestampRecord, err error) {
|
||||
funcs, err = loadTable(db, "functions")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
types, err = loadTable(db, "types")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
apps, err = loadTable(db, "apps")
|
||||
return
|
||||
}
|
||||
|
||||
func loadTable(db *DB, table string) (map[string]timestampRecord, error) {
|
||||
rows, err := db.conn.Query(fmt.Sprintf("SELECT id, created_at, updated_at, content_hash FROM %s", table))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
m := make(map[string]timestampRecord)
|
||||
for rows.Next() {
|
||||
var id, ca, ua, ch string
|
||||
if err := rows.Scan(&id, &ca, &ua, &ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := timestampRecord{ContentHash: ch}
|
||||
rec.CreatedAt, _ = time.Parse(time.RFC3339, ca)
|
||||
rec.UpdatedAt, _ = time.Parse(time.RFC3339, ua)
|
||||
m[id] = rec
|
||||
}
|
||||
return m, rows.Err()
|
||||
}
|
||||
+38
-1
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndexResult holds stats from an indexing run.
|
||||
@@ -24,6 +25,12 @@ type IndexResult struct {
|
||||
// Scans functions/ and types/ at the root level, plus any language-specific
|
||||
// directories (e.g. python/functions/, python/types/).
|
||||
func Index(db *DB, root string) (*IndexResult, error) {
|
||||
// Load existing timestamps before purging so we can preserve created_at
|
||||
oldFuncs, oldTypes, oldApps, err := db.LoadTimestamps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading timestamps: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Purge(); err != nil {
|
||||
return nil, fmt.Errorf("purging database: %w", err)
|
||||
}
|
||||
@@ -109,12 +116,16 @@ func Index(db *DB, root string) (*IndexResult, error) {
|
||||
knownTypes[t.ID] = true
|
||||
}
|
||||
|
||||
// Pass 2: validate and insert
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Pass 2: validate, assign timestamps via hash comparison, and insert
|
||||
for _, t := range types {
|
||||
if verr := ValidateType(t, knownTypes); verr != nil {
|
||||
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
|
||||
continue
|
||||
}
|
||||
t.ContentHash = ComputeTypeHash(t)
|
||||
applyTimestamps(&t.CreatedAt, &t.UpdatedAt, t.ContentHash, oldTypes[t.ID], now)
|
||||
if err := db.InsertType(t); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", t.ID, err))
|
||||
continue
|
||||
@@ -127,6 +138,8 @@ func Index(db *DB, root string) (*IndexResult, error) {
|
||||
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
|
||||
continue
|
||||
}
|
||||
f.ContentHash = ComputeFunctionHash(f)
|
||||
applyTimestamps(&f.CreatedAt, &f.UpdatedAt, f.ContentHash, oldFuncs[f.ID], now)
|
||||
if err := db.InsertFunction(f); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", f.ID, err))
|
||||
continue
|
||||
@@ -139,6 +152,8 @@ func Index(db *DB, root string) (*IndexResult, error) {
|
||||
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
|
||||
continue
|
||||
}
|
||||
a.ContentHash = ComputeAppHash(a)
|
||||
applyTimestamps(&a.CreatedAt, &a.UpdatedAt, a.ContentHash, oldApps[a.ID], now)
|
||||
if err := db.InsertApp(a); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", a.ID, err))
|
||||
continue
|
||||
@@ -149,6 +164,28 @@ func Index(db *DB, root string) (*IndexResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyTimestamps sets created_at and updated_at based on whether the entry
|
||||
// existed before and whether its content changed.
|
||||
// - New entry (no old record): both set to now
|
||||
// - Unchanged (hash matches): both preserved from old record
|
||||
// - Changed (hash differs): created_at preserved, updated_at set to now
|
||||
func applyTimestamps(createdAt, updatedAt *time.Time, newHash string, old timestampRecord, now time.Time) {
|
||||
if old.CreatedAt.IsZero() {
|
||||
// New entry
|
||||
*createdAt = now
|
||||
*updatedAt = now
|
||||
return
|
||||
}
|
||||
// Existing entry — always preserve created_at
|
||||
*createdAt = old.CreatedAt
|
||||
if old.ContentHash == newHash {
|
||||
// No changes — preserve updated_at too
|
||||
*updatedAt = old.UpdatedAt
|
||||
} else {
|
||||
*updatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
// walkMD walks a directory recursively and calls fn for each .md file found.
|
||||
func walkMD(dir string, fn func(path string)) {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add content_hash to detect changes across reindexing and preserve timestamps.
|
||||
ALTER TABLE functions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE types ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE apps ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Source attribution for functions extracted from external repositories.
|
||||
ALTER TABLE functions ADD COLUMN source_repo TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE functions ADD COLUMN source_license TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE functions ADD COLUMN source_file TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE types ADD COLUMN source_repo TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE types ADD COLUMN source_license TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE types ADD COLUMN source_file TEXT NOT NULL DEFAULT '';
|
||||
@@ -54,6 +54,10 @@ type Function struct {
|
||||
Tests []string `json:"tests"`
|
||||
TestFilePath string `json:"test_file_path"`
|
||||
FilePath string `json:"file_path"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
SourceRepo string `json:"source_repo"`
|
||||
SourceLicense string `json:"source_license"`
|
||||
SourceFile string `json:"source_file"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -90,6 +94,10 @@ type Type struct {
|
||||
Documentation string `json:"documentation"`
|
||||
Code string `json:"code"`
|
||||
FilePath string `json:"file_path"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
SourceRepo string `json:"source_repo"`
|
||||
SourceLicense string `json:"source_license"`
|
||||
SourceFile string `json:"source_file"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -109,6 +117,7 @@ type App struct {
|
||||
Documentation string `json:"documentation"`
|
||||
Notes string `json:"notes"`
|
||||
DirPath string `json:"dir_path"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ type rawFunction struct {
|
||||
TestFilePath string `yaml:"test_file_path"`
|
||||
FilePath string `yaml:"file_path"`
|
||||
|
||||
// Source attribution
|
||||
SourceRepo string `yaml:"source_repo"`
|
||||
SourceLicense string `yaml:"source_license"`
|
||||
SourceFile string `yaml:"source_file"`
|
||||
|
||||
// Component fields
|
||||
Props []PropDef `yaml:"props"`
|
||||
Emits []string `yaml:"emits"`
|
||||
@@ -52,6 +57,9 @@ type rawType struct {
|
||||
Tags []string `yaml:"tags"`
|
||||
UsesTypes []string `yaml:"uses_types"`
|
||||
FilePath string `yaml:"file_path"`
|
||||
SourceRepo string `yaml:"source_repo"`
|
||||
SourceLicense string `yaml:"source_license"`
|
||||
SourceFile string `yaml:"source_file"`
|
||||
}
|
||||
|
||||
// rawApp mirrors the YAML frontmatter of an app .md file.
|
||||
@@ -146,6 +154,9 @@ func ParseFunctionMD(path string, root string) (*Function, error) {
|
||||
HasState: raw.HasState,
|
||||
Framework: raw.Framework,
|
||||
Variant: raw.Variant,
|
||||
SourceRepo: raw.SourceRepo,
|
||||
SourceLicense: raw.SourceLicense,
|
||||
SourceFile: raw.SourceFile,
|
||||
}
|
||||
|
||||
if root != "" && raw.FilePath != "" {
|
||||
@@ -196,6 +207,9 @@ func ParseTypeMD(path string, root string) (*Type, error) {
|
||||
Description: raw.Description,
|
||||
Tags: raw.Tags,
|
||||
UsesTypes: raw.UsesTypes,
|
||||
SourceRepo: raw.SourceRepo,
|
||||
SourceLicense: raw.SourceLicense,
|
||||
SourceFile: raw.SourceFile,
|
||||
Examples: sections.example,
|
||||
Notes: sections.notes,
|
||||
Documentation: sections.documentation,
|
||||
|
||||
+36
-23
@@ -57,11 +57,13 @@ func unmarshalProps(s string) []PropDef {
|
||||
|
||||
// InsertFunction inserts or replaces a function entry.
|
||||
func (db *DB) InsertFunction(f *Function) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC()
|
||||
if f.CreatedAt.IsZero() {
|
||||
f.CreatedAt = time.Now().UTC()
|
||||
f.CreatedAt = now
|
||||
}
|
||||
if f.UpdatedAt.IsZero() {
|
||||
f.UpdatedAt = now
|
||||
}
|
||||
f.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if f.ID == "" {
|
||||
f.ID = GenerateID(f.Name, f.Lang, f.Domain)
|
||||
@@ -81,34 +83,39 @@ func (db *DB) InsertFunction(f *Function) error {
|
||||
id, name, kind, lang, domain, version, purity, signature,
|
||||
description, tags, uses_functions, uses_types, returns,
|
||||
returns_optional, error_type, imports, example, tested,
|
||||
tests, test_file_path, file_path, created_at, updated_at,
|
||||
tests, test_file_path, file_path, content_hash, created_at, updated_at,
|
||||
props, emits, has_state, framework, variant,
|
||||
notes, documentation, code
|
||||
notes, documentation, code,
|
||||
source_repo, source_license, source_file
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?
|
||||
)`,
|
||||
f.ID, f.Name, string(f.Kind), f.Lang, f.Domain, f.Version, string(f.Purity), f.Signature,
|
||||
f.Description, marshalStrings(f.Tags), marshalStrings(f.UsesFunctions), marshalStrings(f.UsesTypes), marshalStrings(f.Returns),
|
||||
f.ReturnsOptional, f.ErrorType, marshalStrings(f.Imports), f.Example, f.Tested,
|
||||
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.CreatedAt.Format(time.RFC3339), now,
|
||||
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.ContentHash, f.CreatedAt.Format(time.RFC3339), f.UpdatedAt.Format(time.RFC3339),
|
||||
marshalProps(f.Props), marshalStrings(f.Emits), hasState, f.Framework, marshalStrings(f.Variant),
|
||||
f.Notes, f.Documentation, f.Code,
|
||||
f.SourceRepo, f.SourceLicense, f.SourceFile,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertType inserts or replaces a type entry.
|
||||
func (db *DB) InsertType(t *Type) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = time.Now().UTC()
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if t.ID == "" {
|
||||
t.ID = GenerateID(t.Name, t.Lang, t.Domain)
|
||||
@@ -118,13 +125,15 @@ func (db *DB) InsertType(t *Type) error {
|
||||
INSERT OR REPLACE INTO types (
|
||||
id, name, lang, domain, version, algebraic,
|
||||
definition, description, tags, uses_types,
|
||||
file_path, created_at, updated_at,
|
||||
examples, notes, documentation, code
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
file_path, content_hash, created_at, updated_at,
|
||||
examples, notes, documentation, code,
|
||||
source_repo, source_license, source_file
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Name, t.Lang, t.Domain, t.Version, string(t.Algebraic),
|
||||
t.Definition, t.Description, marshalStrings(t.Tags), marshalStrings(t.UsesTypes),
|
||||
t.FilePath, t.CreatedAt.Format(time.RFC3339), now,
|
||||
t.FilePath, t.ContentHash, t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
|
||||
t.Examples, t.Notes, t.Documentation, t.Code,
|
||||
t.SourceRepo, t.SourceLicense, t.SourceFile,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -263,11 +272,13 @@ func (db *DB) DeleteType(id string) error {
|
||||
|
||||
// InsertApp inserts or replaces an app entry.
|
||||
func (db *DB) InsertApp(a *App) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC()
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
a.CreatedAt = now
|
||||
}
|
||||
if a.UpdatedAt.IsZero() {
|
||||
a.UpdatedAt = now
|
||||
}
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if a.ID == "" {
|
||||
a.ID = GenerateID(a.Name, a.Lang, a.Domain)
|
||||
@@ -277,11 +288,11 @@ func (db *DB) InsertApp(a *App) error {
|
||||
INSERT OR REPLACE INTO apps (
|
||||
id, name, lang, domain, description, tags,
|
||||
uses_functions, uses_types, framework, entry_point,
|
||||
documentation, notes, dir_path, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
documentation, notes, dir_path, content_hash, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags),
|
||||
marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint,
|
||||
a.Documentation, a.Notes, a.DirPath, a.CreatedAt.Format(time.RFC3339), now,
|
||||
a.Documentation, a.Notes, a.DirPath, a.ContentHash, a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -347,7 +358,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error)
|
||||
err := rows.Scan(
|
||||
&a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON,
|
||||
&usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint,
|
||||
&a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt,
|
||||
&a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, &a.ContentHash,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning app: %w", err)
|
||||
@@ -391,7 +402,8 @@ func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Functio
|
||||
&f.ReturnsOptional, &f.ErrorType, &importsJSON, &f.Example, &f.Tested,
|
||||
&testsJSON, &f.TestFilePath, &f.FilePath, &createdAt, &updatedAt,
|
||||
&propsJSON, &emitsJSON, &hasState, &f.Framework, &variantJSON,
|
||||
&f.Notes, &f.Documentation, &f.Code,
|
||||
&f.Notes, &f.Documentation, &f.Code, &f.ContentHash,
|
||||
&f.SourceRepo, &f.SourceLicense, &f.SourceFile,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning function: %w", err)
|
||||
@@ -430,7 +442,8 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error
|
||||
&t.ID, &t.Name, &t.Lang, &t.Domain, &t.Version, &t.Algebraic,
|
||||
&t.Definition, &t.Description, &tagsJSON, &usesTypJSON,
|
||||
&t.FilePath, &createdAt, &updatedAt,
|
||||
&t.Examples, &t.Notes, &t.Documentation, &t.Code,
|
||||
&t.Examples, &t.Notes, &t.Documentation, &t.Code, &t.ContentHash,
|
||||
&t.SourceRepo, &t.SourceLicense, &t.SourceFile,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning type: %w", err)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Manifest de repositorios externos para extraccion de funciones.
|
||||
# Cada entrada registra un repo clonado en sources/ y las funciones extraidas.
|
||||
#
|
||||
# Formato:
|
||||
# - repo: https://github.com/user/project
|
||||
# license: MIT
|
||||
# cloned_dir: project # nombre del directorio en sources/
|
||||
# extracted: # funciones ya extraidas (el agente las registra)
|
||||
# - id: func_name_go_core
|
||||
# source_file: pkg/utils.go # path relativo dentro del repo original
|
||||
# date: 2026-03-29
|
||||
#
|
||||
# Workflow:
|
||||
# 1. Clonar repo en sources/: git clone <url> sources/<nombre>
|
||||
# 2. Invocar agente extractor para analizar y proponer funciones
|
||||
# 3. El agente copia, adapta, crea .go + .md con atribucion
|
||||
# 4. fn index para registrar en registry.db
|
||||
# 5. Actualizar este manifest con las funciones extraidas
|
||||
|
||||
repos: []
|
||||
Reference in New Issue
Block a user