From b4ca54599cac978e82e41b491220674ccc836ab1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 26 Apr 2026 16:41:58 -0400 Subject: [PATCH] feat: add guarded workflow-migrate force command --- CHANGELOG.md | 6 ++ cmd/workflow-migrate/main.go | 1 + internal/golangmigrate/driver.go | 84 +++++++++++++++++++++++ internal/golangmigrate/driver_test.go | 92 +++++++++++++++++++++++++ pkg/cli/root.go | 98 +++++++++++++++++++++++++++ pkg/cli/root_test.go | 77 +++++++++++++++++++++ plugin.json | 1 + 7 files changed, 359 insertions(+) create mode 100644 pkg/cli/root_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 626a9c0..4b22804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `workflow-migrate force ` for force-setting the recorded golang-migrate version after dirty or manual repair workflows. + ## [0.3.1] - 2026-04-24 ### Fixed diff --git a/cmd/workflow-migrate/main.go b/cmd/workflow-migrate/main.go index 22c02cf..ccf5622 100644 --- a/cmd/workflow-migrate/main.go +++ b/cmd/workflow-migrate/main.go @@ -8,6 +8,7 @@ // workflow-migrate down [flags] // workflow-migrate status [flags] // workflow-migrate goto [flags] +// workflow-migrate force [flags] package main import ( diff --git a/internal/golangmigrate/driver.go b/internal/golangmigrate/driver.go index e9c9ad7..13c27b5 100644 --- a/internal/golangmigrate/driver.go +++ b/internal/golangmigrate/driver.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "os" + "strconv" "strings" "time" @@ -184,6 +185,89 @@ func (d *Driver) Goto(_ context.Context, req interfaces.MigrationRequest, target }, nil } +// ForceOptions controls safety checks for metadata-only force repair. +type ForceOptions struct { + // AllowClean permits force-setting a database that is not currently dirty. + // Leave false for normal repair flows so force is limited to dirty states. + AllowClean bool +} + +// Force sets the recorded migration version without applying migration files. +func (d *Driver) Force(_ context.Context, req interfaces.MigrationRequest, target string, opts ForceOptions) (interfaces.MigrationResult, error) { + if err := req.Validate(); err != nil { + return interfaces.MigrationResult{}, err + } + start := time.Now() + + version, err := parseForceTarget(target) + if err != nil { + return interfaces.MigrationResult{}, err + } + if version > 0 { + exists, err := versionExists(req.Source.Dir, uint(version)) + if err != nil { + return interfaces.MigrationResult{}, err + } + if !exists { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: target version %q does not exist in migration source", target) + } + } + + m, err := newMigrate(req) + if err != nil { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate: %w", err) + } + defer m.Close() //nolint:errcheck + + _, dirty, err := m.Version() + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: version before force: %w", err) + } + if !dirty && !opts.AllowClean { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: database is clean; refusing metadata-only force without allow-clean") + } + + if err := m.Force(version); err != nil { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: %w", err) + } + + return interfaces.MigrationResult{ + Applied: nil, + DurationMs: time.Since(start).Milliseconds(), + }, nil +} + +func parseForceTarget(target string) (int, error) { + version, err := strconv.Atoi(target) + if err != nil || version == 0 || version < -1 { + return 0, fmt.Errorf("golang-migrate force: invalid target version %q: must be -1 or a positive integer", target) + } + return version, nil +} + +func versionExists(dir string, target uint) (bool, error) { + src := &migratefile.File{} + s, err := src.Open("file://" + dir) + if err != nil { + return false, fmt.Errorf("golang-migrate: open source for version lookup: %w", err) + } + defer s.Close() //nolint:errcheck + + v, err := s.First() + for { + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("golang-migrate: read source version: %w", err) + } + if v == target { + return true, nil + } + v, err = s.Next(v) + } +} + // newMigrate creates a migrate.Migrate from a MigrationRequest. // The DSN is expected to be a postgres:// URL; we rewrite it to pgx5:// for // the pgx/v5 driver. diff --git a/internal/golangmigrate/driver_test.go b/internal/golangmigrate/driver_test.go index 8f9fceb..73f5deb 100644 --- a/internal/golangmigrate/driver_test.go +++ b/internal/golangmigrate/driver_test.go @@ -2,14 +2,17 @@ package golangmigrate_test import ( "context" + "database/sql" "os" "path/filepath" + "strings" "testing" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow-plugin-migrations/internal/golangmigrate" "github.com/GoCodeAlone/workflow-plugin-migrations/pkg/testharness" + _ "github.com/jackc/pgx/v5/stdlib" ) func TestDriver_Name(t *testing.T) { @@ -99,6 +102,95 @@ func TestDriver_UpDownStatus(t *testing.T) { if st.Current != "2" { t.Errorf("Goto: Current = %q; want %q", st.Current, "2") } + + // Force refuses clean databases by default. + _, err = d.Force(ctx, req, "1", golangmigrate.ForceOptions{}) + if err == nil { + t.Fatal("Force() error = nil; want clean database refusal") + } + if !strings.Contains(err.Error(), "database is clean") { + t.Fatalf("Force() error = %v; want clean database refusal", err) + } + + markDirty(t, h.DSN(), 2) + + // Force: set the recorded version without running migrations. + result, err = d.Force(ctx, req, "1", golangmigrate.ForceOptions{}) + if err != nil { + t.Fatalf("Force() error: %v", err) + } + if len(result.Applied) != 0 { + t.Fatalf("Force() Applied = %v; force must not report applied migrations", result.Applied) + } + st, err = d.Status(ctx, req) + if err != nil { + t.Fatalf("Status() after force error: %v", err) + } + if st.Current != "1" { + t.Errorf("Force: Current = %q; want %q", st.Current, "1") + } + if st.Dirty { + t.Error("Force: expected clean state") + } + + markDirty(t, h.DSN(), 1) + _, err = d.Force(ctx, req, "999", golangmigrate.ForceOptions{}) + if err == nil { + t.Fatal("Force() missing target error = nil; want error") + } + if !strings.Contains(err.Error(), "does not exist in migration source") { + t.Fatalf("Force() missing target error = %v; want missing target", err) + } + + _, err = d.Force(ctx, req, "-1", golangmigrate.ForceOptions{}) + if err != nil { + t.Fatalf("Force(-1) error: %v", err) + } + st, err = d.Status(ctx, req) + if err != nil { + t.Fatalf("Status() after force -1 error: %v", err) + } + if st.Current != "" { + t.Errorf("Force(-1): Current = %q; want nil version", st.Current) + } + if st.Dirty { + t.Error("Force(-1): expected clean state") + } +} + +func TestDriver_ForceRejectsInvalidTarget(t *testing.T) { + ctx := context.Background() + d := golangmigrate.New() + req := interfaces.MigrationRequest{ + DSN: "postgres://user:pass@example.invalid/db", + Source: interfaces.MigrationSource{ + Dir: t.TempDir(), + }, + } + + for _, target := range []string{"", "-2", "0", "abc", "1.5"} { + t.Run(target, func(t *testing.T) { + _, err := d.Force(ctx, req, target, golangmigrate.ForceOptions{}) + if err == nil { + t.Fatal("Force() error = nil; want invalid target error") + } + if !strings.Contains(err.Error(), "invalid target version") { + t.Fatalf("Force() error = %v; want invalid target version", err) + } + }) + } +} + +func markDirty(t *testing.T, dsn string, version int) { + t.Helper() + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() //nolint:errcheck + if _, err := db.Exec(`UPDATE schema_migrations SET version = $1, dirty = true`, version); err != nil { + t.Fatalf("mark schema_migrations dirty: %v", err) + } } func writeSQL(t *testing.T, dir, name, sql string) { diff --git a/pkg/cli/root.go b/pkg/cli/root.go index bdef8ed..002ee90 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -46,6 +47,7 @@ func NewRoot() *cobra.Command { newDownCmd(), newStatusCmd(), newGotoCmd(), + newForceCmd(), newLintCmd(), newTestCmd(), newTenantEnsureCmd(), @@ -196,3 +198,99 @@ func newGotoCmd() *cobra.Command { sharedFlags(cmd) return cmd } + +type forceDriver interface { + Force(ctx context.Context, req interfaces.MigrationRequest, target string, opts golangmigrate.ForceOptions) (interfaces.MigrationResult, error) +} + +func newForceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "force ", + Short: "Force-set the recorded migration version", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + target, flagArgs, err := splitForceArgs(args) + if err != nil { + return err + } + if err := cmd.Flags().Parse(flagArgs); err != nil { + return err + } + confirmation, _ := cmd.Flags().GetString("confirm-force") + if confirmation != "FORCE_MIGRATION_METADATA" { + return fmt.Errorf("force mutates migration metadata without applying SQL; pass --confirm-force FORCE_MIGRATION_METADATA to continue") + } + d, req, err := buildDriverAndRequest(cmd) + if err != nil { + return err + } + f, ok := d.(forceDriver) + if !ok { + return fmt.Errorf("driver %q does not support force", d.Name()) + } + allowClean, _ := cmd.Flags().GetBool("allow-clean") + result, err := f.Force(context.Background(), req, target, golangmigrate.ForceOptions{AllowClean: allowClean}) + if err != nil { + return fmt.Errorf("migrate force %s: %w", target, err) + } + fmt.Printf("Recorded migration version set to %s; no migrations applied. Duration: %dms\n", target, result.DurationMs) + return nil + }, + } + sharedFlags(cmd) + cmd.Flags().String("confirm-force", "", "Typed confirmation required: FORCE_MIGRATION_METADATA") + cmd.Flags().Bool("allow-clean", false, "Allow force-setting a database that is not marked dirty") + return cmd +} + +func splitForceArgs(args []string) (string, []string, error) { + var target string + flagArgs := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + if i+1 >= len(args) { + return "", nil, fmt.Errorf("force requires exactly one version") + } + if target != "" { + return "", nil, fmt.Errorf("force requires exactly one version") + } + target = args[i+1] + if i+2 < len(args) { + flagArgs = append(flagArgs, args[i+2:]...) + } + break + } + if arg == "-1" || !strings.HasPrefix(arg, "-") { + if target != "" { + return "", nil, fmt.Errorf("force requires exactly one version") + } + target = arg + continue + } + flagArgs = append(flagArgs, arg) + if forceFlagNeedsValue(arg) { + if i+1 >= len(args) { + return "", nil, fmt.Errorf("flag %s requires a value", arg) + } + i++ + flagArgs = append(flagArgs, args[i]) + } + } + if target == "" { + return "", nil, fmt.Errorf("force requires exactly one version") + } + return target, flagArgs, nil +} + +func forceFlagNeedsValue(arg string) bool { + if strings.Contains(arg, "=") { + return false + } + switch arg { + case "--driver", "--source-dir", "--dsn", "--confirm-force": + return true + default: + return false + } +} diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go new file mode 100644 index 0000000..6f3e2c3 --- /dev/null +++ b/pkg/cli/root_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestRootIncludesForceCommand(t *testing.T) { + root := NewRoot() + + cmd, _, err := root.Find([]string{"force", "1"}) + if err != nil { + t.Fatalf("Find(force) error: %v", err) + } + if cmd == nil || cmd.Name() != "force" { + t.Fatalf("Find(force) = %v; want force command", cmd) + } +} + +func TestForceCommandRequiresTypedConfirmation(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{ + "force", + "1", + "--source-dir", t.TempDir(), + "--dsn", "postgres://user:pass@example.invalid/db", + }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil; want confirmation error") + } + if !strings.Contains(err.Error(), "--confirm-force FORCE_MIGRATION_METADATA") { + t.Fatalf("Execute() error = %v; want confirmation error", err) + } +} + +func TestForceCommandRejectsInvalidVersionWithConfirmation(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{ + "force", + "not-a-version", + "--source-dir", t.TempDir(), + "--dsn", "postgres://user:pass@example.invalid/db", + "--confirm-force", "FORCE_MIGRATION_METADATA", + }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil; want invalid version error") + } + if !strings.Contains(err.Error(), "invalid target version") { + t.Fatalf("Execute() error = %v; want invalid target version", err) + } +} + +func TestForceCommandAcceptsNegativeNilVersionAsArgument(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{ + "force", + "-1", + "--source-dir", t.TempDir(), + "--dsn", "postgres://user:pass@example.invalid/db", + "--confirm-force", "FORCE_MIGRATION_METADATA", + }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil; want database connection error after parsing -1 as argument") + } + if strings.Contains(err.Error(), "unknown shorthand flag") { + t.Fatalf("Execute() error = %v; -1 was parsed as a flag", err) + } + if strings.Contains(err.Error(), "invalid target version") { + t.Fatalf("Execute() error = %v; -1 should be a valid nil-version target", err) + } +} diff --git a/plugin.json b/plugin.json index 70281a1..6a27e7b 100644 --- a/plugin.json +++ b/plugin.json @@ -34,6 +34,7 @@ {"name": "down", "description": "Roll back migrations"}, {"name": "status", "description": "Show migration status"}, {"name": "goto", "description": "Migrate to a specific version"}, + {"name": "force", "description": "Force-set the recorded migration version"}, {"name": "lint", "description": "Static analysis for migration files"}, {"name": "test", "description": "Run full-cycle and checkpoint migration tests"}, {"name": "tenant-ensure", "description": "Ensure a tenant schema exists in the database"}