355 lines
9.9 KiB
Go
355 lines
9.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type migration struct {
|
|
version int
|
|
name string
|
|
up func(context.Context, *sql.Tx) error
|
|
}
|
|
|
|
func (s *Store) runMigrations(ctx context.Context) error {
|
|
if _, err := s.db.ExecContext(ctx, `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`); err != nil {
|
|
return err
|
|
}
|
|
|
|
migrations := []migration{
|
|
{
|
|
version: 1,
|
|
name: "initial_schema",
|
|
up: func(ctx context.Context, tx *sql.Tx) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS sites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
site_uuid TEXT NOT NULL UNIQUE,
|
|
ssh_user TEXT NOT NULL,
|
|
host TEXT NOT NULL,
|
|
port INTEGER NOT NULL DEFAULT 22,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_run_status TEXT,
|
|
last_run_output TEXT,
|
|
last_run_at DATETIME,
|
|
last_scan_at DATETIME,
|
|
last_scan_state TEXT,
|
|
last_scan_notes TEXT
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS site_targets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump')),
|
|
mysql_host TEXT,
|
|
mysql_user TEXT,
|
|
mysql_db TEXT,
|
|
mysql_password TEXT,
|
|
last_size_bytes INTEGER,
|
|
last_scan_at DATETIME,
|
|
last_error TEXT,
|
|
UNIQUE(site_id, path, mode)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS jobs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('queued', 'running', 'success', 'warning', 'failed')),
|
|
summary TEXT,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
started_at DATETIME,
|
|
finished_at DATETIME
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS job_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
level TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
version: 2,
|
|
name: "ensure_sites_site_uuid",
|
|
up: func(ctx context.Context, tx *sql.Tx) error {
|
|
exists, err := tableExists(ctx, tx, "sites")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
hasUUID, err := columnExists(ctx, tx, "sites", "site_uuid")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !hasUUID {
|
|
if _, err := tx.ExecContext(ctx, `ALTER TABLE sites ADD COLUMN site_uuid TEXT`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx, `SELECT id, site_uuid FROM sites`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var idsNeedingUUID []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
var uuid sql.NullString
|
|
if err := rows.Scan(&id, &uuid); err != nil {
|
|
return err
|
|
}
|
|
if !uuid.Valid || strings.TrimSpace(uuid.String) == "" {
|
|
idsNeedingUUID = append(idsNeedingUUID, id)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, id := range idsNeedingUUID {
|
|
uuid, err := newSiteUUID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE sites SET site_uuid = ? WHERE id = ?`, uuid, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_sites_site_uuid ON sites(site_uuid)`); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
version: 3,
|
|
name: "add_runtime_query_indexes",
|
|
up: func(ctx context.Context, tx *sql.Tx) error {
|
|
stmts := []string{
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_status_id ON jobs(status, id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_site_type_id ON jobs(site_id, type, id DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_job_events_job_id_id ON job_events(job_id, id DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
version: 4,
|
|
name: "site_filters",
|
|
up: func(ctx context.Context, tx *sql.Tx) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS site_filters (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
pattern TEXT NOT NULL,
|
|
UNIQUE(site_id, pattern)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_site_filters_site_id ON site_filters(site_id, id)`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
version: 5,
|
|
name: "site_targets_mysql_dump",
|
|
up: func(ctx context.Context, tx *sql.Tx) error {
|
|
exists, err := tableExists(ctx, tx, "site_targets")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `CREATE TABLE site_targets_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump')),
|
|
mysql_host TEXT,
|
|
mysql_user TEXT,
|
|
mysql_db TEXT,
|
|
mysql_password TEXT,
|
|
last_size_bytes INTEGER,
|
|
last_scan_at DATETIME,
|
|
last_error TEXT,
|
|
UNIQUE(site_id, path, mode)
|
|
)`); err != nil {
|
|
return err
|
|
}
|
|
|
|
hasMySQLHost, err := columnExists(ctx, tx, "site_targets", "mysql_host")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasMySQLHost {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO site_targets_new (id, site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error)
|
|
SELECT id, site_id, path, mode, mysql_host, NULL, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error
|
|
FROM site_targets`); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO site_targets_new (id, site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error)
|
|
SELECT id, site_id, path, mode, NULL, NULL, NULL, NULL, last_size_bytes, last_scan_at, last_error
|
|
FROM site_targets`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `DROP TABLE site_targets`); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `ALTER TABLE site_targets_new RENAME TO site_targets`); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
version: 6,
|
|
name: "site_targets_mysql_user",
|
|
up: func(ctx context.Context, tx *sql.Tx) error {
|
|
exists, err := tableExists(ctx, tx, "site_targets")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
hasUser, err := columnExists(ctx, tx, "site_targets", "mysql_user")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasUser {
|
|
return nil
|
|
}
|
|
_, err = tx.ExecContext(ctx, `ALTER TABLE site_targets ADD COLUMN mysql_user TEXT`)
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
applied, err := migrationApplied(ctx, s.db, m.version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := m.up(ctx, tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %d (%s) failed: %w", m.version, m.name, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version, name) VALUES (?, ?)`, m.version, m.name); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %d (%s) record failed: %w", m.version, m.name, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("migration %d (%s) commit failed: %w", m.version, m.name, err)
|
|
}
|
|
s.debugDB("migration applied", zap.Int("version", m.version), zap.String("name", m.name))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) {
|
|
var count int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version).Scan(&count); err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func tableExists(ctx context.Context, tx *sql.Tx, table string) (bool, error) {
|
|
var count int
|
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name = ?`, table).Scan(&count); err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func columnExists(ctx context.Context, tx *sql.Tx, table, col string) (bool, error) {
|
|
rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var cid int
|
|
var name string
|
|
var ctype string
|
|
var notNull int
|
|
var dflt sql.NullString
|
|
var pk int
|
|
if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dflt, &pk); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.EqualFold(name, col) {
|
|
return true, nil
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|