f54a6ecee3
Introduces the data layer for the Workload refactor (see docs/plans/workload-refactor.md): three new tables and store methods, no behavior changes elsewhere yet. - workloads: unifying primitive over Project/Stack/StaticSite, paired via UNIQUE(kind, ref_id). Notification + webhook config hosted here so it lives in one place across kinds. - containers: normalized index of every Tinyforge-managed container with first-class subdomain/proxy_route_id/npm_proxy_id columns (heavily queried by ListProxyRoutes / stale detection). - apps: optional grouping of workloads; schema only, no UI in v1. Foundation only — deployer surgery, reconciler, and consumer switchover land in the next commit.
621 lines
27 KiB
Go
621 lines
27 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// ErrNotFound is returned when a requested entity does not exist.
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
// Store wraps the SQLite database connection and provides access to all query methods.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// New opens a SQLite database at the given path and runs auto-migration.
|
|
func New(dbPath string) (*Store, error) {
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
|
|
// SQLite only allows one writer at a time. Limit connections to prevent SQLITE_BUSY.
|
|
db.SetMaxOpenConns(1)
|
|
db.SetConnMaxLifetime(0)
|
|
|
|
// Enable WAL mode and foreign keys for better concurrency and referential integrity.
|
|
pragmas := []string{
|
|
"PRAGMA journal_mode=WAL",
|
|
"PRAGMA foreign_keys=ON",
|
|
"PRAGMA busy_timeout=5000",
|
|
}
|
|
for _, p := range pragmas {
|
|
if _, err := db.Exec(p); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("exec pragma %q: %w", p, err)
|
|
}
|
|
}
|
|
|
|
s := &Store{db: db}
|
|
if err := s.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Close closes the underlying database connection.
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
// DB returns the underlying *sql.DB for advanced operations like transactions.
|
|
func (s *Store) DB() *sql.DB {
|
|
return s.db
|
|
}
|
|
|
|
// migrate creates all tables if they do not already exist, then runs
|
|
// incremental migrations for schema changes added after initial release.
|
|
func (s *Store) migrate() error {
|
|
if _, err := s.db.Exec(schema); err != nil {
|
|
return err
|
|
}
|
|
return s.runMigrations()
|
|
}
|
|
|
|
// runMigrations applies additive schema changes that cannot be expressed
|
|
// with CREATE TABLE IF NOT EXISTS.
|
|
func (s *Store) runMigrations() error {
|
|
migrations := []string{
|
|
// Add owner column to registries (2026-03-28).
|
|
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
|
// Add base_volume_path to settings (2026-03-28).
|
|
`ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`,
|
|
// Add enable_proxy to stages (2026-03-29). Default true for backwards compat.
|
|
`ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`,
|
|
// Add ssl_certificate_id to settings (2026-03-29).
|
|
`ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`,
|
|
// Add stale_threshold_days to settings (2026-03-30).
|
|
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`,
|
|
// Add last_alive_at to instances for stale container detection (2026-03-30).
|
|
`ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`,
|
|
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
|
|
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
|
|
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
|
|
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
|
|
// Add DNS management fields to settings (2026-04-02).
|
|
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
|
|
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
|
|
// Add backup management fields to settings (2026-04-02).
|
|
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
|
|
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
|
|
`ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
|
// Add proxy_route_id to instances for provider-agnostic route tracking (2026-04-04).
|
|
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
|
|
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
|
|
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
|
|
// Add Traefik provider settings (2026-04-04).
|
|
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
|
|
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
|
|
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
|
// Set default network for existing databases with empty network.
|
|
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
|
|
// NPM remote mode: forward to server_ip instead of container name.
|
|
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
|
|
// Resource limits per stage.
|
|
`ALTER TABLE stages ADD COLUMN cpu_limit REAL NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE stages ADD COLUMN memory_limit INTEGER NOT NULL DEFAULT 0`,
|
|
// NPM access list support (global default + per-project override).
|
|
`ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
|
|
// Separate public IP for DNS A records.
|
|
`ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`,
|
|
// Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled.
|
|
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
|
|
// Add provider column to static_sites (2026-04-11).
|
|
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
|
|
// Add persistent storage columns to static_sites (2026-04-12).
|
|
`ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
|
|
// Per-project + per-site webhook secrets (2026-04-23). Global
|
|
// settings.webhook_secret is deprecated; its column is retained to
|
|
// avoid a destructive migration on SQLite.
|
|
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
|
|
// Resource metrics collection (2026-04-24). Interval in seconds,
|
|
// retention in hours. 0 in either disables collection.
|
|
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
|
|
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
|
|
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
|
|
// tokens (matches the inbound webhook_secret pattern). Empty = no
|
|
// signing; existing rows stay unsigned on upgrade for back-compat.
|
|
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
// Auto-backup before deploy (2026-05-07). When enabled, the deployer
|
|
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
|
|
// so a corrupted deploy is recoverable without data loss.
|
|
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
|
|
// Per-entity inbound HMAC signing (2026-05-07). webhook_signing_secret
|
|
// is the HMAC-SHA256 key separate from the URL secret so a leaked URL
|
|
// alone is not sufficient to forge a valid request. require_signature
|
|
// rejects unsigned requests when set (defense-in-depth opt-in).
|
|
`ALTER TABLE projects ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
|
|
// Webhook delivery audit log (2026-05-07). Persists every inbound
|
|
// webhook request (project or site) with its outcome so users can
|
|
// debug "why didn't my deploy fire?" without grepping daemon logs.
|
|
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
target_type TEXT NOT NULL,
|
|
target_id TEXT NOT NULL DEFAULT '',
|
|
target_name TEXT NOT NULL DEFAULT '',
|
|
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
source_ip TEXT NOT NULL DEFAULT '',
|
|
signature_state TEXT NOT NULL DEFAULT '',
|
|
status_code INTEGER NOT NULL DEFAULT 0,
|
|
outcome TEXT NOT NULL DEFAULT '',
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
body_size INTEGER NOT NULL DEFAULT 0
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
|
|
}
|
|
|
|
// Workload refactor tables (2026-05-09). Workload is the unifying primitive
|
|
// over Project / Stack / StaticSite; Container is the normalized index of
|
|
// every Tinyforge-managed container; Apps is an optional grouping. These
|
|
// live alongside (not inside) the schema constant so existing databases
|
|
// pick them up on restart.
|
|
workloadTables := []string{
|
|
`CREATE TABLE IF NOT EXISTS workloads (
|
|
id TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
ref_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
app_id TEXT NOT NULL DEFAULT '',
|
|
notification_url TEXT NOT NULL DEFAULT '',
|
|
notification_secret TEXT NOT NULL DEFAULT '',
|
|
webhook_secret TEXT NOT NULL DEFAULT '',
|
|
webhook_signing_secret TEXT NOT NULL DEFAULT '',
|
|
webhook_require_signature INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(kind, ref_id)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS containers (
|
|
id TEXT PRIMARY KEY,
|
|
workload_id TEXT NOT NULL,
|
|
workload_kind TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT '',
|
|
container_id TEXT NOT NULL DEFAULT '',
|
|
image_ref TEXT NOT NULL DEFAULT '',
|
|
image_tag TEXT NOT NULL DEFAULT '',
|
|
host TEXT NOT NULL DEFAULT 'local',
|
|
state TEXT NOT NULL DEFAULT '',
|
|
port INTEGER NOT NULL DEFAULT 0,
|
|
subdomain TEXT NOT NULL DEFAULT '',
|
|
proxy_route_id TEXT NOT NULL DEFAULT '',
|
|
npm_proxy_id INTEGER NOT NULL DEFAULT 0,
|
|
last_seen_at TEXT NOT NULL DEFAULT '',
|
|
extra_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS apps (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
}
|
|
for _, t := range workloadTables {
|
|
if _, err := s.db.Exec(t); err != nil {
|
|
return fmt.Errorf("create workload table: %w", err)
|
|
}
|
|
}
|
|
|
|
// Additive stack tables (2026-04-16). Created here rather than in the
|
|
// schema constant so older databases pick them up on restart.
|
|
statsTables := []string{
|
|
`CREATE TABLE IF NOT EXISTS container_stats_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
container_id TEXT NOT NULL,
|
|
owner_type TEXT NOT NULL,
|
|
owner_id TEXT NOT NULL,
|
|
ts INTEGER NOT NULL,
|
|
cpu_percent REAL NOT NULL DEFAULT 0,
|
|
memory_usage INTEGER NOT NULL DEFAULT 0,
|
|
memory_limit INTEGER NOT NULL DEFAULT 0,
|
|
network_rx INTEGER NOT NULL DEFAULT 0,
|
|
network_tx INTEGER NOT NULL DEFAULT 0,
|
|
block_read INTEGER NOT NULL DEFAULT 0,
|
|
block_write INTEGER NOT NULL DEFAULT 0
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS system_stats_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
ncpu INTEGER NOT NULL DEFAULT 0,
|
|
memory_total INTEGER NOT NULL DEFAULT 0,
|
|
workload_cpu_percent REAL NOT NULL DEFAULT 0,
|
|
workload_mem_usage INTEGER NOT NULL DEFAULT 0,
|
|
containers_running INTEGER NOT NULL DEFAULT 0,
|
|
disk_total_bytes INTEGER NOT NULL DEFAULT 0
|
|
)`,
|
|
}
|
|
for _, t := range statsTables {
|
|
if _, err := s.db.Exec(t); err != nil {
|
|
return fmt.Errorf("create stats table: %w", err)
|
|
}
|
|
}
|
|
|
|
stackTables := []string{
|
|
`CREATE TABLE IF NOT EXISTS stacks (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
compose_project_name TEXT NOT NULL UNIQUE,
|
|
status TEXT NOT NULL DEFAULT 'stopped',
|
|
error TEXT NOT NULL DEFAULT '',
|
|
current_revision_id TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS stack_revisions (
|
|
id TEXT PRIMARY KEY,
|
|
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
|
|
revision INTEGER NOT NULL,
|
|
yaml TEXT NOT NULL,
|
|
author TEXT NOT NULL DEFAULT '',
|
|
deploy_id TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(stack_id, revision)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS stack_deploys (
|
|
id TEXT PRIMARY KEY,
|
|
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
|
|
revision_id TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
log TEXT NOT NULL DEFAULT '',
|
|
error TEXT NOT NULL DEFAULT '',
|
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
finished_at TEXT NOT NULL DEFAULT ''
|
|
)`,
|
|
}
|
|
for _, t := range stackTables {
|
|
if _, err := s.db.Exec(t); err != nil {
|
|
return fmt.Errorf("create stack table: %w", err)
|
|
}
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
if _, err := s.db.Exec(m); err != nil {
|
|
// "duplicate column" / "already exists" are expected when a
|
|
// migration has already been applied. Anything else (typo, FK
|
|
// conflict, real schema bug) must surface, otherwise the store
|
|
// silently runs against the wrong shape.
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "duplicate column") &&
|
|
!strings.Contains(msg, "already exists") {
|
|
return fmt.Errorf("apply migration %q: %w", m, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create indexes on foreign key columns for query performance.
|
|
indexes := []string{
|
|
`CREATE INDEX IF NOT EXISTS idx_instances_stage_id ON instances(stage_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_instances_project_id ON instances(project_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_deploys_project_id ON deploys(project_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_deploys_stage_id ON deploys(stage_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy_id ON deploy_logs(deploy_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_stages_project_id ON stages(project_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_stage_env_stage_id ON stage_env(stage_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_volumes_project_id ON volumes(project_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`,
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`,
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`,
|
|
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`,
|
|
// Workload refactor indexes (2026-05-09).
|
|
`CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`,
|
|
`CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`,
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_workloads_webhook_secret ON workloads(webhook_secret) WHERE webhook_secret != ''`,
|
|
`CREATE INDEX IF NOT EXISTS idx_containers_workload ON containers(workload_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_containers_state ON containers(state)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_containers_container_id ON containers(container_id) WHERE container_id != ''`,
|
|
`CREATE INDEX IF NOT EXISTS idx_containers_kind ON containers(workload_kind)`,
|
|
}
|
|
for _, idx := range indexes {
|
|
if _, err := s.db.Exec(idx); err != nil {
|
|
return fmt.Errorf("create index: %w", err)
|
|
}
|
|
}
|
|
|
|
// Data migration: copy mode→scope for volumes that have scope still empty.
|
|
// shared→project, isolated→instance. Log errors but don't fail startup.
|
|
dataMigrations := []struct{ query, desc string }{
|
|
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`, "migrate shared→project"},
|
|
{`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`, "migrate isolated→instance"},
|
|
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`, "migrate empty→project"},
|
|
}
|
|
for _, dm := range dataMigrations {
|
|
if _, err := s.db.Exec(dm.query); err != nil {
|
|
fmt.Printf("volume scope migration warning (%s): %v\n", dm.desc, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
const schema = `
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
registry TEXT NOT NULL DEFAULT '',
|
|
image TEXT NOT NULL,
|
|
port INTEGER NOT NULL DEFAULT 0,
|
|
healthcheck TEXT NOT NULL DEFAULT '',
|
|
env TEXT NOT NULL DEFAULT '{}',
|
|
volumes TEXT NOT NULL DEFAULT '{}',
|
|
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
|
notification_url TEXT NOT NULL DEFAULT '',
|
|
notification_secret TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS stages (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
tag_pattern TEXT NOT NULL DEFAULT '*',
|
|
auto_deploy INTEGER NOT NULL DEFAULT 0,
|
|
max_instances INTEGER NOT NULL DEFAULT 1,
|
|
confirm INTEGER NOT NULL DEFAULT 0,
|
|
enable_proxy INTEGER NOT NULL DEFAULT 1,
|
|
promote_from TEXT NOT NULL DEFAULT '',
|
|
subdomain TEXT NOT NULL DEFAULT '',
|
|
notification_url TEXT NOT NULL DEFAULT '',
|
|
notification_secret TEXT NOT NULL DEFAULT '',
|
|
cpu_limit REAL NOT NULL DEFAULT 0,
|
|
memory_limit INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(project_id, name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS registries (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
url TEXT NOT NULL,
|
|
type TEXT NOT NULL DEFAULT 'generic',
|
|
token TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
domain TEXT NOT NULL DEFAULT '',
|
|
server_ip TEXT NOT NULL DEFAULT '',
|
|
public_ip TEXT NOT NULL DEFAULT '',
|
|
network TEXT NOT NULL DEFAULT 'tinyforge',
|
|
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
|
|
notification_url TEXT NOT NULL DEFAULT '',
|
|
notification_secret TEXT NOT NULL DEFAULT '',
|
|
npm_url TEXT NOT NULL DEFAULT '',
|
|
npm_email TEXT NOT NULL DEFAULT '',
|
|
npm_password TEXT NOT NULL DEFAULT '',
|
|
webhook_secret TEXT NOT NULL DEFAULT '',
|
|
polling_interval TEXT NOT NULL DEFAULT '5m',
|
|
base_volume_path TEXT NOT NULL DEFAULT '',
|
|
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
|
npm_remote INTEGER NOT NULL DEFAULT 0,
|
|
image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024,
|
|
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
|
traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure',
|
|
traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt',
|
|
traefik_network TEXT NOT NULL DEFAULT '',
|
|
traefik_api_url TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS instances (
|
|
id TEXT PRIMARY KEY,
|
|
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
|
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
container_id TEXT NOT NULL DEFAULT '',
|
|
image_tag TEXT NOT NULL,
|
|
subdomain TEXT NOT NULL DEFAULT '',
|
|
npm_proxy_id INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'stopped',
|
|
port INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS deploys (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
|
|
instance_id TEXT NOT NULL DEFAULT '',
|
|
image_tag TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
finished_at TEXT NOT NULL DEFAULT '',
|
|
error TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS deploy_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE,
|
|
message TEXT NOT NULL,
|
|
level TEXT NOT NULL DEFAULT 'info',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS poll_states (
|
|
stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE,
|
|
last_tag TEXT NOT NULL DEFAULT '',
|
|
last_polled TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL DEFAULT '',
|
|
email TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT 'viewer',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_settings (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
auth_mode TEXT NOT NULL DEFAULT 'local',
|
|
oidc_client_id TEXT NOT NULL DEFAULT '',
|
|
oidc_client_secret TEXT NOT NULL DEFAULT '',
|
|
oidc_issuer_url TEXT NOT NULL DEFAULT '',
|
|
oidc_redirect_url TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
-- Seed the settings row if it does not exist.
|
|
INSERT OR IGNORE INTO settings (id) VALUES (1);
|
|
|
|
-- Seed the auth_settings row if it does not exist.
|
|
INSERT OR IGNORE INTO auth_settings (id) VALUES (1);
|
|
|
|
CREATE TABLE IF NOT EXISTS stage_env (
|
|
id TEXT PRIMARY KEY,
|
|
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(stage_id, key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS volumes (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
source TEXT NOT NULL,
|
|
target TEXT NOT NULL,
|
|
mode TEXT NOT NULL DEFAULT 'shared',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS event_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source TEXT NOT NULL DEFAULT '',
|
|
severity TEXT NOT NULL DEFAULT 'info',
|
|
message TEXT NOT NULL DEFAULT '',
|
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS standalone_proxies (
|
|
id TEXT PRIMARY KEY,
|
|
domain TEXT NOT NULL UNIQUE,
|
|
destination_url TEXT NOT NULL DEFAULT '',
|
|
destination_port INTEGER NOT NULL DEFAULT 0,
|
|
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
|
npm_proxy_id INTEGER NOT NULL DEFAULT 0,
|
|
health_status TEXT NOT NULL DEFAULT 'unknown',
|
|
health_checked_at TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dns_records (
|
|
id TEXT PRIMARY KEY,
|
|
fqdn TEXT NOT NULL UNIQUE,
|
|
provider_record_id TEXT NOT NULL DEFAULT '',
|
|
consumer_type TEXT NOT NULL DEFAULT '',
|
|
consumer_id TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS backups (
|
|
id TEXT PRIMARY KEY,
|
|
filename TEXT NOT NULL UNIQUE,
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
backup_type TEXT NOT NULL DEFAULT 'manual',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS static_sites (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
provider TEXT NOT NULL DEFAULT '',
|
|
gitea_url TEXT NOT NULL DEFAULT '',
|
|
repo_owner TEXT NOT NULL DEFAULT '',
|
|
repo_name TEXT NOT NULL DEFAULT '',
|
|
branch TEXT NOT NULL DEFAULT 'main',
|
|
folder_path TEXT NOT NULL DEFAULT '',
|
|
access_token TEXT NOT NULL DEFAULT '',
|
|
domain TEXT NOT NULL DEFAULT '',
|
|
mode TEXT NOT NULL DEFAULT 'static',
|
|
render_markdown INTEGER NOT NULL DEFAULT 0,
|
|
sync_trigger TEXT NOT NULL DEFAULT 'manual',
|
|
tag_pattern TEXT NOT NULL DEFAULT '',
|
|
container_id TEXT NOT NULL DEFAULT '',
|
|
proxy_route_id TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
last_sync_at TEXT NOT NULL DEFAULT '',
|
|
last_commit_sha TEXT NOT NULL DEFAULT '',
|
|
error TEXT NOT NULL DEFAULT '',
|
|
notification_url TEXT NOT NULL DEFAULT '',
|
|
notification_secret TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS static_site_secrets (
|
|
id TEXT PRIMARY KEY,
|
|
site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(site_id, key)
|
|
);
|
|
`
|
|
|
|
// Now returns the current time formatted for SQLite storage.
|
|
func Now() string {
|
|
return time.Now().UTC().Format("2006-01-02 15:04:05")
|
|
}
|