a9c7775bb7
Add backup/restore functionality for the SQLite database. Users can trigger manual backups, configure automatic backups on an interval with retention policies, list/download/delete backups, and restore from any backup. - Backup engine using VACUUM INTO (safe with WAL mode) - Backup metadata tracked in DB, files stored in DATA_DIR/backups/ - Settings: backup_enabled, backup_interval_hours, backup_retention_count - API: POST/GET/DELETE /api/backups, download, restore endpoints - Autobackup via cron scheduler with configurable interval - Retention: prune on startup, after each backup (manual and auto) - Orphan cleanup: removes backup files without metadata on startup - Restore: replaces DB and triggers graceful server shutdown - Settings UI: /settings/backup with toggle, interval, retention config - Backup list with download, delete, restore actions - i18n: English and Russian translations
334 lines
12 KiB
Go
334 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"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`,
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
// Ignore errors from already-applied migrations (duplicate column).
|
|
_, _ = s.db.Exec(m)
|
|
}
|
|
|
|
// 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)`,
|
|
}
|
|
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 '{}',
|
|
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 '',
|
|
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 '',
|
|
network TEXT NOT NULL DEFAULT '',
|
|
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
|
|
notification_url 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,
|
|
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'))
|
|
);
|
|
`
|
|
|
|
// Now returns the current time formatted for SQLite storage.
|
|
func Now() string {
|
|
return time.Now().UTC().Format("2006-01-02 15:04:05")
|
|
}
|