package main import ( "database/sql" "embed" "encoding/json" "fmt" "io/fs" "sort" "strings" "time" _ "github.com/mattn/go-sqlite3" ) //go:embed migrations/*.sql var migrationsFS embed.FS // 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 { files, err := fs.Glob(migrationsFS, "migrations/*.sql") if err != nil { return err } sort.Strings(files) for _, f := range files { b, err := migrationsFS.ReadFile(f) if err != nil { return err } // Split por sentencia para que cada ALTER pueda fallar idempotentemente // sin abortar el resto. SQLite acepta multiples sentencias en Exec(), // pero no ignora duplicados — ejecutamos una a una. stmts := splitSQLStatements(string(b)) for _, stmt := range stmts { s2 := strings.TrimSpace(stmt) if s2 == "" { continue } if _, err := s.db.Exec(s2); err != nil { if isIdempotentError(err) { continue } return fmt.Errorf("migrate %s: %w", f, err) } } } return nil } func splitSQLStatements(s string) []string { out := []string{} cur := strings.Builder{} for _, line := range strings.Split(s, "\n") { trim := strings.TrimSpace(line) if strings.HasPrefix(trim, "--") || trim == "" { continue } cur.WriteString(line) cur.WriteString("\n") if strings.HasSuffix(trim, ";") { out = append(out, cur.String()) cur.Reset() } } if cur.Len() > 0 { out = append(out, cur.String()) } return out } func isIdempotentError(err error) bool { msg := err.Error() return strings.Contains(msg, "duplicate column") || strings.Contains(msg, "already exists") } 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() }