Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+14
View File
@@ -0,0 +1,14 @@
LOGFLARE_NODE_HOST=127.0.0.1
DB_USERNAME=supabase_admin
DB_DATABASE=_supabase
DB_HOSTNAME=supabase-db
DB_PORT=5432
DB_PASSWORD=${secret:postgres}
DB_SCHEMA=_analytics
LOGFLARE_API_KEY=${secret:logflare}
LOGFLARE_SINGLE_TENANT=true
LOGFLARE_SUPABASE_MODE=true
LOGFLARE_MIN_CLUSTER_SIZE=1
POSTGRES_BACKEND_URL=postgresql://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase
POSTGRES_BACKEND_SCHEMA=_analytics
LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true
@@ -0,0 +1,86 @@
# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push.
#
# logflare 1.4.0 copies two of its settings into its own database once and never reads them again:
# LOGFLARE_API_KEY becomes the default user's api_key when that user is created, and
# POSTGRES_BACKEND_URL becomes each source's backend when that source is created. A changed value
# reaches neither: a rotated key is refused, and a moved database is never written to (every
# source here pointed at db:5432, a name that resolves nowhere, so nothing was stored).
#
# Run by the start script before logflare starts, through logflare's own code (its Repo, its Users
# and Backends contexts and their changesets): it makes both copies say what the environment says.
# It changes nothing when they already do, says what it changed, never prints the key or a URL's
# password, and stops the start when it cannot finish.
fail = fn why ->
IO.puts(:stderr, "analytics: #{why}; not starting")
System.halt(1)
end
say = fn what -> IO.puts(:stderr, "analytics: #{what}") end
key = System.get_env("LOGFLARE_API_KEY")
url = System.get_env("POSTGRES_BACKEND_URL")
schema = System.get_env("POSTGRES_BACKEND_SCHEMA")
if key in [nil, ""], do: fail.("LOGFLARE_API_KEY is not set, so the key logflare must accept is unknown")
if url in [nil, ""], do: fail.("POSTGRES_BACKEND_URL is not set, so where logflare must store logs is unknown")
where = fn u ->
case URI.parse(to_string(u)) do
%URI{host: host, port: port, path: path} when is_binary(host) -> "#{host}:#{port}#{path}"
_ -> "an unreadable URL"
end
end
Application.ensure_all_started(:ssl)
outcome =
Ecto.Migrator.with_repo(Logflare.Repo, fn _repo ->
# Backends.update_source_backend_config restarts the source's pipeline after saving, and looks
# it up in this registry to do so; logflare is not running yet, so the registry is empty and
# there is nothing to restart.
{:ok, _} = Registry.start_link(keys: :unique, name: Logflare.Backends.SourceRegistry)
case Logflare.SingleTenant.get_default_user() do
nil ->
say.("no default user yet; logflare creates it from LOGFLARE_API_KEY and POSTGRES_BACKEND_URL as it starts")
:ok
user ->
if user.api_key == key and is_nil(user.old_api_key) do
say.("the default user's API key is LOGFLARE_API_KEY already")
else
# old_api_key is cleared too: logflare's UI can swap it back in, and the key replaced
# here may be one that leaked.
case Logflare.Users.update_user_all_fields(user, %{api_key: key, old_api_key: nil}) do
{:ok, _} -> say.("the default user's API key was replaced with LOGFLARE_API_KEY")
{:error, changeset} -> fail.("the default user's API key could not be replaced: #{inspect(changeset.errors)}")
end
end
results =
for source <- Logflare.Sources.list_sources_by_user(user),
backend <- Logflare.Backends.list_source_backends(source),
backend.type == :postgres do
if backend.config.url == url and backend.config.schema == schema do
:same
else
case Logflare.Backends.update_source_backend_config(backend, %{url: url, schema: schema}) do
{:ok, _} ->
say.("source #{source.name} stored logs at #{where.(backend.config.url)} schema #{inspect(backend.config.schema)}; now at #{where.(url)} schema #{inspect(schema)}")
{:error, changeset} ->
fail.("source #{source.name}'s backend could not be pointed at #{where.(url)}: #{inspect(changeset.errors)}")
end
end
end
say.("#{Enum.count(results, &(&1 == :same))} source backend(s) already at #{where.(url)}")
:ok
end
end)
case outcome do
{:ok, :ok, _} -> :ok
other -> fail.("logflare's database could not be reconciled: #{inspect(other)}")
end
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push.
#
# Two things logflare 1.4.0 does not do for itself, done here before it starts; either failing
# stops the start, with the reason as the last line of this container's log.
#
# 1. The API key never travels in a URL. logflare prints the whole URL of every request that fails,
# query string included, in the error report Plug.Cowboy writes ("Request: POST
# /api/logs?source_name=...&api_key=..."). That is an error, so no log level hides it while errors
# are logged at all (novox/hq issue 268). logflare reads the key from the x-api-key header as well,
# and the mesh's caller, vector, sends it there; this refuses to start logflare while the vector
# config it is given still passes the key in a URL: an analytics that does not start says why
# here, and one that leaks says nothing.
#
# 2. Its database says what its environment says. logflare copies LOGFLARE_API_KEY into its default
# user and POSTGRES_BACKEND_URL into every source's backend once, when it creates them, and never
# again; reconcile.exs makes both copies match the environment through logflare's own code, after
# its migrations and before it starts. That is what lets the mesh rotate the key by starting
# logflare again, and what moves the sources' backend when the database's address or password
# changes.
set -e
conf=/run/logflare/vector.yml
if [ ! -r "$conf" ]; then
echo "analytics: $conf is not there to check, so whether the API key travels in a URL is unknown; not starting (novox/hq issue 268)" >&2
exit 1
fi
if grep -v '^[[:space:]]*#' "$conf" | grep -q 'api_key='; then
echo "analytics: $conf passes the API key in a URL, and logflare prints a failed request's URL; not starting (novox/hq issue 268)" >&2
exit 1
fi
reconcile=/run/logflare/reconcile.exs
if [ ! -r "$reconcile" ]; then
echo "analytics: $reconcile is not there, so whether logflare's stored key and backends match its environment is unknown; not starting" >&2
exit 1
fi
cd /opt/app/rel/logflare/bin
# The tables reconcile.exs reads exist only once the migrations have run; run.sh runs them again,
# which finds nothing left to do.
./logflare eval Logflare.Release.migrate
./logflare eval 'Code.eval_file("/run/logflare/reconcile.exs")'
exec sh run.sh
+2
View File
@@ -0,0 +1,2 @@
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO '${secret:jwt}';
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600';
+6
View File
@@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _analytics;
alter schema _analytics owner to :pguser;
\c postgres
@@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _supavisor;
alter schema _supavisor owner to :pguser;
\c postgres
@@ -0,0 +1,4 @@
\set pguser `echo "$POSTGRES_USER"`
create schema if not exists _realtime;
alter schema _realtime owner to :pguser;
+7
View File
@@ -0,0 +1,7 @@
\set pgpass `echo "$POSTGRES_PASSWORD"`
ALTER USER authenticator WITH PASSWORD :'pgpass';
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
@@ -0,0 +1,3 @@
\set pguser `echo "$POSTGRES_USER"`
CREATE DATABASE _supabase WITH OWNER :pguser;
+208
View File
@@ -0,0 +1,208 @@
BEGIN;
-- Create pg_net extension
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
-- Create supabase_functions schema
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
-- supabase_functions.migrations definition
CREATE TABLE supabase_functions.migrations (
version text PRIMARY KEY,
inserted_at timestamptz NOT NULL DEFAULT NOW()
);
-- Initial supabase_functions migration
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
-- supabase_functions.hooks definition
CREATE TABLE supabase_functions.hooks (
id bigserial PRIMARY KEY,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
request_id bigint
);
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
CREATE FUNCTION supabase_functions.http_request()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE
request_id bigint;
payload jsonb;
url text := TG_ARGV[0]::text;
method text := TG_ARGV[1]::text;
headers jsonb DEFAULT '{}'::jsonb;
params jsonb DEFAULT '{}'::jsonb;
timeout_ms integer DEFAULT 1000;
BEGIN
IF url IS NULL OR url = 'null' THEN
RAISE EXCEPTION 'url argument is missing';
END IF;
IF method IS NULL OR method = 'null' THEN
RAISE EXCEPTION 'method argument is missing';
END IF;
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
headers = '{"Content-Type": "application/json"}'::jsonb;
ELSE
headers = TG_ARGV[2]::jsonb;
END IF;
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
params = '{}'::jsonb;
ELSE
params = TG_ARGV[3]::jsonb;
END IF;
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
timeout_ms = 1000;
ELSE
timeout_ms = TG_ARGV[4]::integer;
END IF;
CASE
WHEN method = 'GET' THEN
SELECT http_get INTO request_id FROM net.http_get(
url,
params,
headers,
timeout_ms
);
WHEN method = 'POST' THEN
payload = jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA
);
SELECT http_post INTO request_id FROM net.http_post(
url,
payload,
params,
headers,
timeout_ms
);
ELSE
RAISE EXCEPTION 'method argument % is invalid', method;
END CASE;
INSERT INTO supabase_functions.hooks
(hook_table_id, hook_name, request_id)
VALUES
(TG_RELID, TG_NAME, request_id);
RETURN NEW;
END
$function$;
-- Supabase super admin
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_functions_admin'
)
THEN
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
END IF;
END
$$;
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
GRANT supabase_functions_admin TO postgres;
-- Remove unused supabase_pg_net_admin role
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_pg_net_admin'
)
THEN
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
DROP OWNED BY supabase_pg_net_admin;
DROP ROLE supabase_pg_net_admin;
END IF;
END
$$;
-- pg_net grants when extension is already enabled
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_extension
WHERE extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END
$$;
-- Event trigger for pg_net
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
RETURNS event_trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_event_trigger_ddl_commands() AS ev
JOIN pg_extension AS ext
ON ev.objid = ext.oid
WHERE ext.extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END;
$$;
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_event_trigger
WHERE evtname = 'issue_pg_net_access'
) THEN
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
EXECUTE PROCEDURE extensions.grant_pg_net_access();
END IF;
END
$$;
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
ALTER function supabase_functions.http_request() SECURITY DEFINER;
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;
+6
View File
@@ -0,0 +1,6 @@
JWT_SECRET=${secret:jwt}
SUPABASE_URL=http://supabase-kong:8000
SUPABASE_ANON_KEY=${secret:anon-key}
SUPABASE_SERVICE_ROLE_KEY=${secret:service-role-key}
SUPABASE_DB_URL=postgresql://postgres:${secret:postgres}@supabase-db:5432/postgres
VERIFY_JWT=false
@@ -0,0 +1,94 @@
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
console.log('main function started')
const JWT_SECRET = Deno.env.get('JWT_SECRET')
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'
function getAuthToken(req: Request) {
const authHeader = req.headers.get('authorization')
if (!authHeader) {
throw new Error('Missing authorization header')
}
const [bearer, token] = authHeader.split(' ')
if (bearer !== 'Bearer') {
throw new Error(`Auth header is not 'Bearer {token}'`)
}
return token
}
async function verifyJWT(jwt: string): Promise<boolean> {
const encoder = new TextEncoder()
const secretKey = encoder.encode(JWT_SECRET)
try {
await jose.jwtVerify(jwt, secretKey)
} catch (err) {
console.error(err)
return false
}
return true
}
serve(async (req: Request) => {
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
try {
const token = getAuthToken(req)
const isValidJWT = await verifyJWT(token)
if (!isValidJWT) {
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
} catch (e) {
console.error(e)
return new Response(JSON.stringify({ msg: e.toString() }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
}
const url = new URL(req.url)
const { pathname } = url
const path_parts = pathname.split('/')
const service_name = path_parts[1]
if (!service_name || service_name === '') {
const error = { msg: 'missing function name in request' }
return new Response(JSON.stringify(error), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const servicePath = `/home/deno/functions/${service_name}`
console.error(`serving the request with ${servicePath}`)
const memoryLimitMb = 150
const workerTimeoutMs = 1 * 60 * 1000
const noModuleCache = false
const importMapPath = null
const envVarsObj = Deno.env.toObject()
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
try {
const worker = await EdgeRuntime.userWorkers.create({
servicePath,
memoryLimitMb,
workerTimeoutMs,
noModuleCache,
importMapPath,
envVars,
})
return await worker.fetch(req)
} catch (e) {
const error = { msg: e.toString() }
return new Response(JSON.stringify(error), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
})
+28
View File
@@ -0,0 +1,28 @@
GOTRUE_API_HOST=0.0.0.0
GOTRUE_API_PORT=9999
API_EXTERNAL_URL=https://${bound:route:name}
GOTRUE_DB_DRIVER=postgres
GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${secret:postgres}@supabase-db:5432/postgres
GOTRUE_SITE_URL=https://${bound:route:name}
GOTRUE_URI_ALLOW_LIST=
GOTRUE_DISABLE_SIGNUP=true
GOTRUE_JWT_ADMIN_ROLES=service_role
GOTRUE_JWT_AUD=authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated
GOTRUE_JWT_EXP=3600
GOTRUE_JWT_SECRET=${secret:jwt}
GOTRUE_EXTERNAL_EMAIL_ENABLED=false
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false
GOTRUE_MAILER_AUTOCONFIRM=false
GOTRUE_SMTP_ADMIN_EMAIL=admin@example.com
GOTRUE_SMTP_HOST=supabase-mail
GOTRUE_SMTP_PORT=2500
GOTRUE_SMTP_USER=fake_mail_user
GOTRUE_SMTP_PASS=fake_mail_password
GOTRUE_SMTP_SENDER_NAME=fake_sender
GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify
GOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify
GOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify
GOTRUE_EXTERNAL_PHONE_ENABLED=false
GOTRUE_SMS_AUTOCONFIRM=true
+242
View File
@@ -0,0 +1,242 @@
# Written by the mesh (modules/supabase): the gateway's declarative config, credentials rendered in.
_format_version: '2.1'
_transform: true
###
### Consumers / Users
###
consumers:
- username: DASHBOARD
- username: anon
keyauth_credentials:
- key: "${secret:anon-key}"
- username: service_role
keyauth_credentials:
- key: "${secret:service-role-key}"
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### Dashboard credentials
###
basicauth_credentials:
- consumer: DASHBOARD
username: "${secret:dashboard-user}"
password: "${secret:dashboard}"
###
### API Routes
###
services:
## Open Auth routes
- name: auth-v1-open
url: http://supabase-auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
url: http://supabase-auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
url: http://supabase-auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
_comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*'
url: http://supabase-auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure REST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://supabase-rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://supabase-rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: request-transformer
config:
add:
headers:
- Content-Profile:graphql_public
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure Realtime routes
- name: realtime-v1-ws
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/api
protocol: http
routes:
- name: realtime-v1-rest
strip_path: true
paths:
- /realtime/v1/api
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Storage routes: the storage server manages its own auth
- name: storage-v1
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
url: http://supabase-storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
## Edge Functions routes
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
url: http://supabase-edge-functions:9000/
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
## Analytics routes
- name: analytics-v1
_comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
url: http://supabase-analytics:4000/
routes:
- name: analytics-v1-all
strip_path: true
paths:
- /analytics/v1/
## Secure Database routes
- name: meta
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
url: http://supabase-meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Protected Dashboard - catch all remaining routes
- name: dashboard
_comment: 'Studio: /* -> http://studio:3000/*'
url: http://supabase-studio:3000/
routes:
- name: dashboard-all
strip_path: true
paths:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true
+6
View File
@@ -0,0 +1,6 @@
PG_META_PORT=8080
PG_META_DB_HOST=supabase-db
PG_META_DB_PORT=5432
PG_META_DB_NAME=postgres
PG_META_DB_USER=supabase_admin
PG_META_DB_PASSWORD=${secret:postgres}
+14
View File
@@ -0,0 +1,14 @@
PORT=4000
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_PASSWORD=${secret:postgres}
DATABASE_URL=ecto://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase
CLUSTER_POSTGRES=true
SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b}
VAULT_ENC_KEY=${secret:pooler-vault}
API_JWT_SECRET=${secret:jwt}
METRICS_JWT_SECRET=${secret:jwt}
REGION=local
ERL_AFLAGS=-proto_dist inet_tcp
RELEASE_NODE=supavisor@127.0.0.1
POOLER_POOL_MODE=transaction
+43
View File
@@ -0,0 +1,43 @@
{:ok, _} = Application.ensure_all_started(:supavisor)
# Written by the mesh (modules/supabase). Per-machine values come from settings.json (the module's
# one merge:json file); secrets come from the environment the image reads.
settings = "/etc/pooler/settings.json" |> File.read!() |> Jason.decode!() |> Map.get("pooler", %{})
{:ok, version} =
case Supavisor.Repo.query!("select version()") do
%{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
_ -> nil
end
params = %{
"external_id" => to_string(settings["tenant"] || "default"),
"db_host" => "supabase-db",
"db_port" => System.get_env("POSTGRES_PORT"),
"db_database" => System.get_env("POSTGRES_DB"),
"require_user" => false,
"auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
"default_max_clients" => settings["max-client-connections"] || 100,
"default_pool_size" => settings["pool-size"] || 20,
"default_parameter_status" => %{"server_version" => version},
"users" => [%{
"db_user" => "pgbouncer",
"db_password" => System.get_env("POSTGRES_PASSWORD"),
"mode_type" => System.get_env("POOLER_POOL_MODE"),
"pool_size" => settings["pool-size"] || 20,
"is_manager" => true
}]
}
case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
nil ->
{:ok, _} = Supavisor.Tenants.create_tenant(params)
# A tenant made elsewhere (HAL named the database host "db") is pointed at this module's
# database container; nothing else it holds is changed.
%{db_host: host} = tenant when host != "supabase-db" ->
{:ok, _} = Supavisor.Tenants.update_tenant(tenant, %{"db_host" => "supabase-db"})
_ ->
:ok
end
+7
View File
@@ -0,0 +1,7 @@
db-uri = "postgres://authenticator:${secret:postgres}@supabase-db:5432/postgres"
db-schemas = "public,storage,graphql_public"
db-anon-role = "anon"
jwt-secret = "${secret:jwt}"
db-use-legacy-gucs = false
app.settings.jwt_secret = "${secret:jwt}"
app.settings.jwt_exp = "3600"
+16
View File
@@ -0,0 +1,16 @@
PORT=4000
DB_HOST=supabase-db
DB_PORT=5432
DB_USER=supabase_admin
DB_PASSWORD=${secret:postgres}
DB_NAME=postgres
DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime
DB_ENC_KEY=supabaserealtime
API_JWT_SECRET=${secret:jwt}
SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b}
ERL_AFLAGS=-proto_dist inet_tcp
DNS_NODES=''
RLIMIT_NOFILE=10000
APP_NAME=realtime
SEED_SELF_HOST=true
RUN_JANITOR=true
+7
View File
@@ -0,0 +1,7 @@
{
"pooler": {
"tenant": "default",
"pool-size": 20,
"max-client-connections": 100
}
}
+13
View File
@@ -0,0 +1,13 @@
ANON_KEY=${secret:anon-key}
SERVICE_KEY=${secret:service-role-key}
POSTGREST_URL=http://supabase-rest:3000
PGRST_JWT_SECRET=${secret:jwt}
DATABASE_URL=postgres://supabase_storage_admin:${secret:postgres}@supabase-db:5432/postgres
FILE_SIZE_LIMIT=52428800
STORAGE_BACKEND=file
FILE_STORAGE_BACKEND_PATH=/var/lib/storage
TENANT_ID=stub
REGION=stub
GLOBAL_S3_BUCKET=stub
ENABLE_IMAGE_TRANSFORMATION=true
IMGPROXY_URL=http://supabase-imgproxy:5001
+18
View File
@@ -0,0 +1,18 @@
# Next.js listens on the address HOSTNAME names, and docker sets HOSTNAME to the container's id,
# so studio answered only on its network address while its image's health check asks localhost:
# unhealthy for ever while working. Every address, as the upstream compose file has it.
HOSTNAME=::
STUDIO_PG_META_URL=http://supabase-meta:8080
POSTGRES_PASSWORD=${secret:postgres}
DEFAULT_ORGANIZATION_NAME=Default Organization
DEFAULT_PROJECT_NAME=Default Project
OPENAI_API_KEY=${secret:openai}
SUPABASE_URL=http://supabase-kong:8000
SUPABASE_PUBLIC_URL=https://${bound:route:name}
SUPABASE_ANON_KEY=${secret:anon-key}
SUPABASE_SERVICE_KEY=${secret:service-role-key}
AUTH_JWT_SECRET=${secret:jwt}
LOGFLARE_API_KEY=${secret:logflare}
LOGFLARE_URL=http://supabase-analytics:4000
NEXT_PUBLIC_ENABLE_LOGS=true
NEXT_ANALYTICS_BACKEND_PROVIDER=postgres
+258
View File
@@ -0,0 +1,258 @@
# Written by the mesh (modules/supabase).
#
# Every sink hands logflare its API key in the x-api-key header, never as an api_key query parameter:
# logflare prints a failed request's whole URL, query string included, in its error report, so a
# key in the URL is a key in its log (novox/hq issue 268). supabase-analytics refuses to start
# while this file still puts the key in a URL.
api:
enabled: true
address: 0.0.0.0:9001
sources:
docker_host:
type: docker_logs
include_containers:
- supabase-kong
- supabase-auth
- supabase-rest
- realtime-dev.supabase-realtime
- supabase-storage
- supabase-edge-functions
- supabase-db
transforms:
project_logs:
type: remap
inputs:
- docker_host
source: |-
.project = "default"
.event_message = del(.message)
.appname = del(.container_name)
del(.container_created_at)
del(.container_id)
del(.source_type)
del(.stream)
del(.label)
del(.image)
del(.host)
del(.stream)
router:
type: route
inputs:
- project_logs
route:
kong: '.appname == "supabase-kong"'
auth: '.appname == "supabase-auth"'
rest: '.appname == "supabase-rest"'
realtime: '.appname == "realtime-dev.supabase-realtime"'
storage: '.appname == "supabase-storage"'
functions: '.appname == "supabase-edge-functions"'
db: '.appname == "supabase-db"'
# Ignores non nginx errors since they are related with kong booting up
kong_logs:
type: remap
inputs:
- router.kong
source: |-
req, err = parse_nginx_log(.event_message, "combined")
if err == null {
.timestamp = req.timestamp
.metadata.request.headers.referer = req.referer
.metadata.request.headers.user_agent = req.agent
.metadata.request.headers.cf_connecting_ip = req.client
.metadata.request.method = req.method
.metadata.request.path = req.path
.metadata.request.protocol = req.protocol
.metadata.response.status_code = req.status
}
if err != null {
abort
}
# Ignores non nginx errors since they are related with kong booting up
kong_err:
type: remap
inputs:
- router.kong
source: |-
.metadata.request.method = "GET"
.metadata.response.status_code = 200
parsed, err = parse_nginx_log(.event_message, "error")
if err == null {
.timestamp = parsed.timestamp
.severity = parsed.severity
.metadata.request.host = parsed.host
.metadata.request.headers.cf_connecting_ip = parsed.client
url, err = split(parsed.request, " ")
if err == null {
.metadata.request.method = url[0]
.metadata.request.path = url[1]
.metadata.request.protocol = url[2]
}
}
if err != null {
abort
}
# Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency.
auth_logs:
type: remap
inputs:
- router.auth
source: |-
parsed, err = parse_json(.event_message)
if err == null {
.metadata.timestamp = parsed.time
.metadata = merge!(.metadata, parsed)
}
# PostgREST logs are structured so we separate timestamp from message using regex
rest_logs:
type: remap
inputs:
- router.rest
source: |-
parsed, err = parse_regex(.event_message, r'^(?P<time>.*): (?P<msg>.*)$')
if err == null {
.event_message = parsed.msg
.timestamp = to_timestamp!(parsed.time)
.metadata.host = .project
}
# Realtime logs are structured so we parse the severity level using regex (ignore time because it has no date)
realtime_logs:
type: remap
inputs:
- router.realtime
source: |-
.metadata.project = del(.project)
.metadata.external_id = .metadata.project
parsed, err = parse_regex(.event_message, r'^(?P<time>\d+:\d+:\d+\.\d+) \[(?P<level>\w+)\] (?P<msg>.*)$')
if err == null {
.event_message = parsed.msg
.metadata.level = parsed.level
}
# Storage logs may contain json objects so we parse them for completeness
storage_logs:
type: remap
inputs:
- router.storage
source: |-
.metadata.project = del(.project)
.metadata.tenantId = .metadata.project
parsed, err = parse_json(.event_message)
if err == null {
.event_message = parsed.msg
.metadata.level = parsed.level
.metadata.timestamp = parsed.time
.metadata.context[0].host = parsed.hostname
.metadata.context[0].pid = parsed.pid
}
# Postgres logs some messages to stderr which we map to warning severity level
db_logs:
type: remap
inputs:
- router.db
source: |-
.metadata.host = "db-default"
.metadata.parsed.timestamp = .timestamp
parsed, err = parse_regex(.event_message, r'.*(?P<level>INFO|NOTICE|WARNING|ERROR|LOG|FATAL|PANIC?):.*', numeric_groups: true)
if err != null || parsed == null {
.metadata.parsed.error_severity = "info"
}
if parsed != null {
.metadata.parsed.error_severity = parsed.level
}
if .metadata.parsed.error_severity == "info" {
.metadata.parsed.error_severity = "log"
}
.metadata.parsed.error_severity = upcase!(.metadata.parsed.error_severity)
sinks:
logflare_auth:
type: 'http'
inputs:
- auth_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
uri: 'http://supabase-analytics:4000/api/logs?source_name=gotrue.logs.prod'
logflare_realtime:
type: 'http'
inputs:
- realtime_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
uri: 'http://supabase-analytics:4000/api/logs?source_name=realtime.logs.prod'
logflare_rest:
type: 'http'
inputs:
- rest_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
uri: 'http://supabase-analytics:4000/api/logs?source_name=postgREST.logs.prod'
logflare_db:
type: 'http'
inputs:
- db_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
# We must route the sink through kong because ingesting logs before logflare is fully initialised will
# lead to broken queries from studio. This works by the assumption that containers are started in the
# following order: vector > db > logflare > kong
uri: 'http://supabase-kong:8000/analytics/v1/api/logs?source_name=postgres.logs'
logflare_functions:
type: 'http'
inputs:
- router.functions
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
uri: 'http://supabase-analytics:4000/api/logs?source_name=deno-relay-logs'
logflare_storage:
type: 'http'
inputs:
- storage_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
uri: 'http://supabase-analytics:4000/api/logs?source_name=storage.logs.prod.2'
logflare_kong:
type: 'http'
inputs:
- kong_logs
- kong_err
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: '${secret:logflare}'
uri: 'http://supabase-analytics:4000/api/logs?source_name=cloudflare.logs.prod'
File diff suppressed because one or more lines are too long