Compare commits
1 Commits
feat/integ
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8f191029c |
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
*.min.js
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -2,74 +2,36 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
// bootstrapVersion is the migration that creates the schema_migrations
|
func (d *Database) migrate(ctx context.Context) error {
|
||||||
// table itself. It is applied before the normal migration loop.
|
// Create migrations table if not exists
|
||||||
const bootstrapVersion = 0
|
_, err := d.database.ExecContext(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
// ErrInvalidMigrationFilename indicates a migration filename does not follow
|
version TEXT PRIMARY KEY,
|
||||||
// the expected "<version>.sql" or "<version>_<description>.sql" pattern.
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
|
|
||||||
|
|
||||||
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
|
||||||
// filename. Filenames must follow the pattern "<version>.sql" or
|
|
||||||
// "<version>_<description>.sql", where version is a zero-padded numeric
|
|
||||||
// string (e.g. "001", "002"). Returns the version as an integer and an
|
|
||||||
// error if the filename does not match the expected pattern.
|
|
||||||
func ParseMigrationVersion(filename string) (int, error) {
|
|
||||||
name := strings.TrimSuffix(filename, ".sql")
|
|
||||||
if name == "" || name == filename {
|
|
||||||
return 0, fmt.Errorf("%w: %q has no .sql extension or is empty", ErrInvalidMigrationFilename, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split on underscore to separate version from description.
|
|
||||||
// If there's no underscore, the entire stem is the version.
|
|
||||||
versionStr, _, _ := strings.Cut(name, "_")
|
|
||||||
|
|
||||||
if versionStr == "" {
|
|
||||||
return 0, fmt.Errorf("%w: %q has empty version prefix", ErrInvalidMigrationFilename, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the version is purely numeric.
|
|
||||||
for _, ch := range versionStr {
|
|
||||||
if ch < '0' || ch > '9' {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"%w: %q version %q contains non-numeric character %q",
|
|
||||||
ErrInvalidMigrationFilename, filename, versionStr, string(ch),
|
|
||||||
)
|
)
|
||||||
}
|
`)
|
||||||
}
|
|
||||||
|
|
||||||
version, err := strconv.Atoi(versionStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("%w: %q: %w", ErrInvalidMigrationFilename, filename, err)
|
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return version, nil
|
// Get list of migration files
|
||||||
}
|
|
||||||
|
|
||||||
// collectMigrations reads the embedded migrations directory and returns
|
|
||||||
// migration filenames sorted lexicographically.
|
|
||||||
func collectMigrations() ([]string, error) {
|
|
||||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations []string
|
// Sort migrations by name
|
||||||
|
migrations := make([]string, 0, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
@@ -79,113 +41,54 @@ func collectMigrations() ([]string, error) {
|
|||||||
|
|
||||||
sort.Strings(migrations)
|
sort.Strings(migrations)
|
||||||
|
|
||||||
return migrations, nil
|
// Apply each migration
|
||||||
}
|
|
||||||
|
|
||||||
// bootstrapMigrationsTable ensures the schema_migrations table exists by
|
|
||||||
// applying 000_migration.sql if the table is missing.
|
|
||||||
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|
||||||
var tableExists int
|
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
|
||||||
).Scan(&tableExists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check for migrations table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tableExists == 0 {
|
|
||||||
return applyBootstrapMigration(ctx, db, log)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyBootstrapMigration reads and executes 000_migration.sql to create the
|
|
||||||
// schema_migrations table on a fresh database.
|
|
||||||
func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|
||||||
content, err := migrationsFS.ReadFile("migrations/000_migration.sql")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if log != nil {
|
|
||||||
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, string(content))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyMigrations applies all pending migrations to db. An optional logger
|
|
||||||
// may be provided for informational output; pass nil for silent operation.
|
|
||||||
// This is exported so tests can apply the real schema without the full fx
|
|
||||||
// lifecycle.
|
|
||||||
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|
||||||
bootstrapErr := bootstrapMigrationsTable(ctx, db, log)
|
|
||||||
if bootstrapErr != nil {
|
|
||||||
return bootstrapErr
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations, err := collectMigrations()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
version, parseErr := ParseMigrationVersion(migration)
|
applied, err := d.isMigrationApplied(ctx, migration)
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already applied.
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
|
||||||
version,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check migration %s: %w", migration, err)
|
return fmt.Errorf("failed to check migration %s: %w", migration, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if applied {
|
||||||
if log != nil {
|
d.log.Debug("migration already applied", "migration", migration)
|
||||||
log.Debug("migration already applied", "version", version)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply migration in a transaction.
|
err = d.applyMigration(ctx, migration)
|
||||||
applyErr := applyMigrationTx(ctx, db, migration, version)
|
if err != nil {
|
||||||
if applyErr != nil {
|
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
||||||
return applyErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if log != nil {
|
d.log.Info("migration applied", "migration", migration)
|
||||||
log.Info("migration applied", "version", version)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyMigrationTx reads and executes a migration file within a transaction,
|
func (d *Database) isMigrationApplied(ctx context.Context, version string) (bool, error) {
|
||||||
// recording the version in schema_migrations on success.
|
var count int
|
||||||
func applyMigrationTx(ctx context.Context, db *sql.DB, filename string, version int) error {
|
|
||||||
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
err := d.database.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||||
|
version,
|
||||||
|
).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
return false, fmt.Errorf("failed to query migration status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction, err := db.BeginTx(ctx, nil)
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) applyMigration(ctx context.Context, filename string) error {
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
|
return fmt.Errorf("failed to read migration file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction, err := d.database.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -194,27 +97,26 @@ func applyMigrationTx(ctx context.Context, db *sql.DB, filename string, version
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Execute migration
|
||||||
_, err = transaction.ExecContext(ctx, string(content))
|
_, err = transaction.ExecContext(ctx, string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
return fmt.Errorf("failed to execute migration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = transaction.ExecContext(ctx,
|
// Record migration
|
||||||
|
_, err = transaction.ExecContext(
|
||||||
|
ctx,
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||||
version,
|
filename,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to record migration %s: %w", filename, err)
|
return fmt.Errorf("failed to record migration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = transaction.Commit()
|
err = transaction.Commit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
|
return fmt.Errorf("failed to commit migration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) migrate(ctx context.Context) error {
|
|
||||||
return ApplyMigrations(ctx, d.database, d.log)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Migration 000: Schema migrations tracking table
|
|
||||||
-- Applied as a bootstrap step before the normal migration loop.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseMigrationVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
filename string
|
|
||||||
wantVersion int
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{filename: "000_migration.sql", wantVersion: 0},
|
|
||||||
{filename: "001_initial.sql", wantVersion: 1},
|
|
||||||
{filename: "002_remove_container_id.sql", wantVersion: 2},
|
|
||||||
{filename: "007_add_resource_limits.sql", wantVersion: 7},
|
|
||||||
{filename: "100_large_version.sql", wantVersion: 100},
|
|
||||||
{filename: ".sql", wantErr: true},
|
|
||||||
{filename: "_foo.sql", wantErr: true},
|
|
||||||
{filename: "abc_foo.sql", wantErr: true},
|
|
||||||
{filename: "1a2_bad.sql", wantErr: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.filename, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
version, err := database.ParseMigrationVersion(tt.filename)
|
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.wantVersion, version)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMigrationsFreshDatabase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = db.Close() }()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err = database.ApplyMigrations(ctx, db, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify schema_migrations table exists with INTEGER version column.
|
|
||||||
var version int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT version FROM schema_migrations WHERE version = 0",
|
|
||||||
).Scan(&version)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 0, version)
|
|
||||||
|
|
||||||
// Verify that all migrations were recorded.
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations",
|
|
||||||
).Scan(&count)
|
|
||||||
require.NoError(t, err)
|
|
||||||
// 000 bootstrap + 001 through 007 = 8 entries.
|
|
||||||
assert.Equal(t, 8, count)
|
|
||||||
|
|
||||||
// Verify application tables were created by the migrations.
|
|
||||||
var tableCount int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'",
|
|
||||||
).Scan(&tableCount)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, tableCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMigrationsIdempotent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = db.Close() }()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Apply twice — second run should be a no-op.
|
|
||||||
err = database.ApplyMigrations(ctx, db, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = database.ApplyMigrations(ctx, db, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations",
|
|
||||||
).Scan(&count)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 8, count)
|
|
||||||
}
|
|
||||||
69
tools/secret-scan.sh
Executable file
69
tools/secret-scan.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# secret-scan.sh — Scans for private keys and high-entropy secrets
|
||||||
|
# Usage: bash tools/secret-scan.sh [directory]
|
||||||
|
# Uses .secret-scan-allowlist for false positives (one file path per line)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCAN_DIR="${1:-.}"
|
||||||
|
ALLOWLIST=".secret-scan-allowlist"
|
||||||
|
FINDINGS=0
|
||||||
|
|
||||||
|
# Build find exclusions
|
||||||
|
EXCLUDES=(-not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/coverage/*" -not -path "*/dist/*")
|
||||||
|
|
||||||
|
# Load allowlist
|
||||||
|
ALLOWLIST_PATHS=()
|
||||||
|
if [ -f "$ALLOWLIST" ]; then
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
||||||
|
ALLOWLIST_PATHS+=("$line")
|
||||||
|
done < "$ALLOWLIST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_allowed() {
|
||||||
|
local file="$1"
|
||||||
|
for allowed in "${ALLOWLIST_PATHS[@]}"; do
|
||||||
|
if [[ "$file" == *"$allowed"* ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Scanning $SCAN_DIR for secrets..."
|
||||||
|
|
||||||
|
# Scan for private keys
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
is_allowed "$file" && continue
|
||||||
|
if grep -qE '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' "$file" 2>/dev/null; then
|
||||||
|
echo "FINDING [private-key]: $file"
|
||||||
|
FINDINGS=$((FINDINGS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$SCAN_DIR" "${EXCLUDES[@]}" -type f)
|
||||||
|
|
||||||
|
# Scan for high-entropy hex strings (40+ chars)
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
is_allowed "$file" && continue
|
||||||
|
if grep -qE '[0-9a-f]{40,}' "$file" 2>/dev/null; then
|
||||||
|
# Filter out common false positives (git SHAs in lock files, etc.)
|
||||||
|
BASENAME=$(basename "$file")
|
||||||
|
if [[ "$BASENAME" != "package-lock.json" && "$BASENAME" != "*.lock" ]]; then
|
||||||
|
MATCHES=$(grep -oE '[0-9a-f]{40,}' "$file" 2>/dev/null || true)
|
||||||
|
if [ -n "$MATCHES" ]; then
|
||||||
|
echo "FINDING [high-entropy-hex]: $file"
|
||||||
|
FINDINGS=$((FINDINGS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(find "$SCAN_DIR" "${EXCLUDES[@]}" -type f -not -name "package-lock.json" -not -name "*.lock")
|
||||||
|
|
||||||
|
if [ "$FINDINGS" -gt 0 ]; then
|
||||||
|
echo "secret-scan: $FINDINGS finding(s) — FAIL"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "secret-scan: clean — PASS"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user