ff5c17f7ff
- migrations/001_init.sql + 002_target_extras.sql extraidos de schema inline - store.go: applyMigrations() con embed.FS, splitSQLStatements, isIdempotentError - aplica regla db_migrations.md (fn_registry/.claude/rules/) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
6.5 KiB
Go
229 lines
6.5 KiB
Go
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/<app>
|
|
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()
|
|
}
|