diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e29c3e5..f9442fb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -51,6 +51,7 @@ Configuration for PostgreSQL connection and pooling. | `STATICREG_DB_PASSWORD` | string | *(optional)* | PostgreSQL password. Alternative to `STATICREG_DB_URL`. | | `STATICREG_DB_NAME` | string | *(required)* | PostgreSQL database name. Alternative to `STATICREG_DB_URL`. Required if using individual variables. | | `STATICREG_DB_SSLMODE` | string | *(optional)* | PostgreSQL SSL mode. Options: `disable`, `require`, `verify-ca`, `verify-full`. Alternative to `STATICREG_DB_URL`. | +| `STATICREG_DB_SCHEMA` | string | `staticreg` | PostgreSQL schema where StaticReg owns its tables. The schema is created on startup if missing and is set as the connection `search_path`. Must match `^[a-z_][a-z0-9_]*$` (lowercase letters, digits, underscores; max 63 chars). Use a per-deployment value (e.g. `staticreg_prod`) when multiple StaticReg instances share a database. | ### Registry Configuration @@ -105,6 +106,7 @@ export STATICREG_DB_USER="staticreg" export STATICREG_DB_PASSWORD="password" export STATICREG_DB_NAME="staticreg" export STATICREG_DB_SSLMODE="require" +export STATICREG_DB_SCHEMA="staticreg" # Webhook Batching export STATICREG_METRICS_BATCH_SIZE=200 diff --git a/go.mod b/go.mod index 3e7bd17..55b890e 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/google/go-containerregistry v0.20.4 github.com/jackc/pgx/v5 v5.7.6 + github.com/pressly/goose/v3 v3.24.3 github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/samber/slog-gin v1.15.1 github.com/spf13/cobra v1.9.1 @@ -45,6 +46,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -53,6 +55,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -60,6 +63,7 @@ require ( github.com/vbatts/tar-split v0.12.1 // indirect go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.17.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.40.0 // indirect diff --git a/go.sum b/go.sum index a11a730..efc6878 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -120,6 +122,8 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -128,6 +132,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -154,13 +160,20 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM= github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= @@ -194,6 +207,8 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -201,6 +216,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -289,4 +306,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/manifests/deployment.yml b/manifests/deployment.yml index 005d53e..944a0bd 100644 --- a/manifests/deployment.yml +++ b/manifests/deployment.yml @@ -86,6 +86,12 @@ spec: name: staticreg-db-credentials key: STATICREG_DB_SSLMODE optional: true + - name: STATICREG_DB_SCHEMA + valueFrom: + configMapKeyRef: + name: staticreg-config + key: STATICREG_DB_SCHEMA + optional: true # Webhook Batching Configuration - name: STATICREG_METRICS_BATCH_SIZE diff --git a/pkg/db/dbtest/dbtest.go b/pkg/db/dbtest/dbtest.go new file mode 100644 index 0000000..97f9ecc --- /dev/null +++ b/pkg/db/dbtest/dbtest.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Seqera + +//go:build integration + +// Package dbtest provides integration-test helpers shared across packages +// that exercise a real postgres connection. Compiled only with the +// `integration` build tag so it does not enter normal builds. +package dbtest + +import "testing" + +// SetEnv populates the STATICREG_DB_* environment variables to point at the +// docker-compose postgres container with the given dedicated schema. All +// values are restored automatically by t.Setenv on cleanup. +func SetEnv(t *testing.T, schema string) { + t.Helper() + t.Setenv("STATICREG_DB_HOST", "127.0.0.1") + t.Setenv("STATICREG_DB_PORT", "5432") + t.Setenv("STATICREG_DB_USER", "staticreg") + t.Setenv("STATICREG_DB_PASSWORD", "password") + t.Setenv("STATICREG_DB_NAME", "staticreg") + t.Setenv("STATICREG_DB_SSLMODE", "disable") + t.Setenv("STATICREG_DB_SCHEMA", schema) +} diff --git a/pkg/db/postgres.go b/pkg/db/postgres.go index 12f2d16..88570a3 100644 --- a/pkg/db/postgres.go +++ b/pkg/db/postgres.go @@ -5,13 +5,38 @@ import ( "fmt" "log/slog" "os" + "regexp" "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" "github.com/seqeralabs/staticreg/pkg/observability/logger" schemasql "github.com/seqeralabs/staticreg/pkg/sql" ) +// defaultSchema is the postgres schema used when STATICREG_DB_SCHEMA is unset. +const defaultSchema = "staticreg" + +// schemaIdentRegexp validates that a schema name is a safe postgres identifier. +// Only lowercase letters, digits, and underscores; must not start with a digit. +// Identifier is interpolated into DDL (CREATE SCHEMA, search_path) since pgx +// parameters cannot bind identifiers — strict validation prevents injection. +var schemaIdentRegexp = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) + +// resolveSchema returns the validated schema name from STATICREG_DB_SCHEMA or +// the default. Returns an error if the env var is set but invalid. +func resolveSchema() (string, error) { + schema := os.Getenv("STATICREG_DB_SCHEMA") + if schema == "" { + return defaultSchema, nil + } + if len(schema) > 63 || !schemaIdentRegexp.MatchString(schema) { + return "", fmt.Errorf("invalid STATICREG_DB_SCHEMA %q: must match %s and be <=63 chars", schema, schemaIdentRegexp) + } + return schema, nil +} + // buildConnectionString constructs a PostgreSQL connection string from environment variables. // It prioritizes STATICREG_DB_URL if set, otherwise builds the connection string from individual // STATICREG_DB_* environment variables. Returns empty string if no configuration is provided. @@ -62,39 +87,53 @@ func InitPool() *pgxpool.Pool { return nil } + schema, err := resolveSchema() + if err != nil { + slog.Warn("Invalid database schema configuration. Database functions will be disabled.", logger.ErrAttr(err)) + return nil + } + config, err := pgxpool.ParseConfig(connStr) if err != nil { - slog.Warn("Unable to parse database configuration: %v. Database functions will be disabled.", logger.ErrAttr(err)) + slog.Warn("Unable to parse database configuration. Database functions will be disabled.", logger.ErrAttr(err)) return nil } + // Pin every pooled connection to the dedicated schema so unqualified + // identifiers in queries (and goose's bookkeeping table) resolve there. + if config.ConnConfig.RuntimeParams == nil { + config.ConnConfig.RuntimeParams = map[string]string{} + } + config.ConnConfig.RuntimeParams["search_path"] = schema + // Configure connection pool settings config.MaxConns = 25 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + // Short timeout for pool construction + initial ping. A separate, longer + // budget is used for migrations below — running goose under the same + // 10s deadline can produce spurious "context deadline exceeded" failures + // at startup even when the database is healthy. + poolCtx, poolCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer poolCancel() - // Attempt to create the connection pool - pool, err := pgxpool.NewWithConfig(ctx, config) + pool, err := pgxpool.NewWithConfig(poolCtx, config) if err != nil { - slog.Warn("Unable to create connection pool: %v. Database functions will be disabled.", logger.ErrAttr(err)) + slog.Warn("Unable to create connection pool. Database functions will be disabled.", logger.ErrAttr(err)) return nil } - // Attempt to ping the database - if err = pool.Ping(ctx); err != nil { - slog.Warn("Database connection failed to ping: %v. Database functions will be disabled.", logger.ErrAttr(err)) - // Close the pool if ping failed + if err = pool.Ping(poolCtx); err != nil { + slog.Warn("Database connection failed to ping. Database functions will be disabled.", logger.ErrAttr(err)) pool.Close() return nil } - // Success - slog.Info("PostgreSQL connection pool successfully initialized.") + slog.Info("PostgreSQL connection pool successfully initialized.", slog.String("schema", schema)) - // Initialize database schema - if err := initSchema(ctx, pool); err != nil { - slog.Error("Failed to initialize database schema: %v. Exiting application.", logger.ErrAttr(err)) + migrationCtx, migrationCancel := context.WithTimeout(context.Background(), time.Minute) + defer migrationCancel() + if err := initSchema(migrationCtx, pool, schema); err != nil { + slog.Error("Failed to initialize database schema. Exiting application.", logger.ErrAttr(err)) pool.Close() os.Exit(1) } @@ -102,54 +141,35 @@ func InitPool() *pgxpool.Pool { return pool } -// initSchema creates the database schema if it doesn't exist -func initSchema(ctx context.Context, pool *pgxpool.Pool) error { +// initSchema ensures the dedicated schema exists and runs all pending migrations. +// Schema name has already been validated by resolveSchema before this is called. +func initSchema(ctx context.Context, pool *pgxpool.Pool, schema string) error { if pool == nil { return nil } - slog.Info("Initializing database schema...") + // Schema name was validated against schemaIdentRegexp, so direct interpolation is safe. + // Goose's bookkeeping table (goose_db_version) lands inside this schema because the + // pool's search_path was set above, before the first connection was acquired. + if _, err := pool.Exec(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema)); err != nil { + return fmt.Errorf("failed to create schema %q: %w", schema, err) + } - // Execute the SQL schema directly (includes DROP TABLE IF EXISTS which is safe) - _, err := pool.Exec(ctx, schemasql.EventSchemaSQL) - if err != nil { - return fmt.Errorf("failed to execute schema: %w", err) + // stdlib.OpenDBFromPool returns a *sql.DB that wraps the pool; closing it + // does not close the pool (per pgx docs). + db := stdlib.OpenDBFromPool(pool) + defer db.Close() + + goose.SetBaseFS(schemasql.Migrations) + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("failed to set goose dialect: %w", err) } + goose.SetLogger(goose.NopLogger()) - // Validate that the table was created successfully - var tableExists bool - err = pool.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'container_pull_metrics' - )`).Scan(&tableExists) - if err != nil { - return fmt.Errorf("failed to verify table creation: %w", err) - } - if !tableExists { - return fmt.Errorf("table container_pull_metrics was not created") - } - - // Validate that indexes were created successfully - expectedIndexes := []string{"idx_pull_date", "idx_repo_date", "idx_repo_arch_date"} - for _, indexName := range expectedIndexes { - var indexExists bool - err = pool.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT FROM pg_indexes - WHERE schemaname = 'public' - AND tablename = 'container_pull_metrics' - AND indexname = $1 - )`, indexName).Scan(&indexExists) - if err != nil { - return fmt.Errorf("failed to verify index %s: %w", indexName, err) - } - if !indexExists { - return fmt.Errorf("index %s was not created", indexName) - } - } - - slog.Info("Database schema initialized successfully.") + if err := goose.UpContext(ctx, db, "migrations"); err != nil { + return fmt.Errorf("failed to apply migrations: %w", err) + } + + slog.Info("Database schema ready.", slog.String("schema", schema)) return nil } diff --git a/pkg/db/postgres_integration_test.go b/pkg/db/postgres_integration_test.go new file mode 100644 index 0000000..f9e2c2f --- /dev/null +++ b/pkg/db/postgres_integration_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Seqera + +//go:build integration + +package db + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/seqeralabs/staticreg/pkg/db/dbtest" +) + +// Run with: go test -tags=integration ./pkg/db/... +// Requires docker-compose postgres up. + +func TestInitPool_CreatesDedicatedSchemaAndAppliesMigrations(t *testing.T) { + dbtest.SetEnv(t, "staticreg_test") + + pool := InitPool() + if pool == nil { + t.Fatal("InitPool returned nil") + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, "DROP SCHEMA IF EXISTS staticreg_test CASCADE") + pool.Close() + }) + + ctx := context.Background() + + var searchPath string + if err := pool.QueryRow(ctx, "SHOW search_path").Scan(&searchPath); err != nil { + t.Fatalf("show search_path: %v", err) + } + if searchPath != `"staticreg_test"` && searchPath != "staticreg_test" { + t.Fatalf("expected search_path=staticreg_test, got %q", searchPath) + } + + if !exists(t, ctx, pool, `SELECT 1 FROM information_schema.schemata WHERE schema_name = 'staticreg_test'`) { + t.Fatal("schema staticreg_test was not created") + } + + if !exists(t, ctx, pool, `SELECT 1 FROM information_schema.tables WHERE table_schema = 'staticreg_test' AND table_name = 'goose_db_version'`) { + t.Fatal("goose_db_version not in staticreg_test schema") + } + + if !exists(t, ctx, pool, `SELECT 1 FROM information_schema.tables WHERE table_schema = 'staticreg_test' AND table_name = 'container_pull_metrics'`) { + t.Fatal("container_pull_metrics not in staticreg_test schema") + } + + for _, idx := range []string{"idx_pull_date", "idx_repo_date", "idx_repo_arch_date"} { + if !exists(t, ctx, pool, `SELECT 1 FROM pg_indexes WHERE schemaname = 'staticreg_test' AND indexname = $1`, idx) { + t.Fatalf("index %s not in staticreg_test schema", idx) + } + } + + // Idempotent re-init: a second InitPool against the same schema must succeed. + // Keep `pool` open — t.Cleanup uses it to DROP the schema after the test + // body returns. Closing `pool` here would leave the cleanup unable to run + // the DROP, leaking the schema between runs. + pool2 := InitPool() + if pool2 == nil { + t.Fatal("second InitPool returned nil") + } + defer pool2.Close() +} + +func TestInitPool_RejectsInvalidSchemaName(t *testing.T) { + dbtest.SetEnv(t, "Bad-Schema; DROP TABLE") + + if pool := InitPool(); pool != nil { + pool.Close() + t.Fatal("expected nil pool for invalid schema name") + } +} + +func exists(t *testing.T, ctx context.Context, pool *pgxpool.Pool, q string, args ...any) bool { + t.Helper() + rows, err := pool.Query(ctx, q, args...) + if err != nil { + t.Fatalf("query %q: %v", q, err) + } + defer rows.Close() + return rows.Next() +} diff --git a/pkg/sql/event_schema.sql b/pkg/sql/migrations/00001_create_container_pull_metrics.sql similarity index 72% rename from pkg/sql/event_schema.sql rename to pkg/sql/migrations/00001_create_container_pull_metrics.sql index b3b253d..05d088c 100644 --- a/pkg/sql/event_schema.sql +++ b/pkg/sql/migrations/00001_create_container_pull_metrics.sql @@ -1,4 +1,5 @@ --- Create new aggregated metrics table +-- +goose Up +-- +goose StatementBegin CREATE TABLE IF NOT EXISTS container_pull_metrics ( id BIGSERIAL PRIMARY KEY, pull_date DATE NOT NULL, @@ -12,7 +13,15 @@ CREATE TABLE IF NOT EXISTS container_pull_metrics ( UNIQUE(pull_date, repo_name, tag, digest, architecture) ); --- Indexes for efficient queries CREATE INDEX IF NOT EXISTS idx_pull_date ON container_pull_metrics (pull_date DESC); CREATE INDEX IF NOT EXISTS idx_repo_date ON container_pull_metrics (repo_name, pull_date DESC); CREATE INDEX IF NOT EXISTS idx_repo_arch_date ON container_pull_metrics (repo_name, architecture, pull_date DESC); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_repo_arch_date; +DROP INDEX IF EXISTS idx_repo_date; +DROP INDEX IF EXISTS idx_pull_date; +DROP TABLE IF EXISTS container_pull_metrics; +-- +goose StatementEnd diff --git a/pkg/sql/schema.go b/pkg/sql/schema.go index 9f65200..19ba108 100644 --- a/pkg/sql/schema.go +++ b/pkg/sql/schema.go @@ -14,9 +14,7 @@ // limitations under the License. package sql -import ( - _ "embed" -) +import "embed" -//go:embed event_schema.sql -var EventSchemaSQL string +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/pkg/webhook/batchserviceadapter_integration_test.go b/pkg/webhook/batchserviceadapter_integration_test.go new file mode 100644 index 0000000..79169c9 --- /dev/null +++ b/pkg/webhook/batchserviceadapter_integration_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Seqera + +//go:build integration + +package webhook + +import ( + "context" + "log/slog" + "os" + "testing" + "time" + + "github.com/seqeralabs/staticreg/pkg/db" + "github.com/seqeralabs/staticreg/pkg/db/dbtest" +) + +// TestBatchAdapter_WritesToDedicatedSchema confirms that an unqualified +// INSERT issued by BatchServiceAdapter resolves to the dedicated schema +// because the pool sets search_path on every connection. +func TestBatchAdapter_WritesToDedicatedSchema(t *testing.T) { + dbtest.SetEnv(t, "staticreg_batch_test") + pool := db.InitPool() + if pool == nil { + t.Fatal("InitPool returned nil") + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "DROP SCHEMA IF EXISTS staticreg_batch_test CASCADE") + pool.Close() + }) + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + adapter := NewBatchServiceAdapter(log, pool) + + evt := &DistributionEvent{ + Timestamp: time.Now().UTC(), + Action: "pull", + Target: DistributionEventTarget{ + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + Repository: "example/app", + Tag: "v1.0.0", + Digest: "sha256:deadbeef", + }, + Request: DistributionEventRequest{ + UserAgent: "docker/20.10.7 go/go1.16.4 kernel/5.10.0 os/linux arch/amd64", + }, + } + + if err := adapter.SavePullEvent(context.Background(), evt); err != nil { + t.Fatalf("SavePullEvent: %v", err) + } + + // Allow the batch worker to flush. + if err := adapter.Close(5 * time.Second); err != nil { + t.Fatalf("Close: %v", err) + } + + var count int + err := pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM staticreg_batch_test.container_pull_metrics WHERE repo_name = 'example/app'`, + ).Scan(&count) + if err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 row in dedicated schema, got %d", count) + } +}