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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ erl_crash.dump
/config/*.secret.exs
.elixir_ls/
.idea
k8s/dev/secrets/*.env
72 changes: 72 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1.7

ARG TARGETPLATFORM=linux/amd64
ARG BUILDER_IMAGE=hexpm/elixir:1.19.1-erlang-28.1.1-debian-bookworm-20251020-slim
ARG RUNNER_IMAGE=debian:bookworm-20251020-slim

FROM --platform=$TARGETPLATFORM ${BUILDER_IMAGE} AS build

ENV MIX_ENV=prod \
LANG=en_US.UTF-8 \
ERL_INETRC=/etc/erl_inetrc

USER root

RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
git \
curl \
bash \
openssl \
ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
printf '{inet6, false}.\n' > /etc/erl_inetrc

ENV ERL_FLAGS="+JPperf true"

WORKDIR /app

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

COPY guided/mix.exs guided/mix.lock ./
COPY guided/config config

RUN mix deps.get --only ${MIX_ENV} && \
mix deps.compile

COPY guided/priv priv
COPY guided/lib lib
COPY guided/assets assets

RUN mix assets.setup && \
mix assets.deploy

RUN mix release

FROM --platform=$TARGETPLATFORM ${RUNNER_IMAGE} AS runtime

RUN apt-get update && \
apt-get install -y --no-install-recommends \
libstdc++6 \
openssl \
libncurses5 \
ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
printf '{inet6, false}.\n' > /etc/erl_inetrc

WORKDIR /app

COPY --from=build /app/_build/prod/rel/guided ./guided

ENV HOME=/app \
MIX_ENV=prod \
PHX_SERVER=true \
PORT=4000 \
ERL_INETRC=/etc/erl_inetrc

EXPOSE 4000

ENTRYPOINT ["./guided/bin/guided"]
CMD ["start"]
49 changes: 49 additions & 0 deletions Dockerfile.postgres-age
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
ARG BUILDER_IMAGE=debian:bullseye-slim
ARG AGE_VERSION=release/PG16/1.6.0
ARG CNPG_IMAGE=ghcr.io/cloudnative-pg/postgresql:16.4-7

FROM ${BUILDER_IMAGE} AS build

ARG AGE_VERSION

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl gnupg && \
echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
curl -fsSL https://apt.postgresql.org/pub/repos/apt/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/pgdg.gpg && \
apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
git \
cmake \
libreadline-dev \
libssl-dev \
flex \
bison \
postgresql-server-dev-16 && \
rm -rf /var/lib/apt/lists/*

WORKDIR /src

RUN git clone --depth 1 --branch "${AGE_VERSION}" https://github.com/apache/age.git

WORKDIR /src/age

RUN make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config && \
make install PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config

FROM ${CNPG_IMAGE} AS runtime

COPY --from=build /usr/lib/postgresql/16/lib/age.so /usr/lib/postgresql/16/lib/
COPY --from=build /usr/share/postgresql/16/extension/age* /usr/share/postgresql/16/extension/

USER root

RUN mkdir -p /docker-entrypoint-initdb.d && \
echo "shared_preload_libraries = 'age'" >> /usr/share/postgresql/postgresql.conf.sample

USER postgres

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["postgres"]
69 changes: 69 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Helper targets for building images and deploying Guided.dev

REPO_ROOT := $(CURDIR)

# Image tags
STAGING_TAG ?= staging
PROD_TAG ?= prod

GHCR_IMAGE := ghcr.io/carverauto/guided/web
PG_IMAGE := ghcr.io/carverauto/guided/postgres-age

.PHONY: image-build-staging image-build-prod image-push-staging image-push-prod
image-build-staging:
./scripts/docker/build_release_image.sh $(STAGING_TAG)

image-build-prod:
./scripts/docker/build_release_image.sh $(PROD_TAG)

image-push-staging:
./scripts/docker/build_release_image.sh $(STAGING_TAG) --push

image-push-prod:
./scripts/docker/build_release_image.sh $(PROD_TAG) --push

.PHONY: postgres-image-build postgres-image-push
postgres-image-build:
./scripts/docker/build_postgres_age_image.sh latest

postgres-image-push:
./scripts/docker/build_postgres_age_image.sh latest --push

.PHONY: seal-staging seal-prod
seal-staging:
./scripts/k8s/seal_secrets.sh staging

seal-prod:
./scripts/k8s/seal_secrets.sh prod

.PHONY: deploy-dev deploy-staging deploy-prod
deploy-dev:
kubectl apply -k k8s/dev

deploy-staging:
kubectl apply -k k8s/staging

deploy-prod:
kubectl apply -k k8s/prod

.PHONY: smoke-staging smoke-prod
smoke-staging:
./scripts/k8s/smoke_mcp.sh --ingress-host staging.guided.dev

smoke-prod:
./scripts/k8s/smoke_mcp.sh --ingress-host guided.dev

.PHONY: help
help:
@echo "Available targets:"
@echo " image-build-staging Build the staging release image"
@echo " image-build-prod Build the production release image"
@echo " image-push-staging Build and push staging image to GHCR"
@echo " image-push-prod Build and push production image to GHCR"
@echo " seal-staging Generate sealed secrets for staging"
@echo " seal-prod Generate sealed secrets for production"
@echo " deploy-dev Apply dev overlay to current kube-context"
@echo " deploy-staging Apply staging overlay"
@echo " deploy-prod Apply production overlay"
@echo " smoke-staging Run MCP smoke test against staging"
@echo " smoke-prod Run MCP smoke test against production"
3 changes: 1 addition & 2 deletions guided/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/guided"
import {hooks as colocatedHooks} from "../vendor/phoenix-colocated-guided"
import topbar from "../vendor/topbar"

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
Expand Down Expand Up @@ -80,4 +80,3 @@ if (process.env.NODE_ENV === "development") {
window.liveReloader = reloader
})
}

3 changes: 3 additions & 0 deletions guided/assets/vendor/phoenix-colocated-guided.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Placeholder hooks for deployments that do not package the optional
// phoenix-colocated assets. Update this module if colocated hooks are added.
export const hooks = {};
139 changes: 139 additions & 0 deletions k8s/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Guided Kubernetes deployment

This directory contains Kustomize overlays for running the Phoenix + AGE stack
in different environments.

## Structure

- `base/` – namespace, Apache AGE StatefulSet, Phoenix deployment/services, and
config maps common to all environments. Requires the secrets
`guided-postgres-secret` and `guided-app-secret` to already exist.
- `dev/` – convenience overlay that generates development credentials with
`secretGenerator`. Create the local env files under `k8s/dev/secrets/`
(see the README in that directory) before running `kustomize build`.
- `staging/` and `prod/` – production-style overlays that expect Bitnami
SealedSecrets to supply credentials. Each overlay includes ingress
configuration and image tag overrides.
- `archive/` – deprecated manifests kept for reference (e.g. the original
CNPG attempt).

## Managing secrets with SealedSecrets

1. Install the Bitnami SealedSecrets controller in the cluster (for example via
Helm):

```bash
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace sealed-secrets --create-namespace
```

2. Run the helper script to generate sealed secrets for the desired overlay:

```bash
scripts/k8s/seal_secrets.sh staging # or prod
```

The script prompts for database/application passwords, produces freshly
generated `SECRET_KEY_BASE`/`RELEASE_COOKIE` values, and writes sealed
manifests to `k8s/<env>/secrets/`.

Once committed, GitOps will ensure the controller creates the underlying
Kubernetes secrets during deployment.

## Building and publishing container images

Application image:

```bash
scripts/docker/build_release_image.sh staging --push
scripts/docker/build_release_image.sh prod --push

# or via Makefile shortcuts
make image-push-staging
make image-push-prod
```

The script builds from `Dockerfile`, tags the image as
`ghcr.io/carverauto/guided/web:<tag>`, and optionally pushes when `--push` is
provided. Set `GHCR_USER`/`GHCR_TOKEN` before pushing.

PostgreSQL + AGE image (for the CloudNativePG overlays):

```bash
scripts/docker/build_postgres_age_image.sh latest --push

# or via Makefile shortcut
make postgres-image-push
```

The resulting image is referenced by the CloudNativePG cluster manifests as
`ghcr.io/carverauto/guided/postgres-age:latest`. Use a versioned tag in place
of `latest` when you promote to production.

If the cluster cannot pull from GHCR anonymously, create an image pull secret
and attach it to the default service account:

```bash
kubectl create secret docker-registry ghcr-creds \
--namespace guided \
--docker-server=ghcr.io \
--docker-username="$GHCR_USER" \
--docker-password="$GHCR_TOKEN"

kubectl patch serviceaccount default \
-n guided \
--type merge \
-p '{"imagePullSecrets":[{"name":"ghcr-creds"}]}'
```

## Smoke testing the MCP endpoint

After applying an overlay, verify both the web app and the MCP service:

Run the smoke-test helper (or the Make wrapper) to confirm both ingress and the MCP
LoadBalancer are reachable:

```bash
scripts/k8s/smoke_mcp.sh --ingress-host staging.guided.dev

# or
make smoke-staging
```

The script fetches the LoadBalancer IP, performs an MCP initialization call,
and (optionally) issues a HEAD request against the ingress host.

## Installing CloudNativePG

For the high-availability database overlays you need the CloudNativePG
operator installed in the cluster. A minimal Helm-based installation is:

```bash
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm install cnpg cnpg/cloudnative-pg \
--namespace cnpg-system --create-namespace
```

Wait until the `cnpg-controller-manager` deployment reports `AVAILABLE` before
deploying the CNPG-backed overlays.

## High-availability database overlays

The directories `k8s/staging-cnpg` and `k8s/prod-cnpg` extend their
environment counterparts by:

- Scaling the standalone `guided-postgres` StatefulSet to zero replicas
- Re-pointing the `guided-postgres` service at the CNPG primary pod
- Creating a three-instance CloudNativePG `Cluster` named `guided-db`

Apply the staging overlay after pushing both container images:

```bash
kubectl apply -k k8s/staging-cnpg
```

Connectivity for the Phoenix app continues to run through the
`guided-postgres` service; the CNPG cluster exposes the standard `-rw`/`-ro`
services (for example `guided-db-rw`) for administrative access.
6 changes: 6 additions & 0 deletions k8s/archive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Archived manifests

The resources under this directory are kept for reference only and are not
included in any current Kustomize overlays. In particular, the former
CloudNativePG experiments now live in `k8s/archive/cnpg` so it is clear that
the active deployment relies on the Apache AGE container instead.
14 changes: 14 additions & 0 deletions k8s/archive/cnpg/00-create-creds.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generate new passwords
POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
GUIDED_PASSWORD=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')

# Create secrets
kubectl create secret generic cluster-pg-superuser \
--namespace cnpg-system \
--from-literal=username=postgres \
--from-literal=password=$POSTGRES_PASSWORD

kubectl create secret generic guided-db-credentials \
--namespace cnpg-system \
--from-literal=username=guided \
--from-literal=password=$GUIDED_PASSWORD
Loading
Loading