package main import ( "database/sql" "encoding/json" "fmt" "time" _ "github.com/mattn/go-sqlite3" ) // DeployTarget represents a deploy configuration stored in operations.db. type DeployTarget struct { App string `json:"app"` Host string `json:"host"` // SSH alias from ~/.ssh/config RemoteDir string `json:"remote_dir"` BinaryName string `json:"binary_name"` BuildCmd string `json:"build_cmd"` ServiceUser string `json:"service_user"` Port int `json:"port"` HealthPath string `json:"health_path"` Env map[string]string `json:"env"` Strategy string `json:"strategy"` // "systemd" (default), "systemd-remote", "docker-compose" SourceDir string `json:"source_dir"` // override for appDir(); empty = use default apps/ Branch string `json:"branch"` // git branch for remote deploys; default "main" ComposeFiles string `json:"compose_files"` // comma-separated extra compose files CreatedAt time.Time `json:"created_at"` } // DeployLog records a deploy execution. type DeployLog struct { ID int64 `json:"id"` App string `json:"app"` Host string `json:"host"` Status string `json:"status"` // "success", "failure" Trigger string `json:"trigger"` // "manual", "webhook" Error string `json:"error"` Duration int64 `json:"duration_ms"` StartedAt time.Time `json:"started_at"` } // Store manages the deploy_server's SQLite database. type Store struct { db *sql.DB } func OpenStore(path string) (*Store, error) { db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { return nil, err } s := &Store{db: db} if err := s.migrate(); err != nil { db.Close() return nil, err } return s, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS deploy_targets ( app TEXT NOT NULL, host TEXT NOT NULL, remote_dir TEXT NOT NULL DEFAULT '', binary_name TEXT NOT NULL DEFAULT '', build_cmd TEXT NOT NULL DEFAULT '', service_user TEXT NOT NULL DEFAULT '', port INTEGER NOT NULL DEFAULT 0, health_path TEXT NOT NULL DEFAULT '', env TEXT NOT NULL DEFAULT '{}', strategy TEXT NOT NULL DEFAULT 'systemd', source_dir TEXT NOT NULL DEFAULT '', branch TEXT NOT NULL DEFAULT 'main', compose_files TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, PRIMARY KEY (app, host) ); CREATE TABLE IF NOT EXISTS deploy_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, app TEXT NOT NULL, host TEXT NOT NULL, status TEXT NOT NULL, trigger TEXT NOT NULL DEFAULT 'manual', error TEXT NOT NULL DEFAULT '', duration_ms INTEGER NOT NULL DEFAULT 0, started_at TEXT NOT NULL ); `) if err != nil { return err } // Idempotent column additions for existing databases for _, col := range []string{ "ALTER TABLE deploy_targets ADD COLUMN strategy TEXT NOT NULL DEFAULT 'systemd'", "ALTER TABLE deploy_targets ADD COLUMN source_dir TEXT NOT NULL DEFAULT ''", "ALTER TABLE deploy_targets ADD COLUMN branch TEXT NOT NULL DEFAULT 'main'", "ALTER TABLE deploy_targets ADD COLUMN compose_files TEXT NOT NULL DEFAULT ''", } { s.db.Exec(col) // ignore "duplicate column" errors } return nil } func (s *Store) AddTarget(t DeployTarget) error { envJSON, _ := json.Marshal(t.Env) if t.Strategy == "" { t.Strategy = "systemd" } if t.Branch == "" { t.Branch = "main" } _, err := s.db.Exec(` INSERT OR REPLACE INTO deploy_targets (app, host, remote_dir, binary_name, build_cmd, service_user, port, health_path, env, strategy, source_dir, branch, compose_files, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.App, t.Host, t.RemoteDir, t.BinaryName, t.BuildCmd, t.ServiceUser, t.Port, t.HealthPath, string(envJSON), t.Strategy, t.SourceDir, t.Branch, t.ComposeFiles, time.Now().UTC().Format(time.RFC3339)) return err } func (s *Store) RemoveTarget(app string) error { res, err := s.db.Exec("DELETE FROM deploy_targets WHERE app = ?", app) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("target %q not found", app) } return nil } const selectTargetCols = "app, host, remote_dir, binary_name, build_cmd, service_user, port, health_path, env, strategy, source_dir, branch, compose_files, created_at" func (s *Store) GetTargets(app string) ([]DeployTarget, error) { rows, err := s.db.Query("SELECT "+selectTargetCols+" FROM deploy_targets WHERE app = ?", app) if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func (s *Store) ListTargets() ([]DeployTarget, error) { rows, err := s.db.Query("SELECT "+selectTargetCols+" FROM deploy_targets ORDER BY app, host") if err != nil { return nil, err } defer rows.Close() return scanTargets(rows) } func scanTargets(rows *sql.Rows) ([]DeployTarget, error) { var targets []DeployTarget for rows.Next() { var t DeployTarget var envStr, createdStr string if err := rows.Scan(&t.App, &t.Host, &t.RemoteDir, &t.BinaryName, &t.BuildCmd, &t.ServiceUser, &t.Port, &t.HealthPath, &envStr, &t.Strategy, &t.SourceDir, &t.Branch, &t.ComposeFiles, &createdStr); err != nil { return nil, err } json.Unmarshal([]byte(envStr), &t.Env) t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) targets = append(targets, t) } return targets, rows.Err() } func (s *Store) LogDeploy(l DeployLog) error { _, err := s.db.Exec(` INSERT INTO deploy_logs (app, host, status, trigger, error, duration_ms, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, l.App, l.Host, l.Status, l.Trigger, l.Error, l.Duration, l.StartedAt.UTC().Format(time.RFC3339)) return err } func (s *Store) RecentLogs(app string, limit int) ([]DeployLog, error) { query := "SELECT id, app, host, status, trigger, error, duration_ms, started_at FROM deploy_logs" var args []any if app != "" { query += " WHERE app = ?" args = append(args, app) } query += " ORDER BY started_at DESC LIMIT ?" args = append(args, limit) rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var logs []DeployLog for rows.Next() { var l DeployLog var startedStr string if err := rows.Scan(&l.ID, &l.App, &l.Host, &l.Status, &l.Trigger, &l.Error, &l.Duration, &startedStr); err != nil { return nil, err } l.StartedAt, _ = time.Parse(time.RFC3339, startedStr) logs = append(logs, l) } return logs, rows.Err() }