fix: address audit execution failures and unsafe type conversions

This commit is contained in:
2026-10-12 19:53:19 +02:00
parent bfe9852826
commit 2fa48335d3
29 changed files with 1630 additions and 421 deletions
+24 -5
View File
@@ -186,11 +186,30 @@ Future support planned for:
- SQLite
- Generic SQL via database/sql
## Transaction Behavior
## Execution and transaction behavior
**Current**: Each script executes in its own implicit transaction (PostgreSQL default behavior)
This is a raw PostgreSQL script runner, not a migration manager. It has no applied
history, checksums, migration lock, or duplicate-run protection. Re-running repeats
the supplied scripts. Scripts may contain transaction control, so RelSpec does not
wrap them in a transaction and rejects `WriterOptions.AtomicExecution`.
**Future Enhancement**: Option to wrap all scripts in a single transaction for atomic execution with rollback on error.
By default execution stops on the first SQL or embed-preparation failure. Set
`WriterOptions.ContinueOnError`, legacy `Metadata["ignore_errors"]`, or CLI
`--ignore-errors` / `--continue-on-error` / `--coe` to attempt later scripts. Any
failure still returns a nonzero result and prevents dependent jobs from running.
The shared `writers.ExecutionReport` is available at `Metadata["execution_report"]`;
`Metadata["report_path"]` requests a JSON report and report-write failures are errors.
Counters and operations cover every schema in the invocation, including scripts
skipped after stopping or because they are empty. Legacy `execution_total`,
`execution_success`, `execution_failed`, `execution_skipped`, and
`execution_rolled_back` metadata counters are also populated on failure.
Reports use `outcome: script_managed` and successful script status `executed`, rather
than claiming committed database changes. A script may commit, roll back, or leave a
transaction open, and a failed script may already have committed work. The report
records which scripts succeeded/failed/skipped; it cannot infer statement-level
commit or rollback inside arbitrary scripts. The connection closes after execution.
## Performance Considerations
@@ -226,7 +245,7 @@ docker run -d --name postgres-test \
postgres:16
# Run your integration tests
go test -tags=integration ./pkg/writers/sqlexec/
RELSPEC_TEST_PG_CONN='postgres://postgres:test@127.0.0.1:5432/testdb?sslmode=disable' go test -count=1 ./pkg/writers/sqlexec/
# Cleanup
docker stop postgres-test
@@ -237,7 +256,7 @@ docker rm postgres-test
- `WriteTable()` is not supported (returns error)
- Requires PostgreSQL connection (no offline mode)
- No built-in transaction wrapping (yet)
- No executor-level transaction wrapping for raw scripts
- No rollback script support (yet, though `models.Script.Rollback` field exists)
## Related
+44 -105
View File
@@ -42,22 +42,25 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
return fmt.Errorf("connection_string is required in writer metadata")
}
// Preserve schema order and script ordering within each schema, but aggregate once.
var scripts []*models.Script
for _, schema := range db.Schemas {
for _, script := range sortedScripts(schema.Scripts) {
copy := *script
copy.Name = schema.Name + "." + script.Name
scripts = append(scripts, &copy)
}
}
// Connect to database
ctx := context.Background()
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
return writers.FailExecution(w.options, "PostgreSQL scripts", scriptOperations(scripts), fmt.Errorf("failed to connect to database: %w", err))
}
defer conn.Close(ctx)
// Execute scripts from all schemas
for _, schema := range db.Schemas {
if err := w.executeScripts(ctx, conn, schema.Scripts); err != nil {
return fmt.Errorf("failed to execute scripts from schema %s: %w", schema.Name, err)
}
}
return nil
return w.runScripts(ctx, conn, scripts)
}
// WriteSchema executes all scripts from a single schema
@@ -76,7 +79,7 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
ctx := context.Background()
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
return writers.FailExecution(w.options, "PostgreSQL scripts", scriptOperations(sortedScripts(schema.Scripts)), fmt.Errorf("failed to connect to database: %w", err))
}
defer conn.Close(ctx)
@@ -93,113 +96,49 @@ func (w *Writer) WriteTable(table *models.Table) error {
return fmt.Errorf("WriteTable is not supported for SQL script execution")
}
// executeScripts executes scripts in Priority, Sequence, then Name order
// executeScripts executes scripts in Priority, Sequence, then Name order.
func (w *Writer) executeScripts(ctx context.Context, conn *pgx.Conn, scripts []*models.Script) error {
if len(scripts) == 0 {
return nil
}
return w.runScripts(ctx, conn, sortedScripts(scripts))
}
// Check if we should ignore errors
ignoreErrors := false
if val, ok := w.options.Metadata["ignore_errors"].(bool); ok {
ignoreErrors = val
}
// Track failed scripts and execution counts
var failedScripts []struct {
name string
priority int
sequence uint
err error
}
successCount := 0
totalCount := 0
// Sort scripts by Priority (ascending), Sequence (ascending), then Name (ascending)
sortedScripts := make([]*models.Script, len(scripts))
copy(sortedScripts, scripts)
sort.Slice(sortedScripts, func(i, j int) bool {
if sortedScripts[i].Priority != sortedScripts[j].Priority {
return sortedScripts[i].Priority < sortedScripts[j].Priority
func sortedScripts(scripts []*models.Script) []*models.Script {
ordered := append([]*models.Script(nil), scripts...)
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].Priority != ordered[j].Priority {
return ordered[i].Priority < ordered[j].Priority
}
if sortedScripts[i].Sequence != sortedScripts[j].Sequence {
return sortedScripts[i].Sequence < sortedScripts[j].Sequence
if ordered[i].Sequence != ordered[j].Sequence {
return ordered[i].Sequence < ordered[j].Sequence
}
return sortedScripts[i].Name < sortedScripts[j].Name
return ordered[i].Name < ordered[j].Name
})
return ordered
}
// Execute each script in order
for _, script := range sortedScripts {
if script.SQL == "" {
continue
}
totalCount++
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n",
script.Name, script.Priority, script.Sequence)
// Execute the SQL script
func (w *Writer) runScripts(ctx context.Context, conn *pgx.Conn, scripts []*models.Script) error {
operations := scriptOperations(scripts)
backend := writers.ExecutionBackend{ScriptManaged: true, Exec: func(ctx context.Context, i int, _ string) error {
script := scripts[i]
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n", script.Name, script.Priority, script.Sequence)
sql, err := processEmbedDirectives(script)
if err != nil {
if ignoreErrors {
fmt.Printf("⚠ Error preparing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
failedScripts = append(failedScripts, struct {
name string
priority int
sequence uint
err error
}{
name: script.Name,
priority: script.Priority,
sequence: script.Sequence,
err: err,
})
continue
}
return fmt.Errorf("script %s (Priority=%d, Sequence=%d): %w",
script.Name, script.Priority, script.Sequence, err)
return fmt.Errorf("preparing script %s: %w", script.Name, err)
}
_, err = conn.Exec(ctx, sql)
if err != nil {
if ignoreErrors {
fmt.Printf("⚠ Error executing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
failedScripts = append(failedScripts, struct {
name string
priority int
sequence uint
err error
}{
name: script.Name,
priority: script.Priority,
sequence: script.Sequence,
err: err,
})
continue
}
return fmt.Errorf("script %s (Priority=%d, Sequence=%d): %w",
script.Name, script.Priority, script.Sequence, err)
if _, err := conn.Exec(ctx, sql); err != nil {
return fmt.Errorf("script %s: %w", script.Name, err)
}
return nil
}}
// Supplied scripts can manage transactions themselves. Do not promise whole-script rollback.
return writers.RunExecution(ctx, w.options, &writers.ExecutionReport{Dialect: "PostgreSQL scripts"}, operations, backend)
}
successCount++
fmt.Printf("✓ Successfully executed: %s\n", script.Name)
func scriptOperations(scripts []*models.Script) []writers.ExecutionOperation {
operations := make([]writers.ExecutionOperation, len(scripts))
for i, script := range scripts {
operations[i] = writers.ExecutionOperation{Name: script.Name, Statement: script.SQL}
}
// Store execution results in metadata for caller
w.options.Metadata["execution_total"] = totalCount
w.options.Metadata["execution_success"] = successCount
w.options.Metadata["execution_failed"] = len(failedScripts)
// Print summary of failed scripts if any
if len(failedScripts) > 0 {
fmt.Printf("\n⚠ Failed Scripts Summary (%d failed):\n", len(failedScripts))
for i, failed := range failedScripts {
fmt.Printf(" %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n",
i+1, failed.name, failed.priority, failed.sequence, failed.err)
}
}
return nil
return operations
}
func processEmbedDirectives(script *models.Script) (string, error) {
+29 -6
View File
@@ -2,6 +2,7 @@ package sqlexec
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -121,7 +122,7 @@ func TestLive_ExecuteScriptsOrder(t *testing.T) {
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("execution order = %v, want %v", got, want)
}
if opts.Metadata["execution_total"] != 6 || opts.Metadata["execution_success"] != 6 || opts.Metadata["execution_failed"] != 0 {
if opts.Metadata["execution_total"] != 7 || opts.Metadata["execution_success"] != 6 || opts.Metadata["execution_failed"] != 0 {
t.Errorf("counts: %v", opts.Metadata)
}
}
@@ -160,8 +161,8 @@ func TestLive_IgnoreErrorsContinues(t *testing.T) {
{Name: "02_ok", Priority: 2, SQL: fmt.Sprintf("CREATE TABLE %s.after(id int);", schema)},
}
opts := liveOptions(connString, map[string]interface{}{"ignore_errors": true})
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: scripts}); err != nil {
t.Fatalf("ignore_errors must not fail: %v", err)
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: scripts}); err == nil {
t.Fatal("continuation must still return failure")
}
if opts.Metadata["execution_total"] != 2 || opts.Metadata["execution_success"] != 1 || opts.Metadata["execution_failed"] != 1 {
t.Errorf("counts: %v", opts.Metadata)
@@ -186,8 +187,8 @@ func TestLive_EmbedDirectiveErrorHandling(t *testing.T) {
}
opts := liveOptions(connString, map[string]interface{}{"ignore_errors": true})
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: []*models.Script{bad}}); err != nil {
t.Errorf("ignore_errors: %v", err)
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: []*models.Script{bad}}); err == nil {
t.Error("embed preparation failures must propagate even when continuing")
}
if opts.Metadata["execution_failed"] != 1 {
t.Errorf("counts: %v", opts.Metadata)
@@ -217,7 +218,29 @@ func TestLive_WriteDatabaseMultiSchema(t *testing.T) {
// A failure in one schema aborts and names that schema.
db.Schemas[1].Scripts[0].SQL = "SELECT * FROM definitely_missing_table;"
err := NewWriter(liveOptions(connString, nil)).WriteDatabase(db)
if err == nil || !strings.Contains(err.Error(), "schema "+s2) {
if err == nil || !strings.Contains(err.Error(), s2+".b") {
t.Errorf("expected error naming schema %s, got %v", s2, err)
}
}
func TestLive_MultiSchemaFailuresAggregate(t *testing.T) {
connection := liveConn(t)
opts := liveOptions(connection, nil)
opts.ContinueOnError = true
db := &models.Database{Schemas: []*models.Schema{
{Name: "one", Scripts: []*models.Script{{Name: "bad", SQL: "SELECT 1 / 0"}, {Name: "empty"}}},
{Name: "two", Scripts: []*models.Script{{Name: "ok", SQL: "SELECT 1"}}},
}}
err := NewWriter(opts).WriteDatabase(db)
var failure *writers.ExecutionFailure
if !errors.As(err, &failure) {
t.Fatalf("expected structured failure: %v", err)
}
report := failure.Report
if report.TotalStatements != 3 || report.FailedStatements != 1 || report.ExecutedStatements != 1 || report.SkippedStatements != 1 || report.Outcome != "script_managed" {
t.Fatalf("incorrect aggregate: %+v", report)
}
if report.Operations[0].Name != "one.bad" || report.Operations[2].Name != "two.ok" || report.Operations[2].Status != "executed" {
t.Fatalf("script details: %+v", report.Operations)
}
}