diff --git a/.github/workflows/postgres-image.yml b/.github/workflows/postgres-image.yml new file mode 100644 index 00000000..57cefcc4 --- /dev/null +++ b/.github/workflows/postgres-image.yml @@ -0,0 +1,173 @@ +name: PostgreSQL image + +on: + push: + branches: [main] + paths: + - build/package/postgres.Dockerfile + - .github/workflows/postgres-image.yml + workflow_dispatch: + +permissions: {} + +concurrency: + group: postgres-18-image + cancel-in-progress: true + +jobs: + verify: + name: Verify PostgreSQL 18 image + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Build test image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: build/package/postgres.Dockerfile + load: true + tags: onebox-postgres:test + + - name: Verify bundled extensions + shell: bash + run: | + set -euo pipefail + container=onebox-postgres-test + trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT + docker run -d --name "$container" -e POSTGRES_PASSWORD=test onebox-postgres:test \ + -c shared_preload_libraries=pg_cron,pgaudit,pg_stat_statements \ + -c cron.database_name=postgres \ + -c cron.use_background_workers=on + for attempt in $(seq 1 30); do + if docker exec "$container" pg_isready -U postgres -d postgres >/dev/null 2>&1; then + break + fi + if [ "$attempt" -eq 30 ]; then + docker logs "$container" + exit 1 + fi + sleep 1 + done + docker exec "$container" psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c 'CREATE EXTENSION vector' \ + -c 'CREATE EXTENSION vectorscale' \ + -c 'CREATE EXTENSION postgis' \ + -c 'CREATE EXTENSION pg_cron' \ + -c 'CREATE EXTENSION pgaudit' \ + -c 'CREATE EXTENSION pg_repack' \ + -c 'CREATE EXTENSION pg_partman' \ + -c 'CREATE EXTENSION hypopg' \ + -c 'CREATE EXTENSION pg_stat_statements' \ + -c "SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector" \ + -c 'CREATE TABLE onebox_vectorscale(id bigint, embedding vector(3))' \ + -c "INSERT INTO onebox_vectorscale VALUES (1, '[1,2,3]'), (2, '[4,5,6]'), (3, '[7,8,9]')" \ + -c 'CREATE INDEX onebox_vectorscale_diskann ON onebox_vectorscale USING diskann (embedding vector_l2_ops)' \ + -c "SELECT indexdef FROM pg_indexes WHERE indexname = 'onebox_vectorscale_diskann'" \ + -c 'SET enable_seqscan = off' \ + -c "SELECT id FROM onebox_vectorscale ORDER BY embedding <-> '[1,2,3]' LIMIT 1" \ + -c "SELECT ST_AsText(ST_Point(1, 2))" \ + -c "SELECT cron.schedule('onebox-smoke', '0 0 * * *', 'SELECT 1')" \ + -c "SELECT cron.unschedule('onebox-smoke')" \ + -c 'CREATE TABLE onebox_hypopg(id bigint)' \ + -c "SELECT indexrelid IS NOT NULL FROM hypopg_create_index('CREATE INDEX ON onebox_hypopg(id)')" \ + -c "SELECT extname, extversion FROM pg_extension WHERE extname IN ('vector', 'vectorscale', 'postgis', 'pg_cron', 'pgaudit', 'pg_repack', 'pg_partman', 'hypopg', 'pg_stat_statements') ORDER BY extname" + docker exec "$container" pg_repack --version + + publish: + name: Publish PostgreSQL 18 image + needs: verify + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish commit-addressed candidate + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: build/package/postgres.Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/labstack/onebox-postgres:18-${{ github.sha }} + provenance: mode=max + sbom: true + + - name: Verify exact published platform images + shell: bash + run: | + set -euo pipefail + candidate="ghcr.io/labstack/onebox-postgres:18-${GITHUB_SHA}" + for platform in linux/amd64 linux/arm64; do + architecture="${platform#linux/}" + container="onebox-postgres-${architecture}" + trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT + docker run -d --platform "$platform" --name "$container" -e POSTGRES_PASSWORD=test "$candidate" \ + -c shared_preload_libraries=pg_cron,pgaudit,pg_stat_statements \ + -c cron.database_name=postgres \ + -c cron.use_background_workers=on + for attempt in $(seq 1 60); do + if docker exec "$container" pg_isready -U postgres -d postgres >/dev/null 2>&1; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs "$container" + exit 1 + fi + sleep 1 + done + docker exec "$container" psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c 'CREATE EXTENSION vector' \ + -c 'CREATE EXTENSION vectorscale' \ + -c 'CREATE EXTENSION postgis' \ + -c 'CREATE EXTENSION pg_cron' \ + -c 'CREATE EXTENSION pgaudit' \ + -c 'CREATE EXTENSION pg_repack' \ + -c 'CREATE EXTENSION pg_partman' \ + -c 'CREATE EXTENSION hypopg' \ + -c 'CREATE EXTENSION pg_stat_statements' \ + -c "SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector" \ + -c 'CREATE TABLE onebox_vectorscale(id bigint, embedding vector(3))' \ + -c "INSERT INTO onebox_vectorscale VALUES (1, '[1,2,3]'), (2, '[4,5,6]'), (3, '[7,8,9]')" \ + -c 'CREATE INDEX onebox_vectorscale_diskann ON onebox_vectorscale USING diskann (embedding vector_l2_ops)' \ + -c "SELECT indexdef FROM pg_indexes WHERE indexname = 'onebox_vectorscale_diskann'" \ + -c 'SET enable_seqscan = off' \ + -c "SELECT id FROM onebox_vectorscale ORDER BY embedding <-> '[1,2,3]' LIMIT 1" \ + -c "SELECT ST_AsText(ST_Point(1, 2))" \ + -c "SELECT cron.schedule('onebox-smoke', '0 0 * * *', 'SELECT 1')" \ + -c "SELECT cron.unschedule('onebox-smoke')" \ + -c 'CREATE TABLE onebox_hypopg(id bigint)' \ + -c "SELECT indexrelid IS NOT NULL FROM hypopg_create_index('CREATE INDEX ON onebox_hypopg(id)')" + docker exec "$container" pg_repack --version + docker rm -f "$container" + trap - EXIT + done + + - name: Promote tested manifest to PostgreSQL 18 + run: docker buildx imagetools create --tag ghcr.io/labstack/onebox-postgres:18 "ghcr.io/labstack/onebox-postgres:18-${GITHUB_SHA}" + + - name: Verify public anonymous access + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + visibility=$(gh api orgs/labstack/packages/container/onebox-postgres --jq .visibility) + if [ "$visibility" != public ]; then + echo "::error::Make the onebox-postgres package public at https://github.com/orgs/labstack/packages/container/onebox-postgres/settings and re-run this workflow." + exit 1 + fi + docker logout ghcr.io + docker buildx imagetools inspect ghcr.io/labstack/onebox-postgres:18 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 749c132f..a7f9dce0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,17 @@ jobs: exit 1 fi + - name: Require public PostgreSQL 18 distribution + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + visibility=$(gh api orgs/labstack/packages/container/onebox-postgres --jq .visibility) + if [ "$visibility" != public ]; then + echo "The onebox-postgres package must be public before a release can reference it." >&2 + exit 1 + fi + docker manifest inspect ghcr.io/labstack/onebox-postgres:18 >/dev/null + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 diff --git a/build/package/postgres.Dockerfile b/build/package/postgres.Dockerfile new file mode 100644 index 00000000..ad32378d --- /dev/null +++ b/build/package/postgres.Dockerfile @@ -0,0 +1,83 @@ +# syntax=docker/dockerfile:1 + +ARG PG_MAJOR=18 +ARG DEBIAN_CODENAME=trixie +FROM postgres:${PG_MAJOR}-${DEBIAN_CODENAME} + +ARG PG_MAJOR +ARG TARGETARCH +ARG PGVECTOR_COMMIT=8ee86c96f0fd72390f890aa8a336fda6d3ab4c6c +ARG PGVECTOR_VERSION=0.8.6 +ARG PGVECTORSCALE_COMMIT=c66cae4b621664b68546587da9fafd80b791e643 +ARG PGVECTORSCALE_VERSION=0.9.0 +ARG PGVECTORSCALE_AMD64_SHA256=7a5450b81a7403ca20ff5e5a2f81aa13c81795ddd1fdfe9b986c42c48b12ed67 +ARG PGVECTORSCALE_ARM64_SHA256=8d0916df999f082ceb3d019bdfa72f5df395c31b152f7906de59e429ee11edc7 +ARG POSTGIS_PACKAGE_VERSION=3.6.4+dfsg-2.pgdg13+1 +ARG PG_CRON_PACKAGE_VERSION=1.6.7-3.pgdg13+1 +ARG PGAUDIT_PACKAGE_VERSION=18.0-3.pgdg13+1 +ARG PG_REPACK_PACKAGE_VERSION=1.5.3-1.pgdg13+1 +ARG PG_PARTMAN_PACKAGE_VERSION=5.5.0-1.pgdg13+1 +ARG HYPOPG_PACKAGE_VERSION=1.4.3-1.pgdg13+1 + +LABEL org.opencontainers.image.source="https://github.com/labstack/onebox" \ + org.opencontainers.image.description="PostgreSQL for Onebox-managed applications" \ + org.opencontainers.image.licenses="PostgreSQL AND GPL-2.0-or-later" + +# The full commit is immutable. BuildKit checks out that exact source instead +# of trusting a movable release tag, and the final image contains no compiler. +ADD https://github.com/pgvector/pgvector.git#${PGVECTOR_COMMIT} /tmp/pgvector +RUN apt-get update && \ + apt-mark hold locales && \ + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + postgresql-server-dev-${PG_MAJOR} \ + postgresql-${PG_MAJOR}-postgis-3=${POSTGIS_PACKAGE_VERSION} \ + postgresql-${PG_MAJOR}-cron=${PG_CRON_PACKAGE_VERSION} \ + postgresql-${PG_MAJOR}-pgaudit=${PGAUDIT_PACKAGE_VERSION} \ + postgresql-${PG_MAJOR}-repack=${PG_REPACK_PACKAGE_VERSION} \ + postgresql-${PG_MAJOR}-partman=${PG_PARTMAN_PACKAGE_VERSION} \ + postgresql-${PG_MAJOR}-hypopg=${HYPOPG_PACKAGE_VERSION} \ + unzip && \ + cd /tmp/pgvector && \ + make clean && \ + make OPTFLAGS="" && \ + make install && \ + grep -Fqx "default_version = '${PGVECTOR_VERSION}'" vector.control && \ + install -d /usr/share/doc/pgvector && \ + install -m 0644 LICENSE README.md /usr/share/doc/pgvector/ && \ + printf '%s\n' "${PGVECTOR_VERSION}" > /usr/share/doc/pgvector/VERSION && \ + case "${TARGETARCH}" in \ + amd64) pgvectorscale_sha="${PGVECTORSCALE_AMD64_SHA256}" ;; \ + arm64) pgvectorscale_sha="${PGVECTORSCALE_ARM64_SHA256}" ;; \ + *) echo "unsupported pgvectorscale architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac && \ + pgvectorscale_archive="pgvectorscale-${PGVECTORSCALE_VERSION}-pg${PG_MAJOR}-${TARGETARCH}.zip" && \ + curl -fsSL \ + "https://github.com/timescale/pgvectorscale/releases/download/${PGVECTORSCALE_VERSION}/${pgvectorscale_archive}" \ + -o "/tmp/${pgvectorscale_archive}" && \ + echo "${pgvectorscale_sha} /tmp/${pgvectorscale_archive}" | sha256sum -c - && \ + install -d /tmp/pgvectorscale-package && \ + unzip -j "/tmp/${pgvectorscale_archive}" \ + "pgvectorscale-postgresql-${PG_MAJOR}_${PGVECTORSCALE_VERSION}-Linux_${TARGETARCH}.deb" \ + -d /tmp/pgvectorscale-package && \ + apt-get install -y --no-install-recommends \ + "/tmp/pgvectorscale-package/pgvectorscale-postgresql-${PG_MAJOR}_${PGVECTORSCALE_VERSION}-Linux_${TARGETARCH}.deb" && \ + grep -Fqx "default_version = '${PGVECTORSCALE_VERSION}'" \ + "/usr/share/postgresql/${PG_MAJOR}/extension/vectorscale.control" && \ + install -d /usr/share/doc/pgvectorscale && \ + curl -fsSL \ + "https://raw.githubusercontent.com/timescale/pgvectorscale/${PGVECTORSCALE_COMMIT}/LICENSE" \ + -o /usr/share/doc/pgvectorscale/LICENSE && \ + curl -fsSL \ + "https://raw.githubusercontent.com/timescale/pgvectorscale/${PGVECTORSCALE_COMMIT}/NOTICE" \ + -o /usr/share/doc/pgvectorscale/NOTICE && \ + echo "df34f0384d53261f4dc47b3d834a13b570f177b8ab1c8a00266a98f30de2e117 /usr/share/doc/pgvectorscale/LICENSE" | sha256sum -c - && \ + echo "4e204f7b0aa175af0a3b38c3bc56852954adf0110e25babe94eab6e35eeef114 /usr/share/doc/pgvectorscale/NOTICE" | sha256sum -c - && \ + cd / && \ + rm -rf /tmp/pgvector /tmp/pgvectorscale-package "/tmp/${pgvectorscale_archive}" && \ + apt-get remove -y build-essential ca-certificates curl postgresql-server-dev-${PG_MAJOR} unzip && \ + apt-get autoremove -y && \ + apt-mark unhold locales && \ + rm -rf /var/lib/apt/lists/* diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index 0add9b14..b9e39c54 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -1586,6 +1586,31 @@ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", "type": "string" }, + "features": { + "additionalProperties": false, + "description": "Capabilities Onebox must establish before application workloads run.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": {}, + "type": "object" + }, + "description": "PostgreSQL extensions Onebox installs in the managed application database before application migrations run.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "type": "object" + } + }, + "type": "object" + }, "persistence": { "additionalProperties": false, "description": "Data-lifetime declaration for this supporting service.", diff --git a/e2e/server_test.go b/e2e/server_test.go index 8788d1eb..1808dc13 100644 --- a/e2e/server_test.go +++ b/e2e/server_test.go @@ -61,22 +61,16 @@ func (s *server) requireDocker(t *testing.T) { }, "\n")) } -// primeDriverImage puts the PostgreSQL image on the server from a mirror. -// -// The driver names `postgres:18` and the tag is not overridable, so every run -// would otherwise pull it from Docker Hub — which rate-limits anonymous -// requests per source address, and a CI runner shares its address with -// everyone else on it. The image content is identical across registries, so -// retagging it locally is the same bytes by the same digest: `docker image -// inspect` reports postgres@sha256:… first, which is what ob reads when it -// pins the protected image. +// primeDriverImage makes the public Onebox PostgreSQL distribution available +// before bootstrap. Release publication is gated on this exact tag being +// anonymously pullable. func (s *server) primeDriverImage(t *testing.T) { t.Helper() - if err := s.try(t, "docker image inspect postgres:18 >/dev/null 2>&1"); err == nil { + const image = "ghcr.io/labstack/onebox-postgres:18" + if err := s.try(t, "docker image inspect "+image+" >/dev/null 2>&1"); err == nil { return } - s.run(t, "docker pull -q public.ecr.aws/docker/library/postgres:18 >/dev/null && "+ - "docker tag public.ecr.aws/docker/library/postgres:18 postgres:18") + s.run(t, "docker pull -q "+image+" >/dev/null") } // psql runs a query in the protected server and returns the single value. @@ -351,7 +345,7 @@ HTTPServer(("127.0.0.1", 18080), Handler).handle_request() // Issue #88: enable established archiving and then failed every upload // with "x509: certificate signed by unknown authority", because wal-g runs - // inside the driver's image and postgres:18 carries no certificate + // inside the driver's image and the PostgreSQL 18 distribution carries no certificate // authorities. It failed after the base backup, leaving archiving on. // // This endpoint is signed by an authority that exists only on this server, diff --git a/internal/app/constraints.go b/internal/app/constraints.go index 524d1484..87429961 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -87,6 +87,9 @@ var ( gSettingKey = grammar{"driver setting", regexp.MustCompile(`^[a-z][a-z0-9_-]*$`), "a lowercase setting name such as appendonly, maxmemory-policy or shared_buffers"} + gExtension = grammar{"PostgreSQL extension", regexp.MustCompile(`^[a-z][a-z0-9_-]*$`), + "a lowercase PostgreSQL extension name such as vector, pg_trgm or uuid-ossp"} + // Docker admits plugin log drivers with arbitrary names, so this cannot be // an enum without refusing a legitimate one. A grammar still catches the // typo and the metacharacter, which is what reaches the generated runtime. diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index e3fa25ed..ebc55a67 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -311,6 +311,9 @@ var schemaConstraints = []struct { {[]string{"api_version"}, map[string]any{"const": APIVersion}}, {[]string{"app"}, appNameConstraint()}, {[]string{"base_path"}, pattern(gAbsPath)}, + {[]string{"services", "*", "features", "extensions"}, map[string]any{ + "propertyNames": map[string]any{"pattern": gExtension.pattern.String()}, + }}, {[]string{"environments", "*", "base_path"}, pattern(gAbsPath)}, {[]string{"environments", "*", "policy", "min_onebox_version"}, pattern(gCalVer)}, diff --git a/internal/app/postgres_extensions.go b/internal/app/postgres_extensions.go new file mode 100644 index 00000000..bc2c3c57 --- /dev/null +++ b/internal/app/postgres_extensions.go @@ -0,0 +1,122 @@ +package app + +import ( + "fmt" + "sort" + "strings" +) + +// Extensions that need a server library loaded before CREATE EXTENSION. The +// image contains many ordinary extensions, which require no catalogue entry; +// this table exists only for behavior PostgreSQL cannot discover before start. +var postgresExtensionPreloads = map[string]string{ + "pg_cron": "pg_cron", + "pgaudit": "pgaudit", + "pg_stat_statements": "pg_stat_statements", +} + +// Extension dependencies are part of the selected image contract. Users ask +// for the capability they need; Onebox establishes its exact PostgreSQL +// prerequisites without requiring duplicate declarations in the project file. +var postgresExtensionDependencies = map[string][]string{ + "vectorscale": {"vector"}, +} + +func postgresFeatureExtensions(service Service) []string { + roots := make([]string, 0, len(service.Features.Extensions)) + for extension := range service.Features.Extensions { + roots = append(roots, extension) + } + sort.Strings(roots) + + seen := make(map[string]bool, len(roots)+1) + out := make([]string, 0, len(roots)+1) + var add func(string) + add = func(extension string) { + if seen[extension] { + return + } + seen[extension] = true + dependencies := append([]string(nil), postgresExtensionDependencies[extension]...) + sort.Strings(dependencies) + for _, dependency := range dependencies { + add(dependency) + } + out = append(out, extension) + } + for _, extension := range roots { + add(extension) + } + return out +} + +// PostgresPreloadExtensions lists declarations whose installed database object +// cannot safely outlive its feature declaration. Engine preflight uses this to +// refuse a restart that would silently unload an installed extension. +func PostgresPreloadExtensions() []string { + out := make([]string, 0, len(postgresExtensionPreloads)) + for extension := range postgresExtensionPreloads { + out = append(out, extension) + } + sort.Strings(out) + return out +} + +func postgresFeatureSettings(appName string, service Service) map[string]any { + effective := make(map[string]any, len(service.Settings)+3) + for key, value := range service.Settings { + effective[key] = value + } + if service.Features == nil { + return effective + } + + preloads := splitPostgresList(fmt.Sprint(effective["shared_preload_libraries"])) + for extension := range service.Features.Extensions { + if library := postgresExtensionPreloads[extension]; library != "" { + preloads[library] = true + } + } + if len(preloads) > 0 { + libraries := make([]string, 0, len(preloads)) + for library := range preloads { + libraries = append(libraries, library) + } + sort.Strings(libraries) + effective["shared_preload_libraries"] = strings.Join(libraries, ",") + } + + // pg_cron otherwise looks for its metadata in the default `postgres` + // database and opens new password-authenticated local connections. A managed + // service has one application database, and background workers let scheduled + // jobs run there without inventing a second credential path. + if _, requested := service.Features.Extensions["pg_cron"]; requested { + if _, authored := effective["cron.database_name"]; !authored { + effective["cron.database_name"] = appName + } + if _, authored := effective["cron.use_background_workers"]; !authored { + effective["cron.use_background_workers"] = "on" + } + } + return effective +} + +// PostgresServiceSettings returns the effective server settings for recovery +// and other runtimes that do not use the generated Compose command. +func (p *Spec) PostgresServiceSettings(name string) map[string]any { + service, ok := p.Services[name] + if !ok { + return nil + } + return postgresFeatureSettings(p.Name, service) +} + +func splitPostgresList(value string) map[string]bool { + out := map[string]bool{} + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" && item != "" { + out[item] = true + } + } + return out +} diff --git a/internal/app/service_extensions_test.go b/internal/app/service_extensions_test.go new file mode 100644 index 00000000..d26a2c2f --- /dev/null +++ b/internal/app/service_extensions_test.go @@ -0,0 +1,172 @@ +package app + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func TestVectorscaleIncludesItsVectorDependency(t *testing.T) { + spec := &Spec{Services: map[string]Service{ + "database": { + Driver: "postgres", + Version: "18", + Features: &ServiceFeatures{Extensions: map[string]ServiceExtension{ + "vectorscale": {}, + }}, + }, + }} + want := []string{"vector", "vectorscale"} + if got := spec.ServiceExtensions("database"); !reflect.DeepEqual(got, want) { + t.Fatalf("ServiceExtensions() = %v, want %v", got, want) + } +} + +func TestPostgresExtensionsSelectTheOneboxImage(t *testing.T) { + rendered := renderServices(t, `api_version: onebox.run/v1 +app: goal +environments: {production: {server: root@host}} +workloads: + web: {role: application, image: goal:1} +services: + database: + driver: postgres + version: 18 + features: + extensions: + pg_trgm: {} + vector: {} +`) + doc := string(rendered["database"]) + if !strings.Contains(doc, "image: ghcr.io/labstack/onebox-postgres:18\n") { + t.Fatalf("postgres service does not use the Onebox image:\n%s", doc) + } +} + +func TestProtectedPostgresMustAdoptTheOneboxImageBeforeExtensions(t *testing.T) { + resolved := serviceImageTestResolved(true) + service := resolved.Services["database"] + service.Features = &ServiceFeatures{Extensions: map[string]ServiceExtension{"vector": {}}} + resolved.Services["database"] = service + bound, err := resolved.WithServiceRuntimeStates(map[string]ServiceRuntimeState{ + "database": { + BackupState: "enabled", ServiceImage: pinnedServiceImage("postgres", 'a'), + PublicationVerified: true, DigestAvailable: true, + }, + }) + if err != nil { + t.Fatal(err) + } + _, err = bound.RenderServices("production") + var projectError *Error + if !errors.As(err, &projectError) || projectError.Code != "service_patch_unsupported" { + t.Fatalf("protected legacy image error = %v", err) + } +} + +func TestPostgresExtensionsDerivePreloadAndCronSettings(t *testing.T) { + rendered := renderServices(t, `api_version: onebox.run/v1 +app: goal +environments: {production: {server: root@host}} +workloads: + web: {role: application, image: goal:1} +services: + database: + driver: postgres + version: 18 + settings: + shared_preload_libraries: auto_explain + features: + extensions: + pg_cron: {} + pgaudit: {} + pg_stat_statements: {} +`) + doc := string(rendered["database"]) + for _, setting := range []string{ + "shared_preload_libraries=auto_explain,pg_cron,pg_stat_statements,pgaudit", + "cron.database_name=goal", + "cron.use_background_workers=on", + } { + if !strings.Contains(doc, setting) { + t.Errorf("derived setting %q is absent:\n%s", setting, doc) + } + } + if strings.Count(doc, "shared_preload_libraries=") != 1 { + t.Fatalf("preload setting was rendered more than once:\n%s", doc) + } +} + +func TestServiceExtensionsArePostgresOnly(t *testing.T) { + _, err := LoadBytes([]byte(`api_version: onebox.run/v1 +app: sample +environments: {production: {server: root@host}} +workloads: + web: {role: application, image: sample:1} +services: + cache: + driver: redis + version: 8 + features: {extensions: {vector: {}}} +`), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "supported only by the postgres driver") { + t.Fatalf("non-postgres features error = %v", err) + } +} + +func TestPostgresExtensionsRequireThePublishedImageVersion(t *testing.T) { + _, err := LoadBytes([]byte(`api_version: onebox.run/v1 +app: sample +environments: {production: {server: root@host}} +workloads: + web: {role: application, image: sample:1} +services: + postgres: + version: 17 + features: {extensions: {vector: {}}} +`), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "require version 18") { + t.Fatalf("unsupported PostgreSQL version error = %v", err) + } +} + +func TestServiceExtensionNamesAreSafeSQLIdentifiers(t *testing.T) { + _, err := LoadBytes([]byte(`api_version: onebox.run/v1 +app: sample +environments: {production: {server: root@host}} +workloads: + web: {role: application, image: sample:1} +services: + postgres: + version: 18 + features: + extensions: + vector;drop_table: {} +`), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "PostgreSQL extension") { + t.Fatalf("unsafe extension name error = %v", err) + } +} + +func TestPgCronSettingsCannotDisableTheManagedContract(t *testing.T) { + for _, settings := range []string{ + "cron.database_name: elsewhere", + "cron.use_background_workers: off", + } { + _, err := LoadBytes([]byte(`api_version: onebox.run/v1 +app: sample +environments: {production: {server: root@host}} +workloads: + web: {role: application, image: sample:1} +services: + postgres: + version: 18 + settings: {`+settings+`} + features: {extensions: {pg_cron: {}}} +`), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "pg_cron") { + t.Fatalf("settings %q error = %v", settings, err) + } + } +} diff --git a/internal/app/service_image_state.go b/internal/app/service_image_state.go index 3675bb91..6ac0af90 100644 --- a/internal/app/service_image_state.go +++ b/internal/app/service_image_state.go @@ -161,15 +161,7 @@ func (r *Resolved) DeclaredServiceImage(serviceName string) (string, error) { if !ok { return "", errf("project_invalid", "services."+serviceName, "ob validate", "service is not declared") } - driverName := service.Driver - if driverName == "" { - driverName = serviceName - } - driver, ok := drivers[driverName] - if !ok { - return "", errf("unknown_service_driver", "services."+serviceName, "ob validate", "no managed driver named %q", driverName) - } - return driver.image + ":" + versionString(service.Version), nil + return declaredServiceImage(serviceName, service) } // ServiceImageForRuntime exposes the same selection used by generation so @@ -179,15 +171,20 @@ func (r *Resolved) ServiceImageForRuntime(serviceName string) (ServiceImageSelec if !ok { return ServiceImageSelection{}, errf("project_invalid", "services."+serviceName, "ob validate", "service is not declared") } - driverName := service.Driver - if driverName == "" { - driverName = serviceName - } - driver, ok := drivers[driverName] - if !ok { - return ServiceImageSelection{}, errf("unknown_service_driver", "services."+serviceName, "ob validate", "no managed driver named %q", driverName) + image, err := declaredServiceImage(serviceName, service) + if err != nil { + return ServiceImageSelection{}, err + } + if len(r.ServiceExtensions(serviceName)) > 0 { + state, observed := r.serviceRuntime[serviceName] + protected := observed && state.BackupState != "never-enabled" && state.BackupState != "disabled" + if protected && state.RefreshCandidate == nil && !strings.HasPrefix(state.ServiceImage, oneboxPostgresImage+"@") { + return ServiceImageSelection{}, errf("service_patch_unsupported", "services."+serviceName+".features.extensions", + "ob backup disable "+serviceName+" --confirm "+serviceName, + "this protected PostgreSQL service was pinned before extension support; temporarily revert the extension declaration, disable backup, restore the declaration, deploy, then enable backup again") + } } - return r.selectServiceImage(serviceName, driver.image+":"+versionString(service.Version)) + return r.selectServiceImage(serviceName, image) } // serviceImageDigest matches a full sha256 reference, which is what "pinned" diff --git a/internal/app/services.go b/internal/app/services.go index b29f3f51..630f4813 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -242,6 +242,8 @@ var drivers = map[string]driver{ }, } +const oneboxPostgresImage = "ghcr.io/labstack/onebox-postgres" + // UpgradeInPlace reports whether a service's driver can start on a data // directory written by a previous major version. func (p *Spec) UpgradeInPlace(name string) bool { @@ -262,6 +264,28 @@ func (p *Spec) DeclaredVersion(name string) string { return versionString(s.Version) } +// ServiceExtensions returns the sorted extension requirements for a managed +// PostgreSQL service. Installation is an apply-time operation, not a rendering +// side effect, so the same Compose document remains deterministic. +func (p *Spec) ServiceExtensions(name string) []string { + s, ok := p.Services[name] + if !ok || s.Features == nil { + return nil + } + return postgresFeatureExtensions(s) +} + +// HasServiceExtensions reports whether deploy must converge a PostgreSQL +// capability before it can safely start application migrations. +func (p *Spec) HasServiceExtensions() bool { + for name := range p.Services { + if len(p.ServiceExtensions(name)) > 0 { + return true + } + } + return false +} + // MajorOf is the leading component of a version, which is the part that decides // whether a data directory can still be read. func MajorOf(version string) string { @@ -293,6 +317,22 @@ func driverOf(name string, s Service) (string, driver, bool) { return key, d, ok } +// declaredServiceImage is the authored tag reference for a managed service. +// PostgreSQL 18 is the first Onebox-owned distribution. Older declarations +// retain the official repository instead of being redirected to tags Onebox +// does not publish. +func declaredServiceImage(name string, s Service) (string, error) { + key, d, ok := driverOf(name, s) + if !ok { + return "", errf("unknown_service_driver", "services."+name, "ob validate", "no managed driver named %q", key) + } + repository := d.image + if key == "postgres" && versionString(s.Version) == "18" { + repository = oneboxPostgresImage + } + return repository + ":" + versionString(s.Version), nil +} + // HasHealth reports whether the service's driver can be waited for. A `needs` // on a service with no health check resolves to "started": claiming otherwise // would produce a runtime that never starts. @@ -318,7 +358,7 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri image := selectedImage if image == "" { - image = d.image + ":" + versionString(s.Version) + image, _ = declaredServiceImage(name, s) } if err := checkImageRef("services."+name+".version", image); err != nil { return nil, err @@ -357,7 +397,11 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri } command := append([]string(nil), d.command...) - if err := applySettings(name, d, s, s.Settings, env, &command); err != nil { + settings := s.Settings + if key == "postgres" { + settings = postgresFeatureSettings(p.Name, s) + } + if err := applySettings(name, d, s, settings, env, &command); err != nil { return nil, err } diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index 16cc0244..193ae1b4 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -402,7 +402,7 @@ { "case": "conformance/service scalar", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=51d530eb9102fbd1" + "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=16f6016ba043ba3b" }, { "case": "conformance/settings key that is a real driver flag", diff --git a/internal/app/types.go b/internal/app/types.go index 29ab3abf..1869f343 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -274,15 +274,25 @@ type JobSchedule struct { } type Service struct { - Driver string `json:"driver,omitempty" description:"Built-in service driver. Defaults to the service map key." example:"postgres"` - Version any `json:"version" description:"Driver version or image tag to run." example:"17"` - Volumes []string `json:"volumes,omitempty" description:"Additional driver-defined persistent volume names."` - Persistence *Persistence `json:"persistence,omitempty" description:"Data-lifetime declaration for this supporting service."` - Resources *Resources `json:"resources,omitempty" description:"Memory and CPU limits for this supporting service."` - Settings map[string]any `json:"settings,omitempty" description:"Driver-specific settings validated by the selected service driver."` - Backup *BackupPolicy `json:"backup,omitempty" description:"Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup."` + Driver string `json:"driver,omitempty" description:"Built-in service driver. Defaults to the service map key." example:"postgres"` + Version any `json:"version" description:"Driver version or image tag to run." example:"17"` + Features *ServiceFeatures `json:"features,omitempty" description:"Capabilities Onebox must establish before application workloads run."` + Volumes []string `json:"volumes,omitempty" description:"Additional driver-defined persistent volume names."` + Persistence *Persistence `json:"persistence,omitempty" description:"Data-lifetime declaration for this supporting service."` + Resources *Resources `json:"resources,omitempty" description:"Memory and CPU limits for this supporting service."` + Settings map[string]any `json:"settings,omitempty" description:"Driver-specific settings validated by the selected service driver."` + Backup *BackupPolicy `json:"backup,omitempty" description:"Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup."` } +type ServiceFeatures struct { + Extensions map[string]ServiceExtension `json:"extensions,omitempty" description:"PostgreSQL extensions Onebox installs in the managed application database before application migrations run."` +} + +// ServiceExtension is intentionally empty in v1. The selected PostgreSQL image +// owns component versions, keeping the authored path friction-free and the +// service runtime represented by the one image digest Onebox already protects. +type ServiceExtension struct{} + // BackupTarget is a closed S3-compatible destination declaration. It accepts // credential references only, never inline values. type BackupTarget struct { diff --git a/internal/app/validate.go b/internal/app/validate.go index 5ec12561..57a8f3f3 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "strings" "time" @@ -65,7 +66,7 @@ func validateSpec(p *Spec) error { if err := gIdent.check("services."+name, name); err != nil { return err } - if err := validateService(p.Services[name], "services."+name); err != nil { + if err := validateService(p.Name, name, p.Services[name], "services."+name); err != nil { return err } } @@ -495,7 +496,7 @@ func validateHealth(h *Health, path string) error { return nil } -func validateService(s Service, path string) error { +func validateService(appName, name string, s Service, path string) error { if err := gIdent.checkOptional(path+".driver", s.Driver); err != nil { return err } @@ -503,6 +504,36 @@ func validateService(s Service, path string) error { return errf("project_invalid", path+".version", "", "a service must declare its version; an unpinned database is a future surprise") } + if s.Features != nil { + driver, _, known := driverOf(name, s) + if !known || driver != "postgres" { + return errf("project_invalid", path+".features", "", + "service features are supported only by the postgres driver") + } + if len(s.Features.Extensions) > 0 && versionString(s.Version) != "18" { + return errf("project_invalid", path+".features.extensions", "", + "PostgreSQL extensions require version 18, the version published by the Onebox PostgreSQL image") + } + for _, extension := range sortedKeys(s.Features.Extensions) { + if err := gExtension.check(path+".features.extensions."+extension, extension); err != nil { + return err + } + } + if _, requested := s.Features.Extensions["pg_cron"]; requested { + if database, authored := s.Settings["cron.database_name"]; authored && fmt.Sprint(database) != appName { + return errf("project_invalid", path+".settings.cron.database_name", "", + "pg_cron must use the managed application database %q", appName) + } + if workers, authored := s.Settings["cron.use_background_workers"]; authored { + switch strings.ToLower(fmt.Sprint(workers)) { + case "1", "on", "true", "yes": + default: + return errf("project_invalid", path+".settings.cron.use_background_workers", "", + "pg_cron requires background workers in a Onebox-managed database") + } + } + } + } for i, v := range s.Volumes { if err := gIdent.check(indexed(path+".volumes", i), v); err != nil { return err diff --git a/internal/engine/backup_image_test.go b/internal/engine/backup_image_test.go index 7f6b583b..742879f2 100644 --- a/internal/engine/backup_image_test.go +++ b/internal/engine/backup_image_test.go @@ -46,7 +46,7 @@ func TestOnlyDigestPinnedReferencesSkipTheRegistry(t *testing.T) { // up the same way in a live run: `ob backup enable` on an already-enabled // service failed on a Docker Hub 429 while the host held the pinned image. func TestReEnableKeepsTheRecordedPinAndDoesNotReachTheRegistry(t *testing.T) { - const pin = "postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941" + const pin = "ghcr.io/labstack/onebox-postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941" fake := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { if strings.Contains(cmd, "docker image inspect") && strings.Contains(cmd, pin) { return transport.Result{Stdout: "present\n"}, true @@ -57,7 +57,7 @@ func TestReEnableKeepsTheRecordedPinAndDoesNotReachTheRegistry(t *testing.T) { // Recorded as the authored reference, which is what an enable after a // disable still declares even though the runtime selection has reverted. - got, err := e.ResolveProtectedImage(context.Background(), "database", pin, "postgres:18") + got, err := e.ResolveProtectedImage(context.Background(), "database", pin, "ghcr.io/labstack/onebox-postgres:18") if err != nil { t.Fatalf("resolving an already-bound image: %v", err) } @@ -75,20 +75,20 @@ func TestReEnableKeepsTheRecordedPinAndDoesNotReachTheRegistry(t *testing.T) { // produced it. Changing the declared version is how an operator asks for // different bytes, and that has to reach the registry. func TestADeclaredVersionChangeStillResolvesThroughTheRegistry(t *testing.T) { - const stalePin = "postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941" + const stalePin = "ghcr.io/labstack/onebox-postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941" fake := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { switch { case strings.Contains(cmd, "docker pull"): return transport.Result{}, true case strings.Contains(cmd, "RepoDigests"): - return transport.Result{Stdout: "postgres@sha256:" + strings.Repeat("b", 64) + "\n"}, true + return transport.Result{Stdout: "ghcr.io/labstack/onebox-postgres@sha256:" + strings.Repeat("b", 64) + "\n"}, true } return transport.Result{Stdout: "absent\n"}, true }} e := protectedImageTestEngine(fake) // Recorded against postgres:17; the project now declares 18. - got, err := e.ResolveProtectedImage(context.Background(), "database", stalePin, "postgres:17") + got, err := e.ResolveProtectedImage(context.Background(), "database", stalePin, "ghcr.io/labstack/onebox-postgres:17") if err != nil { t.Fatalf("resolving after a declared version change: %v", err) } diff --git a/internal/engine/backup_recovery_config_test.go b/internal/engine/backup_recovery_config_test.go index 8aa201fc..b79770c8 100644 --- a/internal/engine/backup_recovery_config_test.go +++ b/internal/engine/backup_recovery_config_test.go @@ -31,7 +31,7 @@ func TestRecoveryClearsAnyTargetTheBaseBackupCarried(t *testing.T) { fake := &transport.Fake{} e := recoveryConfigTestEngine(fake) - if err := e.replayRecovery(context.Background(), "shop-database-restore-1", ""); err != nil { + if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "database", ""); err != nil { t.Fatalf("replaying to the newest recoverable point: %v", err) } @@ -66,7 +66,7 @@ func TestAStatedRecoveryTargetSurvivesTheClearing(t *testing.T) { fake := &transport.Fake{} e := recoveryConfigTestEngine(fake) - if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "2026-08-20T13:58:00Z"); err != nil { + if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "database", "2026-08-20T13:58:00Z"); err != nil { t.Fatalf("replaying to a point in time: %v", err) } @@ -86,6 +86,31 @@ func TestAStatedRecoveryTargetSurvivesTheClearing(t *testing.T) { } } +func TestRecoveryStartsWithDeclaredExtensionPreloads(t *testing.T) { + fake := &transport.Fake{} + e := recoveryConfigTestEngine(fake) + service := e.Spec.Services["database"] + service.Features = &app.ServiceFeatures{Extensions: map[string]app.ServiceExtension{ + "pg_cron": {}, + "pg_stat_statements": {}, + }} + e.Spec.Services["database"] = service + + if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "database", ""); err != nil { + t.Fatal(err) + } + commands := strings.Join(fake.Commands, "\n") + for _, setting := range []string{ + "shared_preload_libraries=pg_cron,pg_stat_statements", + "cron.database_name=shop", + "cron.use_background_workers=on", + } { + if !strings.Contains(commands, setting) { + t.Errorf("recovery server command is missing %q:\n%s", setting, commands) + } + } +} + // The cluster that goes into service must not keep the settings that recovered // it: every base backup taken from it would carry them into the next recovery. func TestPromotionRemovesTheRecoveryConfiguration(t *testing.T) { diff --git a/internal/engine/backup_restore.go b/internal/engine/backup_restore.go index 3d7b0e9c..be56c083 100644 --- a/internal/engine/backup_restore.go +++ b/internal/engine/backup_restore.go @@ -140,7 +140,7 @@ func (e *Engine) RecoverService(ctx context.Context, service, targetTime string, st(nil) st = e.ui.Step("recovery: replay to "+recoveryTargetLabel(targetTime), false) - if err := e.replayRecovery(ctx, container, targetTime); err != nil { + if err := e.replayRecovery(ctx, container, service, targetTime); err != nil { st(err) return outcome, err } @@ -150,7 +150,7 @@ func (e *Engine) RecoverService(ctx context.Context, service, targetTime string, // the data, and "the restore command exited zero" is exactly the assurance // this product exists to distrust. st = e.ui.Step("recovery: verify the recovered cluster answers", false) - recoveredTo, rows, err := e.probeRecoveredCluster(ctx, container, targetTime) + recoveredTo, rows, err := e.probeRecoveredCluster(ctx, container, service, targetTime) if err != nil { st(err) return outcome, err @@ -399,7 +399,7 @@ func parseWalgBackupList(out string) ([]walgBackupEntry, error) { // recovery_target_time is omitted entirely when no target was asked for, which // means "replay everything available" — the newest recoverable point rather // than an arbitrary one. -func (e *Engine) replayRecovery(ctx context.Context, container, targetTime string) error { +func (e *Engine) replayRecovery(ctx context.Context, container, service, targetTime string) error { settings := []string{ "restore_command = '" + app.WalgBinary + " wal-fetch %f %p'", "recovery_target_action = 'promote'", @@ -448,7 +448,16 @@ func (e *Engine) replayRecovery(ctx context.Context, container, targetTime strin return fmt.Errorf("cannot write the recovery configuration: %s", lastLines(res.Stderr, 3)) } start := "docker exec -u postgres " + q(container) + - " pg_ctl -D " + q(app.PgDataPath) + " -l /tmp/ob-recovery.log -w -t 300 start" + " pg_ctl -D " + q(app.PgDataPath) + " -l /tmp/ob-recovery.log -w -t 300" + serviceSettings := e.Spec.PostgresServiceSettings(service) + if len(serviceSettings) > 0 { + var postgresOptions []string + for _, key := range sortedEnvKeys(serviceSettings) { + postgresOptions = append(postgresOptions, "-c", fmt.Sprintf("%s=%v", key, serviceSettings[key])) + } + start += " -o " + q(shellQuoteAll(postgresOptions)) + } + start += " start" res, err = e.T.Run(ctx, start) if err != nil { return err @@ -465,7 +474,7 @@ func (e *Engine) replayRecovery(ctx context.Context, container, targetTime strin // // This is the difference between "the restore command exited zero" and "the // data is there", which is the whole distinction this product exists to make. -func (e *Engine) probeRecoveredCluster(ctx context.Context, container, targetTime string) (string, string, error) { +func (e *Engine) probeRecoveredCluster(ctx context.Context, container, service, targetTime string) (string, string, error) { query := func(sql string) (string, error) { command := "docker exec -u postgres " + q(container) + " psql -U " + q(app.PgSuperuser) + " -d " + q(e.Spec.Spec.Name) + " -tAc " + q(sql) @@ -503,6 +512,15 @@ func (e *Engine) probeRecoveredCluster(ctx context.Context, container, targetTim "the recovered cluster is still replaying after %s, so it never reached the requested point; "+ "the WAL needed to reach it may not be archived", recoveryPromotionBudget) } + for _, extension := range e.Spec.ServiceExtensions(service) { + installed, err := query("SELECT extversion FROM pg_extension WHERE extname = '" + strings.ReplaceAll(extension, "'", "''") + "';") + if err != nil { + return "", "", err + } + if installed == "" { + return "", "", fmt.Errorf("the recovered cluster is missing declared extension %s", extension) + } + } tables, err := query("SELECT count(*) FROM information_schema.tables WHERE table_schema='public';") if err != nil { return "", "", err diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index 3f950b13..7274aa4e 100644 --- a/internal/engine/deploy.go +++ b/internal/engine/deploy.go @@ -306,6 +306,11 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l // Before any job runs: a job can need a database as readily as an // application can, and both read a file that only exists once it is // written. + if e.Spec.HasServiceExtensions() { + if err := e.ApplyExtensionServices(ctx); err != nil { + return fmt.Errorf("service extensions: %w", err) + } + } if err := e.EnsureServiceConnections(ctx); err != nil { return fmt.Errorf("service connections: %w", err) } diff --git a/internal/engine/service_extensions_test.go b/internal/engine/service_extensions_test.go new file mode 100644 index 00000000..681c8eaf --- /dev/null +++ b/internal/engine/service_extensions_test.go @@ -0,0 +1,179 @@ +package engine + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/labstack/onebox/internal/app" + "github.com/labstack/onebox/internal/transport" +) + +func extensionConfig() *app.Resolved { + cfg := testConfig() + service := cfg.Services["postgres"] + service.Features = &app.ServiceFeatures{Extensions: map[string]app.ServiceExtension{ + "pg_trgm": {}, + "vector": {}, + }} + cfg.Services["postgres"] = service + return cfg +} + +func TestApplyInstallsDeclaredExtensionsBeforeSuccess(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + switch { + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "pg_trgm"): + return transport.Result{Stdout: "1.6\n"}, true + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "vector"): + return transport.Result{Stdout: "0.8.6\n"}, true + case strings.Contains(cmd, "pg_extension"): + return transport.Result{}, true + } + return base(cmd) + } + e := New(extensionConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + if err := e.ApplyServices(context.Background()); err != nil { + t.Fatal(err) + } + commands := strings.Join(f.Commands, "\n") + for _, extension := range []string{"pg_trgm", "vector"} { + if !strings.Contains(commands, `CREATE EXTENSION "`+extension+`"`) { + t.Errorf("extension %s was not installed:\n%s", extension, commands) + } + } +} + +func TestApplyInstallsVectorscaleAfterItsImpliedVectorDependency(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + switch { + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "vectorscale"): + return transport.Result{Stdout: "0.9.0\n"}, true + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "vector"): + return transport.Result{Stdout: "0.8.6\n"}, true + case strings.Contains(cmd, "pg_extension"): + return transport.Result{}, true + } + return base(cmd) + } + cfg := testConfig() + service := cfg.Services["postgres"] + service.Features = &app.ServiceFeatures{Extensions: map[string]app.ServiceExtension{ + "vectorscale": {}, + }} + cfg.Services["postgres"] = service + e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + if err := e.ApplyServices(context.Background()); err != nil { + t.Fatal(err) + } + commands := strings.Join(f.Commands, "\n") + vector := strings.Index(commands, `CREATE EXTENSION "vector"`) + vectorscale := strings.Index(commands, `CREATE EXTENSION "vectorscale"`) + if vector < 0 || vectorscale < 0 || vector > vectorscale { + t.Fatalf("vectorscale dependency order is wrong:\n%s", commands) + } +} + +func TestApplyRefusesAnUnavailableExtension(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, "pg_available_extensions") { + return transport.Result{}, true + } + return base(cmd) + } + e := New(extensionConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + err := e.ApplyServices(context.Background()) + if err == nil || !strings.Contains(err.Error(), "is not available in the selected PostgreSQL image") { + t.Fatalf("unavailable extension error = %v", err) + } +} + +func TestApplyRefusesAnInstalledExtensionVersionMismatch(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + switch { + case strings.Contains(cmd, "docker inspect --format '{{.State.Running}}'"): + return transport.Result{Stdout: "true\n"}, true + case strings.Contains(cmd, "docker run --rm --entrypoint sh") && strings.Contains(cmd, "vector.control"): + return transport.Result{Stdout: "0.8.6\n"}, true + case strings.Contains(cmd, "docker run --rm --entrypoint sh") && strings.Contains(cmd, "pg_trgm.control"): + return transport.Result{Stdout: "1.6\n"}, true + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "vector"): + return transport.Result{Stdout: "0.8.6\n"}, true + case strings.Contains(cmd, "pg_extension") && strings.Contains(cmd, "vector"): + return transport.Result{Stdout: "0.7.4\n"}, true + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "pg_trgm"): + return transport.Result{Stdout: "1.6\n"}, true + case strings.Contains(cmd, "pg_extension"): + return transport.Result{}, true + } + return base(cmd) + } + e := New(extensionConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + err := e.ApplyServices(context.Background()) + if err == nil || !strings.Contains(err.Error(), "refusing restart before an explicit extension upgrade") { + t.Fatalf("version mismatch error = %v", err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "docker compose") { + t.Fatal("service was mutated before candidate extension compatibility was refused") + } +} + +func TestApplyRefusesToUnloadAnInstalledPreloadExtension(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + switch { + case strings.Contains(cmd, "docker inspect --format '{{.State.Running}}'"): + return transport.Result{Stdout: "true\n"}, true + case strings.Contains(cmd, "pg_extension") && strings.Contains(cmd, "pgaudit"): + return transport.Result{Stdout: "18.0\n"}, true + case strings.Contains(cmd, "pg_extension"): + return transport.Result{}, true + } + return base(cmd) + } + e := New(extensionConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + err := e.ApplyServices(context.Background()) + if err == nil || !strings.Contains(err.Error(), "installed extension pgaudit requires preload") { + t.Fatalf("preload removal error = %v", err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "docker compose") { + t.Fatal("service was mutated before preload removal was refused") + } +} + +func TestDeployReconcilesExtensionsBeforeMigrationJobs(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + switch { + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "pg_trgm"): + return transport.Result{Stdout: "1.6\n"}, true + case strings.Contains(cmd, "pg_available_extensions") && strings.Contains(cmd, "vector"): + return transport.Result{Stdout: "0.8.6\n"}, true + case strings.Contains(cmd, "pg_extension"): + return transport.Result{}, true + } + return base(cmd) + } + seedStagedApplicationManifest(f, engineTestDeployReleaseID) + e := New(extensionConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + if err := e.Deploy(context.Background(), engineTestDeployReleaseID, t.TempDir()); err != nil { + t.Fatal(err) + } + commands := strings.Join(f.Commands, "\n") + created := strings.Index(commands, `CREATE EXTENSION "vector"`) + migration := strings.Index(commands, "run --rm --no-deps") + if created < 0 || migration < 0 || created > migration { + t.Fatalf("extension was not reconciled before migration:\n%s", commands) + } +} diff --git a/internal/engine/services.go b/internal/engine/services.go index a66eee9e..316fc80a 100644 --- a/internal/engine/services.go +++ b/internal/engine/services.go @@ -67,7 +67,23 @@ func (e *Engine) EnsureServiceConnections(ctx context.Context) error { // ApplyServices converges every declared service and the connections to it. func (e *Engine) ApplyServices(ctx context.Context) error { - names := e.Spec.ServiceNames() + return e.applyServices(ctx, e.Spec.ServiceNames(), true) +} + +// ApplyExtensionServices converges only services whose declared capabilities +// must exist before an application migration runs. Ordinary deploys keep their +// historical boundary for every other supporting service. +func (e *Engine) ApplyExtensionServices(ctx context.Context) error { + var names []string + for _, name := range e.Spec.ServiceNames() { + if len(e.Spec.ServiceExtensions(name)) > 0 { + names = append(names, name) + } + } + return e.applyServices(ctx, names, false) +} + +func (e *Engine) applyServices(ctx context.Context, names []string, syncSchedules bool) error { if len(names) == 0 { return nil } @@ -81,6 +97,15 @@ func (e *Engine) ApplyServices(ctx context.Context) error { if err := e.MigrateBackupCredentialFiles(ctx); err != nil { return fmt.Errorf("backup credentials: %w", err) } + // This runs before rendering or Compose mutation. Removing the declaration + // does not DROP an extension, so unloading a library it still needs would be + // a silent behavioral change and, for some extensions, a startup failure. + if err := e.refuseInstalledPreloadRemoval(ctx, names); err != nil { + return err + } + if err := e.verifyRunningExtensionCompatibility(ctx, names); err != nil { + return err + } // Rendered here rather than handed in. A caller that forgot would produce // a host missing a database, and the engine already holds everything the // documents are derived from. @@ -142,6 +167,9 @@ func (e *Engine) ApplyServices(ctx context.Context) error { return fmt.Errorf("service %s did not become healthy within %s (last: %s)", name, serviceHealthBudget, last) } + if err := e.reconcileServiceExtensions(ctx, name); err != nil { + return fmt.Errorf("service %s: %w", name, err) + } // Recorded only after health, because the fact worth keeping is which // version successfully opened the data directory — not which image // was last started. @@ -152,12 +180,162 @@ func (e *Engine) ApplyServices(ctx context.Context) error { } // After the services are up, because a timer that fires against a container // that is not running yet is a failed backup in the journal for no reason. - if err := e.SyncBackupSchedules(ctx); err != nil { - return fmt.Errorf("cannot converge the backup schedules: %w", err) + if syncSchedules { + if err := e.SyncBackupSchedules(ctx); err != nil { + return fmt.Errorf("cannot converge the backup schedules: %w", err) + } + } + return nil +} + +func (e *Engine) refuseInstalledPreloadRemoval(ctx context.Context, services []string) error { + for _, service := range services { + client, ok := e.Spec.ClientEnvFor(service) + if !ok || client.Driver != "postgres" { + continue + } + container := e.Spec.NamesFor(e.Opts.Environment).ServiceContainer(service) + running, err := e.T.Run(ctx, "docker inspect --format '{{.State.Running}}' "+q(container)) + if err != nil { + return err + } + if running.ExitCode != 0 || strings.TrimSpace(running.Stdout) != "true" { + continue + } + declared := map[string]bool{} + for _, extension := range e.Spec.ServiceExtensions(service) { + declared[extension] = true + } + for _, extension := range app.PostgresPreloadExtensions() { + if declared[extension] { + continue + } + installed, err := e.postgresServiceScalar(ctx, container, client, + "SELECT extversion FROM pg_extension WHERE extname = '"+extension+"'") + if err != nil { + return fmt.Errorf("service %s: cannot inspect installed extension %s before restart: %w", service, extension, err) + } + if installed != "" { + return fmt.Errorf("service %s: installed extension %s requires preload; keep its feature declaration or explicitly DROP EXTENSION before service apply", service, extension) + } + } + } + return nil +} + +// verifyRunningExtensionCompatibility compares the catalogue version already +// installed in a live database with the exact files in the image Compose would +// select. It runs before the Compose document is written or the server is +// restarted, so a moved channel tag cannot activate a new shared library over +// an older SQL extension and only then report the mismatch. +func (e *Engine) verifyRunningExtensionCompatibility(ctx context.Context, services []string) error { + for _, service := range services { + client, ok := e.Spec.ClientEnvFor(service) + if !ok || client.Driver != "postgres" { + continue + } + container := e.Spec.NamesFor(e.Opts.Environment).ServiceContainer(service) + running, err := e.T.Run(ctx, "docker inspect --format '{{.State.Running}}' "+q(container)) + if err != nil { + return err + } + if running.ExitCode != 0 || strings.TrimSpace(running.Stdout) != "true" { + continue + } + selection, err := e.Spec.ServiceImageForRuntime(service) + if err != nil { + return err + } + for _, extension := range e.Spec.ServiceExtensions(service) { + control := "/usr/share/postgresql/18/extension/" + extension + ".control" + script := `awk -F "'" '/^[[:space:]]*default_version[[:space:]]*=/ {print $2; exit}' ` + control + result, err := e.mutate(ctx, "docker run --rm --entrypoint sh "+q(selection.Image)+" -c "+q(script)) + if err != nil { + return fmt.Errorf("service %s: cannot inspect extension %s in candidate image: %w", service, extension, err) + } + available := strings.TrimSpace(result.Stdout) + if result.ExitCode != 0 || available == "" { + return fmt.Errorf("service %s: extension %s is not available in candidate image %s", service, extension, selection.Image) + } + installed, err := e.postgresServiceScalar(ctx, container, client, + "SELECT extversion FROM pg_extension WHERE extname = '"+strings.ReplaceAll(extension, "'", "''")+"'") + if err != nil { + return fmt.Errorf("service %s: cannot inspect installed extension %s before restart: %w", service, extension, err) + } + if installed != "" && installed != available { + return fmt.Errorf("service %s: extension %s is installed at version %s but candidate image %s provides %s; refusing restart before an explicit extension upgrade", service, extension, installed, selection.Image, available) + } + } } return nil } +// reconcileServiceExtensions establishes the extension contract after the +// server is healthy and before application migrations can run. The managed +// image supplies extension files; PostgreSQL installation is database-local, +// so Onebox creates only a missing declaration in the one application database +// it owns. Existing extensions are left at their installed version: upgrades +// and removal are deliberate lifecycle operations, never an apply side effect. +func (e *Engine) reconcileServiceExtensions(ctx context.Context, service string) error { + extensions := e.Spec.ServiceExtensions(service) + if len(extensions) == 0 { + return nil + } + client, ok := e.Spec.ClientEnvFor(service) + if !ok || client.Driver != "postgres" { + return fmt.Errorf("extensions require a managed postgres service") + } + container := e.Spec.NamesFor(e.Opts.Environment).ServiceContainer(service) + for _, extension := range extensions { + literal := strings.ReplaceAll(extension, "'", "''") + available, err := e.postgresServiceScalar(ctx, container, client, + "SELECT default_version FROM pg_available_extensions WHERE name = '"+literal+"'") + if err != nil { + return fmt.Errorf("cannot inspect extension %s: %w", extension, err) + } + if available == "" { + return fmt.Errorf("extension %s is not available in the selected PostgreSQL image", extension) + } + installed, err := e.postgresServiceScalar(ctx, container, client, + "SELECT extversion FROM pg_extension WHERE extname = '"+literal+"'") + if err != nil { + return fmt.Errorf("cannot inspect extension %s: %w", extension, err) + } + if installed != "" && installed != available { + return fmt.Errorf("extension %s is installed at version %s but the selected image provides %s; automatic extension upgrades are refused", extension, installed, available) + } + if installed != "" { + continue + } + sql := `CREATE EXTENSION "` + strings.ReplaceAll(extension, `"`, `""`) + `"` + cmd := postgresServiceCommand(container, client, sql) + result, err := e.mutate(ctx, cmd) + if err != nil { + return fmt.Errorf("cannot install extension %s: %w", extension, err) + } + if result.ExitCode != 0 { + return fmt.Errorf("cannot install extension %s: %s", extension, strings.TrimSpace(result.Stderr)) + } + } + return nil +} + +func (e *Engine) postgresServiceScalar(ctx context.Context, container string, client app.ClientEnv, sql string) (string, error) { + result, err := e.T.Run(ctx, postgresServiceCommand(container, client, sql)) + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", fmt.Errorf("%s", strings.TrimSpace(result.Stderr)) + } + return strings.TrimSpace(result.Stdout), nil +} + +func postgresServiceCommand(container string, client app.ClientEnv, sql string) string { + return "docker exec " + q(container) + " psql --no-psqlrc --set ON_ERROR_STOP=1 --tuples-only --no-align" + + " --username " + q(client.User) + " --dbname " + q(client.Database) + " --command " + q(sql) +} + // StageServiceCompose writes one service's current rendered runtime without // starting it. Recovery uses this after proving a historical generation and // before swapping volumes, so the recovered cluster can never start with the diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index 0add9b14..b9e39c54 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -1586,6 +1586,31 @@ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", "type": "string" }, + "features": { + "additionalProperties": false, + "description": "Capabilities Onebox must establish before application workloads run.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": {}, + "type": "object" + }, + "description": "PostgreSQL extensions Onebox installs in the managed application database before application migrations run.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "type": "object" + } + }, + "type": "object" + }, "persistence": { "additionalProperties": false, "description": "Data-lifetime declaration for this supporting service.", diff --git a/site/src/content/docs/guides/add-a-database.mdx b/site/src/content/docs/guides/add-a-database.mdx index 9587e62c..787f27c2 100644 --- a/site/src/content/docs/guides/add-a-database.mdx +++ b/site/src/content/docs/guides/add-a-database.mdx @@ -53,6 +53,65 @@ services: cache: {driver: redis, version: "7.4"} ``` +## Install PostgreSQL extensions + +PostgreSQL 18 uses `ghcr.io/labstack/onebox-postgres:18`. No optional extension +is installed by default. Declare the extensions the application requires and +Onebox installs them in its managed database before application migrations run: + +```yaml +services: + database: + driver: postgres + version: 18 + features: + extensions: + pg_trgm: {} + vector: {} +``` + +The application does not need a separate `CREATE EXTENSION` migration. Onebox +creates only missing extensions; it never upgrades or removes an installed +extension during an ordinary apply. An extension absent from the selected image +is reported before application migrations start. A deploy automatically +converges a database with declared extensions before running migration jobs. + +The PostgreSQL 18 image includes the common built-in extensions plus a curated +2026 set: pgvector, pgvectorscale, PostGIS, pg_cron, pgAudit, pg_repack, +pg_partman, and HypoPG. Onebox automatically configures required preload +libraries for pg_cron, pgAudit, and `pg_stat_statements`. Optional PostGIS +extensions such as `postgis_topology` are available through the same declaration. + +Use the PostgreSQL extension name in the declaration. For example, the pgvector +project provides `vector`, while pgvectorscale provides `vectorscale`. Declaring +`vectorscale` automatically establishes its `vector` dependency: + +```yaml +features: + extensions: + vectorscale: {} +``` + +TimescaleDB is not bundled in this first distribution. Its Apache-only package +currently creates a maintenance job that repeatedly fails, while its Community +package has a license unsuitable for a general customer database image. Use a +dedicated TimescaleDB service until the upstream Apache package is clean. + +An existing backup-protected PostgreSQL 18 service may still be pinned to the +older official image. Migrate it deliberately: disable backup while the old +project file is still active, add the extension declaration and deploy, then +enable backup again: + +```console +ob backup disable database --confirm database +# add features.extensions to onebox.yml +ob deploy +ob backup enable database +``` + +The off-host backup repository is retained throughout this one-time image +transition. + Eleven drivers are supported: `postgres`, `mysql`, `mariadb`, `redis`, `valkey`, `mongodb`, `rabbitmq`, `minio`, `meilisearch`, `clickhouse`, `nats`. Anything else is refused with `unknown_service_driver` — guessing an image from a name diff --git a/site/src/content/docs/reference/fields/services.mdx b/site/src/content/docs/reference/fields/services.mdx index 15734d47..7f262662 100644 --- a/site/src/content/docs/reference/fields/services.mdx +++ b/site/src/content/docs/reference/fields/services.mdx @@ -18,7 +18,7 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`allow_downtime` · `backup` · `cpus` · `cron` · `drill` · `driver` · `keep` · `max_age` · `max_data_loss` · `memory` · `mode` · `persistence` · `recovery_kind` · `resources` · `retention` · `schedule` · `settings` · `target` · `timezone` · `version` · `volumes` · `window` +`allow_downtime` · `backup` · `cpus` · `cron` · `drill` · `driver` · `extensions` · `features` · `keep` · `max_age` · `max_data_loss` · `memory` · `mode` · `persistence` · `recovery_kind` · `resources` · `retention` · `schedule` · `settings` · `target` · `timezone` · `version` · `volumes` · `window` ## Reference @@ -41,6 +41,8 @@ cannot drift from what `ob validate` accepts. | `.backup.schedule.timezone` | string | `UTC` | IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin. | | `.backup.target` | string | — | Name of a project-level backup target. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters. | | `.driver` | string | — | Built-in service driver. Defaults to the service map key. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters. | +| `.features` | object | — | Capabilities Onebox must establish before application workloads run. | +| `.features.extensions` | map | — | PostgreSQL extensions Onebox installs in the managed application database before application migrations run. | | `.persistence` | object | — | Data-lifetime declaration for this supporting service. | | `.persistence.mode` | `durable` · `ephemeral` · `external` | `durable` | Data lifetime: durable, ephemeral, or external. | | `.resources` | object | — | Memory and CPU limits for this supporting service. |