← Databases in a tomato suite: migrations, resets and clean state

Setting up the schema with your migration tool

databasesmigrationspostgres
Contents

The reset empties tables but never drops them, so the schema is made once per run, before the first scenario. Make it the way production does, and every run tests your migrations too. Which case are you?

In production, migrations run…In tomato
inside the service, as it starts (a library)nothing to add: tomato starts the serviceCase 1
as a separate step before the service (a CLI: an init container, a release step)a start script runs the CLI, then the serviceCase 2
from SQL files: a dump, schema.sqlthe database container runs them when it startsCase 3

Whichever it is, most tools keep a history table, and some of those need one line in tomato.yml: keep the history table.

Case 1: the service runs its migrations

tomato starts the database, then your service, which migrates before it listens. The ready check waits until it does. Nothing in tomato.yml is about migrations:

app:
  command: ./orders
  ready: { type: http, path: /health, timeout: 120s }
  env:
    DATABASE_URL: "postgres://orders:orders@{{.postgres.host}}:{{.postgres.port.5432}}/orders?sslmode=disable"

The call, in your language:

// golang-migrate, migrations embedded in the binary
//go:embed migrations/*.sql
var migrations embed.FS

func migrateUp(url string) error {
	src, err := iofs.New(migrations, "migrations")
	if err != nil {
		return err
	}
	m, err := migrate.NewWithSourceInstance("iofs", src, url)
	if err != nil {
		return err
	}
	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		return err
	}
	return nil
}

History: schema_migrations, kept by tomato. With goose, goose.Up(db, "migrations"); history goose_db_version, kept too.

Case 2: a migration tool runs before the service

When production migrates in a separate step, a Kubernetes init container or a release command, do the same: a start script runs the tool, then becomes the service.

#!/bin/sh
# scripts/start.sh
set -e
<the migration command, below>
exec ./orders          # or: node app.js, java -jar app.jar, …
app:
  command: ./scripts/start.sh
  ready: { type: http, path: /health, timeout: 120s }
  env:
    DATABASE_URL: "postgres://orders:orders@{{.postgres.host}}:{{.postgres.port.5432}}/orders?sslmode=disable"

The command, for your tool:

# reads FLYWAY_URL, FLYWAY_USER, FLYWAY_PASSWORD from the environment
flyway -locations=filesystem:db/migration migrate
env:
  FLYWAY_URL: "jdbc:postgresql://{{.postgres.host}}:{{.postgres.port.5432}}/orders"
  FLYWAY_USER: orders
  FLYWAY_PASSWORD: orders

History flyway_schema_history: kept. Not run for this post.

Three details of the start script:

  • A script, not sh -c "… && …". app.command is split on spaces and run without a shell, so && and quotes don’t work there.
  • exec the service. It replaces the script, so the SIGTERM tomato sends at the end reaches the service, and so does any coverage it writes on exit.
  • set -e. A failed migration stops the script, and tomato reports that the service didn’t start, with the migration’s error in its log.

Not yet: the tool in a container of its own. Running the tool’s image (flyway/flyway, migrate/migrate) under containers: would mirror a Kubernetes Job, but tomato v2.7.0 treats a container that finishes and exits as one that failed to start. Install the CLI on the runner and use the start script.

Case 3: plain SQL files

A schema.sql, a pg_dump --schema-only, Rails’ structure.sql: the Postgres image runs every file in /docker-entrypoint-initdb.d when it creates the database, before your service starts.

containers:
  postgres:
    image: postgres:16-alpine
    env: { POSTGRES_USER: shop, POSTGRES_PASSWORD: shop, POSTGRES_DB: orders }
    ports: ["5432/tcp"]
    volumes:
      - ${PWD}/initdb:/docker-entrypoint-initdb.d
    wait_for: { type: exec, target: "pg_isready -h 127.0.0.1 -U shop" }
-- initdb/001_databases.sql
CREATE DATABASE inventory;
CREATE DATABASE legacy;
\connect legacy
CREATE TABLE products (sku text PRIMARY KEY, name text NOT NULL);

Use ${PWD}/…, not ./…: tomato v2.7.0 hands a relative path to Docker as a named volume, and Postgres then finds an empty directory. It’s also the simplest way to give each service its own database in one Postgres container, as above. MySQL and MariaDB images read the same directory.

Keep the history table

The reset empties every table, and a migration tool’s history is a table. Empty it and the tool forgets what it applied: the next migration run, a restart in a lifecycle scenario say, tries to create tables that exist.

ToolHistory tableIn tomato.yml
golang-migrate, dbmate, Railsschema_migrationsnothing: kept
goosegoose_db_versionnothing: kept
Flywayflyway_schema_historynothing: kept
Liquibasedatabasechangelog, databasechangeloglocknothing: kept
Alembicalembic_versionexclude: [alembic_version]
Djangodjango_migrationsexclude: [django_migrations]
Prisma_prisma_migrationsexclude: [_prisma_migrations]
Knexknex_migrations, knex_migrations_lockexclude: [knex_migrations, knex_migrations_lock]
node-pg-migratepgmigrationsexclude: [pgmigrations]
sqlx (Rust)_sqlx_migrationsexclude: [_sqlx_migrations]
Entity Framework__EFMigrationsHistoryexclude: [__EFMigrationsHistory]

Guard it with one scenario that fails the moment the history is emptied:

Scenario: The migration history survives the reset
  Then "orders-db" table "orders" is empty
  And "orders-db" table "pgmigrations" has "1" rows

Without exclude: [pgmigrations], it fails with table pgmigrations has 0 rows, expected 1.