diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 056e14f..ad182d9 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,9 +15,10 @@ on: permissions: read-all jobs: - TrivyCode: + GolangCI: runs-on: ubuntu-latest permissions: + contents: read security-events: write steps: - name: Harden Runner @@ -25,26 +26,26 @@ jobs: with: egress-policy: audit - - name: Checkout code + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 - - name: Run Trivy vulnerability scanner in repo mode - uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2 + - name: Run golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - scan-type: 'fs' - ignore-unfixed: true - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL' + version: latest + args: --timeout=5m --output.sarif.path=golangci-lint-results.sarif --output.text.path=stdout - - name: Upload Trivy scan results to GitHub Security tab + - name: Upload golangci-lint results to GitHub Security tab uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: - sarif_file: 'trivy-results.sarif' + sarif_file: golangci-lint-results.sarif - GolangciLint: + TrivyCode: runs-on: ubuntu-latest permissions: + contents: read security-events: write steps: - name: Harden Runner @@ -52,21 +53,22 @@ jobs: with: egress-policy: audit - - name: Checkout + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - name: Run golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + - name: Run Trivy vulnerability scanner in fs mode + uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1 with: - version: latest - args: --output.sarif.path=golangci-lint-results.sarif + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' - - name: Upload golangci-lint results to GitHub Security tab + - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: - sarif_file: golangci-lint-results.sarif + sarif_file: 'trivy-results.sarif' VulnerabilityCheck: strategy: @@ -75,6 +77,7 @@ jobs: - "stable" runs-on: ubuntu-latest permissions: + contents: read security-events: write steps: - name: Harden Runner @@ -82,24 +85,17 @@ jobs: with: egress-policy: audit - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - - name: VulnerabilityCheck + - name: Vulnerability Check uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 with: - repo-checkout: false go-version-input: ${{matrix.go-version}} output-format: sarif output-file: govulncheck-results.sarif - - name: PrintSarif - id: PrintSarif + - name: Print Sarif + id: printSarif run: | cat govulncheck-results.sarif - if grep results govulncheck-results.sarif then echo "hasResults=true" >> $GITHUB_OUTPUT @@ -107,8 +103,8 @@ jobs: echo "hasResults=false" >> $GITHUB_OUTPUT fi - - name: Upload govulncheck results to GitHub Security tab - if: ${{ steps.PrintSarif.outputs.hasResults == 'true' }} + - name: Upload govulncheck results to Security tab + if: ${{ steps.printSarif.outputs.hasResults == 'true' }} uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: sarif_file: govulncheck-results.sarif diff --git a/.golangci.yaml b/.golangci.yaml index 18981e5..62703be 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -15,11 +15,9 @@ linters: disable: - exhaustruct - - forbidigo - noinlineerr - nonamedreturns - wsl - - wsl_v5 exclusions: warn-unused: true @@ -89,6 +87,12 @@ linters: multi-if: true multi-func: true + wsl_v5: + disable: + - decl + - err + - leading-whitespace + issues: max-issues-per-linter: 0 max-same-issues: 0 \ No newline at end of file diff --git a/dialects.go b/dialects.go index dced1b7..6b67172 100644 --- a/dialects.go +++ b/dialects.go @@ -27,29 +27,22 @@ func (b BaseDialect) EnsureMigrationTableExists(ctx context.Context, db *sql.DB, return wrapIfError("could not start transaction", err) } - // Safety net for unexpected panics - defer func() { - if tx != nil { - _ = tx.Rollback() - } - }() + // Safety net for unexpected panics or returns. We can always call Rollback, + // as it does semantically nothing in case of a previous successful commit + defer func() { _ = tx.Rollback() }() if _, execErr := tx.ExecContext(ctx, fmt.Sprintf(b.CreateTemplate, tableName)); execErr != nil { rollbackErr := tx.Rollback() - tx = nil return errors.Join(execErr, rollbackErr) } if err := tx.Commit(); err != nil { rollbackErr := tx.Rollback() - tx = nil return errors.Join(err, rollbackErr) } - tx = nil - return nil } diff --git a/dialects_test.go b/dialects_test.go index cc83886..6fd7108 100644 --- a/dialects_test.go +++ b/dialects_test.go @@ -42,18 +42,21 @@ func TestDialectStatements(t *testing.T) { if len(dialect.CreateTemplate) < 10 { t.Errorf("create template is too short for %v", test.name) } + assert.Contains(t, dialect.CreateTemplate, "%s", "no table name placeholder in create template for", test.name) if len(dialect.AppliedTemplate) < 10 { t.Errorf("applied template is too short for %v", test.name) } + assert.Contains(t, dialect.AppliedTemplate, "%s", "no table name placeholder in applied template for", test.name) if len(dialect.RegisterTemplate) < 10 { t.Errorf("register template is too short for %v", test.name) } + assert.Contains(t, dialect.RegisterTemplate, "%s", "no table name placeholder in register template for", test.name) }) diff --git a/file_migration.go b/file_migration.go index 1a5d35f..e722544 100644 --- a/file_migration.go +++ b/file_migration.go @@ -14,6 +14,7 @@ import ( "log/slog" "os" "path/filepath" + "regexp" "strings" ) @@ -115,15 +116,21 @@ func applyStepsStream(ctx context.Context, tx *sql.Tx, r io.Reader, migrationID const InitialScannerBufSize = 64 * 1024 const MaxScannerBufSize = 1024 * 1024 + // The regex is here, as we do not expect to have overwhelming lots of calls even during larger migrations. + // No need to pollute the global namespace. + initialEmptyRegex := regexp.MustCompile(`^\s*(?:--.*)?$`) + buf := bytes.Buffer{} scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, InitialScannerBufSize), MaxScannerBufSize) + newStep := true + var step int for step = 0; scanner.Scan(); { - if newStep && strings.HasPrefix(scanner.Text(), "--") { + if newStep && initialEmptyRegex.MatchString(scanner.Text()) { // skip leading comments continue } @@ -139,7 +146,9 @@ func applyStepsStream(ctx context.Context, tx *sql.Tx, r io.Reader, migrationID } buf.Reset() + newStep = true + step++ continue @@ -151,6 +160,7 @@ func applyStepsStream(ctx context.Context, tx *sql.Tx, r io.Reader, migrationID } buf.Write(scanner.Bytes()) + newStep = false } diff --git a/go.mod b/go.mod index 3723dc3..975aa55 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/AlphaOne1/dmorph -go 1.25 +go 1.25.0 require ( github.com/stretchr/testify v1.11.1 @@ -24,16 +24,15 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.17.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/gotestsum v1.13.0 // indirect - modernc.org/libc v1.67.6 // indirect + modernc.org/libc v1.69.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 34974a5..4a64ab2 100644 --- a/go.sum +++ b/go.sum @@ -35,22 +35,20 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -61,18 +59,18 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg= +modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8= +modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= diff --git a/migration.go b/migration.go index 78ecfb1..8daac5a 100644 --- a/migration.go +++ b/migration.go @@ -192,6 +192,7 @@ func (m *Morpher) Run(ctx context.Context, db *sql.DB) error { } slices.SortFunc(m.Migrations, migrationOrder) + lastMigration := "" if len(appliedMigrations) == 0 { @@ -224,6 +225,7 @@ func (m *Morpher) applyMigrations(ctx context.Context, db *sql.DB, lastMigration } m.Log.Info("applying migration", slog.String("file", migration.Key())) + startMigration = time.Now() // Check context before starting a transaction @@ -257,13 +259,13 @@ func (m *Morpher) runOneMigration(ctx context.Context, db *sql.DB, mig Migration // allocated resources of the transaction. defer func() { _ = tx.Rollback() }() - if err := mig.Migrate(ctx, tx); err != nil { + if err = mig.Migrate(ctx, tx); err != nil { rollbackErr := tx.Rollback() return errors.Join(err, rollbackErr) } - if err := m.Dialect.RegisterMigration(ctx, tx, mig.Key(), m.TableName); err != nil { + if err = m.Dialect.RegisterMigration(ctx, tx, mig.Key(), m.TableName); err != nil { rollbackErr := tx.Rollback() return errors.Join(err, rollbackErr) diff --git a/migration_test.go b/migration_test.go index f72dddf..cd36ad6 100644 --- a/migration_test.go +++ b/migration_test.go @@ -23,34 +23,17 @@ import ( //go:embed testData var testMigrationsDir embed.FS -// prepareDB creates a temporary SQLite database file and returns its file path. -func prepareDB() (string, error) { - var result string - - dbFile, dbFileErr := os.CreateTemp("", "") - - if dbFileErr != nil { - return "", dmorph.TwrapIfError("could not create temporary db file", dbFileErr) //nolint:wrapcheck - } - - result = dbFile.Name() - - _ = dbFile.Close() - - return result, nil -} - +// openTempSQLite opens a temporary in-memory SQLite database for testing and ensures it is closed after the test ends. func openTempSQLite(t *testing.T) *sql.DB { t.Helper() - dbFile, err := prepareDB() - require.NoError(t, err, "DB file could not be created") - t.Cleanup(func() { _ = os.Remove(dbFile) }) - - db, dbErr := sql.Open("sqlite", dbFile) - require.NoError(t, dbErr, "DB could not be opened") + db, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err, "DB could not be opened") t.Cleanup(func() { _ = db.Close() }) + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + return db } @@ -540,3 +523,84 @@ func TestMigrationApplyUnableCommit(t *testing.T) { morpher.TapplyMigrations(t.Context(), db, ""), "morpher should fail to register") } + +type okDialect struct{} + +func (okDialect) EnsureMigrationTableExists( + _ /* ctx */ context.Context, + _ /* db */ *sql.DB, + _ /* tableName */ string) error { + + return nil +} + +func (okDialect) AppliedMigrations( + _ /* ctx */ context.Context, + _ /* db */ *sql.DB, + _ /* tableName */ string) ([]string, error) { + + return nil, nil +} + +func (okDialect) RegisterMigration( + _ /* ctx */ context.Context, + _ /* tx */ *sql.Tx, + _ /* id */ string, + _ /* tableName */ string) error { + + return nil +} + +type oneMigration struct { + key string +} + +func (m oneMigration) Key() string { + return m.key +} + +func (m oneMigration) Migrate(_ /* ctx */ context.Context, _ /* tx */ *sql.Tx) error { + return nil +} + +func TestRunOneMigrationFailsOnClosedDB(t *testing.T) { + t.Parallel() + + db := openTempSQLite(t) + + require.NoError(t, db.Close()) + + logger := slog.New(slog.DiscardHandler) + + err := dmorph.Run( + context.Background(), + db, + dmorph.WithDialect(okDialect{}), + dmorph.WithMigrations(oneMigration{key: "001_test"}), + dmorph.WithLog(logger), + ) + + require.Error(t, err) + require.ErrorContains(t, err, "begin tx") +} + +func TestApplyFailsOnCanceledContext(t *testing.T) { + t.Parallel() + + db := openTempSQLite(t) + + logger := slog.New(slog.DiscardHandler) + ctx, ctxCancel := context.WithCancel(context.Background()) + ctxCancel() + + err := dmorph.Run( + ctx, + db, + dmorph.WithDialect(okDialect{}), + dmorph.WithMigrations(oneMigration{key: "001_test"}), + dmorph.WithLog(logger), + ) + + require.Error(t, err) + require.ErrorContains(t, err, "context cancelled") +}