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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
// DeployTarget represents a deploy configuration stored in operations.db.
|
// DeployTarget represents a deploy configuration stored in operations.db.
|
||||||
type DeployTarget struct {
|
type DeployTarget struct {
|
||||||
App string `json:"app"`
|
App string `json:"app"`
|
||||||
@@ -62,50 +69,63 @@ func (s *Store) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
_, err := s.db.Exec(`
|
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Idempotent column additions for existing databases
|
sort.Strings(files)
|
||||||
for _, col := range []string{
|
for _, f := range files {
|
||||||
"ALTER TABLE deploy_targets ADD COLUMN strategy TEXT NOT NULL DEFAULT 'systemd'",
|
b, err := migrationsFS.ReadFile(f)
|
||||||
"ALTER TABLE deploy_targets ADD COLUMN source_dir TEXT NOT NULL DEFAULT ''",
|
if err != nil {
|
||||||
"ALTER TABLE deploy_targets ADD COLUMN branch TEXT NOT NULL DEFAULT 'main'",
|
return err
|
||||||
"ALTER TABLE deploy_targets ADD COLUMN compose_files TEXT NOT NULL DEFAULT ''",
|
}
|
||||||
} {
|
// Split por sentencia para que cada ALTER pueda fallar idempotentemente
|
||||||
s.db.Exec(col) // ignore "duplicate column" errors
|
// 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
|
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 {
|
func (s *Store) AddTarget(t DeployTarget) error {
|
||||||
envJSON, _ := json.Marshal(t.Env)
|
envJSON, _ := json.Marshal(t.Env)
|
||||||
if t.Strategy == "" {
|
if t.Strategy == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user