Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/sync-go-toolchain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
ref: ${{ github.event.pull_request.head.sha }}

- uses: actions/setup-go@v6
with:
Expand Down Expand Up @@ -43,7 +43,7 @@ jobs:
go mod edit -toolchain="go${NEW_VERSION}"

- name: Commit and push changes
if: steps.gomod.outputs.toolchain != format('go{0}', steps.dockerfile.outputs.version)
if: steps.gomod.outputs.toolchain != format('go{0}', steps.dockerfile.outputs.version) && github.event.pull_request.head.repo.full_name == github.repository
env:
NEW_VERSION: ${{ steps.dockerfile.outputs.version }}
run: |
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ RUN git config --global --add safe.directory /src

# install development tools
RUN apt-get update \
&& apt-get install -qq --no-install-recommends \
&& apt-get -y install -qq --no-install-recommends \
curl \
file \
mariadb-client \
postgresql-client \
sqlite3 \
nodejs \
npm \
postgresql-client \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*

# golangci-lint
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ The following options are available with all commands. You must use command line
- `--strict` - fail if migrations would be applied out of order _(env: `DBMATE_STRICT`)_
- `--wait` - wait for the db to become available before executing the subsequent command _(env: `DBMATE_WAIT`)_
- `--wait-timeout 60s` - timeout for --wait flag _(env: `DBMATE_WAIT_TIMEOUT`)_
- `--checksum-mode lenient` - specify checksum mode on applied migrations comparison, see [details about concept](#checksum-validation) _(env: `DBMATE_CHECKSUM_MODE`)_

## Usage

Expand Down Expand Up @@ -602,12 +603,26 @@ The table is very simple:

```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY
version VARCHAR(128) PRIMARY KEY
checksum VARCHAR(64)
)
```

You can customize the name of this table using the `--migrations-table` flag or `DBMATE_MIGRATIONS_TABLE` environment variable.

### Checksum validation

Dbmate supports validating migration file integrity using checksums. When enabled, each migration's contents are hashed (SHA-256) and stored in the `schema_migrations` table. On subsequent runs, dbmate compares the current migration file's checksum with the stored value to detect any changes.

You can configure checksum validation using the `DBMATE_CHECKSUM_MODE` environment variable or the `--checksum-mode` command-line option. Supported modes are:
- `NONE`: Disable checksum validation.
- `LENIENT`: Warn if a migration file has changed after being applied (default).
- `STRICT`: Fail if a migration file has changed after being applied.

Whatever mode is defined, Dbmate will still record each migration file's hash in database.

This feature helps ensure that applied migrations remain unchanged, improving database integrity and team collaboration by explicitly raising bad practices using database schema migration tool.

## Alternatives

Why another database schema migration tool? Dbmate was inspired by many other tools, primarily [Active Record Migrations](http://guides.rubyonrails.org/active_record_migrations.html), with the goals of being trivial to configure, and language & framework independent. Here is a comparison between dbmate and other popular migration tools.
Expand Down
18 changes: 18 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ func NewApp() *cli.App {
Usage: "timeout for --wait flag",
Value: defaultDB.WaitTimeout,
},
&cli.StringFlag{
Name: "checksum-mode",
EnvVars: []string{"DBMATE_CHECKSUM_MODE"},
Usage: "set the checksum mode used during local and applied migrations comparison",
},
}

app.Commands = []*cli.Command{
Expand Down Expand Up @@ -127,6 +132,9 @@ func NewApp() *cli.App {
Action: action(func(db *dbmate.DB, c *cli.Context) error {
db.Strict = c.Bool("strict")
db.Verbose = c.Bool("verbose")
if db.Verbose {
fmt.Fprintf(db.Log, "Checksum mode: %s\n", dbmate.ModeToString(db.ChecksumMode))
}
return db.CreateAndMigrate()
}),
},
Expand Down Expand Up @@ -163,6 +171,9 @@ func NewApp() *cli.App {
Action: action(func(db *dbmate.DB, c *cli.Context) error {
db.Strict = c.Bool("strict")
db.Verbose = c.Bool("verbose")
if db.Verbose {
fmt.Fprintf(db.Log, "Checksum mode: %s\n", dbmate.ModeToString(db.ChecksumMode))
}
return db.Migrate()
}),
},
Expand All @@ -180,6 +191,9 @@ func NewApp() *cli.App {
},
Action: action(func(db *dbmate.DB, c *cli.Context) error {
db.Verbose = c.Bool("verbose")
if db.Verbose {
fmt.Fprintf(db.Log, "Checksum mode: %s\n", dbmate.ModeToString(db.ChecksumMode))
}
return db.Rollback()
}),
},
Expand Down Expand Up @@ -293,6 +307,10 @@ func action(f func(*dbmate.DB, *cli.Context) error) cli.ActionFunc {
}
db := dbmate.New(u)
db.AutoDumpSchema = !c.Bool("no-dump-schema")
db.ChecksumMode, err = dbmate.ParseChecksumMode(c.String("checksum-mode"))
if err != nil {
return err
}
db.MigrationsDir = c.StringSlice("migrations-dir")
db.MigrationsTableName = c.String("migrations-table")
db.SchemaFile = c.String("schema-file")
Expand Down
62 changes: 62 additions & 0 deletions pkg/dbmate/checksum.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package dbmate

import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
)

type ChecksumMode int

const (
ChecksumNone ChecksumMode = iota
ChecksumLenient
ChecksumStrict
)

var ErrUnknownChecksumMode = errors.New("unknown checksum mode")

var utf8BOM = []byte{0xEF, 0xBB, 0xBF}

