diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..2fccf82f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,36 @@ +ARG GO_VERSION=1.24 +ARG DEBIAN_CODENAME=trixie + +FROM mcr.microsoft.com/devcontainers/go:${GO_VERSION}-${DEBIAN_CODENAME} + +ARG PG_MAJOR=16 +ARG MARIADB_MAJOR=12.0.2 + +USER root + +# Add PostgreSQL and MariaDB official repositories (detect codename from /etc/os-release) +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + wget ca-certificates gnupg curl \ + && wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg \ + && echo "deb http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && curl -LsSO https://r.mariadb.com/downloads/mariadb_repo_setup \ + && chmod +x mariadb_repo_setup \ + && ./mariadb_repo_setup --mariadb-server-version="mariadb-${MARIADB_MAJOR}" --skip-maxscale --skip-tools \ + && rm -f mariadb_repo_setup \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + postgresql-${PG_MAJOR} postgresql-client-${PG_MAJOR} \ + mariadb-server mariadb-client \ + && rm -rf /var/lib/apt/lists/* + +# Prepare user-owned data dirs for rootless startup +RUN mkdir -p /home/vscode/.local/share/pg/pgdata \ + && mkdir -p /home/vscode/.local/share/mysql \ + && chown -R vscode:vscode /home/vscode/.local/share + +# Set environment variables based on build args +ENV PG_BIN_DIR=/usr/lib/postgresql/${PG_MAJOR}/bin + +USER vscode + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f2c092ac --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,45 @@ +{ + "name": "singularity", + "build": { + "dockerfile": "Dockerfile", + "args": { + "GO_VERSION": "1.24", + "DEBIAN_CODENAME": "trixie", + "PG_MAJOR": "16", + "MARIADB_MAJOR": "12.0.2" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "eamodio.gitlens" + ], + "settings": { + "go.useLanguageServer": true, + "go.toolsEnvVars": { + "GOPATH": "/home/vscode/go" + } + } + } + }, + "forwardPorts": [], + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/singularity,type=bind,consistency=cached,relabel=private", + "workspaceFolder": "/workspaces/singularity", + "postCreateCommand": "/bin/bash -lc '${containerWorkspaceFolder}/.devcontainer/post-create.sh'", + "postStartCommand": "/bin/bash -lc '${containerWorkspaceFolder}/.devcontainer/start-postgres.sh && ${containerWorkspaceFolder}/.devcontainer/start-mysql.sh'", + "remoteUser": "vscode", + "containerEnv": { + "GOPATH": "/home/vscode/go", + "MYSQL_DATABASE": "singularity", + "MYSQL_USER": "singularity", + "MYSQL_PASSWORD": "singularity", + "MYSQL_SOCKET": "/home/vscode/.local/share/mysql/mysql.sock", + "PGDATA": "/home/vscode/.local/share/pg/pgdata", + "PGPORT": "55432", + "PGSOCK_DIR": "/home/vscode/.local/share/pg" + }, + "runArgs": ["--userns=keep-id"], + "containerUser": "vscode" +} + diff --git a/.devcontainer/init-mysql.sh b/.devcontainer/init-mysql.sh new file mode 100755 index 00000000..b9e3f1af --- /dev/null +++ b/.devcontainer/init-mysql.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MariaDB client is required for init + +# Resolve socket path; default to user-owned socket +SOCKET="${MYSQL_SOCKET:-${HOME}/.local/share/mysql/mysql.sock}" + +## Removed one-time init guard; operations below are idempotent + +# Determine root auth flags +MYSQL_ROOT_FLAGS=("-uroot") +if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then + MYSQL_ROOT_FLAGS+=("-p${MYSQL_ROOT_PASSWORD}") +fi + +# Wait for server readiness (best effort) +echo "Waiting for MySQL server at socket: $SOCKET" +for i in {1..60}; do + if mariadb-admin --socket="$SOCKET" ping "${MYSQL_ROOT_FLAGS[@]}" >/dev/null 2>&1; then + echo "MySQL server is ready for init" + break + fi + sleep 1 +done + +# Bail if still unreachable +if ! mariadb-admin --socket="$SOCKET" ping "${MYSQL_ROOT_FLAGS[@]}" >/dev/null 2>&1; then + echo "MySQL server not reachable, init failed" + exit 1 +fi + +# Create database and user idempotently (MySQL 8+ supports IF NOT EXISTS for users) +DB=${MYSQL_DATABASE:-singularity} +USER=${MYSQL_USER:-singularity} +PASS=${MYSQL_PASSWORD:-singularity} + +echo "Creating database and user: ${USER}@localhost and ${USER}@%" +mariadb --socket="$SOCKET" "${MYSQL_ROOT_FLAGS[@]}" <> /home/vscode/.local/share/pg/pgdata/postgresql.conf + { + echo 'host all all 127.0.0.1/32 trust' + echo 'host all all ::1/128 trust' + echo 'local all all trust' + } >> /home/vscode/.local/share/pg/pgdata/pg_hba.conf +fi + +# Initialize MariaDB +if [ ! -d "/home/vscode/.local/share/mysql/data/mysql" ]; then + echo "Initializing MariaDB..." + mariadb-install-db --datadir=/home/vscode/.local/share/mysql/data --auth-root-authentication-method=normal --skip-test-db >/dev/null +fi + +# Start both servers +echo "Starting database servers..." +.devcontainer/start-postgres.sh +.devcontainer/start-mysql.sh + +# Create users (databases will be created during testing as needed) +echo "Creating database users..." + +# Postgres setup +psql -h localhost -p 55432 -d postgres -c "CREATE USER singularity WITH SUPERUSER CREATEDB CREATEROLE LOGIN;" + +# MySQL setup +mariadb --socket=/home/vscode/.local/share/mysql/mysql.sock -uroot </dev/null 2>&1; then + echo "MySQL already running" + exit 0 +fi + +# Start MariaDB server +echo "Starting MySQL server" +touch "${LOG_FILE}" +nohup mariadbd \ + --datadir="${DATA_DIR}" \ + --socket="${SOCKET}" \ + --pid-file="${PID_FILE}" \ + --bind-address=127.0.0.1 \ + --port="${PORT}" \ + --skip-name-resolve \ + --log-error="${LOG_FILE}" \ + >/dev/null 2>&1 & + +# Wait for MySQL to be ready +for i in {1..60}; do + if [ -S "${SOCKET}" ] && grep -q "ready for connections" "${LOG_FILE}" >/dev/null 2>&1; then + echo "MySQL server is ready" + exit 0 + fi + sleep 1 +done + +echo "MySQL server failed to start" +if [ -f "${LOG_FILE}" ]; then + echo "--- Begin MariaDB error log ---" + tail -n 200 "${LOG_FILE}" || true + echo "--- End MariaDB error log ---" +fi +exit 1 \ No newline at end of file diff --git a/.devcontainer/start-postgres.sh b/.devcontainer/start-postgres.sh new file mode 100755 index 00000000..b90cb1a8 --- /dev/null +++ b/.devcontainer/start-postgres.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Postgres server configuration +PGDATA_DIR="${PGDATA:-/home/vscode/.local/share/pg/pgdata}" +LOG_FILE="${PGDATA_DIR}/postgres.log" +PGSOCK_DIR="${PGSOCK_DIR:-/home/vscode/.local/share/pg}" +PGPORT="${PGPORT:-55432}" +PG_BIN_DIR="${PG_BIN_DIR:-/usr/lib/postgresql/16/bin}" + +# Check if already running +if "${PG_BIN_DIR}/pg_ctl" -D "$PGDATA_DIR" status >/dev/null 2>&1; then + echo "Postgres already running" + exit 0 +fi + +# Start Postgres server +echo "Starting Postgres server" +"${PG_BIN_DIR}/pg_ctl" -D "$PGDATA_DIR" -l "$LOG_FILE" -w -o "-p ${PGPORT} -k ${PGSOCK_DIR}" start + + diff --git a/.github/workflows/devcontainer-podman.yml b/.github/workflows/devcontainer-podman.yml new file mode 100644 index 00000000..84934b1e --- /dev/null +++ b/.github/workflows/devcontainer-podman.yml @@ -0,0 +1,94 @@ +name: CI (Podman Devcontainer) + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} + cancel-in-progress: true + +jobs: + devcontainer-checks: + name: Devcontainer CI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Podman as Docker + uses: parkan/github-actions/setup-podman-docker@v2 + with: + disable-docker: true + cache-storage: false # Disabled until cache issues resolved + + - name: Build devcontainer + id: build + uses: parkan/github-actions/devcontainer-build@v2 + with: + workspace-folder: . + container-runtime: podman + container-id-label: ci=podman + + # Fast surface checks and codegen first + - name: Generate swagger code + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && mkdir -p client/swagger/client && go install github.com/go-swagger/go-swagger/cmd/swagger@v0.30.5 && go generate ./client/swagger/...' + container-runtime: podman + + - name: Check formatting + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && gofmt -l .' + container-runtime: podman + + - name: Run go vet + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && go vet ./...' + container-runtime: podman + + - name: Run staticcheck + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && staticcheck ./...' + container-runtime: podman + + - name: Build binary + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && go build -o singularity .' + container-runtime: podman + + - name: Run tests + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && go test -v ./...' + container-runtime: podman + + - name: Run integration tests + uses: parkan/github-actions/devcontainer-exec@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + command: 'cd /workspaces/singularity && SINGULARITY_TEST_INTEGRATION=true go test -v -timeout 20m -run "Integration" ./cmd/...' + container-runtime: podman + + - name: Cleanup + if: always() + uses: parkan/github-actions/devcontainer-cleanup@v2 + with: + container-id: ${{ steps.build.outputs.container-id }} + container-runtime: podman diff --git a/.github/workflows/go-check-config.json b/.github/workflows/go-check-config.json deleted file mode 100644 index 4b37308d..00000000 --- a/.github/workflows/go-check-config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "gogenerate": true -} diff --git a/.github/workflows/go-check.yml b/.github/workflows/go-check.yml deleted file mode 100644 index 826de5c2..00000000 --- a/.github/workflows/go-check.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Go Checks - -on: - pull_request: - push: - branches: ["main"] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} - cancel-in-progress: true - -jobs: - go-check: - uses: ipdxco/unified-github-workflows/.github/workflows/go-check.yml@v1.0.22 - - staticcheck: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: "1.21" - - - name: Install staticcheck - run: go install honnef.co/go/tools/cmd/staticcheck@latest - - - name: Run staticcheck - run: staticcheck ./... diff --git a/.github/workflows/go-test-config.json b/.github/workflows/go-test-config.json deleted file mode 100644 index 209dca21..00000000 --- a/.github/workflows/go-test-config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "skip32bit": true -} diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml deleted file mode 100644 index 92b1383b..00000000 --- a/.github/workflows/go-test.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Go Test - -on: - pull_request: - push: - branches: ["main"] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} - cancel-in-progress: true - -jobs: - go-test: - uses: ipdxco/unified-github-workflows/.github/workflows/go-test.yml@v1.0.22 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml deleted file mode 100644 index 1b53cbf0..00000000 --- a/.github/workflows/integration-test.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Integration Tests - -on: - pull_request: - paths: - # Run when integration test files are modified - - 'cmd/auto_prep_deals_integration_test.go' - - 'cmd/*_integration_test.go' - - # Run when related source files are modified - - 'cmd/deal/**' - - 'cmd/dataset/**' - - 'cmd/storage/**' - - 'cmd/wallet/**' - - 'cmd/run/**' - - 'cmd/job/**' - - # Run when core functionality changes - - 'handler/deal/**' - - 'handler/dataset/**' - - 'handler/storage/**' - - 'handler/wallet/**' - - 'handler/job/**' - - # Run when worker and scheduler code changes - - 'worker/**' - - 'scheduler/**' - - # Run when models change - - 'model/**' - - # Run when database migrations change - - 'migrate/**' - - # Run when workflow itself changes - - '.github/workflows/integration-test.yml' - - push: - branches: ["main"] - paths: - # Same paths as pull_request - - 'cmd/auto_prep_deals_integration_test.go' - - 'cmd/*_integration_test.go' - - 'cmd/deal/**' - - 'cmd/dataset/**' - - 'cmd/storage/**' - - 'cmd/wallet/**' - - 'cmd/run/**' - - 'cmd/job/**' - - 'handler/deal/**' - - 'handler/dataset/**' - - 'handler/storage/**' - - 'handler/wallet/**' - - 'handler/job/**' - - 'worker/**' - - 'scheduler/**' - - 'model/**' - - 'migrate/**' - - '.github/workflows/integration-test.yml' - - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} - cancel-in-progress: true - -jobs: - integration-test: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.21" - - - name: Install dependencies - run: go mod download - - - name: Run integration tests - run: | - # Run only integration tests with verbose output - go test -v -timeout 20m -run "Integration" ./cmd/... - env: - # Set any required environment variables for integration tests - SINGULARITY_TEST_INTEGRATION: "true" \ No newline at end of file diff --git a/cmd/functional_test.go b/cmd/functional_test.go index 9867d033..faea9f32 100644 --- a/cmd/functional_test.go +++ b/cmd/functional_test.go @@ -513,7 +513,7 @@ func TestNoDuplicatedOutput(t *testing.T) { // run the dataset worker. If multiple workers try to work on the same // job, then this will return fail because a previous worker will have // removed files. - _, _, err = runner.Run(ctx, "singularity run dataset-worker --exit-on-complete=true --exit-on-error=true --concurrency=8") + _, _, err = runner.Run(ctx, "singularity run dataset-worker --exit-on-complete=true --exit-on-error=true --concurrency=8 --enable-dag=false") require.NoError(t, err) // Check output to make sure is has some CAR files diff --git a/database/util.go b/database/util.go index fd3ae43e..062b23b6 100644 --- a/database/util.go +++ b/database/util.go @@ -61,7 +61,13 @@ func (d *databaseLogger) Trace(ctx context.Context, begin time.Time, fc func() ( sql = "[SLOW!] " + sql } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && !strings.Contains(err.Error(), sqlSerializationFailure) { - lvl = logging.LevelError + // Demote noisy missing-table errors during test setup/teardown + emsg := err.Error() + if strings.Contains(emsg, "no such table") || strings.Contains(emsg, "does not exist") || strings.Contains(emsg, "doesn't exist") { + lvl = logging.LevelDebug + } else { + lvl = logging.LevelError + } } // Uncomment for logging everything in testing @@ -93,5 +99,10 @@ func OpenFromCLI(c *cli.Context) (*gorm.DB, io.Closer, error) { func retryOn(err error) bool { emsg := err.Error() - return strings.Contains(emsg, sqlSerializationFailure) || strings.Contains(emsg, "database is locked") || strings.Contains(emsg, "database table is locked") + return strings.Contains(emsg, sqlSerializationFailure) || + strings.Contains(emsg, "database is locked") || + strings.Contains(emsg, "database table is locked") || + // MySQL/InnoDB serialization conflict + strings.Contains(emsg, "Record has changed since last read") || + strings.Contains(emsg, "Error 1020 (HY000)") } diff --git a/service/datasetworker/find.go b/service/datasetworker/find.go index b8077a89..367c6915 100644 --- a/service/datasetworker/find.go +++ b/service/datasetworker/find.go @@ -30,11 +30,11 @@ func (w *Thread) findJob(ctx context.Context, typesOrdered []model.JobType) (*mo } var job model.Job for _, jobType := range typesOrdered { - err := database.DoRetry(ctx, func() error { - return db.Transaction(func(db *gorm.DB) error { - err := db.Preload("Attachment.Preparation.OutputStorages").Preload("Attachment.Storage"). - Where("type = ? AND state = ? OR (state = ? AND worker_id is null)", jobType, model.Ready, model.Processing). - First(&job).Error + err := database.DoRetry(ctx, func() error { + return db.Transaction(func(db *gorm.DB) error { + err := db.Preload("Attachment.Preparation.OutputStorages").Preload("Attachment.Storage"). + Where("type = ? AND (state = ? OR (state = ? AND worker_id IS NULL))", jobType, model.Ready, model.Processing). + First(&job).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { job.ID = 0 diff --git a/util/testutil/testutils.go b/util/testutil/testutils.go index fa56a1f8..4842d6db 100644 --- a/util/testutil/testutils.go +++ b/util/testutil/testutils.go @@ -5,8 +5,8 @@ import ( "crypto/rand" "io" rand2 "math/rand" - "net" "os" + "os/exec" "strings" "testing" "time" @@ -14,6 +14,7 @@ import ( "github.com/cockroachdb/errors" "github.com/data-preservation-programs/singularity/database" "github.com/data-preservation-programs/singularity/model" + "github.com/google/uuid" "github.com/ipfs/boxo/util" "github.com/ipfs/go-cid" "github.com/stretchr/testify/require" @@ -53,9 +54,9 @@ func RandomLetterString(length int) string { return string(b) } -// GenerateUniqueName creates a unique name for testing by combining a prefix with a random suffix +// GenerateUniqueName creates a unique name for testing by combining a prefix with a UUID suffix func GenerateUniqueName(prefix string) string { - return prefix + "-" + RandomLetterString(8) + "-" + RandomLetterString(4) + return prefix + "-" + strings.ReplaceAll(uuid.New().String(), "-", "") } func GetFileTimestamp(t *testing.T, path string) int64 { @@ -86,55 +87,80 @@ func getTestDB(t *testing.T, dialect string) (db *gorm.DB, closer io.Closer, con require.NoError(t, err) return } - dbName := RandomLetterString(6) - var opError *net.OpError + // Use UUID for database names to ensure uniqueness and avoid MySQL's 64-character limit + // Remove hyphens to make it a valid database identifier + dbName := "test_" + strings.ReplaceAll(uuid.New().String(), "-", "") switch dialect { case "mysql": - if socket := os.Getenv("MYSQL_SOCKET"); socket != "" { - connStr = "mysql://singularity:singularity@unix(" + socket + ")/singularity?parseTime=true" - } else { - connStr = "mysql://singularity:singularity@tcp(localhost:3306)/singularity?parseTime=true" - } + socket := os.Getenv("MYSQL_SOCKET") + connStr = "mysql://singularity:singularity@unix(" + socket + ")/mysql?parseTime=true" case "postgres": - connStr = "postgres://postgres@localhost:5432/postgres?sslmode=disable" + pgPort := os.Getenv("PGPORT") + connStr = "postgres://singularity@localhost:" + pgPort + "/postgres?sslmode=disable" default: require.Fail(t, "Unsupported dialect: "+dialect) } - var db1 *gorm.DB - var closer1 io.Closer - db1, closer1, err = database.OpenWithLogger(connStr) - if errors.As(err, &opError) { - t.Logf("Database %s not available: %v", dialect, err) + // Skip initial connection test - databases will be created during testing + // Create database using shell commands to avoid driver transaction issues + switch dialect { + case "postgres": + // Use createdb command for PostgreSQL + cmd := exec.Command("createdb", "-h", "localhost", "-p", os.Getenv("PGPORT"), "-U", "singularity", dbName) + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Failed to create PostgreSQL database %s: %v, output: %s", dbName, err, string(output)) + return nil, nil, "" + } + t.Logf("Created PostgreSQL database %s", dbName) + case "mysql": + // Use mysql command for MySQL + socket := os.Getenv("MYSQL_SOCKET") + cmd := exec.Command("mariadb", "--socket="+socket, "-usingularity", "-psingularity", "-e", "CREATE DATABASE "+dbName) + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Failed to create MySQL database %s: %v, output: %s", dbName, err, string(output)) + return nil, nil, "" + } + t.Logf("Created MySQL database %s", dbName) + default: + t.Logf("Unsupported dialect for shell database creation: %s", dialect) return nil, nil, "" } - if err != nil { - t.Logf("Failed to connect to %s database: %v", dialect, err) - return nil, nil, "" + // Replace database name in connection string + if strings.Contains(connStr, "postgres?") { + connStr = strings.ReplaceAll(connStr, "postgres?", dbName+"?") + } else if strings.Contains(connStr, "mysql?") { + connStr = strings.ReplaceAll(connStr, "mysql?", dbName+"?") } - err = db1.Exec("CREATE DATABASE " + dbName + "").Error - if err != nil { - t.Logf("Failed to create test database %s: %v", dbName, err) - _ = closer1.Close() - return nil, nil, "" - } - connStr = strings.ReplaceAll(connStr, "singularity?", dbName+"?") var closer2 io.Closer db, closer2, err = database.OpenWithLogger(connStr) if err != nil { t.Logf("Failed to connect to test database %s: %v", dbName, err) - db1.Exec("DROP DATABASE " + dbName + "") - _ = closer1.Close() + // Cleanup using shell commands + switch dialect { + case "postgres": + cmd := exec.Command("dropdb", "-h", "localhost", "-p", os.Getenv("PGPORT"), "-U", "singularity", dbName) + cmd.Run() // Ignore errors during cleanup + case "mysql": + socket := os.Getenv("MYSQL_SOCKET") + cmd := exec.Command("mariadb", "--socket="+socket, "-usingularity", "-psingularity", "-e", "DROP DATABASE "+dbName) + cmd.Run() // Ignore errors during cleanup + } return nil, nil, "" } closer = CloserFunc(func() error { if closer2 != nil { _ = closer2.Close() } - if db1 != nil { - db1.Exec("DROP DATABASE " + dbName + "") - } - if closer1 != nil { - return closer1.Close() + // Cleanup using shell commands + switch dialect { + case "postgres": + cmd := exec.Command("dropdb", "-h", "localhost", "-p", os.Getenv("PGPORT"), "-U", "singularity", dbName) + cmd.Run() // Ignore errors during cleanup + case "mysql": + socket := os.Getenv("MYSQL_SOCKET") + cmd := exec.Command("mariadb", "--socket="+socket, "-usingularity", "-psingularity", "-e", "DROP DATABASE "+dbName) + cmd.Run() // Ignore errors during cleanup } return nil }) @@ -182,6 +208,36 @@ func doOne(t *testing.T, backend string, testFunc func(ctx context.Context, t *t err := model.GetMigrator(db).Migrate() require.NoError(t, err) + // Clear any existing data from tables with unique constraints + tables := []string{ + "output_attachments", + "source_attachments", + "storages", + "wallets", + "deal_schedules", + "preparations", + } + + // Get DB type from connection string + isPostgres := strings.HasPrefix(connStr, "postgres:") + for _, table := range tables { + var err error + if isPostgres { + err = db.Exec("TRUNCATE TABLE " + table + " CASCADE").Error + } else { + err = db.Exec("DELETE FROM " + table).Error + } + if err != nil { + emsg := err.Error() + // Suppress noisy logs when tables don't exist yet across backends + if strings.Contains(emsg, "no such table") || strings.Contains(emsg, "does not exist") || strings.Contains(emsg, "doesn't exist") { + continue + } + t.Logf("Warning: Failed to clear table %s: %v", table, err) + // Don't fail the test for other errors either + } + } + t.Run(backend, func(t *testing.T) { testFunc(ctx, t, db) })