Files
Egutierrez 1dc09931b6 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>
2026-04-24 20:23:30 +02:00

191 lines
4.3 KiB
Go

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")
}