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