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)`, // Add stage_id to containers (2026-05-09). Backfill via the deployer // re-write path; the LEFT JOIN in ListContainersByStageID falls back // to (project_id, role=stage_name) so legacy rows still resolve. `ALTER TABLE containers ADD COLUMN stage_id TEXT NOT NULL DEFAULT ''`, // Workload-first refactor columns (2026-05-10). Land additively so // the legacy kind/ref_id columns continue to serve existing // project/stack/site rows during cutover. `ALTER TABLE workloads ADD COLUMN source_kind TEXT NOT NULL DEFAULT ''`, `ALTER TABLE workloads ADD COLUMN source_config TEXT NOT NULL DEFAULT '{}'`, `ALTER TABLE workloads ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT ''`, `ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`, `ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`, `ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`, } // 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 '', source_kind TEXT NOT NULL DEFAULT '', source_config TEXT NOT NULL DEFAULT '{}', trigger_kind TEXT NOT NULL DEFAULT '', trigger_config TEXT NOT NULL DEFAULT '{}', public_faces TEXT NOT NULL DEFAULT '[]', parent_workload_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 '', stage_id 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')) )`, // workload_env: per-workload env overrides (encrypt-at-rest for // secrets). Functional analog of stage_env. Workload deletion // cascades through the FK so orphan rows are impossible. `CREATE TABLE IF NOT EXISTS workload_env ( id TEXT PRIMARY KEY, workload_id TEXT NOT NULL REFERENCES workloads(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(workload_id, key) )`, // workload_volumes: per-workload mount declarations. Mirrors the // legacy `volumes` table shape (source / target / scope / name) // but keyed on workload_id. UNIQUE on (workload_id, target) so a // re-add overwrites instead of duplicating. `CREATE TABLE IF NOT EXISTS workload_volumes ( id TEXT PRIMARY KEY, workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE, source TEXT NOT NULL DEFAULT '', target TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'absolute', name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(workload_id, target) )`, } 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) } } // Observability: event_triggers — consume EventLog entries off the // bus and dispatch webhook actions. Schema kept flat (comma-list // filters, single optional regex) — see LOGSCAN_AND_TRIGGERS_TODO.md. observabilityTables := []string{ `CREATE TABLE IF NOT EXISTS event_triggers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, filter_severity TEXT NOT NULL DEFAULT '', filter_source TEXT NOT NULL DEFAULT '', filter_message_regex TEXT NOT NULL DEFAULT '', action_type TEXT NOT NULL DEFAULT 'webhook', action_target TEXT NOT NULL DEFAULT '', action_secret TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) )`, // log_scan_rules: regex patterns the log-scanner manager // applies to container log lines. WorkloadID is nullable (via // "" sentinel) so a global rule can have OverridesID = 0 and // per-workload overrides reference the global's id. `CREATE TABLE IF NOT EXISTS log_scan_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, workload_id TEXT NOT NULL DEFAULT '', overrides_id INTEGER NOT NULL DEFAULT 0, name TEXT NOT NULL, pattern TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'warn', streams TEXT NOT NULL DEFAULT 'all', cooldown_seconds INTEGER NOT NULL DEFAULT 60, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) )`, `CREATE INDEX IF NOT EXISTS idx_log_scan_rules_workload ON log_scan_rules(workload_id)`, `CREATE INDEX IF NOT EXISTS idx_log_scan_rules_overrides ON log_scan_rules(overrides_id)`, } for _, t := range observabilityTables { if _, err := s.db.Exec(t); err != nil { return fmt.Errorf("create observability 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. "no such table" is // expected for obsolete ALTER TABLE migrations targeting tables // the workload refactor dropped (e.g. instances). Anything // else must surface — silently running against the wrong shape // is worse than a startup failure. msg := err.Error() if !strings.Contains(msg, "duplicate column") && !strings.Contains(msg, "already exists") && !strings.Contains(msg, "no such table") { return fmt.Errorf("apply migration %q: %w", m, err) } } } // Create indexes on foreign key columns for query performance. indexes := []string{ // instances table dropped 2026-05-09 (workload refactor) — no indexes // needed; containers replaces it with idx_containers_workload below. `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)`, // Drop the legacy instances table — containers is the canonical index // after the workload refactor (2026-05-09). Idempotent: SQLite's // DROP TABLE IF EXISTS is a no-op on databases that already shed it. `DROP TABLE IF EXISTS instances`, // 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)`, `CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`, `CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`, `CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`, } 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')) ); -- The instances table was removed in the workload refactor (2026-05-09). -- Container state lives in the containers table; see runMigrations for the -- current schema. The DROP TABLE migration runs unconditionally on boot. 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") }