feat(infra): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: audit_doctor_snapshot
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "audit_doctor_snapshot(doctor_subcommand: string, snapshot_base_dir: string) -> void"
|
||||||
|
description: "Ejecuta un subcomando de fn doctor --json, guarda un snapshot JSON fechado en <base>/<sub>/<stamp>.json, lo compara con la corrida anterior (latest.json) y emite a stdout un resumen legible: count actual, count previo, IDs nuevos y resueltos. Pieza de observabilidad Nivel 1 para DAGs de auditoría periódica."
|
||||||
|
tags: [audit, registry, infra, doctor, snapshot, diff, dag]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: doctor_subcommand
|
||||||
|
desc: "Subcomando de fn doctor a ejecutar (unused, capabilities, artefacts, copied-code, uses-functions, cpp-apps, services, sync, etc.)."
|
||||||
|
- name: snapshot_base_dir
|
||||||
|
desc: "Directorio base donde se crea la carpeta <base>/<subcommand>/ con los snapshots fechados y latest.json."
|
||||||
|
output: "Resumen a stdout: '[audit:<sub>] count=N prev=M +X new -Y resolved'. Si hay IDs nuevos/resueltos, líneas adicionales NEW:/RESOLVED: con hasta 8 IDs. Snapshots JSON en disco."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/audit_doctor_snapshot.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Primera corrida — establece baseline
|
||||||
|
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
|
||||||
|
FN_BIN=/home/enmanuel/fn_registry/fn \
|
||||||
|
bash bash/functions/infra/audit_doctor_snapshot.sh \
|
||||||
|
unused \
|
||||||
|
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
||||||
|
# => [audit:unused] count=12 prev=- baseline (sin corrida previa)
|
||||||
|
|
||||||
|
# Segunda corrida — compara contra latest.json
|
||||||
|
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
|
||||||
|
FN_BIN=/home/enmanuel/fn_registry/fn \
|
||||||
|
bash bash/functions/infra/audit_doctor_snapshot.sh \
|
||||||
|
unused \
|
||||||
|
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
||||||
|
# => [audit:unused] count=12 prev=12 +0 new -0 resolved
|
||||||
|
|
||||||
|
# Con otro subcomando (directorio independiente automático)
|
||||||
|
audit_doctor_snapshot artefacts /tmp/audits/weekly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala en un DAG/cron que ejecuta `fn doctor` periódicamente y quieres **persistir el resultado y ver qué cambió desde la última corrida**: funciones huérfanas que aparecieron, artefactos rotos nuevos, capabilities sin doc, etc. Es la pieza "snapshot + diff" del Nivel 1 de observabilidad de auditorías — el DAG llama esta función en vez de descartar el output de `fn doctor`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Depende de `FN_BIN` o `FN_REGISTRY_ROOT`** en el entorno. Si ninguno está seteado, asume `$HOME/fn_registry/fn`. En DAGs, asegúrate de exportar `FN_REGISTRY_ROOT` antes de invocar.
|
||||||
|
- **`latest.json` se sobreescribe cada corrida** — es el snapshot de referencia para el diff siguiente. No es un historial acumulado; el historial está en los archivos fechados `<stamp>.json`.
|
||||||
|
- **Si cambias de subcomando, el subdirectorio es distinto** (`<base>/unused/` vs `<base>/artefacts/`), así que no hay contaminación entre subcomandos aunque compartan el mismo `base_dir`.
|
||||||
|
- **Si `fn doctor <sub>` falla (rc != 0)**, la función propaga ese exit code. Esto es intencional: doctor roto = problema real que el DAG debe reportar. Los hallazgos normales (funciones huérfanas, artefactos con drift) tienen rc=0 en `fn doctor`.
|
||||||
|
- **jq es dependencia requerida**. Está disponible en el ecosistema del registry pero si el entorno no lo tiene, los conteos y diffs de IDs caen a `?`/textual respectivamente.
|
||||||
|
- **Retención automática**: snapshots fechados con más de 30 días se borran con `find -mtime +30`. `latest.json` nunca se borra.
|
||||||
|
- **Estructura del JSON de `fn doctor`**: el diff de IDs busca campos `.ID` o `.id` en los elementos. Si el subcomando produce una estructura distinta (objeto anidado sin esos campos), el diff cae a comparación textual, que sigue siendo útil.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Diseñada para ser invocada desde steps del dag_engine (`daily-registry-audit`, `weekly-deep-scan`) como reemplazo del descarte silencioso del output de `fn doctor --json`. La salida stdout es legible por humanos y parseable por el orquestador del DAG para decidir si crear proposals.
|
||||||
|
|
||||||
|
Binario `fn` resuelto en orden: `$FN_BIN` → `${FN_REGISTRY_ROOT}/fn` → `$HOME/fn_registry/fn`.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit_doctor_snapshot — ejecuta un subcomando de fn doctor, guarda snapshot JSON
|
||||||
|
# fechado, compara con la corrida anterior y emite resumen legible de cambios.
|
||||||
|
#
|
||||||
|
# Uso: audit_doctor_snapshot <doctor_subcommand> <snapshot_base_dir>
|
||||||
|
#
|
||||||
|
# Ejemplo:
|
||||||
|
# audit_doctor_snapshot unused /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
audit_doctor_snapshot() {
|
||||||
|
local sub="${1:-}"
|
||||||
|
local base="${2:-}"
|
||||||
|
|
||||||
|
# --- validacion de argumentos ---
|
||||||
|
if [[ -z "$sub" || -z "$base" ]]; then
|
||||||
|
echo "usage: audit_doctor_snapshot <subcommand> <base_dir>" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- resolver binario fn ---
|
||||||
|
local fn_bin="${FN_BIN:-${FN_REGISTRY_ROOT:-$HOME/fn_registry}/fn}"
|
||||||
|
if [[ ! -x "$fn_bin" ]]; then
|
||||||
|
echo "audit_doctor_snapshot: binario fn no encontrado o no ejecutable: $fn_bin" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- preparar directorio ---
|
||||||
|
local dir="$base/$sub"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
|
||||||
|
# --- ejecutar fn doctor ---
|
||||||
|
local stderr_tmp
|
||||||
|
stderr_tmp="$(mktemp /tmp/audit_doctor_snapshot_stderr.XXXXXX)"
|
||||||
|
local json rc
|
||||||
|
json="$("$fn_bin" doctor "$sub" --json 2>"$stderr_tmp")" || rc=$?
|
||||||
|
rc="${rc:-0}"
|
||||||
|
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
cat "$stderr_tmp" >&2
|
||||||
|
echo "audit_doctor_snapshot: 'fn doctor $sub' fallo (rc=$rc)" >&2
|
||||||
|
rm -f "$stderr_tmp"
|
||||||
|
return "$rc"
|
||||||
|
fi
|
||||||
|
rm -f "$stderr_tmp"
|
||||||
|
|
||||||
|
# --- normalizar con jq (diff estable) ---
|
||||||
|
local stamp
|
||||||
|
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
local curr="$dir/${stamp}.json"
|
||||||
|
local nojson=0
|
||||||
|
|
||||||
|
if ! echo "$json" | jq -S . > "$curr" 2>/dev/null; then
|
||||||
|
# salida no es JSON valido -> guardar crudo
|
||||||
|
printf '%s' "$json" > "$curr"
|
||||||
|
nojson=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- snapshot anterior ---
|
||||||
|
local prev="$dir/latest.json"
|
||||||
|
|
||||||
|
# --- contar hallazgos actuales ---
|
||||||
|
local count="?"
|
||||||
|
if [[ "$nojson" -eq 0 ]]; then
|
||||||
|
if jq -e 'type == "array"' "$curr" >/dev/null 2>&1; then
|
||||||
|
count="$(jq 'length' "$curr")"
|
||||||
|
elif jq -e 'type == "object"' "$curr" >/dev/null 2>&1; then
|
||||||
|
count="$(jq 'keys | length' "$curr")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- contar hallazgos previos ---
|
||||||
|
local prevcount="-"
|
||||||
|
if [[ -f "$prev" ]]; then
|
||||||
|
if jq -e 'type == "array"' "$prev" >/dev/null 2>&1; then
|
||||||
|
prevcount="$(jq 'length' "$prev")"
|
||||||
|
elif jq -e 'type == "object"' "$prev" >/dev/null 2>&1; then
|
||||||
|
prevcount="$(jq 'keys | length' "$prev")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- diff de identidad ---
|
||||||
|
local new_count=0
|
||||||
|
local resolved_count=0
|
||||||
|
local new_ids=()
|
||||||
|
local resolved_ids=()
|
||||||
|
local diff_label=""
|
||||||
|
|
||||||
|
if [[ ! -f "$prev" ]]; then
|
||||||
|
diff_label="baseline (sin corrida previa)"
|
||||||
|
elif [[ "$nojson" -eq 1 ]]; then
|
||||||
|
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
|
||||||
|
diff_label="changed (textual)"
|
||||||
|
else
|
||||||
|
diff_label="+0 new -0 resolved"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# extraer IDs estables: .ID o .id
|
||||||
|
local curr_ids prev_ids
|
||||||
|
curr_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$curr" 2>/dev/null | sort -u)"
|
||||||
|
prev_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$prev" 2>/dev/null | sort -u)"
|
||||||
|
|
||||||
|
if [[ -n "$curr_ids" || -n "$prev_ids" ]]; then
|
||||||
|
# NEW: en curr pero no en prev
|
||||||
|
local new_raw resolved_raw
|
||||||
|
new_raw="$(comm -23 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
|
||||||
|
resolved_raw="$(comm -13 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -n "$new_raw" ]]; then
|
||||||
|
mapfile -t new_ids <<< "$new_raw"
|
||||||
|
fi
|
||||||
|
if [[ -n "$resolved_raw" ]]; then
|
||||||
|
mapfile -t resolved_ids <<< "$resolved_raw"
|
||||||
|
fi
|
||||||
|
|
||||||
|
new_count="${#new_ids[@]}"
|
||||||
|
resolved_count="${#resolved_ids[@]}"
|
||||||
|
diff_label="+${new_count} new -${resolved_count} resolved"
|
||||||
|
else
|
||||||
|
# sin campo .ID/.id — fallback textual
|
||||||
|
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
|
||||||
|
diff_label="changed (textual)"
|
||||||
|
else
|
||||||
|
diff_label="+0 new -0 resolved"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- resumen a stdout ---
|
||||||
|
echo "[audit:$sub] count=$count prev=$prevcount $diff_label"
|
||||||
|
|
||||||
|
# listar nuevos (max 8)
|
||||||
|
if [[ "${#new_ids[@]}" -gt 0 ]]; then
|
||||||
|
local listed=("${new_ids[@]:0:8}")
|
||||||
|
local extra=$(( ${#new_ids[@]} - 8 ))
|
||||||
|
local line
|
||||||
|
line="$(IFS=', '; echo "${listed[*]}")"
|
||||||
|
if [[ "$extra" -gt 0 ]]; then
|
||||||
|
line="${line} (+${extra} más)"
|
||||||
|
fi
|
||||||
|
echo " NEW: $line"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# listar resueltos (max 8)
|
||||||
|
if [[ "${#resolved_ids[@]}" -gt 0 ]]; then
|
||||||
|
local listed_r=("${resolved_ids[@]:0:8}")
|
||||||
|
local extra_r=$(( ${#resolved_ids[@]} - 8 ))
|
||||||
|
local line_r
|
||||||
|
line_r="$(IFS=', '; echo "${listed_r[*]}")"
|
||||||
|
if [[ "$extra_r" -gt 0 ]]; then
|
||||||
|
line_r="${line_r} (+${extra_r} más)"
|
||||||
|
fi
|
||||||
|
echo " RESOLVED: $line_r"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- actualizar puntero latest ---
|
||||||
|
cp "$curr" "$prev"
|
||||||
|
|
||||||
|
# --- retención: borrar snapshots fechados > 30 días ---
|
||||||
|
find "$dir" -maxdepth 1 -name '*.json' ! -name 'latest.json' -mtime +30 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecución directa
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
audit_doctor_snapshot "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
id: "0167"
|
||||||
|
title: "fn run de library function Go ejecuta go test del paquete entero (arrastra tests flaky vecinos)"
|
||||||
|
status: pendiente
|
||||||
|
type: enhancement
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: media
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0077"]
|
||||||
|
created: 2026-06-03
|
||||||
|
updated: 2026-06-03
|
||||||
|
tags: [fn-run, go, testing, flaky, dag-engine, reliability]
|
||||||
|
---
|
||||||
|
# 0167 — fn run de library function Go ejecuta go test del paquete entero
|
||||||
|
|
||||||
|
## APP Metadata
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| **ID** | 0167 |
|
||||||
|
| **Estado** | pendiente |
|
||||||
|
| **Prioridad** | media |
|
||||||
|
| **Tipo** | enhancement — dispatcher de `fn run` |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Cuando `fn run <id>` recibe una **library function Go sin `main.go`** que tiene tests
|
||||||
|
declarados (`tested: true` + `test_file_path`), el dispatcher (`cmd/fn/run.go:171-181`)
|
||||||
|
ejecuta:
|
||||||
|
|
||||||
|
```
|
||||||
|
go test -v -count=1 -tags fts5 ./functions/<domain> # el PAQUETE ENTERO
|
||||||
|
```
|
||||||
|
|
||||||
|
Es decir, no ejecuta "la función" (no se puede: no tiene `main`), sino que corre **todos
|
||||||
|
los tests del paquete**. Consecuencia: el éxito de `fn run miFuncion` depende de que pasen
|
||||||
|
los tests de **todas las demás funciones del mismo paquete**, no solo los suyos.
|
||||||
|
|
||||||
|
### Cómo se manifestó
|
||||||
|
|
||||||
|
Los DAGs `daily-registry-audit` y `weekly-deep-scan` del `dag_engine` invocaban funciones
|
||||||
|
`*_go_infra` (`find_unused_functions`, `artefact_doctor`, etc.) como `function:` steps.
|
||||||
|
Cada step disparaba `go test ./functions/infra` (paquete completo), que contiene tests
|
||||||
|
impuros con recursos fijos:
|
||||||
|
|
||||||
|
- `TestSSHTunnelOpenClose` → `bind [127.0.0.1]:19876: Address already in use`
|
||||||
|
- `TestDockerContainerExec` → `listen unix .../docker_exec_test.sock: bind: invalid argument` (path de socket > 108 chars con TMPDIR largo)
|
||||||
|
|
||||||
|
Al correr dos `function:` steps en paralelo (ambos `depends` del mismo padre), las dos
|
||||||
|
invocaciones de `go test ./functions/infra` colisionaban en el **puerto fijo 19876** →
|
||||||
|
una pasaba y la otra fallaba de forma no determinista. Resultado: el DAG fallaba sin
|
||||||
|
auditar nada, y el fallo parecía "la auditoría encontró un problema" cuando en realidad
|
||||||
|
era un test de red vecino.
|
||||||
|
|
||||||
|
> Nota: el síntoma operativo en los DAGs ya se resolvió por otra vía (2026-06-03): los
|
||||||
|
> steps ahora usan `audit_doctor_snapshot_bash_infra` (Bash), que ejecuta `fn doctor <sub>`
|
||||||
|
> real en vez de `go test` del paquete. Este issue es la **causa raíz general** del
|
||||||
|
> dispatcher, que sigue afectando a cualquier `fn run <library_go_fn_con_tests>`.
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
1. `fn run` de una library function NO ejecuta la función — corre el paquete de test entero.
|
||||||
|
2. Los tests impuros de un paquete (puertos/sockets/red fijos) no son seguros para
|
||||||
|
ejecuciones concurrentes ni reproducibles en cualquier entorno (TMPDIR, CI).
|
||||||
|
3. Un único test flaky en `functions/infra` rompe `fn run` de las ~N funciones testeadas
|
||||||
|
del paquete, y por extensión cualquier DAG/cron que las invoque.
|
||||||
|
|
||||||
|
## Opciones de solución (decidir en implementación)
|
||||||
|
|
||||||
|
### Opción A — library Go sin main → siempre compile-check (`go vet`/`go build`)
|
||||||
|
`fn run <lib_fn>` significa "verifica que la función va"; para código sin `main` eso es
|
||||||
|
"compila". Testear es responsabilidad de `go test` / CI, no de `fn run` en un cron.
|
||||||
|
|
||||||
|
- **Pro**: determinista, rápido, elimina el flaky de raíz.
|
||||||
|
- **Contra**: rompe el comportamiento documentado en `CLAUDE.md` ("`fn run filter_slice_go_core`
|
||||||
|
→ Go function con tests → `go test -v`"). Perderíamos la capacidad de correr los tests de
|
||||||
|
una función vía `fn run`.
|
||||||
|
|
||||||
|
### Opción B — go test acotado con `-run` a los tests de la función
|
||||||
|
Si la función declara sus tests, ejecutar solo esos:
|
||||||
|
|
||||||
|
```
|
||||||
|
go test -v -count=1 -tags fts5 -run '^(TestX|TestY)$' ./functions/<domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pro**: aísla del flaky vecino manteniendo "fn run corre mis tests".
|
||||||
|
- **Contra / RIESGO**: si los nombres de `fn.Tests` (frontmatter YAML, `registry/parser.go:32`)
|
||||||
|
tienen **drift** respecto al código, `-run` no matchea y `go test` sale 0 con
|
||||||
|
"no tests to run" → **falso-verde** en una primitiva crítica de todo el ecosistema.
|
||||||
|
Mitigación obligatoria si se elige B: reconciliar `fn.Tests` con los tests extraídos por
|
||||||
|
el indexer (`registry/test_parser.go::parseGoTests`, que ya puebla `unit_tests`) y/o
|
||||||
|
detectar "0 tests ejecutados" parseando el output y tratarlo como fallo.
|
||||||
|
|
||||||
|
### Opción C — aislar los tests impuros del paquete
|
||||||
|
Hacer robustos los tests culpables: puerto efímero (`:0` en vez de `19876`), socket en path
|
||||||
|
corto bajo `/tmp` con nombre acotado, `t.Parallel`-safe. No cambia el dispatcher pero reduce
|
||||||
|
la probabilidad de colisión.
|
||||||
|
|
||||||
|
- **Pro**: no toca `fn run` (cero blast radius sistémico).
|
||||||
|
- **Contra**: no resuelve el problema conceptual (sigue corriendo el paquete entero); otros
|
||||||
|
paquetes pueden introducir tests impuros nuevos y reincidir.
|
||||||
|
|
||||||
|
## Recomendación
|
||||||
|
|
||||||
|
Combinar **C** (saneamiento inmediato de `TestSSHTunnelOpenClose` y `TestDockerContainerExec`,
|
||||||
|
bajo riesgo) con **B** endurecida (acotar `-run` + guard anti-falso-verde apoyado en
|
||||||
|
`unit_tests` extraídos, no en el frontmatter manual). La Opción A es la más limpia
|
||||||
|
conceptualmente pero rompe comportamiento documentado; evaluar si ese comportamiento
|
||||||
|
("fn run corre los tests") aún se usa de verdad o puede deprecarse hacia `go test` directo.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: `fn run` de library fn testeada | e2e | `./fn run find_unused_functions_go_infra` | exit 0 sin depender de tests de funciones vecinas |
|
||||||
|
| Edge: dos `fn run` concurrentes del mismo paquete | e2e | dos invocaciones en paralelo de funciones de `functions/infra` | ambas exit 0, sin colisión de puerto/socket |
|
||||||
|
| Error: nombres de test con drift (si se elige B) | unit | `fn.Tests` con un nombre inexistente | NO produce falso-verde (se detecta "0 tests run" → fallo) |
|
||||||
|
| Tests impuros saneados | unit | `go test -run 'TestSSHTunnelOpenClose\|TestDockerContainerExec' ./functions/infra` repetido 5× | 5/5 PASS deterministas |
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Archivos clave: `cmd/fn/run.go` (dispatcher, líneas 145-194), `registry/parser.go`
|
||||||
|
(campo `Tests`), `registry/test_parser.go` (extracción de nombres de test),
|
||||||
|
`functions/infra/ssh_tunnel_open_close_test.go` y `functions/infra/docker_container_exec_test.go`
|
||||||
|
(tests culpables).
|
||||||
|
- Relacionado con 0077 (fn-run-bash-output-mudo): familia de issues sobre la semántica y
|
||||||
|
observabilidad de `fn run`.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveRegistryRoot returns the absolute path of the fn_registry root
|
||||||
|
// (the directory that contains registry.db), or an error if not found.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. FN_REGISTRY_ROOT env var — if set and <dir>/registry.db exists, return it.
|
||||||
|
// 2. Walk up from the executable's directory — up to 6 levels; first dir
|
||||||
|
// that contains registry.db wins. Covers binaries at <root>/fn and at
|
||||||
|
// <root>/apps/<x>/<bin>.
|
||||||
|
// 3. $HOME/fn_registry — if <$HOME>/fn_registry/registry.db exists.
|
||||||
|
// 4. Error — no method found a valid registry root.
|
||||||
|
func ResolveRegistryRoot() (string, error) {
|
||||||
|
// 1. Env var
|
||||||
|
if envDir := os.Getenv("FN_REGISTRY_ROOT"); envDir != "" {
|
||||||
|
if nonEmptyFile(filepath.Join(envDir, "registry.db")) {
|
||||||
|
return filepath.Clean(envDir), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up from executable
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if nonEmptyFile(filepath.Join(dir, "registry.db")) {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
// Reached filesystem root
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. $HOME/fn_registry
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
candidate := filepath.Join(home, "fn_registry")
|
||||||
|
if nonEmptyFile(filepath.Join(candidate, "registry.db")) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("resolve_registry_root: no se encontro registry.db (set FN_REGISTRY_ROOT)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonEmptyFile reports whether path is an existing, non-empty regular file.
|
||||||
|
// A zero-byte registry.db — which sql.Open silently creates when handed a
|
||||||
|
// mis-resolved path — must NOT count as a valid registry root, otherwise such
|
||||||
|
// a shadow file would hijack the walk-up and mask the real database.
|
||||||
|
func nonEmptyFile(path string) bool {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
return err == nil && fi.Mode().IsRegular() && fi.Size() > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: resolve_registry_root
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func ResolveRegistryRoot() (string, error)"
|
||||||
|
description: "Devuelve la ruta absoluta de la raíz del fn_registry (el directorio que contiene registry.db). Resuelve en orden: env var FN_REGISTRY_ROOT, walk-up desde el ejecutable (hasta 6 niveles), y $HOME/fn_registry. Retorna error si ningún método localiza registry.db. Centraliza la resolución dinámica para que ningún binario del ecosistema necesite un path hardcodeado."
|
||||||
|
tags: [registry, infra, root, path, env]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
params:
|
||||||
|
- name: (ninguno)
|
||||||
|
desc: "La función no recibe argumentos."
|
||||||
|
output: "Ruta absoluta del directorio raíz del fn_registry (contiene registry.db), o error descriptivo si no se encontró."
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["fmt", "os", "path/filepath"]
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "env var apunta a tmpdir con registry.db"
|
||||||
|
- "HOME apunta a tmpdir con fn_registry/registry.db"
|
||||||
|
- "nada existe devuelve error"
|
||||||
|
test_file_path: "functions/infra/resolve_registry_root_test.go"
|
||||||
|
file_path: "functions/infra/resolve_registry_root.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
root, err := infra.ResolveRegistryRoot()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("no se pudo localizar fn_registry: %v", err)
|
||||||
|
}
|
||||||
|
// Construir ruta al binario fn
|
||||||
|
fnBin := filepath.Join(root, "fn")
|
||||||
|
cmd := exec.Command(fnBin, "run", "mi_funcion")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando un binario Go del ecosistema (dag_engine, deploy_server, registry_mcp, etc.) necesita localizar la raíz del registry para construir rutas a `fn run`, abrir `registry.db`, o encontrar `python/.venv` — y `FN_REGISTRY_ROOT` puede no estar seteado en el entorno del usuario.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Depende del entorno en tiempo de ejecución** (env vars + filesystem): no es determinista. Dos llamadas sucesivas desde entornos distintos pueden devolver rutas distintas.
|
||||||
|
- **El paso 2 (walk-up desde ejecutable) asume que el binario vive dentro del árbol del registry.** Si el binario se copia a `/usr/local/bin` o a un directorio externo, este paso no encontrará nada — los pasos 1 y 3 sirven como fallback.
|
||||||
|
- **En `go test` el exe es un binario temporal** generado por el framework de tests, que vive en `/tmp/`. Por eso el paso 2 no encontrará `registry.db` durante los tests unitarios. Los tests de esta función usan `t.Setenv` para cubrir los pasos 1 y 3 sin depender del entorno real.
|
||||||
|
- **El paso 3 deriva `$HOME` dinámicamente** con `os.UserHomeDir()` — nunca hay paths de usuario hardcodeados.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveRegistryRoot(t *testing.T) {
|
||||||
|
t.Run("env var apunta a tmpdir con registry.db", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "registry.db")
|
||||||
|
if err := os.WriteFile(dbPath, []byte("SQLite format 3\x00"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("FN_REGISTRY_ROOT", dir)
|
||||||
|
|
||||||
|
got, err := ResolveRegistryRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(dir) {
|
||||||
|
t.Errorf("got %q, want %q", got, filepath.Clean(dir))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HOME apunta a tmpdir con fn_registry/registry.db", func(t *testing.T) {
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
registryDir := filepath.Join(homeDir, "fn_registry")
|
||||||
|
if err := os.MkdirAll(registryDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(registryDir, "registry.db")
|
||||||
|
if err := os.WriteFile(dbPath, []byte("SQLite format 3\x00"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Clear env var so we fall through to the HOME check.
|
||||||
|
t.Setenv("FN_REGISTRY_ROOT", "")
|
||||||
|
t.Setenv("HOME", homeDir)
|
||||||
|
|
||||||
|
got, err := ResolveRegistryRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if got != registryDir {
|
||||||
|
t.Errorf("got %q, want %q", got, registryDir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nada existe devuelve error", func(t *testing.T) {
|
||||||
|
emptyHome := t.TempDir()
|
||||||
|
t.Setenv("FN_REGISTRY_ROOT", "")
|
||||||
|
t.Setenv("HOME", emptyHome)
|
||||||
|
|
||||||
|
_, err := ResolveRegistryRoot()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when nothing exists, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user