Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
self-hosted-runner:
labels:
- blacksmith-2vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- arm-runner
55 changes: 54 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- "mix.lock"
- "Dockerfile"
- "run.sh"
- "mise.toml"
- "test/e2e/nix-build.sh"
- ".github/workflows/**"
- ".github/actionlint.yaml"
Expand All @@ -24,8 +25,11 @@
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
POSTGRES_IMAGE: supabase/postgres:17.6.1.074

jobs:
tests:
lint:
name: Lint
runs-on: blacksmith-4vcpu-ubuntu-2404

Expand Down Expand Up @@ -75,7 +79,56 @@
- name: Run dialyzer
run: mix dialyzer

tenant-db-baseline:
name: Check Tenant DB baseline
runs-on: blacksmith-2vcpu-ubuntu-2404

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup mise
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
- name: Cache Mix
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
deps
_build
key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-

- name: Install dependencies
run: mix deps.get

- name: Cache Docker images
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
id: docker-cache
with:
path: /tmp/docker-images
key: docker-images-zstd-${{ env.POSTGRES_IMAGE }}
- name: Load Docker images from cache
if: steps.docker-cache.outputs.cache-hit == 'true'
run: zstd -d --stdout /tmp/docker-images/postgres.tar.zst | docker image load
- name: Pull and save Docker images
if: steps.docker-cache.outputs.cache-hit != 'true'
run: |
docker pull ${{ env.POSTGRES_IMAGE }}
mkdir -p /tmp/docker-images
docker image save ${{ env.POSTGRES_IMAGE }} | zstd -T0 -o /tmp/docker-images/postgres.tar.zst
- name: Start Postgres
run: docker compose -f compose.dbs.yml up -d --wait
- name: Set up realtime DB and migrate tenant DB
run: mix ecto.setup
- name: Regenerate baseline snapshot
run: mix realtime.export_tenant_db_baseline
- name: Check baseline is up to date
run: |
if ! git diff --exit-code priv/repo/tenant_db_baseline.json; then
echo "::error file=priv/repo/tenant_db_baseline.json::Baseline snapshot is stale. Run 'mix realtime.export_tenant_db_baseline' locally and commit the result."
exit 1
fi

actionlint:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Actionlint
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
Expand Down
47 changes: 44 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ ARG OTP_VERSION=27.3
ARG DEBIAN_VERSION=bookworm-20250929-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
# @supabase/pg-delta@1.0.0-alpha.24
ARG PG_DELTA_COMMIT=102ef99ae5aabb29510d48b39fbb8ecee34f5458

FROM debian:${DEBIAN_VERSION} AS pgdelta-builder
ARG PG_DELTA_COMMIT
ARG BUN_VERSION=1.3.14

