Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore (examples): cloud deployment of the phoenix liveview example #2193

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
67 changes: 67 additions & 0 deletions examples/phoenix-liveview/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
ARG ELIXIR_VERSION=1.17.2
ARG OTP_VERSION=27.0.1
ARG DEBIAN_VERSION=bookworm-20240722-slim

ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"

FROM ${BUILDER_IMAGE} AS builder
LABEL maintainer="[email protected]"

RUN apt-get update -y && \
apt-get install -y build-essential git curl && \
apt-get clean && \
rm -f /var/lib/apt/lists/*_*

RUN mix local.hex --force && mix local.rebar --force

ENV MIX_ENV=prod

WORKDIR /app

COPY mix.* /app/
COPY config/config.exs /app/config/
COPY config/prod.exs /app/config/
RUN mix deps.get --only $MIX_ENV --check-locked
RUN mix deps.compile
RUN mix assets.setup

COPY config/*runtime.exs /app/config/

COPY lib /app/lib/
RUN mix compile

COPY assets /app/assets/
COPY priv /app/priv/
RUN mix assets.deploy

COPY rel /app/rel/

RUN mix release

FROM ${RUNNER_IMAGE} AS runner_setup

RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates curl && \
apt-get clean && \
rm -f /var/lib/apt/lists/*_*

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

ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8

WORKDIR "/app"
RUN chown nobody /app

FROM runner_setup AS runner

ARG RELEASE_NAME=electric_phoenix_example

COPY --from=builder /app/_build/prod/rel/${RELEASE_NAME} ./
RUN mv /app/bin/${RELEASE_NAME} /app/bin/entrypoint

CMD ["/app/bin/server"]

6 changes: 4 additions & 2 deletions examples/phoenix-liveview/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ if config_env() == :prod do
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []

config :electric_phoenix_example, Electric.PhoenixExample.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
socket_options: maybe_ipv6,
ssl_opts: [
verify: :verify_none
]

# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
Expand Down
7 changes: 7 additions & 0 deletions examples/phoenix-liveview/db/migrations/001-create_todos.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE todos (
id BIGSERIAL PRIMARY KEY,
text VARCHAR(255) NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
inserted_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
);
28 changes: 28 additions & 0 deletions examples/phoenix-liveview/lib/electric_phoenix_example/release.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Electric.PhoenixExample.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :electric_phoenix_example

def migrate do
load_app()

for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end

def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end

defp repos do
Application.fetch_env!(@app, :ecto_repos)
end

defp load_app do
Application.load(@app)
end
end
15 changes: 15 additions & 0 deletions examples/phoenix-liveview/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "phoenix-liveview",
"version": "0.0.0",
"dependencies": {
"camelcase": "^8.0.0"
},
"devDependencies": {
"@databases/pg-migrations": "^5.0.3",
"typescript": "5.7.2",
"sst": "3.3.7"
},
"scripts": {
"deploy": "sst deploy --stage production"
}
}
5 changes: 5 additions & 0 deletions examples/phoenix-liveview/rel/overlays/bin/migrate
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu

cd -P -- "$(dirname -- "$0")"
exec ./entrypoint eval Electric.PhoenixExample.Release.migrate
5 changes: 5 additions & 0 deletions examples/phoenix-liveview/rel/overlays/bin/server
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu

cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./entrypoint start
18 changes: 18 additions & 0 deletions examples/phoenix-liveview/sst-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
export {}
declare module "sst" {
export interface Resource {
"electricRegionVpc2EuWest1": {
"type": "sst.aws.Vpc"
}
"phoenixLiveviewServiceEuWest1": {
"service": string
"type": "sst.aws.Service"
"url": string
}
}
}
165 changes: 165 additions & 0 deletions examples/phoenix-liveview/sst.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/// <reference path="./.sst/platform/config.d.ts" />

import { execSync } from "child_process"
import camelcase from "camelcase"

const isProduction = (stage) => stage.toLowerCase() === `production`

const regionName = `eu-west-1`

export default $config({
app(input) {
return {
name: "phoenix-liveview",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
providers: {
cloudflare: `5.42.0`,
aws: { version: `6.57.0`, region: regionName },
postgresql: `3.14.0`,
},
}
},
async run() {
if (!process.env.SECRET_KEY_BASE) {
throw new Error(
`Env variable SECRET_KEY_BASE must be set`
)
}

if (!process.env.ELECTRIC_API || !process.env.ELECTRIC_ADMIN_API)
throw new Error(
`Env variables ELECTRIC_API and ELECTRIC_ADMIN_API must be set`
)

if (
!process.env.EXAMPLES_DATABASE_HOST ||
!process.env.EXAMPLES_DATABASE_PASSWORD
) {
throw new Error(
`Env variables EXAMPLES_DATABASE_HOST and EXAMPLES_DATABASE_PASSWORD must be set`
)
}

const provider = new postgresql.Provider(`neon`, {
host: process.env.EXAMPLES_DATABASE_HOST,
database: `neondb`,
username: `neondb_owner`,
password: process.env.EXAMPLES_DATABASE_PASSWORD,
})

const pg = new postgresql.Database(`liveview`, {}, { provider })

const pgBaseUri = $interpolate`postgresql://${provider.username}:${provider.password}@${provider.host}/${pg.name}`
const pgUriForElectric = $interpolate`${pgBaseUri}?sslmode=require`
const electricInfo = pgUriForElectric.apply((uri) => {
return addDatabaseToElectric(uri, `eu-west-1`)
})

const domainName = `phoenix-liveview${isProduction($app.stage) ? `` : `-stage-${$app.stage}`}.examples.electric-sql.com`

// Run the server on ECS
const vpcName = camelcase(`electric-region-vpc-2-${regionName}`)
const vpc = new sst.aws.Vpc(
vpcName,
{
nat: $app.stage === `production` ? `managed` : `ec2`,
},
{ provider }
)

const cluster = new sst.aws.Cluster(
camelcase(`phoenix-liveview-cluster-${regionName}`),
{ forceUpgrade: `v2`, vpc },
{ provider }
)

const liveviewService = cluster.addService(
camelcase(`phoenix-liveview-service-${regionName}`),
{
loadBalancer: {
public: true,
domain: {
name: domainName,
dns: sst.cloudflare.dns(),
},
ports: [
{ listen: `443/https`, forward: `4000/http` },
{ listen: `80/http`, forward: `4000/http` },
],
},
cpu: `0.25 vCPU`,
memory: `0.5 GB`,
// Uncomment the line below if you're trying to deploy from a Mac
//architecture: `arm64`,
transform: {
target: {
deregistrationDelay: 0,
},
service: {
waitForSteadyState: true,
},
},
environment: {
DATABASE_URL: $interpolate`${pgBaseUri}?ssl=true`,
ELECTRIC_URL: `https://api-dev-production.electric-sql.com`,
SECRET_KEY_BASE: process.env.SECRET_KEY_BASE,
PHX_HOST: domainName,
ELECTRIC_CLIENT_PARAMS: $interpolate`{ "database_id": "${electricInfo.id}", "token": "${electricInfo.token}" }`,
},
image: {
context: `.`,
dockerfile: `Dockerfile`,
},
}
)

pgUriForElectric.apply(applyMigrations)

return {
liveview: liveviewService.url,
databaseId: electricInfo.id,
token: electricInfo.token,
}
},
})

function applyMigrations(uri: string) {
execSync(`pnpm exec pg-migrations apply --directory ./db/migrations`, {
env: {
...process.env,
DATABASE_URL: uri,
},
})
}

async function addDatabaseToElectric(
uri: string,
region: string
): Promise<{
id: string
token: string
}> {
const adminApi = process.env.ELECTRIC_ADMIN_API
const url = new URL(`/v1/databases`, adminApi)
const result = await fetch(url, {
method: `PUT`,
headers: { "Content-Type": `application/json` },
body: JSON.stringify({
database_url: uri,
region,
}),
})
if (!result.ok) {
throw new Error(
`Could not add database to Electric (${
result.status
}): ${await result.text()}`
)
}
return (await result.json()) as {
token: string
id: string
}
}
Loading
Loading