From 99479003896d71d126e1c552b8aa3a8383ee3a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Wed, 16 Sep 2026 23:41:42 +0200 Subject: [PATCH] fix: [outline] align database ownership before migrations The ``pre_deploy`` hook reassigns every object of the outline database (tables, sequences, views, materialized views, standalone types, functions, procedures) to the application role before the container runs its migrations. Historical provisioning or restores running as the ``postgres`` superuser leave objects owned by ``postgres``, so any later ``ALTER`` on them fails with "must be owner of ..." and puts outline in a crash-loop at migration time. Seen on elabore.coop when upgrading 1.6.1 -> 1.10.0: migration ``20260714000000-add-mcp-to-search-queries-source.js`` failed on ``enum_search_queries_source``. Extensions are excluded from the realignment (they are managed by the ``postgres`` charm). The hook is idempotent, silent when there is no drift, and blocks the deployment (``exit 1``) if any drift remains, so the problem surfaces at deploy time instead of as a cryptic crash-loop. --- outline/README.org | 22 +++ outline/hooks/pre_deploy | 150 ++++++++++++++++++ outline/lib/common | 65 ++++++++ .../tests/compose/basic-deploy/compose.yml | 13 ++ 4 files changed, 250 insertions(+) create mode 100755 outline/hooks/pre_deploy create mode 100644 outline/lib/common create mode 100644 outline/tests/compose/basic-deploy/compose.yml diff --git a/outline/README.org b/outline/README.org index 5b43509..9f2a0c5 100644 --- a/outline/README.org +++ b/outline/README.org @@ -42,6 +42,28 @@ outline: We monkey-patch odoo in order to make it work, be sure to use latest version in 14.0 of galicea openIDConnection module +* Database ownership alignment + +The =pre_deploy= hook ensures that every object of the database +(tables, sequences, views, materialized views, standalone types, +functions, procedures) is owned by the application role before the +container starts and runs its migrations. + +Historical provisioning or restores executed as the =postgres= +superuser leave objects owned by =postgres=, which makes any later +=ALTER= on these objects fail with "must be owner of ..." and puts +outline in a crash-loop at migration time. This was seen on +2026-09-11 when upgrading elabore.coop from 1.6.1 to 1.10.0: +migration =20260714000000-add-mcp-to-search-queries-source.js= +failed on =enum_search_queries_source=. The same drift was found +on every managed server (lokavaluto.fr, lagemme.org, moneko.org). + +Extensions are excluded from the realignment (they are managed by +the =postgres= charm). The hook is idempotent and silent when +there is no drift, and blocks the deployment (=exit 1=) if the +realignment fails, so the problem is visible at deploy time instead +of as a cryptic crash-loop. + * Building a new image We use the official image with an added patch due to 2 bugs: diff --git a/outline/hooks/pre_deploy b/outline/hooks/pre_deploy new file mode 100755 index 0000000..14dc981 --- /dev/null +++ b/outline/hooks/pre_deploy @@ -0,0 +1,150 @@ +#!/bin/bash + +## Should be executable N time in a row with same result. +## +## Ensure the outline application role owns every object of its +## database before the container boots and runs its migrations. +## +## Historical provisioning or restores executed as the "postgres" +## superuser leave objects owned by "postgres", which makes any +## later ALTER on these objects fail with "must be owner of ..." +## and puts outline in a crash-loop at migration time. See +## README.org, section "Database ownership alignment". + +. lib/common + +set -e + +relation="postgres-database" + +db_role=$(outline:named-relation-get "$relation" user) || { + err "Couldn't get ${WHITE}user${NORMAL} value" \ + "in ${DARKCYAN}$relation${NORMAL} relation's data." + exit 1 +} + +## List every object of schema "public" not owned by the application +## role: tables, sequences, views, materialized views, standalone +## types and functions. Extensions are excluded (managed by the +## postgres charm). +audit_query="SET app.role = '$db_role'; +SELECT obj FROM ( + SELECT CASE c.relkind + WHEN 'S' THEN 'sequence ' + WHEN 'v' THEN 'view ' + WHEN 'm' THEN 'matview ' + ELSE 'table ' + END || c.relname AS obj + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r','p','S','v','m') + AND pg_get_userbyid(c.relowner) <> current_setting('app.role') + UNION ALL + SELECT 'type ' || t.typname AS obj + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' + AND t.typtype IN ('e','d','c') + AND NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.reltype = t.oid) + AND pg_get_userbyid(t.typowner) <> current_setting('app.role') + UNION ALL + SELECT CASE p.prokind + WHEN 'p' THEN 'procedure ' + ELSE 'function ' + END || p.proname AS obj + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind IN ('f','p') + AND pg_get_userbyid(p.proowner) <> current_setting('app.role') +) drift +ORDER BY obj;" + +drift=$(sql < <(e "$audit_query")) || { + err "Failed to audit database ownership for ${WHITE}$db_role${NORMAL}." + exit 1 +} + +if [ -z "$drift" ]; then + ## fast-path: nothing to do + exit 0 +fi + +info "Found database objects not owned by '${db_role}', reassigning ownership:" +e "$drift" | prefix " ${GRAY}|${NORMAL} " >&2 + +dbname=$(outline:named-relation-get "$relation" dbname) || { + err "Couldn't get ${WHITE}dbname${NORMAL} value" \ + "in ${DARKCYAN}$relation${NORMAL} relation's data." + exit 1 +} + +## Reassign ownership of every drifted object, then the database +## itself. Role name is passed through a session GUC and quoted +## with format('%I') in every generated statement. +repair_query="SET app.role = '$db_role'; +DO \$\$ +DECLARE + r record; + app_role text := current_setting('app.role'); +BEGIN + FOR r IN + SELECT CASE c.relkind + WHEN 'S' THEN format('ALTER SEQUENCE public.%I OWNER TO %I', c.relname, app_role) + WHEN 'v' THEN format('ALTER VIEW public.%I OWNER TO %I', c.relname, app_role) + WHEN 'm' THEN format('ALTER MATERIALIZED VIEW public.%I OWNER TO %I', c.relname, app_role) + ELSE format('ALTER TABLE public.%I OWNER TO %I', c.relname, app_role) + END AS stmt + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r','p','S','v','m') + AND pg_get_userbyid(c.relowner) <> app_role + UNION ALL + SELECT format('ALTER TYPE public.%I OWNER TO %I', t.typname, app_role) + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' + AND t.typtype IN ('e','d','c') + AND NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.reltype = t.oid) + AND pg_get_userbyid(t.typowner) <> app_role + UNION ALL + SELECT CASE p.prokind + WHEN 'p' THEN format('ALTER PROCEDURE public.%I(%s) OWNER TO %I', + p.proname, pg_get_function_identity_arguments(p.oid), app_role) + ELSE format('ALTER FUNCTION public.%I(%s) OWNER TO %I', + p.proname, pg_get_function_identity_arguments(p.oid), app_role) + END AS stmt + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind IN ('f','p') + AND pg_get_userbyid(p.proowner) <> app_role + LOOP + EXECUTE r.stmt; + END LOOP; +END \$\$; +ALTER DATABASE \"$dbname\" OWNER TO \"$db_role\";" + +sql < <(e "$repair_query") || { + err "Failed to reassign ownership to '${db_role}'." + exit 1 +} + +## Fail-hard: verify the realignment actually worked. +remaining_drift=$(sql < <(e "$audit_query")) || { + err "Failed to re-audit database ownership for ${WHITE}$db_role${NORMAL}." + exit 1 +} + +if [ -n "$remaining_drift" ]; then + err "Some database objects are still not owned by '${db_role}':" + e "$remaining_drift" | prefix " ${GRAY}|${NORMAL} " >&2 + err "Deployment halted. Fix the ownership manually and retry, e.g.:" + err " docker exec psql -U postgres -d \"$dbname\" \\" >&2 + err " -c 'ALTER TYPE public. OWNER TO \"$db_role\"'" >&2 + exit 1 +fi + +info "Database ownership aligned on '${db_role}'." diff --git a/outline/lib/common b/outline/lib/common new file mode 100644 index 0000000..44d73cb --- /dev/null +++ b/outline/lib/common @@ -0,0 +1,65 @@ +# -*- mode: shell-script -*- + +## +## Database access helpers (from cyclos/immich pattern in 0k-charms) +## + +## Get target service name for a named relation +outline:relation-get-target-service() { + local relation="$1" ts + if ! read-0 ts _ _ < <(get_service_relation "$SERVICE_NAME" "$relation"); then + err "Couldn't find relation ${DARKCYAN}$relation${NORMAL}." + return 1 + fi + e "$ts" +} + + +## Get the raw data of a named relation +outline:relation-get-config() { + local relation="$1" ts relation_dir + ts=$(outline:relation-get-target-service "$relation") || return 1 + relation_dir=$(get_relation_data_dir "$SERVICE_NAME" "$ts" "$relation") || return 1 + cat "${relation_dir}/data" +} + + +## Get a key from the relation data +outline:named-relation-get() { + local relation="$1" key="$2" config + config=$(outline:relation-get-config "$relation") || return 1 + e "$config" | shyaml get-value "$key" || { + err "Couldn't get ${WHITE}$key${NORMAL} value" \ + "in ${DARKCYAN}$relation${NORMAL} relation's data." + return 1 + } +} + + +## Run SQL as the postgres superuser on the database related to this +## service through the "postgres-database" relation. +## Usage: sql < <(echo "SELECT ...") +## echo "SELECT ..." | sql +sql() { + ( + local dbname ts target_charm target_charm_path + dbname="$(outline:named-relation-get "postgres-database" dbname)" || exit 1 + ts=$(outline:relation-get-target-service "postgres-database") || exit 1 + + export SERVICE_NAME="$ts" + export SERVICE_DATASTORE="$DATASTORE/$SERVICE_NAME" + DOCKER_BASE_IMAGE=$(service_ensure_image_ready "$SERVICE_NAME") || exit 1 + export DOCKER_BASE_IMAGE + + target_charm=$(get_service_charm "$ts") || exit 1 + target_charm_path=$(charm.get_dir "$target_charm") || exit 1 + + set +e + . "$target_charm_path/lib/common" + set -e + + ensure_db_docker_running + + ddb -d "$dbname" -v ON_ERROR_STOP=1 + ) +} diff --git a/outline/tests/compose/basic-deploy/compose.yml b/outline/tests/compose/basic-deploy/compose.yml new file mode 100644 index 0000000..72cc806 --- /dev/null +++ b/outline/tests/compose/basic-deploy/compose.yml @@ -0,0 +1,13 @@ +outline: + options: + sender-email: outline@example.com + oidc-client-id: test-client + oidc-client-secret: test-secret + oidc-auth-uri: https://example.com/auth + oidc-token-uri: https://example.com/token + oidc-user-info-uri: https://example.com/userinfo + oidc-logout-uri: https://example.com/logout + +smtp-stub: + options: + host: smtp.example.com