RUN set -eux; \
apt-get update -y; \
apt-get install -y --no-install-recommends curl ca-certificates unzip xz-utils; \
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"; \
export PATH="/root/.bun/bin:${PATH}"; \
mkdir -p /build && cd /build; \
curl -fsSL "https://github.com/supabase/pg-toolbelt/archive/${PG_DELTA_COMMIT}.tar.gz" \
| tar xz --strip-components=1; \
bun install --frozen-lockfile --ignore-scripts; \
cd /build/packages/pg-delta; \
bun build --compile src/cli/bin/cli.ts --outfile /tmp/pgdelta; \
/tmp/pgdelta --help > /dev/null; \
xz -9 -e -T0 -c /tmp/pgdelta > /tmp/pgdelta.xz; \
cd / && find build -path '*/@libpg-query/parser/wasm/libpg-query.wasm' \
| tar -czf /tmp/libpg-query.tar.gz -T -; \
printf '%s\n' \
'#!/bin/sh' \
'set -e' \
'BIN=/app/.pgdelta-cache/pgdelta' \
'if [ ! -x "$BIN" ]; then' \
' mkdir -p "$(dirname "$BIN")"' \
' xz -dcT0 /usr/local/share/pgdelta/pgdelta.xz > "$BIN"' \
' chmod +x "$BIN"' \
'fi' \
'exec "$BIN" "$@"' \
> /tmp/pgdelta-wrapper; \
chmod +x /tmp/pgdelta-wrapper; \
rm -rf /tmp/pgdelta /build /root/.bun /var/lib/apt/lists/*

FROM ${BUILDER_IMAGE} AS builder

Expand Down Expand Up @@ -75,15 +110,21 @@ ENV SLOT_NAME_SUFFIX="${SLOT_NAME_SUFFIX}" \
ERL_AFLAGS="-proto_dist inet6_tcp"

RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales iptables sudo tini curl awscli jq && \
apt-get clean && rm -f /var/lib/apt/lists/*_*
apt-get install -y --no-install-recommends \
libstdc++6 openssl libncurses5 locales iptables sudo tini curl awscli jq xz-utils && \
apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=pgdelta-builder /tmp/pgdelta.xz /usr/local/share/pgdelta/pgdelta.xz
COPY --from=pgdelta-builder /tmp/pgdelta-wrapper /usr/local/bin/pgdelta
COPY --from=pgdelta-builder /tmp/libpg-query.tar.gz /tmp/libpg-query.tar.gz
RUN tar -C / -xzf /tmp/libpg-query.tar.gz && rm /tmp/libpg-query.tar.gz

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

WORKDIR "/app"

RUN chown nobody /app
RUN chown nobody /app && mkdir -p /app/.pgdelta-cache && chown nobody /app/.pgdelta-cache

COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/realtime ./
COPY run.sh run.sh
Expand Down
74 changes: 74 additions & 0 deletions lib/mix/tasks/realtime.export_tenant_db_baseline.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
defmodule Mix.Tasks.Realtime.ExportTenantDbBaseline do
@shortdoc "Regenerate priv/repo/tenant_db_baseline.json"

@moduledoc """
Writes the baseline catalog snapshot at `priv/repo/tenant_db_baseline.json`
used by `RealtimeWeb.Dashboard.TenantMigrations` to detect drifted DB state.

Usage:

mix realtime.export_tenant_db_baseline

The target DB is read from `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` /
`DB_PASSWORD` env vars.

The target DB is expected to already have all tenant migrations applied.

Requires `pgdelta` on `$PATH`.
"""
use Mix.Task

@baseline_path "priv/repo/tenant_db_baseline.json"
Comment thread
leandrocp marked this conversation as resolved.

@impl Mix.Task
def run(_args) do
url = build_url_from_env()
Mix.shell().info("[export_tenant_db_baseline] target: #{redact(url)}")

unless System.find_executable("pgdelta") do
Mix.raise("pgdelta not found on PATH")
end

output = Path.expand(@baseline_path, File.cwd!())
args = ["catalog-export", "--target", url, "--output", output]

case System.cmd("pgdelta", args, stderr_to_stdout: true) do
{output_str, 0} ->
validate_snapshot!(output)
Mix.shell().info(output_str)

{output_str, code} ->
Mix.raise("pgdelta catalog-export exited #{code}:\n#{output_str}")
end
end

defp build_url_from_env do
host = System.get_env("DB_HOST", "127.0.0.1")
port = System.get_env("DB_PORT", "5433")
name = System.get_env("DB_NAME", "postgres")
user = System.get_env("DB_USER", "supabase_admin")
password = System.get_env("DB_PASSWORD", "postgres")

"postgresql://#{URI.encode_www_form(user)}:#{URI.encode_www_form(password)}@#{host}:#{port}/#{name}"
end

defp validate_snapshot!(path) do
with {:ok, content} <- File.read(path),
{:ok, _} <- Jason.decode(content) do
:ok
else
_ -> Mix.raise("baseline snapshot at #{path} is invalid")
end
end

defp redact(url) do
case URI.parse(url) do
%URI{userinfo: nil} = u ->
URI.to_string(u)

%URI{userinfo: userinfo} = u ->
user = userinfo |> String.split(":", parts: 2) |> hd()
URI.to_string(%{u | userinfo: "#{user}:***"})
end
end
end
50 changes: 46 additions & 4 deletions lib/realtime_web/dashboard/tenant_info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,44 @@ defmodule RealtimeWeb.Dashboard.TenantInfo do
Secrets (jwt_secret and encrypted extension fields) are never displayed.
"""
use Phoenix.LiveDashboard.PageBuilder
use Realtime.Logs

alias Realtime.Api
alias Realtime.Api.Tenant
alias Realtime.Crypto
alias Realtime.Database

@application_name "realtime_dashboard_tenant_info"

@impl true
def menu_link(_, _), do: {:ok, "Tenant Info"}

@impl true
def mount(_params, _, socket) do
{:ok, assign(socket, external_id: "", tenant: nil, error: nil)}
{:ok, assign(socket, external_id: "", tenant: nil, pg_version: nil, error: nil)}
end

@impl true
def handle_params(%{"external_id" => ref}, _uri, socket) when ref != "" do
ref = String.trim(ref)

case Api.get_tenant_by_external_id(ref) do
nil -> {:noreply, assign(socket, external_id: ref, tenant: nil, error: "Tenant not found")}
tenant -> {:noreply, assign(socket, external_id: ref, tenant: prepare_tenant(tenant), error: nil)}
nil ->
{:noreply, assign(socket, external_id: ref, tenant: nil, pg_version: nil, error: "Tenant not found")}

%Tenant{} = tenant ->
{:noreply,
assign(socket,
external_id: ref,
tenant: prepare_tenant(tenant),
pg_version: fetch_pg_version(tenant),
error: nil
)}
end
end

def handle_params(_params, _uri, socket) do
{:noreply, assign(socket, external_id: "", tenant: nil, error: nil)}
{:noreply, assign(socket, external_id: "", tenant: nil, pg_version: nil, error: nil)}
end

@impl true
Expand Down Expand Up @@ -84,6 +98,22 @@ defmodule RealtimeWeb.Dashboard.TenantInfo do
</tbody>
</table>

<h6 class="mt-4">Database</h6>
<table class="table table-hover">
<tbody>
<tr>
<td>postgres_version</td>
<td>
<%= case @pg_version do %>
<% nil -> %>
<% {:ok, version} -> %><span class="font-monospace"><%= version %></span>
<% {:error, msg} -> %><span class="text-danger"><%= msg %></span>
<% end %>
</td>
</tr>
</tbody>
</table>

<%= for ext <- @tenant.extensions do %>
<h6 class="mt-4">Extension: <%= ext.type %></h6>
<table class="table table-hover">
Expand Down Expand Up @@ -133,6 +163,18 @@ defmodule RealtimeWeb.Dashboard.TenantInfo do
%{ext | settings: settings}
end

defp fetch_pg_version(%Tenant{} = tenant) do
with {:ok, settings} <- Database.from_tenant(tenant, @application_name, :stop),
{:ok, conn} <- Database.connect_db(settings),
{:ok, %{rows: [[version]]}} <- Postgrex.query(conn, "SELECT version()", []) do
{:ok, version}
else
{:error, reason} ->
log_warning("TenantInfoPgVersionFailed", reason)
{:error, "Failed to query postgres version: #{inspect(reason)}"}
end
end

defp resolve_host(host) do
host_charlist = String.to_charlist(host)

Expand Down
Loading
Loading