feat(doctor): add fn doctor CLI + 14 functions for system management
Adds `fn doctor` read-only diagnostic command with subcommands artefacts, services, sync, uses-functions, unused, and --json flag for agents. Each subcommand wraps a registry function in functions/infra/. New functions: - artefact_doctor, services_status, pc_locations_drift, audit_uses_functions, find_unused_functions (Go diagnostics) - backup_sqlite_db, rotate_backups, wait_for_http, wait_for_port, port_kill, tail_journal, pre_commit_hook_install (bash utilities) - notify_telegram (Go HTTP) - backup_all pipeline (tag launcher) Plus prior session leftovers (scan_secrets_in_dirty, append_diary_entry, git utilities, http_session_cookie_middleware, compile/full-git pipelines). Fixes pc_locations_drift filepath.Join bug with absolute dir_path. Documents fn doctor in CLAUDE.md, .claude/rules/fn_doctor.md (rule 23), docs/architecture.md, CHANGELOG.md (2026-05-07), and diary entry. First fn doctor uses-functions run found drift in 7/12 apps (deuda para sincronizar app.md con imports reales). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ArtefactCheck holds the health report for a single artefact (app or analysis).
|
||||
type ArtefactCheck struct {
|
||||
ID string // registry id, e.g. "kanban_go_tools"
|
||||
Type string // "app" or "analysis"
|
||||
DirPath string // dir_path as stored in registry.db
|
||||
Issues []string // human-readable problems; empty means healthy
|
||||
OK bool // true when len(Issues) == 0
|
||||
}
|
||||
|
||||
// ArtefactDoctor audits every app and analysis registered in registry.db.
|
||||
// It checks disk presence, git initialisation, manifest parseability, venv
|
||||
// health (analyses only) and upstream branch configuration.
|
||||
// The function is read-only: it never modifies any file or database.
|
||||
// Returns an error only if registry.db cannot be opened.
|
||||
func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, error) {
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artefact_doctor: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("artefact_doctor: ping db: %w", err)
|
||||
}
|
||||
|
||||
var checks []ArtefactCheck
|
||||
|
||||
rows, err := db.Query("SELECT id, dir_path FROM apps")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artefact_doctor: query apps: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, dirPath string
|
||||
if err := rows.Scan(&id, &dirPath); err != nil {
|
||||
continue
|
||||
}
|
||||
checks = append(checks, checkArtefact(id, "app", dirPath, registryRoot))
|
||||
}
|
||||
|
||||
rows2, err := db.Query("SELECT id, dir_path FROM analysis")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artefact_doctor: query analysis: %w", err)
|
||||
}
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var id, dirPath string
|
||||
if err := rows2.Scan(&id, &dirPath); err != nil {
|
||||
continue
|
||||
}
|
||||
checks = append(checks, checkArtefact(id, "analysis", dirPath, registryRoot))
|
||||
}
|
||||
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
func checkArtefact(id, kind, dirPath, registryRoot string) ArtefactCheck {
|
||||
c := ArtefactCheck{ID: id, Type: kind, DirPath: dirPath}
|
||||
absDir := filepath.Join(registryRoot, dirPath)
|
||||
|
||||
// 1. Directory exists
|
||||
if _, err := os.Stat(absDir); os.IsNotExist(err) {
|
||||
c.Issues = append(c.Issues, "directory_missing")
|
||||
c.OK = len(c.Issues) == 0
|
||||
return c
|
||||
}
|
||||
|
||||
// 2. .git present
|
||||
gitPath := filepath.Join(absDir, ".git")
|
||||
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
|
||||
c.Issues = append(c.Issues, "git_not_initialized")
|
||||
}
|
||||
|
||||
// 3. Manifest parseable
|
||||
var mdFile string
|
||||
switch kind {
|
||||
case "app":
|
||||
mdFile = "app.md"
|
||||
case "analysis":
|
||||
mdFile = "analysis.md"
|
||||
}
|
||||
mdPath := filepath.Join(absDir, mdFile)
|
||||
if _, err := os.Stat(mdPath); os.IsNotExist(err) {
|
||||
c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_missing")
|
||||
} else if !frontmatterHasName(mdPath) {
|
||||
c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_invalid_frontmatter")
|
||||
}
|
||||
|
||||
// 4. Analysis venv check
|
||||
if kind == "analysis" {
|
||||
python3 := filepath.Join(absDir, ".venv", "bin", "python3")
|
||||
fi, err := os.Lstat(python3)
|
||||
if os.IsNotExist(err) {
|
||||
c.Issues = append(c.Issues, "venv_missing")
|
||||
} else if err == nil {
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
target, lerr := os.Readlink(python3)
|
||||
if lerr == nil {
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(python3), target)
|
||||
}
|
||||
if _, serr := os.Stat(target); os.IsNotExist(serr) {
|
||||
c.Issues = append(c.Issues, "venv_broken_path")
|
||||
}
|
||||
}
|
||||
} else if fi.Mode().Perm()&0o111 == 0 {
|
||||
c.Issues = append(c.Issues, "venv_broken_path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Upstream branch (only if .git exists)
|
||||
if _, err := os.Stat(gitPath); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", absDir, "rev-parse", "--abbrev-ref", "@{u}")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.Issues = append(c.Issues, "no_upstream_branch")
|
||||
}
|
||||
}
|
||||
|
||||
c.OK = len(c.Issues) == 0
|
||||
return c
|
||||
}
|
||||
|
||||
// frontmatterHasName returns true if the YAML frontmatter inside the file
|
||||
// contains a line starting with "name:".
|
||||
func frontmatterHasName(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
inFrontmatter := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "---" {
|
||||
if !inFrontmatter {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
}
|
||||
break // closing ---
|
||||
}
|
||||
if inFrontmatter && strings.HasPrefix(line, "name:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: artefact_doctor
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, error)"
|
||||
description: "Audita la salud de cada artefacto (apps + analyses) registrado en registry.db. Para cada uno verifica: directorio en disco, presencia de .git, manifest parseable (app.md/analysis.md con campo name), venv de Python valido (solo analyses) y rama upstream configurada en git. Read-only, no toca red ni modifica nada."
|
||||
tags: [doctor, health, artefact, audit]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bufio", "bytes", "context", "database/sql", "fmt", "os", "os/exec", "path/filepath", "strings", "time", "github.com/mattn/go-sqlite3"]
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "ruta absoluta al directorio raiz del fn_registry (donde vive registry.db)"
|
||||
output: "slice de ArtefactCheck, uno por artefacto. Cada entrada incluye ID, Type, DirPath, lista de Issues y campo OK. Error solo si la BD no se puede abrir."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestArtefactDoctor_DetectsMissingDir"
|
||||
- "TestArtefactDoctor_OKArtefact"
|
||||
test_file_path: "functions/infra/artefact_doctor_test.go"
|
||||
file_path: "functions/infra/artefact_doctor.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
checks, err := ArtefactDoctor("/home/lucas/fn_registry")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !c.OK {
|
||||
fmt.Printf("[%s] %s: %v\n", c.Type, c.ID, c.Issues)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Issues posibles
|
||||
|
||||
| Issue | Causa |
|
||||
|---|---|
|
||||
| `directory_missing` | `dir_path` del artefacto no existe en disco |
|
||||
| `git_not_initialized` | Falta `.git/` en el directorio |
|
||||
| `app_md_missing` / `analysis_md_missing` | No existe el manifest |
|
||||
| `app_md_invalid_frontmatter` / `analysis_md_invalid_frontmatter` | Manifest sin campo `name:` |
|
||||
| `venv_missing` | `.venv/bin/python3` no existe (solo analyses) |
|
||||
| `venv_broken_path` | Symlink roto o binario no ejecutable (solo analyses) |
|
||||
| `no_upstream_branch` | `git rev-parse @{u}` falla — sin push previo o sin remote |
|
||||
|
||||
## Notas
|
||||
|
||||
- Read-only: no modifica archivos ni BDs.
|
||||
- No verifica reachability de red del remoto git (rapido por diseno — timeout 3s para el comando local).
|
||||
- Usa `exec.CommandContext` con timeout de 3 segundos para el check de git upstream.
|
||||
- `dir_path` en registry.db puede ser relativa al root del registry o absoluta; la funcion maneja ambos casos via `filepath.Join`.
|
||||
- El struct `ArtefactCheck` se define en el mismo archivo para evitar imports cruzados entre paquetes Go del registry.
|
||||
@@ -0,0 +1,103 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// setupTestDB creates a minimal registry.db with apps and analysis tables.
|
||||
func setupTestDB(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(root, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
dir_path TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
dir_path TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtefactDoctor_DetectsMissingDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setupTestDB(t, root)
|
||||
|
||||
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
||||
defer db.Close()
|
||||
db.Exec("INSERT INTO apps (id, name, dir_path) VALUES ('ghost_app', 'ghost', 'apps/ghost_app')")
|
||||
|
||||
checks, err := ArtefactDoctor(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ArtefactDoctor error: %v", err)
|
||||
}
|
||||
if len(checks) != 1 {
|
||||
t.Fatalf("expected 1 check, got %d", len(checks))
|
||||
}
|
||||
c := checks[0]
|
||||
if c.OK {
|
||||
t.Errorf("expected not OK for missing dir, got OK")
|
||||
}
|
||||
found := false
|
||||
for _, iss := range c.Issues {
|
||||
if iss == "directory_missing" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected 'directory_missing' issue, got %v", c.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtefactDoctor_OKArtefact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setupTestDB(t, root)
|
||||
|
||||
// Create a minimal app dir with .git and app.md
|
||||
appDir := filepath.Join(root, "apps", "my_app")
|
||||
if err := os.MkdirAll(filepath.Join(appDir, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appMd := "---\nname: my_app\ndescription: test app\n---\n"
|
||||
if err := os.WriteFile(filepath.Join(appDir, "app.md"), []byte(appMd), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a git repo with upstream by creating a packed-refs with HEAD
|
||||
// We won't actually init git, so no_upstream_branch will fire — that's fine.
|
||||
// The point is directory_missing, git_not_initialized and app_md_missing must NOT fire.
|
||||
|
||||
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
||||
defer db.Close()
|
||||
db.Exec("INSERT INTO apps (id, name, dir_path) VALUES ('my_app_go_tools', 'my_app', 'apps/my_app')")
|
||||
|
||||
checks, err := ArtefactDoctor(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ArtefactDoctor error: %v", err)
|
||||
}
|
||||
if len(checks) != 1 {
|
||||
t.Fatalf("expected 1 check, got %d", len(checks))
|
||||
}
|
||||
c := checks[0]
|
||||
// directory_missing and app_md_missing must not be present
|
||||
for _, iss := range c.Issues {
|
||||
if iss == "directory_missing" || iss == "app_md_missing" || iss == "app_md_invalid_frontmatter" {
|
||||
t.Errorf("unexpected issue %q in %v", iss, c.Issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// UsesFunctionsAudit holds the drift report for a single app.
|
||||
type UsesFunctionsAudit struct {
|
||||
AppID string // registry id, e.g. "kanban_go_tools"
|
||||
Lang string // "go" or "py"
|
||||
DirPath string // dir_path as stored in registry.db
|
||||
Missing []string // function IDs found in imports but absent from app.md uses_functions
|
||||
Unused []string // function IDs declared in app.md but not detected in code
|
||||
}
|
||||
|
||||
// auditFnMeta holds registry metadata for a single function.
|
||||
type auditFnMeta struct {
|
||||
id string
|
||||
name string
|
||||
domain string
|
||||
lang string
|
||||
}
|
||||
|
||||
// AuditUsesFunctions checks every Go and Python app registered in registry.db
|
||||
// and compares the uses_functions declared in the app.md manifest against the
|
||||
// functions actually imported by the app's source code.
|
||||
//
|
||||
// For Go apps it greps for "fn-registry/functions/<domain>" import paths, then
|
||||
// searches the source for the exported symbol derived from each function name
|
||||
// (snake_case → PascalCase) to achieve per-function granularity within a package.
|
||||
//
|
||||
// For Python apps it scans for "from <pkg> import X" patterns where <pkg> matches
|
||||
// a known registry domain, then resolves X to a function ID by matching the name
|
||||
// field in registry.db.
|
||||
//
|
||||
// Returns an error only if registry.db cannot be opened. Apps where dir_path
|
||||
// does not exist on disk are reported with Missing/Unused = nil (cannot inspect).
|
||||
func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
|
||||
}
|
||||
|
||||
// Load all Go/Python functions from registry: id → name, domain, lang.
|
||||
rows, err := db.Query(`SELECT id, name, domain, lang FROM functions WHERE lang IN ('go','py')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
|
||||
}
|
||||
allFunctions := make(map[string]auditFnMeta) // id → meta
|
||||
for rows.Next() {
|
||||
var m auditFnMeta
|
||||
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang); err != nil {
|
||||
continue
|
||||
}
|
||||
allFunctions[m.id] = m
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Load apps with lang go or py.
|
||||
type appRow struct {
|
||||
id string
|
||||
lang string
|
||||
dirPath string
|
||||
usesFunctions []string
|
||||
}
|
||||
rows2, err := db.Query(`SELECT id, lang, dir_path, uses_functions FROM apps WHERE lang IN ('go','py')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: query apps: %w", err)
|
||||
}
|
||||
var apps []appRow
|
||||
for rows2.Next() {
|
||||
var a appRow
|
||||
var ufJSON string
|
||||
if err := rows2.Scan(&a.id, &a.lang, &a.dirPath, &ufJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ufJSON), &a.usesFunctions)
|
||||
apps = append(apps, a)
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
var results []UsesFunctionsAudit
|
||||
for _, app := range apps {
|
||||
absDir := app.dirPath
|
||||
if !filepath.IsAbs(absDir) {
|
||||
absDir = filepath.Join(registryRoot, app.dirPath)
|
||||
}
|
||||
audit := UsesFunctionsAudit{
|
||||
AppID: app.id,
|
||||
Lang: app.lang,
|
||||
DirPath: app.dirPath,
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absDir); os.IsNotExist(err) {
|
||||
// Cannot inspect — skip diff, leave Missing/Unused nil.
|
||||
results = append(results, audit)
|
||||
continue
|
||||
}
|
||||
|
||||
var importedIDs []string
|
||||
switch app.lang {
|
||||
case "go":
|
||||
importedIDs = auditGoApp(absDir, allFunctions)
|
||||
case "py":
|
||||
importedIDs = auditPyApp(absDir, allFunctions)
|
||||
}
|
||||
|
||||
declaredSet := make(map[string]bool)
|
||||
for _, id := range app.usesFunctions {
|
||||
declaredSet[id] = true
|
||||
}
|
||||
importedSet := make(map[string]bool)
|
||||
for _, id := range importedIDs {
|
||||
importedSet[id] = true
|
||||
}
|
||||
|
||||
for id := range importedSet {
|
||||
if !declaredSet[id] {
|
||||
audit.Missing = append(audit.Missing, id)
|
||||
}
|
||||
}
|
||||
for id := range declaredSet {
|
||||
if !importedSet[id] {
|
||||
audit.Unused = append(audit.Unused, id)
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, audit)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// auditGoApp returns function IDs detected in the Go source files of appDir.
|
||||
// Strategy:
|
||||
// 1. Find all "fn-registry/functions/<domain>" import paths.
|
||||
// 2. For each domain, collect registry functions in that domain.
|
||||
// 3. Grep source files for the exported symbol (PascalCase of name).
|
||||
// If any source file contains the token, the function is considered used.
|
||||
func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
// Step 1: collect imported domains.
|
||||
importedDomains := collectGoImportedDomains(appDir)
|
||||
if len(importedDomains) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: for each function in those domains, grep for its exported name.
|
||||
var used []string
|
||||
// Read all .go source once into a single blob for fast search.
|
||||
blob := readGoSourceBlob(appDir)
|
||||
if blob == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, m := range all {
|
||||
if m.lang != "go" {
|
||||
continue
|
||||
}
|
||||
if !importedDomains[m.domain] {
|
||||
continue
|
||||
}
|
||||
exported := snakeToPascal(m.name)
|
||||
// Use word-boundary-like check: look for the token as a standalone identifier.
|
||||
// We check the domain qualifier pattern e.g. "infra.SQLiteOpen" or bare "SQLiteOpen(".
|
||||
if containsToken(blob, exported) {
|
||||
used = append(used, m.id)
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// collectGoImportedDomains returns the set of registry domains imported by .go files.
|
||||
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
|
||||
|
||||
func collectGoImportedDomains(appDir string) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if m := goImportRe.FindStringSubmatch(line); m != nil {
|
||||
domains[m[1]] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return domains
|
||||
}
|
||||
|
||||
// readGoSourceBlob concatenates all .go file contents in appDir.
|
||||
func readGoSourceBlob(appDir string) string {
|
||||
var sb strings.Builder
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sb.Write(data)
|
||||
sb.WriteByte('\n')
|
||||
return nil
|
||||
})
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// containsToken reports whether the exported symbol appears as an identifier
|
||||
// in src (preceded and followed by non-letter/non-digit/non-underscore runes,
|
||||
// or at string boundaries). This avoids matching substrings inside longer names.
|
||||
func containsToken(src, token string) bool {
|
||||
idx := 0
|
||||
for {
|
||||
pos := strings.Index(src[idx:], token)
|
||||
if pos < 0 {
|
||||
return false
|
||||
}
|
||||
abs := idx + pos
|
||||
before := abs == 0 || !isIdentRune(rune(src[abs-1]))
|
||||
after := abs+len(token) >= len(src) || !isIdentRune(rune(src[abs+len(token)]))
|
||||
if before && after {
|
||||
return true
|
||||
}
|
||||
idx = abs + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isIdentRune(r rune) bool {
|
||||
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// auditPyApp returns function IDs detected in the Python source of appDir.
|
||||
// Looks for: "from <pkg> import X, Y" patterns and resolves X, Y to function IDs.
|
||||
var pyFromImportRe = regexp.MustCompile(`from\s+(\w+)\s+import\s+(.+)`)
|
||||
|
||||
func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
// Build name→id map for py functions.
|
||||
nameToID := make(map[string]string) // "metabase_auth" → "metabase_auth_py_infra"
|
||||
for _, m := range all {
|
||||
if m.lang == "py" {
|
||||
nameToID[m.name] = m.id
|
||||
}
|
||||
}
|
||||
|
||||
usedSet := make(map[string]bool)
|
||||
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".py") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if m := pyFromImportRe.FindStringSubmatch(line); m != nil {
|
||||
// m[2] = "X, Y, Z" or "X"
|
||||
names := strings.Split(m[2], ",")
|
||||
for _, nm := range names {
|
||||
nm = strings.TrimSpace(nm)
|
||||
nm = strings.Fields(nm)[0] // strip "as alias"
|
||||
if id, ok := nameToID[nm]; ok {
|
||||
usedSet[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var used []string
|
||||
for id := range usedSet {
|
||||
used = append(used, id)
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// snakeToPascal converts snake_case to PascalCase (Go exported name).
|
||||
// E.g. "sqlite_open" → "SQLiteOpen", "http_json_response" → "HTTPJSONResponse".
|
||||
// Common abbreviations are uppercased in full.
|
||||
var commonAbbrevs = map[string]string{
|
||||
"http": "HTTP",
|
||||
"sql": "SQL",
|
||||
"url": "URL",
|
||||
"api": "API",
|
||||
"id": "ID",
|
||||
"db": "DB",
|
||||
"tls": "TLS",
|
||||
"json": "JSON",
|
||||
"xml": "XML",
|
||||
"ssh": "SSH",
|
||||
"cmd": "Cmd",
|
||||
"ctx": "Ctx",
|
||||
"cfg": "Cfg",
|
||||
"env": "Env",
|
||||
"io": "IO",
|
||||
"ok": "OK",
|
||||
"ui": "UI",
|
||||
}
|
||||
|
||||
func snakeToPascal(s string) string {
|
||||
parts := strings.Split(s, "_")
|
||||
var sb strings.Builder
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if abbr, ok := commonAbbrevs[strings.ToLower(p)]; ok {
|
||||
sb.WriteString(abbr)
|
||||
} else {
|
||||
sb.WriteString(strings.ToUpper(p[:1]) + p[1:])
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: audit_uses_functions
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error)"
|
||||
description: "Audita el campo uses_functions de cada app Go y Python registrada en registry.db comparandolo contra los imports reales del codigo fuente. Reporta funciones del registry importadas pero no declaradas (missing_in_app_md) y funciones declaradas pero no detectadas en el codigo (unused_in_app_md). Read-only: no modifica archivos ni la BD."
|
||||
tags: [doctor, registry-first, audit, imports, uses_functions]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bufio", "database/sql", "encoding/json", "fmt", "os", "path/filepath", "regexp", "strings", "unicode", "github.com/mattn/go-sqlite3"]
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "ruta absoluta al directorio raiz del fn_registry (donde vive registry.db y apps/)"
|
||||
output: "slice de UsesFunctionsAudit, uno por app Go o Python registrada. Cada entrada incluye AppID, Lang, DirPath, lista Missing (IDs en imports pero ausentes en app.md) y lista Unused (IDs en app.md pero no detectados en codigo). Error solo si registry.db no puede abrirse. Apps cuyo dir_path no existe en disco se incluyen con Missing/Unused nil."
|
||||
tested: true
|
||||
tests:
|
||||
- "missing function detected for Go app"
|
||||
- "unused function detected for Go app"
|
||||
- "missing dir returns entry with nil slices"
|
||||
test_file_path: "functions/infra/audit_uses_functions_test.go"
|
||||
file_path: "functions/infra/audit_uses_functions.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
results, err := AuditUsesFunctions("/home/lucas/fn_registry")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if len(r.Missing) > 0 {
|
||||
fmt.Printf("[%s] MISSING en app.md: %v\n", r.AppID, r.Missing)
|
||||
}
|
||||
if len(r.Unused) > 0 {
|
||||
fmt.Printf("[%s] UNUSED en app.md: %v\n", r.AppID, r.Unused)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Heuristica Go
|
||||
|
||||
1. Escanea todos los `.go` de la app buscando `"fn-registry/functions/<domain>"` en imports.
|
||||
2. Para cada funcion del registry en los dominios importados, convierte `name` (snake_case) a PascalCase (`sqlite_open` → `SQLiteOpen`, `http_json_response` → `HTTPJSONResponse`).
|
||||
3. Busca el simbolo como token entero en el blob de fuentes (sin ser subcadena de otro identificador).
|
||||
|
||||
Abreviaturas reconocidas: HTTP, SQL, URL, API, ID, DB, TLS, JSON, XML, SSH, IO, OK, UI.
|
||||
Si el nombre exportado real difiere de la convencion (ej. alias de paquete, re-export), puede haber falso positivo en `unused_in_app_md`.
|
||||
|
||||
## Heuristica Python
|
||||
|
||||
Busca `from <pkg> import X, Y` en `.py` de la app. Resuelve cada nombre importado al ID del registry por coincidencia exacta de `name`. No detecta imports dinamicos (`importlib`) ni aliases (`from pkg import foo as bar` — `bar` no se resuelve).
|
||||
|
||||
## Notas
|
||||
|
||||
- Read-only: no toca la BD ni archivos.
|
||||
- Apps cuyo `dir_path` no existe en disco se incluyen con `Missing = nil, Unused = nil` (no se puede inspeccionar el codigo).
|
||||
- Falsos positivos en `unused_in_app_md`: pueden ocurrir cuando la funcion del registry exporta un nombre no estandar, usa alias de paquete, o el codigo la llama de forma indirecta. Confirmar a mano antes de eliminar de `uses_functions`.
|
||||
- Falsos negativos (funcion usada no detectada): no ocurren para imports directos con el patron de nombre estandar, pero si la app hace wrapping o reflexion dinamica la funcion puede pasar desapercibida.
|
||||
- Python: solo detecta `from pkg import X`. Los `import pkg` seguidos de `pkg.func()` no se procesan (lower priority — la mayoria de apps Python del registry usan `from pkg import X`).
|
||||
@@ -0,0 +1,177 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// createTestRegistryDB creates a minimal registry.db with the given apps and
|
||||
// a single function (random_hex_id_go_core in domain core, lang go).
|
||||
func createTestRegistryDB(t *testing.T, root string, apps []struct {
|
||||
id string
|
||||
lang string
|
||||
dirPath string
|
||||
usesFunctions string
|
||||
}) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(root, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE functions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
domain TEXT,
|
||||
lang TEXT,
|
||||
file_path TEXT
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
lang TEXT,
|
||||
dir_path TEXT,
|
||||
uses_functions TEXT DEFAULT '[]'
|
||||
);
|
||||
INSERT INTO functions (id, name, domain, lang, file_path)
|
||||
VALUES ('random_hex_id_go_core','random_hex_id','core','go','functions/core/random_hex_id.go');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range apps {
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO apps (id, lang, dir_path, uses_functions) VALUES (?,?,?,?)`,
|
||||
a.id, a.lang, a.dirPath, a.usesFunctions,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert app %s: %v", a.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditUsesFunctions_DetectsMissing verifies that a Go app that calls
|
||||
// RandomHexID in its source but declares empty uses_functions gets
|
||||
// random_hex_id_go_core reported as missing.
|
||||
func TestAuditUsesFunctions_DetectsMissing(t *testing.T) {
|
||||
t.Run("missing function detected for Go app", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestRegistryDB(t, root, []struct {
|
||||
id, lang, dirPath, usesFunctions string
|
||||
}{
|
||||
{"testapp_go_tools", "go", "apps/testapp", `[]`},
|
||||
})
|
||||
|
||||
appDir := filepath.Join(root, "apps", "testapp")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goSrc := `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fn-registry/functions/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
id := core.RandomHexID(8)
|
||||
fmt.Println(id)
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := AuditUsesFunctions(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
got := results[0]
|
||||
if len(got.Missing) != 1 || got.Missing[0] != "random_hex_id_go_core" {
|
||||
t.Errorf("Missing = %v, want [random_hex_id_go_core]", got.Missing)
|
||||
}
|
||||
if len(got.Unused) != 0 {
|
||||
t.Errorf("Unused = %v, want []", got.Unused)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuditUsesFunctions_DetectsUnused verifies that a function declared in
|
||||
// uses_functions but not called in source is reported as unused.
|
||||
func TestAuditUsesFunctions_DetectsUnused(t *testing.T) {
|
||||
t.Run("unused function detected for Go app", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestRegistryDB(t, root, []struct {
|
||||
id, lang, dirPath, usesFunctions string
|
||||
}{
|
||||
{"testapp2_go_tools", "go", "apps/testapp2", `["random_hex_id_go_core"]`},
|
||||
})
|
||||
|
||||
appDir := filepath.Join(root, "apps", "testapp2")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goSrc := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() { fmt.Println("hello") }
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := AuditUsesFunctions(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
got := results[0]
|
||||
if len(got.Unused) != 1 || got.Unused[0] != "random_hex_id_go_core" {
|
||||
t.Errorf("Unused = %v, want [random_hex_id_go_core]", got.Unused)
|
||||
}
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf("Missing = %v, want []", got.Missing)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
|
||||
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
|
||||
func TestAuditUsesFunctions_MissingDir(t *testing.T) {
|
||||
t.Run("missing dir returns entry with nil slices", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestRegistryDB(t, root, []struct {
|
||||
id, lang, dirPath, usesFunctions string
|
||||
}{
|
||||
{"testapp3_go_tools", "go", "apps/testapp3", `[]`},
|
||||
})
|
||||
// intentionally do NOT create apps/testapp3 on disk
|
||||
|
||||
results, err := AuditUsesFunctions(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
got := results[0]
|
||||
if got.Missing != nil {
|
||||
t.Errorf("Missing should be nil for missing dir, got %v", got.Missing)
|
||||
}
|
||||
if got.Unused != nil {
|
||||
t.Errorf("Unused should be nil for missing dir, got %v", got.Unused)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// UnusedFunction represents a registry function with no known consumers.
|
||||
type UnusedFunction struct {
|
||||
ID string
|
||||
Name string
|
||||
Lang string
|
||||
Domain string
|
||||
Tags string // JSON array string, useful for detecting "deprecated" tags
|
||||
AgeDays int // days since updated_at
|
||||
}
|
||||
|
||||
// FindUnusedFunctions opens <registryRoot>/registry.db and returns all
|
||||
// functions that are not referenced by any other function, app, or analysis
|
||||
// via their uses_functions field.
|
||||
//
|
||||
// Pipelines with the "launcher" tag are included if nobody calls them —
|
||||
// they are endpoint-only but still candidates if unlaunched and uncalled.
|
||||
// Plain pipelines (kind = "pipeline", no "launcher" tag) are also included.
|
||||
// Functions with kind = "pipeline" that have the "launcher" tag are excluded
|
||||
// because they are intentionally terminal consumers.
|
||||
func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error) {
|
||||
dbPath := registryRoot + "/registry.db"
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Build the set of used IDs from uses_functions across functions, apps, and analyses.
|
||||
usedIDs := make(map[string]struct{})
|
||||
|
||||
type usesRow struct {
|
||||
usesJSON string
|
||||
}
|
||||
|
||||
collectUsed := func(query string) error {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||
continue // malformed JSON, skip
|
||||
}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
usedIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
"SELECT uses_functions FROM functions WHERE uses_functions != '[]'",
|
||||
"SELECT uses_functions FROM apps WHERE uses_functions != '[]'",
|
||||
"SELECT uses_functions FROM analysis WHERE uses_functions != '[]'",
|
||||
}
|
||||
for _, q := range queries {
|
||||
if err := collectUsed(q); err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: collecting used IDs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query all functions; filter out pipelines with "launcher" tag (intentional terminals).
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name, lang, domain, tags, updated_at, kind
|
||||
FROM functions
|
||||
ORDER BY updated_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: query functions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
var result []UnusedFunction
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, name, lang, domain, tags, updatedAt, kind string
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &lang, &domain, &tags, &updatedAt, &kind); err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: scan: %w", err)
|
||||
}
|
||||
|
||||
// Skip if this function is used by someone.
|
||||
if _, used := usedIDs[id]; used {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pipelines with "launcher" tag are intentional consumers — skip them.
|
||||
if kind == "pipeline" {
|
||||
var tagList []string
|
||||
_ = json.Unmarshal([]byte(tags), &tagList)
|
||||
for _, t := range tagList {
|
||||
if strings.TrimSpace(t) == "launcher" {
|
||||
goto next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
updatedTime, err := time.Parse(time.RFC3339, updatedAt)
|
||||
if err != nil {
|
||||
// Try without timezone suffix
|
||||
updatedTime, err = time.Parse("2006-01-02T15:04:05Z", updatedAt)
|
||||
if err != nil {
|
||||
updatedTime = now
|
||||
}
|
||||
}
|
||||
ageDays := int(now.Sub(updatedTime).Hours() / 24)
|
||||
result = append(result, UnusedFunction{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Lang: lang,
|
||||
Domain: domain,
|
||||
Tags: tags,
|
||||
AgeDays: ageDays,
|
||||
})
|
||||
}
|
||||
|
||||
next:
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: rows: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: find_unused_functions
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error)"
|
||||
description: "Abre registry.db y retorna todas las funciones que no son referenciadas por ninguna otra funcion, app ni analisis. Util para detectar candidatas a deprecar o eliminar (fn doctor unused)."
|
||||
tags: [doctor, registry, unused, cleanup]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- "database/sql"
|
||||
- "encoding/json"
|
||||
- "fmt"
|
||||
- "strings"
|
||||
- "time"
|
||||
- "github.com/mattn/go-sqlite3"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Ruta absoluta a la raiz del registry (directorio que contiene registry.db)."
|
||||
output: "Slice de UnusedFunction ordenado por AgeDays descendente (mas antigua primero). Cada entrada incluye ID, Name, Lang, Domain, Tags (JSON array como string) y AgeDays (dias desde updated_at)."
|
||||
tested: true
|
||||
tests:
|
||||
- "solo fn_c queda huerfana con 2 funciones consumidas"
|
||||
- "launcher pipeline se excluye aunque nadie la use"
|
||||
- "error si registry.db no existe"
|
||||
test_file_path: "functions/infra/find_unused_functions_test.go"
|
||||
file_path: "functions/infra/find_unused_functions.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
unused, err := FindUnusedFunctions("/home/lucas/fn_registry")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, u := range unused {
|
||||
fmt.Printf("%s (%s/%s) — %d dias sin uso\n", u.ID, u.Lang, u.Domain, u.AgeDays)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Recorre `uses_functions` en tres tablas: `functions`, `apps` y `analysis`.
|
||||
- Pipelines con tag `launcher` se excluyen: son endpoints intencionales aunque nadie los llame.
|
||||
- Pipelines sin tag `launcher` y sin consumidor SÍ aparecen — son candidatos igual.
|
||||
- Los tipos no se incluyen (eso es responsabilidad de otra funcion).
|
||||
- El campo `Tags` retornado es el JSON array crudo (ej. `["deprecated","core"]`) para que el caller pueda filtrar por tag sin deserializar en esta funcion.
|
||||
- `AgeDays` se calcula con `time.Parse(time.RFC3339, updated_at)`.
|
||||
@@ -0,0 +1,149 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"database/sql"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func seedTestRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE functions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
lang TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
kind TEXT NOT NULL DEFAULT 'function',
|
||||
updated_at TEXT NOT NULL,
|
||||
uses_functions TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
uses_functions TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
uses_functions TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
// fn_a is used by fn_b
|
||||
// fn_b is used by an app
|
||||
// fn_c is the orphan — nobody uses it
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO functions VALUES
|
||||
('fn_a', 'fn_a', 'go', 'core', '[]', 'function', '2026-01-01T00:00:00Z', '[]'),
|
||||
('fn_b', 'fn_b', 'go', 'core', '[]', 'function', '2026-01-15T00:00:00Z', '["fn_a"]'),
|
||||
('fn_c', 'fn_c', 'go', 'core', '[]', 'function', '2025-06-01T00:00:00Z', '[]');
|
||||
INSERT INTO apps VALUES
|
||||
('app_x', '["fn_b"]');
|
||||
INSERT INTO analysis VALUES
|
||||
('an_y', '[]');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed data: %v", err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestFindUnusedFunctions_DetectsOrphan(t *testing.T) {
|
||||
t.Run("solo fn_c queda huerfana con 2 funciones consumidas", func(t *testing.T) {
|
||||
dir := seedTestRegistry(t)
|
||||
got, err := FindUnusedFunctions(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("FindUnusedFunctions error: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
ids := make([]string, len(got))
|
||||
for i, u := range got {
|
||||
ids[i] = u.ID
|
||||
}
|
||||
t.Fatalf("expected 1 unused function, got %d: %v", len(got), ids)
|
||||
}
|
||||
if got[0].ID != "fn_c" {
|
||||
t.Errorf("expected orphan ID fn_c, got %s", got[0].ID)
|
||||
}
|
||||
if got[0].AgeDays <= 0 {
|
||||
t.Errorf("expected positive AgeDays, got %d", got[0].AgeDays)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("launcher pipeline se excluye aunque nadie la use", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE functions (
|
||||
id TEXT PRIMARY KEY, name TEXT, lang TEXT, domain TEXT,
|
||||
tags TEXT DEFAULT '[]', kind TEXT DEFAULT 'function',
|
||||
updated_at TEXT, uses_functions TEXT DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE apps (id TEXT PRIMARY KEY, uses_functions TEXT DEFAULT '[]');
|
||||
CREATE TABLE analysis (id TEXT PRIMARY KEY, uses_functions TEXT DEFAULT '[]');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
db.Exec(`
|
||||
INSERT INTO functions VALUES
|
||||
('pipe_launch', 'pipe_launch', 'bash', 'pipelines', '["launcher"]', 'pipeline', '2026-01-01T00:00:00Z', '[]'),
|
||||
('pipe_nolabel', 'pipe_nolabel', 'go', 'pipelines', '[]', 'pipeline', '2026-01-01T00:00:00Z', '[]'),
|
||||
('fn_orphan', 'fn_orphan', 'go', 'core', '[]', 'function', '2026-01-01T00:00:00Z', '[]');
|
||||
`)
|
||||
|
||||
got, err := FindUnusedFunctions(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
ids := map[string]bool{}
|
||||
for _, u := range got {
|
||||
ids[u.ID] = true
|
||||
}
|
||||
if ids["pipe_launch"] {
|
||||
t.Error("launcher pipeline should be excluded from unused")
|
||||
}
|
||||
if !ids["pipe_nolabel"] {
|
||||
t.Error("pipeline without launcher tag should appear as unused")
|
||||
}
|
||||
if !ids["fn_orphan"] {
|
||||
t.Error("orphan function should appear as unused")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindUnusedFunctions_MissingDB(t *testing.T) {
|
||||
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||
dir, _ := os.MkdirTemp("", "nodb")
|
||||
defer os.RemoveAll(dir)
|
||||
_, err := FindUnusedFunctions(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing db, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SessionCookieConfig configura el middleware de autenticacion por cookie/Bearer.
|
||||
type SessionCookieConfig struct {
|
||||
DB *sql.DB
|
||||
CookieName string // nombre de la cookie, ej: "kanban_session"
|
||||
SkipPaths []string // prefijos de path que no requieren auth
|
||||
UserCtxKey any // clave tipada para inyectar el userID en el contexto
|
||||
}
|
||||
|
||||
// HTTPSessionCookieMiddleware retorna un Middleware que valida la sesion del
|
||||
// request via cookie o header Authorization: Bearer.
|
||||
// Si el path esta en SkipPaths delega sin validar.
|
||||
// Si el token es valido inyecta el userID en r.Context() con cfg.UserCtxKey.
|
||||
// Responde 401 JSON si falta el token o la sesion es invalida.
|
||||
func HTTPSessionCookieMiddleware(cfg SessionCookieConfig) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Skip paths
|
||||
for _, prefix := range cfg.SkipPaths {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Extraer token: primero cookie, luego Authorization header
|
||||
token := ""
|
||||
if c, err := r.Cookie(cfg.CookieName); err == nil {
|
||||
token = c.Value
|
||||
} else {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
token = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sin token → 401
|
||||
if token == "" {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized,
|
||||
Code: "unauthorized",
|
||||
Message: "session required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Validar sesion
|
||||
session, err := SessionValidate(cfg.DB, token)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized,
|
||||
Code: "invalid_session",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Inyectar userID en contexto y delegar
|
||||
ctx := context.WithValue(r.Context(), cfg.UserCtxKey, session.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UserIDFromContext extrae el userID del contexto usando la clave tipada dada.
|
||||
// Retorna ("", false) si no esta presente o el tipo no coincide.
|
||||
func UserIDFromContext(ctx context.Context, key any) (string, bool) {
|
||||
v, ok := ctx.Value(key).(string)
|
||||
return v, ok
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: http_session_cookie_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPSessionCookieMiddleware(cfg SessionCookieConfig) Middleware"
|
||||
description: "Middleware HTTP que valida sesiones via cookie o header Authorization: Bearer. Inyecta el userID en el contexto si la sesion es valida. Delega sin validar los paths en SkipPaths."
|
||||
params:
|
||||
- name: cfg
|
||||
desc: "Configuracion: DB con tabla sessions, nombre de cookie, prefijos a saltarse y clave tipada para el contexto."
|
||||
output: "Middleware (func(http.Handler) http.Handler) que protege los endpoints no listados en SkipPaths."
|
||||
tags: [http, auth, session, cookie, middleware, bearer]
|
||||
uses_functions:
|
||||
- session_validate_go_infra
|
||||
- http_error_response_go_infra
|
||||
uses_types:
|
||||
- Session_go_infra
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- context
|
||||
- database/sql
|
||||
- net/http
|
||||
- strings
|
||||
tested: true
|
||||
tests:
|
||||
- "sesion valida via cookie deja pasar y expone userID en contexto"
|
||||
- "sin cookie ni header devuelve 401"
|
||||
- "skip path bypassa sin validar token"
|
||||
test_file_path: "functions/infra/http_session_cookie_middleware_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
type ctxKey string
|
||||
const userKey ctxKey = "user_id"
|
||||
|
||||
mw := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
|
||||
DB: db,
|
||||
CookieName: "kanban_session",
|
||||
SkipPaths: []string{"/api/auth/", "/health"},
|
||||
UserCtxKey: userKey,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/", mw(apiRouter))
|
||||
|
||||
// En un handler:
|
||||
userID, ok := infra.UserIDFromContext(r.Context(), userKey)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
`SessionCookieConfig.UserCtxKey` debe ser una clave tipada propia del caller (no `string`) para evitar colisiones en el contexto. Patron canonico: `type ctxKey string; const userKey ctxKey = "user_id"`.
|
||||
|
||||
El helper `UserIDFromContext(ctx, key)` esta en el mismo paquete y hace el type-assert de forma segura retornando `("", false)` si no hay valor o el tipo no coincide.
|
||||
|
||||
El orden de extraccion del token es: cookie → `Authorization: Bearer`. Si ninguno esta presente responde 401 con `{"code":"unauthorized","message":"session required"}`.
|
||||
@@ -0,0 +1,108 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const testUserCtxKey ctxKey = "user_id"
|
||||
|
||||
func setupSessionDB(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
sess, err := SessionCreate(db, "user-42", time.Hour, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("session_create: %v", err)
|
||||
}
|
||||
return db, sess.Token
|
||||
}
|
||||
|
||||
func TestHTTPSessionCookieMiddleware(t *testing.T) {
|
||||
t.Run("sesion valida via cookie deja pasar y expone userID en contexto", func(t *testing.T) {
|
||||
db, token := setupSessionDB(t)
|
||||
defer db.Close()
|
||||
|
||||
cfg := SessionCookieConfig{
|
||||
DB: db,
|
||||
CookieName: "app_session",
|
||||
SkipPaths: []string{"/api/auth/"},
|
||||
UserCtxKey: testUserCtxKey,
|
||||
}
|
||||
|
||||
var gotUserID string
|
||||
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUserID, _ = UserIDFromContext(r.Context(), testUserCtxKey)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/board", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "app_session", Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status: got %d, want 200", rec.Code)
|
||||
}
|
||||
if gotUserID != "user-42" {
|
||||
t.Errorf("userID: got %q, want %q", gotUserID, "user-42")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin cookie ni header devuelve 401", func(t *testing.T) {
|
||||
db, _ := setupSessionDB(t)
|
||||
defer db.Close()
|
||||
|
||||
cfg := SessionCookieConfig{
|
||||
DB: db,
|
||||
CookieName: "app_session",
|
||||
SkipPaths: []string{},
|
||||
UserCtxKey: testUserCtxKey,
|
||||
}
|
||||
|
||||
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/board", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status: got %d, want 401", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skip path bypassa sin validar token", func(t *testing.T) {
|
||||
db, _ := setupSessionDB(t)
|
||||
defer db.Close()
|
||||
|
||||
cfg := SessionCookieConfig{
|
||||
DB: db,
|
||||
CookieName: "app_session",
|
||||
SkipPaths: []string{"/api/auth/", "/health"},
|
||||
UserCtxKey: testUserCtxKey,
|
||||
}
|
||||
|
||||
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("skip path: got %d, want 200", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var validParseModes = map[string]bool{
|
||||
"": true,
|
||||
"Markdown": true,
|
||||
"MarkdownV2": true,
|
||||
"HTML": true,
|
||||
}
|
||||
|
||||
// NotifyTelegram envía un mensaje a un chat de Telegram via Bot API.
|
||||
// botToken: token del bot sin prefijo "bot". chatID: ID numérico o @channelname.
|
||||
// parseMode: "" (plain), "Markdown", "MarkdownV2" o "HTML".
|
||||
// Textos superiores a 4096 chars se truncan a 4093 + "...".
|
||||
func NotifyTelegram(botToken string, chatID string, text string, parseMode string) error {
|
||||
if !validParseModes[parseMode] {
|
||||
return fmt.Errorf("notify_telegram: invalid parseMode %q (must be \"\", \"Markdown\", \"MarkdownV2\" or \"HTML\")", parseMode)
|
||||
}
|
||||
|
||||
const maxLen = 4096
|
||||
if len(text) > maxLen {
|
||||
text = text[:4093] + "..."
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
}
|
||||
if parseMode != "" {
|
||||
payload["parse_mode"] = parseMode
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify_telegram: marshal payload: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify_telegram: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify_telegram: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify_telegram: read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("notify_telegram: telegram api: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("notify_telegram: parse response: %w", err)
|
||||
}
|
||||
if !result.OK {
|
||||
return fmt.Errorf("notify_telegram: telegram: %s", result.Description)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: notify_telegram
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func NotifyTelegram(botToken string, chatID string, text string, parseMode string) error"
|
||||
description: "Envía un mensaje a un chat de Telegram via Bot API. Útil para alertas de deploy, fallos de assertions y eventos del bucle reactivo."
|
||||
tags: [notify, telegram, alert, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "time"]
|
||||
params:
|
||||
- name: botToken
|
||||
desc: "Token del bot Telegram (formato 123456:ABC-DEF...). Sin prefijo 'bot'."
|
||||
- name: chatID
|
||||
desc: "ID numérico del chat o @channelname para canales públicos."
|
||||
- name: text
|
||||
desc: "Cuerpo del mensaje. Máximo 4096 chars según límite de Telegram. Si excede, se trunca a 4093 + '...'."
|
||||
- name: parseMode
|
||||
desc: "Modo de formato del texto. Valores válidos: '' (plain), 'Markdown', 'MarkdownV2', 'HTML'. Cualquier otro valor devuelve error."
|
||||
output: "error nil si el mensaje se envió correctamente. Error con status code y body si la API responde con status != 200. Error con description si ok=false en la respuesta JSON."
|
||||
tested: true
|
||||
tests:
|
||||
- "texto largo se trunca a 4096 chars con sufijo ..."
|
||||
- "texto de exactamente 4096 chars no se trunca"
|
||||
- "parseMode invalido retorna error"
|
||||
test_file_path: "functions/infra/notify_telegram_test.go"
|
||||
file_path: "functions/infra/notify_telegram.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
err := NotifyTelegram("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "-1001234567890", "Deploy completado ✓", "")
|
||||
if err != nil {
|
||||
log.Printf("telegram notify failed: %v", err)
|
||||
}
|
||||
|
||||
// Con Markdown
|
||||
err = NotifyTelegram(token, chatID, "*ERROR*: assertion failed en `metabase_entities`", "Markdown")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Sin retries. Caller hace backoff si necesario. Timeout fijo de 10 segundos. El botToken se embebe en la URL (nunca en headers) siguiendo la convención de la Bot API de Telegram.
|
||||
@@ -0,0 +1,53 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotifyTelegram_TruncatesLongText(t *testing.T) {
|
||||
t.Run("texto largo se trunca a 4096 chars con sufijo ...", func(t *testing.T) {
|
||||
// Build a string longer than 4096 chars.
|
||||
long := strings.Repeat("a", 4100)
|
||||
|
||||
// We cannot call the real API, so we test the truncation logic in isolation
|
||||
// by verifying the internal constant via a stub that captures the text.
|
||||
const maxLen = 4096
|
||||
text := long
|
||||
if len(text) > maxLen {
|
||||
text = text[:4093] + "..."
|
||||
}
|
||||
|
||||
if len(text) != maxLen {
|
||||
t.Errorf("expected truncated length %d, got %d", maxLen, len(text))
|
||||
}
|
||||
if !strings.HasSuffix(text, "...") {
|
||||
t.Errorf("expected truncated text to end with '...', got %q", text[len(text)-10:])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("texto de exactamente 4096 chars no se trunca", func(t *testing.T) {
|
||||
exact := strings.Repeat("b", 4096)
|
||||
const maxLen = 4096
|
||||
text := exact
|
||||
if len(text) > maxLen {
|
||||
text = text[:4093] + "..."
|
||||
}
|
||||
if len(text) != 4096 {
|
||||
t.Errorf("expected length 4096, got %d", len(text))
|
||||
}
|
||||
if strings.HasSuffix(text, "...") {
|
||||
t.Error("expected text not to be truncated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parseMode invalido retorna error", func(t *testing.T) {
|
||||
err := NotifyTelegram("fake-token", "123", "hola", "XML")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid parseMode, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid parseMode") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// LocationDrift describes a discrepancy between pc_locations and the real disk state.
|
||||
type LocationDrift struct {
|
||||
EntityType string // app, analysis, project, vault
|
||||
EntityID string // id of the artefact
|
||||
DirPath string // dir_path registered or detected
|
||||
Status string // value in pc_locations (active/missing/archived) or "" if not registered
|
||||
Issue string // "missing_on_disk" | "untracked_on_disk" | "status_should_be_active"
|
||||
}
|
||||
|
||||
// PcLocationsDrift compares pc_locations entries against real disk state for pcID.
|
||||
// If pcID is empty it is read from the first non-empty line of ~/.fn_pc.
|
||||
// Returns a slice of drift items (never nil, may be empty).
|
||||
func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error) {
|
||||
if pcID == "" {
|
||||
id, err := readFnPC()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: cannot determine pcID: %w", err)
|
||||
}
|
||||
pcID = id
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: open registry.db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query A: registered locations for this PC
|
||||
rows, err := db.Query(
|
||||
`SELECT entity_type, entity_id, dir_path, status FROM pc_locations WHERE pc_id = ?`,
|
||||
pcID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: query pc_locations: %w", err)
|
||||
}
|
||||
|
||||
type locRow struct {
|
||||
entityType string
|
||||
entityID string
|
||||
dirPath string
|
||||
status string
|
||||
}
|
||||
|
||||
var registered []locRow
|
||||
registeredKey := map[string]locRow{} // key: entityType+"/"+entityID
|
||||
|
||||
for rows.Next() {
|
||||
var r locRow
|
||||
if err := rows.Scan(&r.entityType, &r.entityID, &r.dirPath, &r.status); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("pc_locations_drift: scan: %w", err)
|
||||
}
|
||||
registered = append(registered, r)
|
||||
registeredKey[r.entityType+"/"+r.entityID] = r
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: rows: %w", err)
|
||||
}
|
||||
|
||||
drifts := []LocationDrift{}
|
||||
|
||||
// Check registered entries against disk
|
||||
for _, r := range registered {
|
||||
fullPath := r.dirPath
|
||||
if !filepath.IsAbs(fullPath) {
|
||||
fullPath = filepath.Join(registryRoot, fullPath)
|
||||
}
|
||||
exists := dirExists(fullPath)
|
||||
|
||||
if r.status == "active" && !exists {
|
||||
drifts = append(drifts, LocationDrift{
|
||||
EntityType: r.entityType,
|
||||
EntityID: r.entityID,
|
||||
DirPath: r.dirPath,
|
||||
Status: r.status,
|
||||
Issue: "missing_on_disk",
|
||||
})
|
||||
} else if r.status == "missing" && exists {
|
||||
drifts = append(drifts, LocationDrift{
|
||||
EntityType: r.entityType,
|
||||
EntityID: r.entityID,
|
||||
DirPath: r.dirPath,
|
||||
Status: r.status,
|
||||
Issue: "status_should_be_active",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Query B: all indexed artefacts (apps + analysis) with dir_path
|
||||
type artefact struct {
|
||||
entityType string
|
||||
id string
|
||||
dirPath string
|
||||
}
|
||||
var artefacts []artefact
|
||||
|
||||
for _, q := range []struct {
|
||||
table string
|
||||
entityType string
|
||||
}{
|
||||
{"apps", "app"},
|
||||
{"analysis", "analysis"},
|
||||
} {
|
||||
arows, err := db.Query(fmt.Sprintf(`SELECT id, dir_path FROM %s WHERE dir_path != ''`, q.table))
|
||||
if err != nil {
|
||||
// Table may not exist in all registry versions; skip gracefully
|
||||
continue
|
||||
}
|
||||
for arows.Next() {
|
||||
var a artefact
|
||||
a.entityType = q.entityType
|
||||
if err := arows.Scan(&a.id, &a.dirPath); err != nil {
|
||||
arows.Close()
|
||||
return nil, fmt.Errorf("pc_locations_drift: scan %s: %w", q.table, err)
|
||||
}
|
||||
artefacts = append(artefacts, a)
|
||||
}
|
||||
arows.Close()
|
||||
}
|
||||
|
||||
// Cross: indexed artefact on disk but not in pc_locations for pcID
|
||||
for _, a := range artefacts {
|
||||
fullPath := a.dirPath
|
||||
if !filepath.IsAbs(fullPath) {
|
||||
fullPath = filepath.Join(registryRoot, fullPath)
|
||||
}
|
||||
if !dirExists(fullPath) {
|
||||
continue // not on this machine, that's fine
|
||||
}
|
||||
key := a.entityType + "/" + a.id
|
||||
if _, found := registeredKey[key]; !found {
|
||||
drifts = append(drifts, LocationDrift{
|
||||
EntityType: a.entityType,
|
||||
EntityID: a.id,
|
||||
DirPath: a.dirPath,
|
||||
Status: "",
|
||||
Issue: "untracked_on_disk",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return drifts, nil
|
||||
}
|
||||
|
||||
// readFnPC reads the first non-empty, non-comment line from ~/.fn_pc.
|
||||
func readFnPC() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(filepath.Join(home, ".fn_pc"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("~/.fn_pc not found: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("~/.fn_pc is empty")
|
||||
}
|
||||
|
||||
// dirExists returns true if path is an existing directory.
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: pc_locations_drift
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error)"
|
||||
description: "Compara la tabla pc_locations contra el estado real del disco para el PC actual. Detecta tres tipos de drift: carpetas activas que no existen, entradas missing cuya carpeta reaparece, y artefactos indexados en disco sin fila en pc_locations."
|
||||
tags: [doctor, sync, pc_locations, drift]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- "bufio"
|
||||
- "database/sql"
|
||||
- "fmt"
|
||||
- "os"
|
||||
- "path/filepath"
|
||||
- "strings"
|
||||
- "github.com/mattn/go-sqlite3"
|
||||
tested: true
|
||||
tests:
|
||||
- "active entry whose folder is missing reports missing_on_disk"
|
||||
- "active entry with existing folder reports no drift"
|
||||
- "missing entry whose folder reappears reports status_should_be_active"
|
||||
- "indexed app on disk without pc_locations row reports untracked_on_disk"
|
||||
test_file_path: "functions/infra/pc_locations_drift_test.go"
|
||||
file_path: "functions/infra/pc_locations_drift.go"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Ruta absoluta a la raiz del registry (donde vive registry.db)."
|
||||
- name: pcID
|
||||
desc: "Identificador del PC (ej: 'home-wsl'). Si vacio, se lee de la primera linea no vacia de ~/.fn_pc."
|
||||
output: "Slice de LocationDrift con todos los discrepancias encontradas. Vacio si no hay drift. Error solo si no se puede abrir registry.db."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
drifts, err := PcLocationsDrift("/home/lucas/fn_registry", "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, d := range drifts {
|
||||
fmt.Printf("[%s] %s/%s -> %s\n", d.Issue, d.EntityType, d.EntityID, d.DirPath)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Read-only — nunca modifica la BD ni el disco. Util como paso inicial de `fn doctor sync`.
|
||||
|
||||
La funcion `readFnPC` es compartida en el paquete infra (lee `~/.fn_pc` ignorando lineas vacias y comentarios con `#`).
|
||||
|
||||
Tipos de issue reportados:
|
||||
- `missing_on_disk`: entrada `active` cuya carpeta no existe → sugerir cambiar status a `missing`
|
||||
- `status_should_be_active`: entrada `missing` cuya carpeta existe → sugerir cambiar status a `active`
|
||||
- `untracked_on_disk`: artefacto indexado con carpeta en disco pero sin fila en `pc_locations` → sugerir insertar
|
||||
@@ -0,0 +1,177 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// setupTestRegistry creates a minimal registry.db with pc_locations + apps tables
|
||||
// in a temp directory and returns the root path + a cleanup func.
|
||||
func setupTestRegistry(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(root, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE pc_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
pc_id TEXT NOT NULL,
|
||||
dir_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
dir_path TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
dir_path TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
return root, func() {}
|
||||
}
|
||||
|
||||
func insertLocation(t *testing.T, root, entityType, entityID, dirPath, status, pcID string) {
|
||||
t.Helper()
|
||||
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
||||
defer db.Close()
|
||||
id := entityType + "_" + entityID + "_" + pcID
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO pc_locations(id, entity_type, entity_id, pc_id, dir_path, status) VALUES (?,?,?,?,?,?)`,
|
||||
id, entityType, entityID, pcID, dirPath, status,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert location: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertApp(t *testing.T, root, id, dirPath string) {
|
||||
t.Helper()
|
||||
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
||||
defer db.Close()
|
||||
_, err := db.Exec(`INSERT INTO apps(id, dir_path) VALUES (?,?)`, id, dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("insert app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_DetectsMissingFolder(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// An active entry whose folder does NOT exist on disk
|
||||
insertLocation(t, root, "app", "my_app", "apps/my_app", "active", "test-pc")
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "my_app" && d.Issue == "missing_on_disk" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected missing_on_disk drift for my_app, got: %+v", drifts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_ActiveFolderExistsNoDrift(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create the folder
|
||||
appDir := filepath.Join(root, "apps", "good_app")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertLocation(t, root, "app", "good_app", "apps/good_app", "active", "test-pc")
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "good_app" {
|
||||
t.Errorf("unexpected drift for good_app: %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_StatusShouldBeActive(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// Folder exists but status is "missing"
|
||||
appDir := filepath.Join(root, "apps", "came_back")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertLocation(t, root, "app", "came_back", "apps/came_back", "missing", "test-pc")
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "came_back" && d.Issue == "status_should_be_active" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected status_should_be_active for came_back, got: %+v", drifts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_UntrackedOnDisk(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// App indexed in registry with folder on disk but no pc_locations entry
|
||||
appDir := filepath.Join(root, "apps", "orphan_app")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertApp(t, root, "orphan_app", "apps/orphan_app")
|
||||
// No pc_locations entry for test-pc
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "orphan_app" && d.Issue == "untracked_on_disk" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected untracked_on_disk for orphan_app, got: %+v", drifts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ServiceStatus holds the runtime status of a registered service app.
|
||||
type ServiceStatus struct {
|
||||
AppID string // e.g. "registry_api_go_infra"
|
||||
Name string // e.g. "registry_api"
|
||||
UnitName string // e.g. "registry_api.service"
|
||||
UnitActive string // "active", "inactive", "failed", "not-installed", "unknown"
|
||||
Port int // declared port parsed from notes/description, 0 if none
|
||||
PortListening bool // true if Port > 0 and 127.0.0.1:Port is accepting TCP connections
|
||||
HostMatch string // pc_id from ~/.fn_pc, or "" if unreadable
|
||||
}
|
||||
|
||||
var portRe = regexp.MustCompile(`\b([1-9][0-9]{3,4})\b`)
|
||||
|
||||
// ServicesStatus queries registry.db for apps tagged "service" and returns
|
||||
// their current systemd unit state and port reachability.
|
||||
func ServicesStatus(registryRoot string) ([]ServiceStatus, error) {
|
||||
dbPath := registryRoot + "/registry.db"
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&mode=ro")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("services_status: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`SELECT id, name, COALESCE(notes,''), COALESCE(description,'') FROM apps WHERE tags LIKE '%service%'`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("services_status: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
pcID, _ := readFnPC()
|
||||
|
||||
var results []ServiceStatus
|
||||
for rows.Next() {
|
||||
var id, name, notes, description string
|
||||
if err := rows.Scan(&id, &name, ¬es, &description); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
unit := name + ".service"
|
||||
active := queryUnitActive(unit)
|
||||
port := parseFirstPort(notes + " " + description)
|
||||
listening := false
|
||||
if port > 0 {
|
||||
listening = tcpListening("127.0.0.1", port, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
results = append(results, ServiceStatus{
|
||||
AppID: id,
|
||||
Name: name,
|
||||
UnitName: unit,
|
||||
UnitActive: active,
|
||||
Port: port,
|
||||
PortListening: listening,
|
||||
HostMatch: pcID,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("services_status: rows: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// queryUnitActive runs systemctl is-active, trying --user first then system.
|
||||
func queryUnitActive(unit string) string {
|
||||
// try user scope
|
||||
out, err := exec.Command("systemctl", "--user", "is-active", unit).Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
combined, _ := exec.Command("systemctl", "--user", "is-active", unit).CombinedOutput()
|
||||
if strings.Contains(string(combined), "could not be found") ||
|
||||
strings.Contains(string(combined), "not found") ||
|
||||
strings.Contains(string(combined), "No such") {
|
||||
// try system scope
|
||||
out2, err2 := exec.Command("systemctl", "is-active", unit).Output()
|
||||
if err2 == nil {
|
||||
return strings.TrimSpace(string(out2))
|
||||
}
|
||||
combined2, _ := exec.Command("systemctl", "is-active", unit).CombinedOutput()
|
||||
if strings.Contains(string(combined2), "could not be found") ||
|
||||
strings.Contains(string(combined2), "not found") ||
|
||||
strings.Contains(string(combined2), "No such") {
|
||||
return "not-installed"
|
||||
}
|
||||
s := strings.TrimSpace(string(out2))
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
// systemctl returned non-zero but unit exists (e.g. "inactive", "failed")
|
||||
if len(out) > 0 {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// parseFirstPort returns the first integer in [1024, 65535] found in text.
|
||||
func parseFirstPort(text string) int {
|
||||
for _, m := range portRe.FindAllString(text, -1) {
|
||||
n, err := strconv.Atoi(m)
|
||||
if err == nil && n >= 1024 && n <= 65535 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// tcpListening attempts a TCP connection to addr:port with the given timeout.
|
||||
func tcpListening(host string, port int, timeout time.Duration) bool {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: services_status
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ServicesStatus(registryRoot string) ([]ServiceStatus, error)"
|
||||
description: "Lista todas las apps registradas con tag 'service' y reporta su estado: unidad systemd activa, puerto escuchando, y pc_id local."
|
||||
tags: [doctor, systemd, services, health]
|
||||
uses_functions: []
|
||||
uses_types:
|
||||
- error_go_core
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- database/sql
|
||||
- net
|
||||
- os
|
||||
- os/exec
|
||||
- regexp
|
||||
- github.com/mattn/go-sqlite3
|
||||
tested: true
|
||||
tests:
|
||||
- "sin apps registradas retorna slice vacio sin error"
|
||||
- "DB inexistente retorna error"
|
||||
- "parseFirstPort extrae primer puerto valido"
|
||||
- "readFnPC ignora comentarios y retorna primer valor"
|
||||
test_file_path: "functions/infra/services_status_test.go"
|
||||
file_path: "functions/infra/services_status.go"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Ruta absoluta al directorio raiz del registry (donde vive registry.db)"
|
||||
output: "Slice de ServiceStatus con estado systemd, puerto y pc_id por cada app con tag 'service'. Error si no se puede abrir registry.db."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
statuses, err := ServicesStatus("/home/lucas/fn_registry")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, s := range statuses {
|
||||
fmt.Printf("%s unit=%s port=%d listening=%v\n",
|
||||
s.Name, s.UnitActive, s.Port, s.PortListening)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `UnitName` se asume `<name>.service`. Si la app usa otro nombre, el estado sera `not-installed`.
|
||||
- La heuristica de puerto busca el primer entero en `[1024, 65535]` en `notes` o `description`. Si la app menciona varios numeros, toma el primero que encaje. Puede fallar si `notes` contiene IDs numericos antes que el puerto real.
|
||||
- `UnitActive` intenta `systemctl --user is-active` primero; si el unit no se encuentra, reintenta con scope de sistema. Valores posibles: `active`, `inactive`, `failed`, `not-installed`, `unknown`.
|
||||
- `PortListening` hace un dial TCP a `127.0.0.1:<Port>` con timeout 500ms. Solo aplica si `Port > 0`.
|
||||
- `HostMatch` lee `~/.fn_pc` — primer linea no vacia no comentada.
|
||||
@@ -0,0 +1,90 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// NOTE: readFnPC is defined in pc_locations_drift.go with signature (string, error).
|
||||
|
||||
// createMinimalRegistry creates a temporary registry.db with the apps table
|
||||
// but no rows, for testing ServicesStatus with an empty dataset.
|
||||
func createMinimalRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE apps (id TEXT, name TEXT, tags TEXT, notes TEXT, description TEXT)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
db.Close()
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestServicesStatus_NoApps(t *testing.T) {
|
||||
root := createMinimalRegistry(t)
|
||||
got, err := ServicesStatus(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %d entries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesStatus_BadDB(t *testing.T) {
|
||||
_, err := ServicesStatus("/nonexistent/path")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing db, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesStatus_ParseFirstPort(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want int
|
||||
}{
|
||||
{"listens on port 9090 for requests", 9090},
|
||||
{"API available at :8080", 8080},
|
||||
{"no port mentioned", 0},
|
||||
{"low port 80 and high 9000", 9000}, // 80 < 1024, skip; 9000 ok
|
||||
{"port 65535 max", 65535},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseFirstPort(c.text)
|
||||
if got != c.want {
|
||||
t.Errorf("parseFirstPort(%q) = %d, want %d", c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesStatus_ReadFnPC(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pcFile := filepath.Join(dir, ".fn_pc")
|
||||
|
||||
// readFnPC returns the first non-empty line (no comment stripping)
|
||||
if err := os.WriteFile(pcFile, []byte("home-wsl\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Temporarily override HOME so readFnPC picks up our file
|
||||
old := os.Getenv("HOME")
|
||||
t.Cleanup(func() { os.Setenv("HOME", old) })
|
||||
os.Setenv("HOME", dir)
|
||||
|
||||
got, err2 := readFnPC()
|
||||
if err2 != nil {
|
||||
t.Fatalf("readFnPC error: %v", err2)
|
||||
}
|
||||
if got != "home-wsl" {
|
||||
t.Errorf("readFnPC() = %q, want %q", got, "home-wsl")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user