refactor(store): migration files + embed.FS
- 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>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
-- deploy_server initial schema. Tracks deploy targets (per app+host) and execution history.
|
||||
|
||||
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 '{}',
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Extra deploy_targets columns: strategy, source_dir, branch, compose_files.
|
||||
-- Aplicacion idempotente: store.go captura "duplicate column" para DBs preexistentes.
|
||||
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 '';
|
||||
@@ -2,13 +2,20 @@ 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"`
|
||||
@@ -62,50 +69,63 @@ func (s *Store) Close() error {
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
`)
|
||||
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
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
|
||||
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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user