// ParseChecksumMode parses environment/CLI strings to a ChecksumMode.
// Accepted strings (case-insensitive): "NONE", "LENIENT", "STRICT".
func ParseChecksumMode(s string) (ChecksumMode, error) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "NONE":
return ChecksumNone, nil
case "", "LENIENT":
return ChecksumLenient, nil
case "STRICT":
return ChecksumStrict, nil
default:
return ChecksumLenient, ErrUnknownChecksumMode
}
}

func ModeToString(m ChecksumMode) string {
switch m {
case ChecksumNone:
return "NONE"
case ChecksumLenient:
return "LENIENT"
case ChecksumStrict:
return "STRICT"
default:
return "UNKNOWN"
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

// ComputeChecksum computes a SHA256 checksum of the given bytes after
// canonicalizing text. We strip a leading UTF-8 BOM (if present) and normalize
// CRLF -> LF so checksums are stable across platforms.
func ComputeChecksum(b []byte) string {
// strip UTF-8 BOM if present
b = bytes.TrimPrefix(b, utf8BOM)

// normalize CRLF -> LF
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
44 changes: 44 additions & 0 deletions pkg/dbmate/checksum_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package dbmate

import (
"bytes"
"testing"
)

func TestComputeChecksum_LFvsCRLF(t *testing.T) {
a := []byte("-- migrate:up\nCREATE TABLE foo (id INTEGER);\n")
b := []byte("-- migrate:up\r\nCREATE TABLE foo (id INTEGER);\r\n")
ha := ComputeChecksum(a)
hb := ComputeChecksum(b)
if ha != hb {
t.Fatalf("checksums differ for LF vs CRLF: %s != %s", ha, hb)
}
}

func TestComputeChecksum_BOMStripped(t *testing.T) {
bom := []byte{0xEF, 0xBB, 0xBF}
body := []byte("-- migrate:up\nCREATE TABLE foo (id INTEGER);\n")
withBOM := append(bom, body...)
h1 := ComputeChecksum(body)
h2 := ComputeChecksum(withBOM)
if h1 != h2 {
t.Fatalf("checksums differ with/without BOM: %s != %s", h1, h2)
}
}

func TestComputeChecksum_CRLFandBOM(t *testing.T) {
bom := []byte{0xEF, 0xBB, 0xBF}
lf := []byte("-- migrate:up\nCREATE TABLE foo (id INTEGER);\n")
crlf := bytes.ReplaceAll(lf, []byte("\n"), []byte("\r\n"))
hlf := ComputeChecksum(lf)
hcrlf := ComputeChecksum(crlf)
if hlf != hcrlf {
t.Fatalf("checksums differ for CRLF vs LF: %s != %s", hlf, hcrlf)
}
// BOM CRLF
withBOM := append(bom, crlf...)
h3 := ComputeChecksum(withBOM)
if h3 != hlf {
t.Fatalf("checksums differ for BOMCRLF vs LF: %s != %s", h3, hlf)
}
}
42 changes: 39 additions & 3 deletions pkg/dbmate/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ type DB struct {
WaitInterval time.Duration
// WaitTimeout specifies maximum time for connection attempts
WaitTimeout time.Duration
// ChecksumMode sepcifies migration checksum validation mode
ChecksumMode ChecksumMode
}

// StatusResult represents an available migration status
Expand All @@ -83,6 +85,7 @@ func New(databaseURL *url.URL) *DB {
WaitBefore: false,
WaitInterval: time.Second,
WaitTimeout: 60 * time.Second,
ChecksumMode: ChecksumLenient,
}
}

Expand Down Expand Up @@ -402,7 +405,7 @@ func (db *DB) Migrate() error {
}

// record migration
return drv.InsertMigration(tx, migration.Version)
return drv.InsertMigration(tx, migration.Version, migration.Checksum)
}

if migrationSection.UpOptions.Transaction() {
Expand Down Expand Up @@ -468,13 +471,25 @@ func (db *DB) FindMigrations() ([]Migration, error) {
defer dbutil.MustClose(sqlDB)

// find applied migrations
appliedMigrations := map[string]bool{}
appliedMigrations := map[string]*string{}
migrationsTableExists, err := drv.MigrationsTableExists(sqlDB)
if err != nil {
return nil, err
}

if migrationsTableExists {
hasChecksumColumn, err := drv.HasChecksumColumn(sqlDB)
if err != nil {
return nil, err
}

if !hasChecksumColumn {
err = drv.AddChecksumColumn(sqlDB)
if err != nil {
return nil, err
}
}

appliedMigrations, err = drv.SelectMigrations(sqlDB, -1)
if err != nil {
return nil, err
Expand Down Expand Up @@ -505,9 +520,30 @@ func (db *DB) FindMigrations() ([]Migration, error) {
FilePath: path.Join(dir, matches[0]),
FS: db.FS,
Version: matches[1],
Checksum: "",
}
if ok := appliedMigrations[migration.Version]; ok {

contents, err := migration.readFile()
if err != nil {
return nil, err
}

migration.Checksum = ComputeChecksum([]byte(contents))

if checksum, ok := appliedMigrations[migration.Version]; ok {
migration.Applied = true

if db.ChecksumMode != ChecksumNone && checksum != nil && *checksum != "" && migration.Checksum != *checksum {
errMsg := fmt.Sprintf("The migration file `%s` has been modified since it was applied. Please ensure that the applied migrations are not modified afterwards.", migration.FileName)

if db.ChecksumMode == ChecksumStrict {
return nil, fmt.Errorf("%s%s%s", "\x1b[31m", errMsg, "\x1b[0m")
}

if db.ChecksumMode == ChecksumLenient {
fmt.Fprintf(db.Log, "%sWarning: %s%s\n", "\x1b[33m", errMsg, "\x1b[0m")
}
}
}

migrations = append(migrations, migration)
Expand Down
Loading