Initial extraction from fn_registry

sqlite_api: API REST HTTP read-only sobre registry.db y operations.db.
Bind por defecto 127.0.0.1:8484. Go + net/http + SQLite FTS5.

Extraido de fn_registry/projects/fn_monitoring/apps/sqlite_api/ como
repo independiente. La metadata del registry queda en project.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-04-24 20:23:30 +02:00
commit 1dc09931b6
7 changed files with 972 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Compiled binary
sqlite_api
# Runtime artifacts
sqlite_api.log
sqlite_api.pid
# Go
*.exe
*.test
*.out
# OS
.DS_Store
Thumbs.db
+59
View File
@@ -0,0 +1,59 @@
---
name: sqlite_api
lang: go
domain: infra
description: "API REST HTTP read-only sobre registry.db y operations.db de cada app. Permite consultas SQL (solo SELECT/PRAGMA), busqueda FTS5, exploracion de tablas y schema. Bind por defecto a localhost:8484."
tags: [service, api, sqlite, http, registry, fts5]
uses_functions: []
uses_types: []
framework: "net/http"
entry_point: "main.go"
dir_path: "projects/fn_monitoring/apps/sqlite_api"
---
## Uso
```bash
# Arrancar (default: 127.0.0.1:8484)
cd apps/sqlite_api && go run -tags fts5 .
# Bind personalizado
go run -tags fts5 . --bind 0.0.0.0:8484
```
## Endpoints
| Metodo | Path | Descripcion |
|--------|------|-------------|
| GET | `/health` | Health check |
| GET | `/api/databases` | Lista DBs disponibles (registry + ops:*) |
| GET | `/api/databases/:db/tables` | Tablas y vistas de una DB |
| GET | `/api/databases/:db/schema` | Schema SQL completo |
| POST | `/api/databases/:db/query` | Ejecuta query SQL read-only |
| GET | `/api/databases/:db/fts?q=...&table=...` | Busqueda FTS5 directa |
## Seguridad
- Solo queries SELECT, PRAGMA, WITH y EXPLAIN
- SQLite abierto con `?mode=ro` (read-only a nivel driver)
- Timeout de 5 segundos por query
- Bind a localhost por defecto
- CORS habilitado para acceso desde frontends
## Bases de datos
- `registry` — registry.db de la raiz
- `ops:{app}` — operations.db de apps/{app}/ y projects/*/apps/{app}/
Auto-descubre operations.db al arrancar escaneando apps/ y projects/*/apps/.
## Health check
```bash
curl http://localhost:8484/health
# {"status":"ok"}
```
## Puerto
8484 (no colisiona con Metabase 3000, Jupyter 8888, deploy_server 9090).
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
_ "github.com/mattn/go-sqlite3"
)
// DBEntry represents a registered database.
type DBEntry struct {
Alias string // "registry" or "ops:app_name"
Path string // absolute path to .db file
Kind string // "registry" or "operations"
}
// DBPool manages read-only connections to registered databases.
type DBPool struct {
mu sync.RWMutex
entries map[string]DBEntry
conns map[string]*sql.DB
}
func NewDBPool() *DBPool {
return &DBPool{
entries: make(map[string]DBEntry),
conns: make(map[string]*sql.DB),
}
}
// Register adds a database entry. Does not open the connection until first use.
func (p *DBPool) Register(entry DBEntry) {
p.mu.Lock()
defer p.mu.Unlock()
p.entries[entry.Alias] = entry
}
// Get returns a read-only connection to the named database.
func (p *DBPool) Get(alias string) (*sql.DB, error) {
p.mu.RLock()
if db, ok := p.conns[alias]; ok {
p.mu.RUnlock()
return db, nil
}
p.mu.RUnlock()
p.mu.Lock()
defer p.mu.Unlock()
// Double-check after acquiring write lock.
if db, ok := p.conns[alias]; ok {
return db, nil
}
entry, ok := p.entries[alias]
if !ok {
return nil, fmt.Errorf("database %q not found", alias)
}
dsn := fmt.Sprintf("file:%s?mode=ro", entry.Path)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", alias, err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("pinging %s: %w", alias, err)
}
db.SetMaxOpenConns(4)
p.conns[alias] = db
return db, nil
}
// List returns all registered database entries.
func (p *DBPool) List() []DBEntry {
p.mu.RLock()
defer p.mu.RUnlock()
out := make([]DBEntry, 0, len(p.entries))
for _, e := range p.entries {
out = append(out, e)
}
return out
}
// Close closes all open connections.
func (p *DBPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for _, db := range p.conns {
db.Close()
}
}
// Config holds the server configuration.
type Config struct {
Bind string // address to bind (default "127.0.0.1:8484")
RegistryDir string // root of fn_registry (where registry.db lives)
}
// DiscoverDatabases finds registry.db and all operations.db files.
func DiscoverDatabases(root string) []DBEntry {
var entries []DBEntry
// registry.db
regPath := filepath.Join(root, "registry.db")
if _, err := os.Stat(regPath); err == nil {
entries = append(entries, DBEntry{
Alias: "registry",
Path: regPath,
Kind: "registry",
})
}
// apps/*/operations.db
appsDir := filepath.Join(root, "apps")
dirEntries, err := os.ReadDir(appsDir)
if err != nil {
return entries
}
for _, d := range dirEntries {
if !d.IsDir() {
continue
}
opsPath := filepath.Join(appsDir, d.Name(), "operations.db")
if _, err := os.Stat(opsPath); err == nil {
entries = append(entries, DBEntry{
Alias: "ops:" + d.Name(),
Path: opsPath,
Kind: "operations",
})
}
}
// projects/*/apps/*/operations.db
projectsDir := filepath.Join(root, "projects")
projEntries, err := os.ReadDir(projectsDir)
if err != nil {
return entries
}
for _, p := range projEntries {
if !p.IsDir() {
continue
}
projAppsDir := filepath.Join(projectsDir, p.Name(), "apps")
projAppEntries, err := os.ReadDir(projAppsDir)
if err != nil {
continue
}
for _, a := range projAppEntries {
if !a.IsDir() {
continue
}
opsPath := filepath.Join(projAppsDir, a.Name(), "operations.db")
if _, err := os.Stat(opsPath); err == nil {
entries = append(entries, DBEntry{
Alias: "ops:" + a.Name(),
Path: opsPath,
Kind: "operations",
})
}
}
}
return entries
}
// ValidateQuery checks that the SQL is a read-only statement (SELECT or PRAGMA).
func ValidateQuery(sql string) error {
trimmed := strings.TrimSpace(sql)
upper := strings.ToUpper(trimmed)
// Remove leading comments
for strings.HasPrefix(upper, "--") {
idx := strings.Index(upper, "\n")
if idx < 0 {
return fmt.Errorf("query contains only comments")
}
upper = strings.TrimSpace(upper[idx+1:])
}
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "PRAGMA") ||
strings.HasPrefix(upper, "WITH") || strings.HasPrefix(upper, "EXPLAIN") {
return nil
}
return fmt.Errorf("only SELECT, PRAGMA, WITH, and EXPLAIN queries are allowed")
}
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
const queryTimeout = 5 * time.Second
// Server holds the HTTP handlers and DB pool.
type Server struct {
pool *DBPool
}
func NewServer(pool *DBPool) *Server {
return &Server{pool: pool}
}
// Routes registers all API routes on the given mux.
func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /api/databases", s.handleDatabases)
mux.HandleFunc("GET /api/databases/{db}/tables", s.handleTables)
mux.HandleFunc("GET /api/databases/{db}/schema", s.handleSchema)
mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery)
mux.HandleFunc("GET /api/databases/{db}/fts", s.handleFTS)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleDatabases(w http.ResponseWriter, r *http.Request) {
entries := s.pool.List()
type dbInfo struct {
Alias string `json:"alias"`
Kind string `json:"kind"`
}
out := make([]dbInfo, len(entries))
for i, e := range entries {
out[i] = dbInfo{Alias: e.Alias, Kind: e.Kind}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleTables(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT type, name FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
type tableInfo struct {
Type string `json:"type"`
Name string `json:"name"`
}
var tables []tableInfo
for rows.Next() {
var t tableInfo
if err := rows.Scan(&t.Type, &t.Name); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tables = append(tables, t)
}
if tables == nil {
tables = []tableInfo{}
}
writeJSON(w, http.StatusOK, tables)
}
func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
var statements []string
for rows.Next() {
var stmt string
if err := rows.Scan(&stmt); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
statements = append(statements, stmt)
}
writeJSON(w, http.StatusOK, map[string]any{
"statements": statements,
"count": len(statements),
})
}
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
var req struct {
SQL string `json:"sql"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.SQL == "" {
writeError(w, http.StatusBadRequest, "sql field is required")
return
}
if err := ValidateQuery(req.SQL); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
start := time.Now()
rows, err := db.QueryContext(ctx, req.SQL)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var resultRows [][]any
for rows.Next() {
vals := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Convert []byte to string for JSON serialization.
for i, v := range vals {
if b, ok := v.([]byte); ok {
vals[i] = string(b)
}
}
resultRows = append(resultRows, vals)
}
if resultRows == nil {
resultRows = [][]any{}
}
durationMs := time.Since(start).Milliseconds()
writeJSON(w, http.StatusOK, map[string]any{
"columns": columns,
"rows": resultRows,
"count": len(resultRows),
"duration_ms": durationMs,
})
}
func (s *Server) handleFTS(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
q := r.URL.Query().Get("q")
if q == "" {
writeError(w, http.StatusBadRequest, "q parameter is required")
return
}
table := r.URL.Query().Get("table")
if table == "" {
table = "functions"
}
// Map table to its FTS table and columns to return.
ftsTable := table + "_fts"
var selectCols string
switch table {
case "functions":
selectCols = "id, name, kind, purity, domain, description"
case "types":
selectCols = "id, name, algebraic, domain, description"
case "unit_tests":
selectCols = "id, name, function_id, lang"
default:
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported FTS table: %s", table))
return
}
limit := r.URL.Query().Get("limit")
if limit == "" {
limit = "20"
}
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE id IN (SELECT id FROM %s WHERE %s MATCH ?) LIMIT %s",
selectCols, table, ftsTable, ftsTable, limit,
)
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
start := time.Now()
rows, err := db.QueryContext(ctx, query, q)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var resultRows [][]any
for rows.Next() {
vals := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for i, v := range vals {
if b, ok := v.([]byte); ok {
vals[i] = string(b)
}
}
resultRows = append(resultRows, vals)
}
if resultRows == nil {
resultRows = [][]any{}
}
durationMs := time.Since(start).Milliseconds()
writeJSON(w, http.StatusOK, map[string]any{
"columns": columns,
"rows": resultRows,
"count": len(resultRows),
"duration_ms": durationMs,
})
}
// resolveDBAlias converts URL path segment to pool alias.
// "registry" → "registry", "ops:app_name" → "ops:app_name"
// URL-encoded colons: "ops%3Aapp_name" → already decoded by net/http.
func resolveDBAlias(raw string) string {
return raw
}
// CORS middleware for browser access.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// sanitizeFTS escapes special FTS5 characters in user input.
func sanitizeFTS(input string) string {
// Wrap each term in double quotes to treat as literal.
terms := strings.Fields(input)
for i, t := range terms {
terms[i] = `"` + strings.ReplaceAll(t, `"`, `""`) + `"`
}
return strings.Join(terms, " ")
}
+276
View File
@@ -0,0 +1,276 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func setupTestDB(t *testing.T) (*DBPool, string) {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// Create a small test database with FTS5.
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT, kind TEXT)`,
`INSERT INTO items VALUES ('a', 'alpha', 'first')`,
`INSERT INTO items VALUES ('b', 'beta', 'second')`,
`CREATE VIRTUAL TABLE items_fts USING fts5(id, name, kind, content=items, content_rowid=rowid)`,
`INSERT INTO items_fts(items_fts) VALUES('rebuild')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("setup sql %q: %v", s, err)
}
}
db.Close()
pool := NewDBPool()
pool.Register(DBEntry{Alias: "testdb", Path: dbPath, Kind: "test"})
return pool, dir
}
func TestHealthEndpoint(t *testing.T) {
pool := NewDBPool()
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %s", resp["status"])
}
}
func TestDatabasesEndpoint(t *testing.T) {
pool := NewDBPool()
pool.Register(DBEntry{Alias: "registry", Path: "/fake/path", Kind: "registry"})
pool.Register(DBEntry{Alias: "ops:myapp", Path: "/fake/path2", Kind: "operations"})
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
req := httptest.NewRequest("GET", "/api/databases", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp []map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if len(resp) != 2 {
t.Fatalf("expected 2 databases, got %d", len(resp))
}
}
func TestQueryEndpoint(t *testing.T) {
pool, _ := setupTestDB(t)
defer pool.Close()
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
body := `{"sql": "SELECT id, name FROM items ORDER BY id"}`
req := httptest.NewRequest("POST", "/api/databases/testdb/query", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
if int(resp["count"].(float64)) != 2 {
t.Fatalf("expected 2 rows, got %v", resp["count"])
}
cols := resp["columns"].([]any)
if len(cols) != 2 || cols[0] != "id" || cols[1] != "name" {
t.Fatalf("unexpected columns: %v", cols)
}
}
func TestQueryRejectsWrite(t *testing.T) {
pool, _ := setupTestDB(t)
defer pool.Close()
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
cases := []string{
`{"sql": "INSERT INTO items VALUES ('c', 'gamma', 'third')"}`,
`{"sql": "UPDATE items SET name = 'x' WHERE id = 'a'"}`,
`{"sql": "DELETE FROM items WHERE id = 'a'"}`,
`{"sql": "DROP TABLE items"}`,
}
for _, body := range cases {
req := httptest.NewRequest("POST", "/api/databases/testdb/query", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for %s, got %d", body, w.Code)
}
}
}
func TestTablesEndpoint(t *testing.T) {
pool, _ := setupTestDB(t)
defer pool.Close()
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
req := httptest.NewRequest("GET", "/api/databases/testdb/tables", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if len(resp) == 0 {
t.Fatal("expected at least one table")
}
found := false
for _, tbl := range resp {
if tbl["name"] == "items" {
found = true
}
}
if !found {
t.Fatal("expected 'items' table in response")
}
}
func TestSchemaEndpoint(t *testing.T) {
pool, _ := setupTestDB(t)
defer pool.Close()
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
req := httptest.NewRequest("GET", "/api/databases/testdb/schema", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
count := int(resp["count"].(float64))
if count == 0 {
t.Fatal("expected at least one schema statement")
}
}
func TestNotFoundDB(t *testing.T) {
pool := NewDBPool()
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
body := `{"sql": "SELECT 1"}`
req := httptest.NewRequest("POST", "/api/databases/nonexistent/query", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestValidateQuery(t *testing.T) {
valid := []string{
"SELECT * FROM t",
" select id from t",
"PRAGMA table_info(t)",
"WITH cte AS (SELECT 1) SELECT * FROM cte",
"EXPLAIN SELECT * FROM t",
"-- comment\nSELECT 1",
}
for _, q := range valid {
if err := ValidateQuery(q); err != nil {
t.Errorf("expected valid: %q, got error: %v", q, err)
}
}
invalid := []string{
"INSERT INTO t VALUES (1)",
"UPDATE t SET x = 1",
"DELETE FROM t",
"DROP TABLE t",
"CREATE TABLE t (id INT)",
"ALTER TABLE t ADD COLUMN x INT",
}
for _, q := range invalid {
if err := ValidateQuery(q); err == nil {
t.Errorf("expected invalid: %q, got nil error", q)
}
}
}
func TestDiscoverDatabases(t *testing.T) {
dir := t.TempDir()
// Create registry.db
os.WriteFile(filepath.Join(dir, "registry.db"), []byte{}, 0644)
// Create apps/myapp/operations.db
os.MkdirAll(filepath.Join(dir, "apps", "myapp"), 0755)
os.WriteFile(filepath.Join(dir, "apps", "myapp", "operations.db"), []byte{}, 0644)
// Create projects/proj1/apps/papp/operations.db
os.MkdirAll(filepath.Join(dir, "projects", "proj1", "apps", "papp"), 0755)
os.WriteFile(filepath.Join(dir, "projects", "proj1", "apps", "papp", "operations.db"), []byte{}, 0644)
entries := DiscoverDatabases(dir)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d: %+v", len(entries), entries)
}
aliases := map[string]bool{}
for _, e := range entries {
aliases[e.Alias] = true
}
for _, want := range []string{"registry", "ops:myapp", "ops:papp"} {
if !aliases[want] {
t.Errorf("missing alias %q", want)
}
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
bind := flag.String("bind", "127.0.0.1:8484", "address to bind")
flag.Parse()
root := findRegistryRoot()
if root == "" {
log.Fatal("cannot find fn_registry root (no registry.db found). Set FN_REGISTRY_ROOT or run from the registry directory.")
}
pool := NewDBPool()
for _, entry := range DiscoverDatabases(root) {
pool.Register(entry)
log.Printf("registered database: %s (%s)", entry.Alias, entry.Path)
}
srv := NewServer(pool)
mux := http.NewServeMux()
srv.Routes(mux)
handler := corsMiddleware(mux)
log.Printf("sqlite_api listening on %s (registry root: %s)", *bind, root)
if err := http.ListenAndServe(*bind, handler); err != nil {
log.Fatalf("server error: %v", err)
}
}
// findRegistryRoot walks up from cwd (or uses FN_REGISTRY_ROOT) to find registry.db.
func findRegistryRoot() string {
if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" {
if _, err := os.Stat(filepath.Join(env, "registry.db")); err == nil {
return env
}
}
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return ""
}
func init() {
log.SetFlags(log.Ltime)
log.SetPrefix("[sqlite_api] ")
fmt.Fprintln(os.Stderr, "sqlite_api — HTTP API for fn_registry databases")
}
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Start sqlite_api in the background. Logs to sqlite_api.log.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG="$SCRIPT_DIR/sqlite_api.log"
PID_FILE="$SCRIPT_DIR/sqlite_api.pid"
# Kill previous instance if running
if [ -f "$PID_FILE" ]; then
old_pid=$(cat "$PID_FILE")
if kill -0 "$old_pid" 2>/dev/null; then
echo "Stopping previous instance (PID $old_pid)"
kill "$old_pid"
sleep 0.5
fi
rm -f "$PID_FILE"
fi
export FN_REGISTRY_ROOT="$REGISTRY_ROOT"
cd "$REGISTRY_ROOT"
CGO_ENABLED=1 go run -tags fts5 ./projects/fn_monitoring/apps/sqlite_api/ "$@" \
>"$LOG" 2>&1 &
echo $! > "$PID_FILE"
echo "sqlite_api started (PID $!, log: $LOG)"
sleep 0.5
if curl -sf http://127.0.0.1:8484/health >/dev/null 2>&1; then
echo "Health check OK"
else
echo "Waiting for startup..."
sleep 1.5
if curl -sf http://127.0.0.1:8484/health >/dev/null 2>&1; then
echo "Health check OK"
else
echo "Warning: health check failed — check $LOG"
fi
fi