From cfc18b2613101fb4e3814c4d9af433be68e2f1dc Mon Sep 17 00:00:00 2001 From: Gavin Elder Date: Tue, 7 Jul 2026 18:47:30 +0100 Subject: [PATCH] feat: isolate postgres schema --- docs/configuration.md | 6 ++ docs/install/_templates/wave.env | 5 + docs/install/configure-wave-build.md | 3 +- docs/install/docker-compose.md | 31 ++---- docs/install/kubernetes.md | 32 +++--- docs/migrations/1-36-0.md | 25 +++++ docs/migrations/index.md | 1 + lite/wave.env | 5 + migrations/2026-07-move-wave-schema.sql | 100 ++++++++++++++++++ .../postgres/PostgresPersistentService.groovy | 10 -- .../postgres/PostgresSchemaService.groovy | 37 +++++++ src/main/resources/application-postgres.yml | 3 +- .../postgres/PostgresSchemaServiceTest.groovy | 83 +++++++++++++-- .../resources/postgres/public-wave-legacy.sql | 3 + 14 files changed, 284 insertions(+), 60 deletions(-) create mode 100644 docs/migrations/1-36-0.md create mode 100644 migrations/2026-07-move-wave-schema.sql create mode 100644 src/test/resources/postgres/public-wave-legacy.sql diff --git a/docs/configuration.md b/docs/configuration.md index 75eef17b57..f6f7a6ad8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -427,6 +427,10 @@ Configure PostgreSQL with the following options: : Password for the PostgreSQL database user. Can be set using the `${WAVE_DB_PASSWORD}` environment variable. +`wave.db.schema` *(optional)* +: PostgreSQL schema used for Wave tables (default: `wave`). + Can be set using the `${WAVE_DB_SCHEMA}` environment variable. + `wave.db.uri` *(required)* : JDBC connection string for the PostgreSQL database. For example, `jdbc:postgresql://localhost:5432/wave`. @@ -436,6 +440,8 @@ Configure PostgreSQL with the following options: : Username for authenticating with the PostgreSQL database. Can be set using the `${WAVE_DB_USER}` environment variable. +For isolated deployments that share a PostgreSQL database, provision one role and schema per Wave instance, then set `WAVE_DB_USER` and `WAVE_DB_SCHEMA` to that pair (for example, `wave_pr_123`). Wave sets the datasource schema from `wave.db.schema`, so role and schema can differ if the role owns or can create the configured schema. + ## Blob cache Configure how Wave caches container blobs to improve client performance and optionally delegates transfer tasks to Kubernetes pods for scalability with the following options: diff --git a/docs/install/_templates/wave.env b/docs/install/_templates/wave.env index 5f383081d2..edfc0478db 100644 --- a/docs/install/_templates/wave.env +++ b/docs/install/_templates/wave.env @@ -16,6 +16,11 @@ WAVE_DB_URI=jdbc:postgresql://host.docker.internal:5432/changeme # WAVE_DB_USER=changeme +# +# PostgreSQL schema used by Wave. +# +WAVE_DB_SCHEMA=wave + # # Password required to access the PostgreSQL database # used by the Wave service. diff --git a/docs/install/configure-wave-build.md b/docs/install/configure-wave-build.md index 1d61cf7083..3c437c11a9 100644 --- a/docs/install/configure-wave-build.md +++ b/docs/install/configure-wave-build.md @@ -173,8 +173,9 @@ data: # Existing database, redis, and platform configuration... db: uri: "jdbc:postgresql://your-postgres-host:5432/wave" - user: "wave_user" + user: "wave" password: "your_secure_password_here" + schema: "wave" redis: uri: "redis://your-redis-host:6379" diff --git a/docs/install/docker-compose.md b/docs/install/docker-compose.md index b127cb9a7a..29da41380c 100644 --- a/docs/install/docker-compose.md +++ b/docs/install/docker-compose.md @@ -33,34 +33,25 @@ The minimum system requirements for self-hosted Wave in Docker Compose are: Wave requires a PostgreSQL database to operate. -Create a dedicated `wave` database and user account with the appropriate privileges: +Create a dedicated `wave` database and application role with the appropriate privileges: ```sql --- Create a dedicated user for Wave -CREATE ROLE wave_user LOGIN PASSWORD 'your_secure_password'; +-- Create a dedicated role for Wave. +CREATE ROLE wave LOGIN PASSWORD 'your_secure_password'; --- Create the Wave database -CREATE DATABASE wave; +-- Create the Wave database owned by that role. +-- On managed PostgreSQL, grant role membership to the admin user first if required. +GRANT wave TO CURRENT_USER; +CREATE DATABASE wave OWNER wave; --- Connect to the wave database +-- Connect to the wave database. \c wave; --- Grant basic schema access -GRANT USAGE, CREATE ON SCHEMA public TO wave_user; - --- Grant privileges on existing tables and sequences -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wave_user; -GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO wave_user; - --- Grant privileges on future tables and sequences -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO wave_user; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO wave_user; +-- Let Wave create and own its dedicated schema on first startup. +GRANT CONNECT, CREATE ON DATABASE wave TO wave; ``` -Wave will automatically handle schema migrations on startup and create the required database objects. +Wave creates its dedicated PostgreSQL schema on startup and creates the required database objects there. The default schema is `wave`; set `WAVE_DB_SCHEMA` to use a different per-instance schema. ## Wave config diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index f6cd0eed84..416a6d362e 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -42,31 +42,22 @@ This guide assumes: Wave requires a PostgreSQL database to operate. -Create a dedicated `wave` database and user account with the appropriate privileges: +Create a dedicated `wave` database and application role with the appropriate privileges: ```sql --- Create a dedicated user for Wave -CREATE ROLE wave_user LOGIN PASSWORD 'your_secure_password'; +-- Create a dedicated role for Wave. +CREATE ROLE wave LOGIN PASSWORD 'your_secure_password'; --- Create the Wave database -CREATE DATABASE wave; +-- Create the Wave database owned by that role. +-- On managed PostgreSQL, grant role membership to the admin user first if required. +GRANT wave TO CURRENT_USER; +CREATE DATABASE wave OWNER wave; --- Connect to the wave database +-- Connect to the wave database. \c wave; --- Grant basic schema access -GRANT USAGE, CREATE ON SCHEMA public TO wave_user; - --- Grant privileges on existing tables and sequences -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wave_user; -GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO wave_user; - --- Grant privileges on future tables and sequences -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO wave_user; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO wave_user; +-- Let Wave create and own its dedicated schema on first startup. +GRANT CONNECT, CREATE ON DATABASE wave TO wave; ``` ## Create namespace @@ -128,8 +119,9 @@ data: # Database connection settings db: uri: "jdbc:postgresql://your-postgres-host:5432/wave" - user: "wave_user" + user: "wave" password: "your_secure_password" + schema: "wave" # Redis configuration for caching and session management redis: diff --git a/docs/migrations/1-36-0.md b/docs/migrations/1-36-0.md new file mode 100644 index 0000000000..b10175649c --- /dev/null +++ b/docs/migrations/1-36-0.md @@ -0,0 +1,25 @@ +--- +title: Migrating to 1.36.0 +tags: [migration notes, wave] +--- + +Wave 1.36.0 moves PostgreSQL tables out of the `public` schema and into a dedicated Wave schema. + +## Mandatory steps + +If your deployment already uses PostgreSQL, move the legacy `public.wave_*` tables before deploying this version: + +```bash +psql "postgresql://@:5432/wave" \ + -v target_schema="${WAVE_DB_SCHEMA:-wave}" \ + -v owner_role="${WAVE_DB_USER:-wave}" \ + -f migrations/2026-07-move-wave-schema.sql +``` + +The `target_schema` value must match the `WAVE_DB_SCHEMA` value used by the new Wave deployment. The migration is a metadata-only move (`ALTER TABLE ... SET SCHEMA`), not a row copy. + +If the old Wave build is still running during the migration, use a target schema that matches the current database role so the default PostgreSQL search path (`"$user", public`) can still resolve the moved tables. Otherwise, run the script during the deployment window and start the new build with `WAVE_DB_SCHEMA` set to the target schema. + +The application now fails startup if any `wave_*` tables remain in `public`, to prevent creating empty shadow tables in the new schema. + +New deployments do not need to run the migration script. Wave creates the configured schema automatically on startup. The default schema is `wave`; set `WAVE_DB_SCHEMA` for isolated deployments such as PR previews. diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 2d9316abea..6db69c7786 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -5,6 +5,7 @@ tags: [migration notes, wave] This section covers mandatory steps for migrating to new versions of Wave. +- [Migrating to 1.36.0](./1-36-0.md) - [Migrating to 1.25.0](./1-25-0.md) - [Migrating to 1.24.0](./1-24-0.md) - [Migrating to 1.21.0](./1-21-0.md) diff --git a/lite/wave.env b/lite/wave.env index 1c3dacbbd7..ea6d0b8c98 100644 --- a/lite/wave.env +++ b/lite/wave.env @@ -16,6 +16,11 @@ WAVE_DB_URI=jdbc:postgresql://db:5432/wave # WAVE_DB_USER=wave +# +# PostgreSQL schema used by Wave. +# +WAVE_DB_SCHEMA=wave + # # Password required to access the PostgreSQL database # used by the Wave service. diff --git a/migrations/2026-07-move-wave-schema.sql b/migrations/2026-07-move-wave-schema.sql new file mode 100644 index 0000000000..0fca3f75f6 --- /dev/null +++ b/migrations/2026-07-move-wave-schema.sql @@ -0,0 +1,100 @@ +-- ============================================================================= +-- Wave PostgreSQL schema migration: public -> dedicated schema +-- ============================================================================= +-- +-- PURPOSE +-- Move Wave's legacy public.wave_* tables into a dedicated schema. +-- ALTER TABLE ... SET SCHEMA is metadata-only; table data is not rewritten. +-- +-- USAGE +-- psql "postgresql://@:5432/wave" \ +-- -v target_schema=wave \ +-- -v owner_role=wave \ +-- -f migrations/2026-07-move-wave-schema.sql +-- +-- REQUIRED PRIVILEGES +-- Run as a role that can create schemas and alter the legacy public.wave_* +-- tables. On managed PostgreSQL, this is typically the DB owner/admin. +-- +-- IDEMPOTENCY +-- Re-running is safe after the tables have moved: ALTER TABLE IF EXISTS +-- public. becomes a no-op once the public table is gone. +-- If both public.
and .
exist, the script fails +-- before moving anything. Do not merge by hand; inspect which table has real +-- data and remove only confirmed empty shadow tables. +-- +-- DOWNTIME / SEARCH PATH +-- Moving tables into a schema that does not match the currently running app +-- role can break old app connections whose search_path is "$user", public. +-- For live compatibility, use a target_schema matching the app role. Otherwise +-- run this during the deploy window and start the new build with WAVE_DB_SCHEMA +-- set to the same target_schema. +-- +-- ROLLBACK +-- BEGIN; +-- ALTER TABLE IF EXISTS :"target_schema".wave_build SET SCHEMA public; +-- ALTER TABLE IF EXISTS :"target_schema".wave_request SET SCHEMA public; +-- ALTER TABLE IF EXISTS :"target_schema".wave_mirror SET SCHEMA public; +-- ALTER TABLE IF EXISTS :"target_schema".wave_scan SET SCHEMA public; +-- COMMIT; +-- +-- ============================================================================= + +\if :{?target_schema} +\else +\set target_schema wave +\endif + +\if :{?owner_role} +\else +\set owner_role wave +\endif + +\set ON_ERROR_STOP on + +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM pg_tables public_tables + JOIN pg_tables target_tables + ON target_tables.tablename = public_tables.tablename + WHERE public_tables.schemaname = 'public' + AND target_tables.schemaname = :'target_schema' + AND public_tables.tablename IN ('wave_build', 'wave_request', 'wave_mirror', 'wave_scan') +) THEN 'true' ELSE 'false' END AS has_target_collision +\gset + +\if :has_target_collision +\echo 'Refusing to migrate: at least one wave_* table exists in both public and target schema.' +\echo 'Inspect target tables before retrying; drop only confirmed empty shadow tables.' +\quit 1 +\endif + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS :"target_schema" AUTHORIZATION :"owner_role"; +ALTER SCHEMA :"target_schema" OWNER TO :"owner_role"; + +ALTER TABLE IF EXISTS public.wave_build SET SCHEMA :"target_schema"; +ALTER TABLE IF EXISTS public.wave_request SET SCHEMA :"target_schema"; +ALTER TABLE IF EXISTS public.wave_mirror SET SCHEMA :"target_schema"; +ALTER TABLE IF EXISTS public.wave_scan SET SCHEMA :"target_schema"; + +ALTER TABLE IF EXISTS :"target_schema".wave_build OWNER TO :"owner_role"; +ALTER TABLE IF EXISTS :"target_schema".wave_request OWNER TO :"owner_role"; +ALTER TABLE IF EXISTS :"target_schema".wave_mirror OWNER TO :"owner_role"; +ALTER TABLE IF EXISTS :"target_schema".wave_scan OWNER TO :"owner_role"; + +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM pg_tables + WHERE schemaname = 'public' + AND tablename IN ('wave_build', 'wave_request', 'wave_mirror', 'wave_scan') +) THEN 'true' ELSE 'false' END AS has_public_leftovers +\gset + +\if :has_public_leftovers +\echo 'Migration incomplete: one or more wave_* tables remain in public.' +\quit 1 +\endif + +COMMIT; diff --git a/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresPersistentService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresPersistentService.groovy index 3508cee637..0fe69b71f2 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresPersistentService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresPersistentService.groovy @@ -26,8 +26,6 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Primary import io.micronaut.context.annotation.Requires -import io.micronaut.runtime.event.ApplicationStartupEvent -import io.micronaut.runtime.event.annotation.EventListener import io.micronaut.scheduling.TaskExecutors import io.seqera.wave.core.ContainerDigestPair import io.seqera.wave.service.mirror.MirrorResult @@ -57,9 +55,6 @@ import jakarta.inject.Named @CompileStatic class PostgresPersistentService implements PersistenceService { - @Inject - private PostgresSchemaService dbInitService - @Inject private BuildRepository buildRepository @@ -76,11 +71,6 @@ class PostgresPersistentService implements PersistenceService { @Named(TaskExecutors.BLOCKING) private ExecutorService ioExecutor - @EventListener - void onApplicationStartup(ApplicationStartupEvent event) { - dbInitService.create() - } - // ===== --- build records ---- ===== private Runnable safeRun(Runnable action, GString msg) { new Runnable() { diff --git a/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaService.groovy index 0471722cfc..76559fe85d 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaService.groovy @@ -19,10 +19,14 @@ package io.seqera.wave.service.persistence.postgres import java.sql.SQLException +import java.util.regex.Pattern import groovy.transform.CompileStatic +import io.micronaut.context.annotation.Context import io.micronaut.context.annotation.Requires +import io.micronaut.context.annotation.Value import io.micronaut.data.jdbc.runtime.JdbcOperations +import jakarta.annotation.PostConstruct import jakarta.inject.Inject import jakarta.inject.Singleton @@ -32,13 +36,19 @@ import jakarta.inject.Singleton * @author Paolo Di Tommaso */ @Requires(env='postgres') +@Context @Singleton @CompileStatic class PostgresSchemaService { + final static private Pattern SAFE_IDENT = Pattern.compile('[a-z_][a-z0-9_]*') + @Inject private JdbcOperations jdbcOperations + @Value('${wave.db.schema:wave}') + private String schemaName + final static private String ddl = ''' -- BUILD entity CREATE TABLE IF NOT EXISTS wave_build ( @@ -94,10 +104,18 @@ class PostgresSchemaService { '''.stripIndent() + @PostConstruct + void init() { + create() + } void create() { + assertSafeIdentifier(schemaName) + assertNoLegacyTablesInPublic() jdbcOperations.execute((conn)-> { try(final stmt = conn.createStatement()) { + stmt.execute("CREATE SCHEMA IF NOT EXISTS ${schemaName} AUTHORIZATION CURRENT_USER") + stmt.execute("SET search_path = ${schemaName}") stmt.execute(ddl) } catch (SQLException e) { @@ -123,4 +141,23 @@ class PostgresSchemaService { } }) } + + private void assertNoLegacyTablesInPublic() { + final count = jdbcOperations.prepareStatement( + "SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'wave\\_%' ESCAPE '\\'" + ) { stmt -> + final resultSet = stmt.executeQuery() + return resultSet.next() ? resultSet.getInt(1) : 0 + } + if( count>0 ) { + throw new IllegalStateException( + "${count} legacy wave_* table(s) in 'public' require migration to '${schemaName}' before starting this build. Contact support.") + } + } + + static private void assertSafeIdentifier(String name) { + if( !name || !SAFE_IDENT.matcher(name).matches() ) { + throw new IllegalStateException("Invalid wave.db.schema: must match ${SAFE_IDENT.pattern()} (got: ${name})") + } + } } diff --git a/src/main/resources/application-postgres.yml b/src/main/resources/application-postgres.yml index 2fac5fba8e..c335b842b0 100644 --- a/src/main/resources/application-postgres.yml +++ b/src/main/resources/application-postgres.yml @@ -3,6 +3,7 @@ wave: uri: "jdbc:postgresql://localhost:5432/changeme" user: "changeme" password: "changeme" + schema: "${WAVE_DB_SCHEMA:wave}" datasources: default: db-type: postgres @@ -11,4 +12,4 @@ datasources: url: "${wave.db.uri}" username: "${wave.db.user}" password: "${wave.db.password}" - + schema: "${wave.db.schema}" diff --git a/src/test/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaServiceTest.groovy index a17303a612..219699b16d 100644 --- a/src/test/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/persistence/postgres/PostgresSchemaServiceTest.groovy @@ -21,6 +21,7 @@ package io.seqera.wave.service.persistence.postgres import spock.lang.Specification +import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Property import io.micronaut.data.jdbc.runtime.JdbcOperations import io.micronaut.test.extensions.spock.annotation.MicronautTest @@ -34,6 +35,8 @@ import jakarta.inject.Inject @Property(name = "datasources.default.url", value = "jdbc:tc:postgresql:///db") class PostgresSchemaServiceTest extends Specification { + private static final List WAVE_TABLES = ["wave_build", "wave_request", "wave_scan", "wave_mirror"] + @Inject JdbcOperations jdbcOperations @@ -46,19 +49,83 @@ class PostgresSchemaServiceTest extends Specification { dbInitService.create() then: - def tables = jdbcOperations.prepareStatement( - "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" + def tables = tableNames(jdbcOperations, 'wave') + def publicTables = tableNames(jdbcOperations, 'public') + + expect: "the tables exist" + tables.containsAll(WAVE_TABLES) + !publicTables.any { it.startsWith('wave_') } + } + + def "should create tables in a custom schema"() { + given: + final schema = 'wave_custom' + final ctx = ApplicationContext.run(postgresProperties("custom_${System.nanoTime()}", schema), 'postgres') + + when: + final jdbc = ctx.getBean(JdbcOperations) + + then: + tableNames(jdbc, schema).containsAll(WAVE_TABLES) + !tableNames(jdbc, 'public').any { it.startsWith('wave_') } + + cleanup: + ctx?.close() + } + + def "should reject invalid schema names"() { + when: + final ctx = ApplicationContext.run(postgresProperties("invalid_${System.nanoTime()}", 'wave-bad'), 'postgres') + ctx.close() + + then: + final error = thrown(Throwable) + rootMessages(error).any { it.contains('Invalid wave.db.schema') } + } + + def "should fail when legacy wave tables remain in public"() { + when: + final props = postgresProperties("legacy_${System.nanoTime()}", 'wave') + [ + "datasources.default.url": "jdbc:tc:postgresql:15:///legacy_${System.nanoTime()}?TC_INITSCRIPT=postgres/public-wave-legacy.sql" + ] + final ctx = ApplicationContext.run(props, 'postgres') + ctx.close() + + then: + final error = thrown(Throwable) + rootMessages(error).any { it.contains("legacy wave_* table(s) in 'public' require migration to 'wave'") } + } + + private static Map postgresProperties(String dbName, String schema) { + return [ + "datasources.default.driver-class-name": "org.testcontainers.jdbc.ContainerDatabaseDriver", + "datasources.default.url": "jdbc:tc:postgresql:15:///${dbName}", + "wave.db.schema": schema + ] + } + + private static List tableNames(JdbcOperations jdbc, String schema) { + return jdbc.prepareStatement( + "SELECT tablename FROM pg_tables WHERE schemaname = ?" ) { stmt -> - def resultSet = stmt.executeQuery() - def tableNames = [] + stmt.setString(1, schema) + final resultSet = stmt.executeQuery() + final tableNames = [] while (resultSet.next()) { tableNames << resultSet.getString("tablename") } return tableNames - } - - expect: "the tables exist" - tables.containsAll(["wave_build", "wave_request", "wave_scan", "wave_mirror"]) + } as List } + private static List rootMessages(Throwable error) { + final result = [] + def current = error + while( current ) { + if( current.message ) + result << current.message + current = current.cause + } + return result as List + } } diff --git a/src/test/resources/postgres/public-wave-legacy.sql b/src/test/resources/postgres/public-wave-legacy.sql new file mode 100644 index 0000000000..7ccf695b33 --- /dev/null +++ b/src/test/resources/postgres/public-wave-legacy.sql @@ -0,0 +1,3 @@ +CREATE TABLE public.wave_build ( + id TEXT PRIMARY KEY +);