diff --git a/.gitignore b/.gitignore index 9088605..c288c45 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ erl_crash.dump /config/*.secret.exs .elixir_ls/ .idea +k8s/dev/secrets/*.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ba79d4e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.postgres-age b/Dockerfile.postgres-age new file mode 100644 index 0000000..1879f74 --- /dev/null +++ b/Dockerfile.postgres-age @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c41e5ac --- /dev/null +++ b/Makefile @@ -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" diff --git a/guided/assets/js/app.js b/guided/assets/js/app.js index d101045..71506c1 100644 --- a/guided/assets/js/app.js +++ b/guided/assets/js/app.js @@ -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") @@ -80,4 +80,3 @@ if (process.env.NODE_ENV === "development") { window.liveReloader = reloader }) } - diff --git a/guided/assets/vendor/phoenix-colocated-guided.js b/guided/assets/vendor/phoenix-colocated-guided.js new file mode 100644 index 0000000..9df29d7 --- /dev/null +++ b/guided/assets/vendor/phoenix-colocated-guided.js @@ -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 = {}; diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..539946f --- /dev/null +++ b/k8s/README.md @@ -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//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:`, 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. diff --git a/k8s/archive/README.md b/k8s/archive/README.md new file mode 100644 index 0000000..eb0ae7a --- /dev/null +++ b/k8s/archive/README.md @@ -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. diff --git a/k8s/archive/cnpg/00-create-creds.sh b/k8s/archive/cnpg/00-create-creds.sh new file mode 100755 index 0000000..ad99643 --- /dev/null +++ b/k8s/archive/cnpg/00-create-creds.sh @@ -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 diff --git a/k8s/archive/cnpg/README.md b/k8s/archive/cnpg/README.md new file mode 100644 index 0000000..a753541 --- /dev/null +++ b/k8s/archive/cnpg/README.md @@ -0,0 +1,28 @@ +# cloud-native postgres + +## Auth Setup + +After you create the auth, you might need to login with psql +manually through a port-forward and set the password for the `guided` user. + +```sql +-- Create users if they don't exist +CREATE USER guided WITH PASSWORD 'your_password' SUPERUSER CREATEDB; + +-- Create databases +CREATE DATABASE guided WITH OWNER guided; + +-- Connect to guided database and set up permissions +\c guided +GRANT ALL PRIVILEGES ON DATABASE guided TO guided; +GRANT ALL PRIVILEGES ON SCHEMA public TO guided; +ALTER DATABASE guided OWNER TO guided; + +-- Ensure public schema exists and has correct permissions in both DBs + +\c guided +CREATE SCHEMA IF NOT EXISTS public; +GRANT ALL ON SCHEMA public TO guided; +GRANT ALL ON ALL TABLES IN SCHEMA public TO guided; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO guided; +``` diff --git a/k8s/archive/cnpg/backups.yaml b/k8s/archive/cnpg/backups.yaml new file mode 100644 index 0000000..3e71fb1 --- /dev/null +++ b/k8s/archive/cnpg/backups.yaml @@ -0,0 +1,20 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: cluster-pg-backup + namespace: cnpg-system +spec: + schedule: "0 0 * * *" # Daily at midnight + backupOwnerReference: self + cluster: + name: cluster-pg +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + name: cluster-pg-snapshot + namespace: cnpg-system +spec: + volumeSnapshotClassName: local-path + source: + persistentVolumeClaimName: cluster-pg-1 \ No newline at end of file diff --git a/k8s/archive/cnpg/cnpg-auth.yaml b/k8s/archive/cnpg/cnpg-auth.yaml new file mode 100644 index 0000000..6a4781d --- /dev/null +++ b/k8s/archive/cnpg/cnpg-auth.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cluster-pg-superuser + namespace: cnpg-system +type: Opaque +stringData: + username: postgres + password: + +--- +apiVersion: v1 +kind: Secret +metadata: + name: guided-db-credentials + namespace: cnpg-system +type: Opaque +stringData: + username: guided + password: changeme diff --git a/k8s/archive/cnpg/new-pg-cluster.yaml b/k8s/archive/cnpg/new-pg-cluster.yaml new file mode 100644 index 0000000..5a36932 --- /dev/null +++ b/k8s/archive/cnpg/new-pg-cluster.yaml @@ -0,0 +1,54 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: cluster-pg + namespace: cnpg-system + labels: + istio-injection: "disabled" +spec: + instances: 3 + + postgresql: + parameters: + shared_buffers: 256MB + max_connections: "100" + password_encryption: scram-sha-256 + + storage: + size: 15Gi + storageClass: local-path + + superuserSecret: + name: cluster-pg-superuser + + bootstrap: + initdb: + # You can choose to create the postgres database only, + # and then let the operator create the application (guided) database. + # Here’s an example to directly create guided: + database: guided + owner: guided + secret: + name: guided-db-credentials + + managed: + roles: + - name: guided + ensure: present + login: true + superuser: true + createdb: true + createrole: true + inherit: true + replication: false + bypassrls: false + + monitoring: + enablePodMonitor: true + + env: + - name: GUIDED_PASSWORD + valueFrom: + secretKeyRef: + name: guided-db-credentials + key: password # corrected key name diff --git a/k8s/base/README.md b/k8s/base/README.md new file mode 100644 index 0000000..393929d --- /dev/null +++ b/k8s/base/README.md @@ -0,0 +1,17 @@ +# guided base manifests + +This base kustomization provisions the shared `guided` namespace, a +single-node PostgreSQL instance with the Apache AGE extension enabled, +and a Phoenix deployment with matching services. Secrets for the +database and Phoenix release must be provided by the overlay (for +example via SealedSecrets or another secret management workflow). + +## Applying + +```bash +kustomize build k8s/base | kubectl apply -f - +``` + +On first boot the container initialisation scripts create the `guided` +role/database, load the `age` library, create the `age` extension, and update +the default search path so the application can immediately run Cypher queries. diff --git a/k8s/base/app/configmap.yaml b/k8s/base/app/configmap.yaml new file mode 100644 index 0000000..ac5caab --- /dev/null +++ b/k8s/base/app/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: guided-app-config + namespace: guided +data: + PHX_HOST: guided.local + PORT: "4000" diff --git a/k8s/base/app/deployment.yaml b/k8s/base/app/deployment.yaml new file mode 100644 index 0000000..2b510cd --- /dev/null +++ b/k8s/base/app/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: guided-web + namespace: guided + labels: + app.kubernetes.io/name: guided-web +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: guided-web + template: + metadata: + labels: + app.kubernetes.io/name: guided-web + spec: + containers: + - name: guided-web + image: ghcr.io/carverauto/guided/web:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 4000 + name: http + envFrom: + - secretRef: + name: guided-postgres-secret + - secretRef: + name: guided-app-secret + env: + - name: DATABASE_URL + value: ecto://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@guided-postgres:5432/$(POSTGRES_DB) + - name: PHX_SERVER + value: "true" + - name: PHX_HOST + valueFrom: + configMapKeyRef: + name: guided-app-config + key: PHX_HOST + - name: PORT + valueFrom: + configMapKeyRef: + name: guided-app-config + key: PORT + - name: MIX_ENV + value: prod + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + initialDelaySeconds: 15 + livenessProbe: + httpGet: + path: / + port: http + periodSeconds: 20 + initialDelaySeconds: 30 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/k8s/base/app/kustomization.yaml b/k8s/base/app/kustomization.yaml new file mode 100644 index 0000000..7db04d3 --- /dev/null +++ b/k8s/base/app/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: guided +resources: + - configmap.yaml + - deployment.yaml + - service.yaml + - service-mcp.yaml diff --git a/k8s/base/app/service-mcp.yaml b/k8s/base/app/service-mcp.yaml new file mode 100644 index 0000000..c95c86e --- /dev/null +++ b/k8s/base/app/service-mcp.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: guided-mcp + namespace: guided + labels: + app.kubernetes.io/name: guided-web + annotations: + metallb.universe.tf/address-pool: k3s-pool + metallb.universe.tf/allow-shared-ip: "true" +spec: + type: LoadBalancer + selector: + app.kubernetes.io/name: guided-web + ports: + - name: mcp + port: 4000 + targetPort: http diff --git a/k8s/base/app/service.yaml b/k8s/base/app/service.yaml new file mode 100644 index 0000000..5d3a301 --- /dev/null +++ b/k8s/base/app/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: guided-web + namespace: guided + labels: + app.kubernetes.io/name: guided-web +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: guided-web + ports: + - name: http + port: 80 + targetPort: http diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..1242bc9 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - postgres-age + - app diff --git a/k8s/base/namespace.yaml b/k8s/base/namespace.yaml new file mode 100644 index 0000000..3ebabe7 --- /dev/null +++ b/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: guided diff --git a/k8s/base/postgres-age/configmap-init.yaml b/k8s/base/postgres-age/configmap-init.yaml new file mode 100644 index 0000000..3a9324d --- /dev/null +++ b/k8s/base/postgres-age/configmap-init.yaml @@ -0,0 +1,65 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: guided-postgres-init + namespace: guided +data: + 001-create-guided-db.sh: | + #!/bin/bash + set -euo pipefail + + GUIDED_APP_USER="${GUIDED_APP_USER:-guided}" + GUIDED_APP_PASSWORD="${GUIDED_APP_PASSWORD:-guided}" + GUIDED_DB="${POSTGRES_DB:-guided}" + + echo "Ensuring role ${GUIDED_APP_USER} and database ${GUIDED_DB} exist..." + + psql -v ON_ERROR_STOP=1 \ + --username "${POSTGRES_USER}" \ + --dbname "${POSTGRES_DB}" \ + -v guided_user="${GUIDED_APP_USER}" \ + -v guided_pass="${GUIDED_APP_PASSWORD}" \ + -v guided_db="${GUIDED_DB}" <<'EOSQL' + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles WHERE rolname = :'guided_user' + ) THEN + EXECUTE format('CREATE ROLE %I WITH LOGIN PASSWORD %L SUPERUSER CREATEDB;', :'guided_user', :'guided_pass'); + ELSE + EXECUTE format('ALTER ROLE %I WITH LOGIN PASSWORD %L SUPERUSER CREATEDB;', :'guided_user', :'guided_pass'); + END IF; + END + $$; + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_database WHERE datname = :'guided_db' + ) THEN + EXECUTE format('CREATE DATABASE %I WITH OWNER %I;', :'guided_db', :'guided_user'); + ELSE + EXECUTE format('ALTER DATABASE %I OWNER TO %I;', :'guided_db', :'guided_user'); + END IF; + END + $$; + EOSQL + + echo "Loading Apache AGE extension..." + psql -v ON_ERROR_STOP=1 \ + --username "${POSTGRES_USER}" \ + --dbname "${GUIDED_DB}" \ + -v guided_user="${GUIDED_APP_USER}" \ + -v guided_db="${GUIDED_DB}" <<'EOSQL' + LOAD 'age'; + CREATE EXTENSION IF NOT EXISTS age; + DO $$ + DECLARE + db_name text := :'guided_db'; + role_name text := :'guided_user'; + BEGIN + EXECUTE format('ALTER DATABASE %I SET search_path = ag_catalog, "$user", public;', db_name); + EXECUTE format('ALTER ROLE %I SET search_path = ag_catalog, "$user", public;', role_name); + END + $$; + EOSQL diff --git a/k8s/base/postgres-age/job-bootstrap.yaml b/k8s/base/postgres-age/job-bootstrap.yaml new file mode 100644 index 0000000..9bf60ee --- /dev/null +++ b/k8s/base/postgres-age/job-bootstrap.yaml @@ -0,0 +1,71 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: guided-postgres-bootstrap + namespace: guided + labels: + app.kubernetes.io/name: guided-postgres-bootstrap +spec: + backoffLimit: 3 + ttlSecondsAfterFinished: 600 + template: + metadata: + labels: + app.kubernetes.io/name: guided-postgres-bootstrap + spec: + restartPolicy: OnFailure + containers: + - name: bootstrap + image: apache/age:latest + imagePullPolicy: IfNotPresent + envFrom: + - secretRef: + name: guided-postgres-secret + command: + - /bin/bash + - -ec + - | + export PGPASSWORD="${POSTGRES_PASSWORD}" + + until pg_isready -h guided-postgres -p 5432 -U "${POSTGRES_USER}" >/dev/null 2>&1; do + echo "Waiting for guided-postgres primary to accept connections..." + sleep 2 + done + psql --host guided-postgres \ + --username "${POSTGRES_USER}" \ + --dbname "${POSTGRES_DB}" \ + -v guided_app_user="${GUIDED_APP_USER}" \ + -v guided_app_password="${GUIDED_APP_PASSWORD}" \ + -v guided_db="${POSTGRES_DB}" \ + -v graph_name="guided_graph" <<'EOSQL' + LOAD 'age'; + CREATE EXTENSION IF NOT EXISTS age; + + SELECT format( + CASE + WHEN EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = :'guided_app_user') + THEN 'ALTER ROLE %I WITH LOGIN PASSWORD %L SUPERUSER CREATEDB;' + ELSE + 'CREATE ROLE %I WITH LOGIN PASSWORD %L SUPERUSER CREATEDB;' + END, + :'guided_app_user', :'guided_app_password' + ) \gexec + + SELECT format( + CASE + WHEN EXISTS (SELECT FROM pg_database WHERE datname = :'guided_db') + THEN 'ALTER DATABASE %I OWNER TO %I;' + ELSE + 'CREATE DATABASE %I WITH OWNER %I;' + END, + :'guided_db', :'guided_app_user', :'guided_db', :'guided_app_user' + ) \gexec + + SELECT format('ALTER DATABASE %I SET search_path = ag_catalog, "$user", public;', :'guided_db') \gexec + SELECT format('ALTER ROLE %I SET search_path = ag_catalog, "$user", public;', :'guided_app_user') \gexec + + SELECT format('SELECT ag_catalog.create_graph(%L);', :'graph_name') + WHERE NOT EXISTS ( + SELECT 1 FROM ag_catalog.ag_graph WHERE name = :'graph_name' + ) \gexec + EOSQL diff --git a/k8s/base/postgres-age/kustomization.yaml b/k8s/base/postgres-age/kustomization.yaml new file mode 100644 index 0000000..6378291 --- /dev/null +++ b/k8s/base/postgres-age/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: guided +resources: + - configmap-init.yaml + - service.yaml + - statefulset.yaml + - job-bootstrap.yaml diff --git a/k8s/base/postgres-age/service.yaml b/k8s/base/postgres-age/service.yaml new file mode 100644 index 0000000..f0eadfd --- /dev/null +++ b/k8s/base/postgres-age/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: guided-postgres + namespace: guided + labels: + app.kubernetes.io/name: guided-postgres +spec: + type: ClusterIP + ports: + - name: postgres + port: 5432 + targetPort: 5432 + selector: + app.kubernetes.io/name: guided-postgres diff --git a/k8s/base/postgres-age/statefulset.yaml b/k8s/base/postgres-age/statefulset.yaml new file mode 100644 index 0000000..5a6be00 --- /dev/null +++ b/k8s/base/postgres-age/statefulset.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: guided-postgres + namespace: guided + labels: + app.kubernetes.io/name: guided-postgres +spec: + serviceName: guided-postgres + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: guided-postgres + template: + metadata: + labels: + app.kubernetes.io/name: guided-postgres + spec: + containers: + - name: postgres + image: apache/age:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + name: postgres + envFrom: + - secretRef: + name: guided-postgres-secret + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: init-scripts + mountPath: /docker-entrypoint-initdb.d + volumes: + - name: init-scripts + configMap: + name: guided-postgres-init + defaultMode: 0755 + volumeClaimTemplates: + - metadata: + name: data + labels: + app.kubernetes.io/name: guided-postgres + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 10Gi diff --git a/k8s/dev/README.md b/k8s/dev/README.md new file mode 100644 index 0000000..92200c6 --- /dev/null +++ b/k8s/dev/README.md @@ -0,0 +1,11 @@ +# Dev overlay + +Applies the base manifests together with development-only secrets generated via +`secretGenerator`. Use this overlay for local clusters that do not support +SealedSecrets. + +```bash +kubectl apply -k k8s/dev +``` + +Do not use these credentials in shared environments. diff --git a/k8s/dev/kustomization.yaml b/k8s/dev/kustomization.yaml new file mode 100644 index 0000000..a03fcc5 --- /dev/null +++ b/k8s/dev/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: guided + +resources: + - ../base + +secretGenerator: + - name: guided-postgres-secret + namespace: guided + options: + disableNameSuffixHash: true + envs: + - secrets/postgres.env + - name: guided-app-secret + namespace: guided + options: + disableNameSuffixHash: true + envs: + - secrets/app.env diff --git a/k8s/dev/secrets/README.md b/k8s/dev/secrets/README.md new file mode 100644 index 0000000..a20176d --- /dev/null +++ b/k8s/dev/secrets/README.md @@ -0,0 +1,19 @@ +# Development Secrets + +The development kustomize overlay expects two local env files that are **not** +committed to version control: + +``` +k8s/dev/secrets/postgres.env +k8s/dev/secrets/app.env +``` + +Create them by copying the examples and updating the values: + +```bash +cp k8s/dev/secrets/postgres.env.example k8s/dev/secrets/postgres.env +cp k8s/dev/secrets/app.env.example k8s/dev/secrets/app.env +``` + +Both files are ignored via `.gitignore`, so each developer can keep personal +credentials without leaking them into the repo. diff --git a/k8s/dev/secrets/app.env.example b/k8s/dev/secrets/app.env.example new file mode 100644 index 0000000..be93bc9 --- /dev/null +++ b/k8s/dev/secrets/app.env.example @@ -0,0 +1,3 @@ +# Copy to app.env and replace with freshly generated secrets +SECRET_KEY_BASE=replace-with-mix-phx-gen-secret +RELEASE_COOKIE=guided-dev-cookie diff --git a/k8s/dev/secrets/postgres.env.example b/k8s/dev/secrets/postgres.env.example new file mode 100644 index 0000000..6a6c31f --- /dev/null +++ b/k8s/dev/secrets/postgres.env.example @@ -0,0 +1,9 @@ +# Copy to postgres.env and edit as needed +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres-password +POSTGRES_DB=guided +GUIDED_APP_USER=guided +GUIDED_APP_PASSWORD=guided-password +# Optional compatibility keys for some clients +username=${POSTGRES_USER} +password=${POSTGRES_PASSWORD} diff --git a/k8s/prod-cnpg/cnpg-cluster.yaml b/k8s/prod-cnpg/cnpg-cluster.yaml new file mode 100644 index 0000000..773049c --- /dev/null +++ b/k8s/prod-cnpg/cnpg-cluster.yaml @@ -0,0 +1,39 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: guided-db + namespace: guided +spec: + instances: 3 + imageName: ghcr.io/carverauto/guided/postgres-age:latest + enableSuperuserAccess: true + superuserSecret: + name: guided-postgres-secret + storage: + size: 20Gi + storageClass: local-path + bootstrap: + initdb: + database: guided + postInitSQL: + - | + CREATE EXTENSION IF NOT EXISTS age; + ALTER DATABASE guided OWNER TO guided; + ALTER DATABASE guided SET search_path = ag_catalog, "$user", public; + ALTER ROLE guided SET search_path = ag_catalog, "$user", public; + postgresql: + parameters: + shared_buffers: 256MB + max_connections: "100" + password_encryption: scram-sha-256 + managed: + roles: + - name: guided + ensure: present + login: true + superuser: true + createdb: true + createrole: true + passwordSecret: + name: guided-postgres-secret + key: GUIDED_APP_PASSWORD diff --git a/k8s/prod-cnpg/kustomization.yaml b/k8s/prod-cnpg/kustomization.yaml new file mode 100644 index 0000000..6dadd99 --- /dev/null +++ b/k8s/prod-cnpg/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../prod + - cnpg-cluster.yaml + +patches: + - path: patches/disable-standalone-postgres.yaml + - path: patches/service-guided-postgres.yaml diff --git a/k8s/prod-cnpg/patches/disable-standalone-postgres.yaml b/k8s/prod-cnpg/patches/disable-standalone-postgres.yaml new file mode 100644 index 0000000..ea87ea9 --- /dev/null +++ b/k8s/prod-cnpg/patches/disable-standalone-postgres.yaml @@ -0,0 +1,7 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: guided-postgres + namespace: guided +spec: + replicas: 0 diff --git a/k8s/prod-cnpg/patches/service-guided-postgres.yaml b/k8s/prod-cnpg/patches/service-guided-postgres.yaml new file mode 100644 index 0000000..a165b84 --- /dev/null +++ b/k8s/prod-cnpg/patches/service-guided-postgres.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: guided-postgres + namespace: guided +spec: + selector: + cnpg.io/cluster: guided-db + role: primary + ports: + - name: postgres + port: 5432 + targetPort: 5432 + type: ClusterIP diff --git a/k8s/prod/app-config.patch.yaml b/k8s/prod/app-config.patch.yaml new file mode 100644 index 0000000..563ae80 --- /dev/null +++ b/k8s/prod/app-config.patch.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: guided-app-config + namespace: guided +data: + PHX_HOST: guided.dev diff --git a/k8s/prod/ingress.yaml b/k8s/prod/ingress.yaml new file mode 100644 index 0000000..f95dec3 --- /dev/null +++ b/k8s/prod/ingress.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: guided-prod + annotations: + cert-manager.io/cluster-issuer: carverauto-issuer + external-dns.alpha.kubernetes.io/hostname: guided.dev + metallb.universe.tf/allow-shared-ip: "true" + metallb.universe.tf/address-pool: k3s-pool + nginx.ingress.kubernetes.io/proxy-body-size: "50m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "120" + nginx.ingress.kubernetes.io/proxy-send-timeout: "120" +spec: + tls: + - hosts: + - guided.dev + secretName: guided-prod-tls + rules: + - host: guided.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: guided-web + port: + name: http diff --git a/k8s/prod/kustomization.yaml b/k8s/prod/kustomization.yaml new file mode 100644 index 0000000..f6df505 --- /dev/null +++ b/k8s/prod/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: guided + +resources: + - ../base + - ingress.yaml + - secrets/guided-postgres-secret.yaml + - secrets/guided-app-secret.yaml + +images: + - name: ghcr.io/carverauto/guided/web + newTag: prod + +patches: + - path: app-config.patch.yaml diff --git a/k8s/prod/secrets/README.md b/k8s/prod/secrets/README.md new file mode 100644 index 0000000..ccc4488 --- /dev/null +++ b/k8s/prod/secrets/README.md @@ -0,0 +1,13 @@ +# Production secrets + +Generate sealed versions of the database and Phoenix secrets before applying +the production overlay. The helper script automates the process and now also +populates the `username`/`password` keys expected by the CloudNativePG +manifests: + +```bash +scripts/k8s/seal_secrets.sh prod +``` + +This prompts for the required values and produces `SealedSecret` manifests in +this directory. Ensure the SealedSecrets controller is running in the cluster. diff --git a/k8s/prod/secrets/guided-app-secret.yaml b/k8s/prod/secrets/guided-app-secret.yaml new file mode 100644 index 0000000..2107cf4 --- /dev/null +++ b/k8s/prod/secrets/guided-app-secret.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: guided-app-secret + namespace: guided +spec: + encryptedData: + RELEASE_COOKIE: AgA+ImkDa8OOnd3w5vuX+vZk0x4i9hSyK4npo9TvW09inoOYLGI8Eg1oUh5r9QvztFE/aEtfV5fQjdD51x9D3CYqQ65YKZHcVKD1qW1sAa7NO+3v4VMj3pLk6zf5u7ScE4yV2PewvJHHTstUoYhVwcGDTy5Efv6s69anTM18rTE/yz/41/RYXcQZAb/uiZaQXtMJGjsAewFFNcISVFOwFd+yxJbkZejkyQY3+YO84p6rvdV+DBBdbeUthwJ+7Yf9PW4/Vz7kEbesFPPsNpiYZ3/gzPJ4VyqqRBAb1k4DkJTX+sBNq+bE+Pzwi0yCwqHB6TQQUjjO8if8JZLugUSOPfK0RQ9VD8U4ac/TMVqEJ0SibXKwouqH/OJtAVC3J6LwnN5RUoy4qnLxRMti4r83wZKlpuvUXM563Jn80F+rTY2QRI8cgKCrrrV49mFHGW3250MXUq2DEGeN+qG5WrZy4mMVXXGhudjxEprWio58uAxHXSF2zuDgxKD/vfJVFkVc2/2l2iWthy0aSKTbQeb5oJ4FDleYHogQ3+QWdGEiKy5oMUGz5++vOMivbI9YLAVomxTa9Y9PyZDztgG2+Fdt61W8WAZ+UKloSX8TIQLmFerMfiwOjj0buHWOpzREyLNCuCmfaLwpPOadPpRQXsTCZCzieN/6oYFIM4ptu9yD4dTWIVWEyLc0IWlv8AzhBH7kgKSYYzsEUqbnIY+tdQPEcx+oZ2rIyLRE/1bzDxl/nKIgSxWTfE8nQz5DqpNpjeTzTiwBlpUS+66lmrFVHKTV8/M2 + SECRET_KEY_BASE: AgAzPcQhjwIh7rV3tBtMYmOulY0WZDjULOateUQ8szS19pmTHIbD3YXwVhmEj/N4bmw/bljfccZ1oq00MFpm4Z3oE9aYD2492qsrYOyWm/2Xi97BgogdE/40q4n7UlDV0x5dhPDNSKc73kImfj03ZrvlXV+wBEP/vYYmb2yLNGhe760EDFpTb1KwSV5jbSYWLCl+GWJBqtYipsllI9PdgKGcBb3zkxcvs/D7rBNKcLrF90YFIA4zKaguN0k9OXI7+u46oNhGjWXqB1vSKEDI9ZlcnGhslCBKuH1N2bHaUcDuvwPGQ0HkkVOX1UtkanVLbPo5wjESLALbZDlthJs3zCBqunckFfgrD0XAQzljdlDlnb4ROG6ODTauDF1z38++lYKo/gDKRIH5MfFogntKjGQ5tn1ar+8fdEWIfocuqIfoyFxncP/GFGGdCLSti17KPMbMlJ5kaoqpPn4OlTfxwwQ3x7zT8v5Z0yV4KkyMKXX2wMN6rY2mU95jB4fKMtdMJEDqo79aiq0juyqJdlRRXu3Ktxz9C+R78X1v3DCN75JK5ChnKPpCkwpZa4c91Mb8h6ZH7TF3mWAtsDfBICQ8nxlhU1VLuNm5m2KAd1xOOEmKeI940MjbcI2F99PaINJ28yRqCpcXFSlbwxrIW2KfAr3ST7LwHgGmq7LTCrTNV12jx7MAClQrjpqNS5uor2vsw8PmCR7PkKsRdbBxHXPPaALz7A4gXzc4lGtIdxhR2pqkFNU/B3YZwLX8tQDKV0iB/Xl84ih48rOEruidWRN5Qm1g + template: + metadata: + name: guided-app-secret + namespace: guided diff --git a/k8s/prod/secrets/guided-postgres-secret.yaml b/k8s/prod/secrets/guided-postgres-secret.yaml new file mode 100644 index 0000000..75932b3 --- /dev/null +++ b/k8s/prod/secrets/guided-postgres-secret.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: guided-postgres-secret + namespace: guided +spec: + encryptedData: + GUIDED_APP_PASSWORD: Ag== + GUIDED_APP_USER: Ag== + POSTGRES_DB: Ag== + POSTGRES_PASSWORD: Ag== + POSTGRES_USER: Ag== + password: Ag== + username: Ag== + template: + metadata: + name: guided-postgres-secret + namespace: guided diff --git a/k8s/staging-cnpg/cnpg-cluster.yaml b/k8s/staging-cnpg/cnpg-cluster.yaml new file mode 100644 index 0000000..2f81e46 --- /dev/null +++ b/k8s/staging-cnpg/cnpg-cluster.yaml @@ -0,0 +1,30 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: guided-db + namespace: guided +spec: + instances: 3 + imageName: ghcr.io/carverauto/guided/postgres-age:20251027-1 + imagePullSecrets: + - name: ghcr-io-cred + enableSuperuserAccess: true + superuserSecret: + name: guided-postgres-secret + storage: + size: 20Gi + storageClass: local-path + bootstrap: + initdb: + database: guided + postInitSQL: + - | + CREATE EXTENSION IF NOT EXISTS age; + ALTER DATABASE guided OWNER TO guided; + ALTER DATABASE guided SET search_path = ag_catalog, "$user", public; + ALTER ROLE guided SET search_path = ag_catalog, "$user", public; + postgresql: + parameters: + shared_buffers: 256MB + max_connections: "100" + password_encryption: scram-sha-256 diff --git a/k8s/staging-cnpg/kustomization.yaml b/k8s/staging-cnpg/kustomization.yaml new file mode 100644 index 0000000..d6b8b95 --- /dev/null +++ b/k8s/staging-cnpg/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../staging + - cnpg-cluster.yaml + +patches: + - path: patches/disable-standalone-postgres.yaml + - path: patches/service-guided-postgres.yaml diff --git a/k8s/staging-cnpg/patches/disable-standalone-postgres.yaml b/k8s/staging-cnpg/patches/disable-standalone-postgres.yaml new file mode 100644 index 0000000..ea87ea9 --- /dev/null +++ b/k8s/staging-cnpg/patches/disable-standalone-postgres.yaml @@ -0,0 +1,7 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: guided-postgres + namespace: guided +spec: + replicas: 0 diff --git a/k8s/staging-cnpg/patches/service-guided-postgres.yaml b/k8s/staging-cnpg/patches/service-guided-postgres.yaml new file mode 100644 index 0000000..a165b84 --- /dev/null +++ b/k8s/staging-cnpg/patches/service-guided-postgres.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: guided-postgres + namespace: guided +spec: + selector: + cnpg.io/cluster: guided-db + role: primary + ports: + - name: postgres + port: 5432 + targetPort: 5432 + type: ClusterIP diff --git a/k8s/staging/app-config.patch.yaml b/k8s/staging/app-config.patch.yaml new file mode 100644 index 0000000..ebfb088 --- /dev/null +++ b/k8s/staging/app-config.patch.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: guided-app-config + namespace: guided +data: + PHX_HOST: staging.guided.dev diff --git a/k8s/staging/ingress.yaml b/k8s/staging/ingress.yaml new file mode 100644 index 0000000..b0af5cd --- /dev/null +++ b/k8s/staging/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: guided-staging + annotations: + cert-manager.io/cluster-issuer: carverauto-issuer + external-dns.alpha.kubernetes.io/hostname: staging.guided.dev + metallb.universe.tf/allow-shared-ip: "true" + metallb.universe.tf/address-pool: k3s-pool +spec: + tls: + - hosts: + - staging.guided.dev + secretName: guided-staging-tls + rules: + - host: staging.guided.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: guided-web + port: + name: http diff --git a/k8s/staging/kustomization.yaml b/k8s/staging/kustomization.yaml new file mode 100644 index 0000000..c14e988 --- /dev/null +++ b/k8s/staging/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: guided + +resources: + - ../base + - ingress.yaml + - secrets/guided-postgres-secret.yaml + - secrets/guided-app-secret.yaml + +images: + - name: ghcr.io/carverauto/guided/web + newTag: staging + +patches: + - path: app-config.patch.yaml diff --git a/k8s/staging/secrets/README.md b/k8s/staging/secrets/README.md new file mode 100644 index 0000000..7a2c06a --- /dev/null +++ b/k8s/staging/secrets/README.md @@ -0,0 +1,14 @@ +# Staging secrets + +These manifests are placeholders for Bitnami SealedSecrets. The recommended +workflow is to run the helper script, which prompts for passwords and writes +sealed output into this directory (the script now also adds the `username` and +`password` keys required by CloudNativePG): + +```bash +scripts/k8s/seal_secrets.sh staging +``` + +If you prefer to seal secrets manually, make sure the SealedSecrets controller +is running and replace the `Ag==` values with real encrypted payloads using +`kubeseal`. diff --git a/k8s/staging/secrets/guided-app-secret.yaml b/k8s/staging/secrets/guided-app-secret.yaml new file mode 100644 index 0000000..7fb1b2d --- /dev/null +++ b/k8s/staging/secrets/guided-app-secret.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: guided-app-secret + namespace: guided +spec: + encryptedData: + RELEASE_COOKIE: AgCIViS9LS5gMXE1sl5V6eOFigJYDu2NjxcU23PSuVObr6ar2VaAYdhAbP/KG7Oo9+Ukg5DXv2Lb8E247XsXkwH0726wYikkaYgQygq22Dq9fVTU0GoiuDHdF63rXlhSshTY7iRINSj9NX8MyiTA737Yq7EA/JE4vy/jT6XCq5overpEXVQnNDd1RMmyvnHdSjN2+sv1Sit35ak+e6Gp7Zpr+gxqDQryfmUh2ybF4wcjJ89iV2TXgfe1XgvEs0ly/xhHIdhiFYvj4pdmrUa1NonLe9SOBJZoC3RfNsRhiDa/Y7n+UPtZeUayyrqNY85MlB7N1uLaNDngLZFe8MHeakQQDTh1S4v8tJTGdwNW9pWckse2+6BJvVSNceuwaTZswkH2SRBQPtAX5QxYHW36J+HFDpGPmH+g6R+y376H2a9lCm08XEUOGW8A9fPS6/6NmHmSLul/16EBZXcDehpbsECOEgDQQ7vmcvG4wQhWYLZ/gOm19ZRfcC8l/XeaCMavK11fQBd7UXz0w/ThLm0yjYDFxvPDQWIQUqwdz19ACYEtpy4L88aYGKH/yjx230x7YNN95+VdtRinf+t1cFr4Ahya/ionVeDWAmOuIW2XZ6NNQk+rx66Tzy777/exm7tmeriOO45zHi6SS2kY9mUKY9Csbq712hJWjzefiE5gYhwIb5efXYWCDi4+c7TUlLvAHjTdINy4QcfOz1vZJJFKf9LtJQKKOqzCaWPEDNpKMggsaBW2bekmmo+XVF+EcTR5TYFr0iJUQ8OkK+nnsSCsvqUr + SECRET_KEY_BASE: AgAuuVtDqhGLgXZn1tyHcx3nveoZEHwBfig0zylYvaj9pfMkNjdxLofb1D9S8YDNXWZBCm3FDvwbEiMAMec8qQHm0/vQilPaoPY8EsOXLF5rl17sb7Fy0pbF1owcBpfOCRgaXF8WA910cv1TKx12BydaoGsQhh2c/Y4rwEgUIKhf/l0a7wWQe/kUL4CbjNCa3fHkznFdFG12s6GjfjMK8H8HRLHuUaRt6QYTV5uBsjOjOIbySHchkyiN2izR12tQEryouCzzIvyWEO1cm9FbK7yOXvtwK7POPT7eJu7UjqvxP1K8hcQRaxppLwXOYfow5UOs+W+chuwyr1NIUfIdiH8ftn82fMcL9+Yk+5HUIJf3MsOF4fxP2LS8IvlMig8Yu4wDRjdMxnnKetBnIN3A5S6YP0sRFoWNsUXq6yO2P7lFHCY/YDe3G6NQqFf0QcsXZYT08D33i0ehoqEnMvd/PL+Yex729xSptA2fH3W3m5chPkzRJ+3bmYQY9z1KAaJcZLce1+GK6R7A6hthMXE/IIiZoSE2Ds347mw5Ytp4FAjvoVrjVH54Oa40hR8DZ4H02pKqnz6B0cSVPQD9NN9gGUfpiul1qduwJsrBVLxDz5kbTLtIrqeYK+PwyQwwNlMILYYaa2J4OSfNjh0ZnwonMMW8SuvlA+xWoL9RvkGjlo+dINq4ZBrzwlQwcwW8W8XBImCllRuDxIAvCGhksZf+0/oAV4En0QVb2T3xjMTntluPxmor/+dqBueDdhGlDyxufB21bmZkxnOnjTJNDZEyfyq+ + template: + metadata: + name: guided-app-secret + namespace: guided diff --git a/k8s/staging/secrets/guided-postgres-secret.yaml b/k8s/staging/secrets/guided-postgres-secret.yaml new file mode 100644 index 0000000..75932b3 --- /dev/null +++ b/k8s/staging/secrets/guided-postgres-secret.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: guided-postgres-secret + namespace: guided +spec: + encryptedData: + GUIDED_APP_PASSWORD: Ag== + GUIDED_APP_USER: Ag== + POSTGRES_DB: Ag== + POSTGRES_PASSWORD: Ag== + POSTGRES_USER: Ag== + password: Ag== + username: Ag== + template: + metadata: + name: guided-postgres-secret + namespace: guided diff --git a/scripts/docker/build_postgres_age_image.sh b/scripts/docker/build_postgres_age_image.sh new file mode 100755 index 0000000..7d92dd4 --- /dev/null +++ b/scripts/docker/build_postgres_age_image.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'DOC' +Usage: scripts/docker/build_postgres_age_image.sh [--push] + +Builds the CloudNativePG-compatible PostgreSQL image with the Apache AGE +extension. Pass --push to publish to ghcr.io/carverauto/guided/postgres-age:. + +Environment variables: + BUILD_ARGS Additional docker build arguments (optional) + AGE_VERSION Git branch or tag to build (default release/PG16/1.6.0) + BUILDER_IMAGE Debian image for compilation (default debian:bullseye-slim) + CNPG_IMAGE Base CloudNativePG image (default ghcr.io/cloudnative-pg/postgresql:16.4-7) + PLATFORM Target platform (default: linux/amd64) + GHCR_USER Username for docker login (required when using --push) + GHCR_TOKEN Personal access token for docker login (required when using --push) +DOC +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi + +TAG="$1" +shift + +PUSH_IMAGE=false +while [[ $# -gt 0 ]]; do + case "$1" in + --push) + PUSH_IMAGE=true + shift + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IMAGE_NAME="ghcr.io/carverauto/guided/postgres-age:${TAG}" +PLATFORM="${PLATFORM:-linux/amd64}" + +BUILD_ARGS_COMBINED=( ${BUILD_ARGS:-} \ + --build-arg "AGE_VERSION=${AGE_VERSION:-release/PG16/1.6.0}" \ + --build-arg "BUILDER_IMAGE=${BUILDER_IMAGE:-debian:bullseye-slim}" \ + --build-arg "CNPG_IMAGE=${CNPG_IMAGE:-ghcr.io/cloudnative-pg/postgresql:16.4-7}" ) + +echo "Building ${IMAGE_NAME} for ${PLATFORM}" + +BUILD_CMD=(docker buildx build --platform "${PLATFORM}" -f "${REPO_ROOT}/Dockerfile.postgres-age" -t "${IMAGE_NAME}") +BUILD_CMD+=("${BUILD_ARGS_COMBINED[@]}") + +if [[ "${PUSH_IMAGE}" == "true" ]]; then + if [[ -z "${GHCR_USER:-}" || -z "${GHCR_TOKEN:-}" ]]; then + echo "GHCR_USER and GHCR_TOKEN must be set to push images." >&2 + exit 1 + fi + + echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin + BUILD_CMD+=(--push "${REPO_ROOT}") +else + BUILD_CMD+=(--load "${REPO_ROOT}") +fi + +"${BUILD_CMD[@]}" diff --git a/scripts/docker/build_release_image.sh b/scripts/docker/build_release_image.sh new file mode 100755 index 0000000..62d2c4e --- /dev/null +++ b/scripts/docker/build_release_image.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/docker/build_release_image.sh [--push] + +Builds the Phoenix release image using the repository Dockerfile. Pass --push +to push the resulting image to ghcr.io/carverauto/guided/web:. + +Environment variables: + BUILD_ARGS Additional docker build arguments (optional) + PLATFORM Target platform (default: linux/amd64) + GHCR_USER Username for docker login (required when using --push) + GHCR_TOKEN Personal access token for docker login (required when using --push) +EOF +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi + +TAG="$1" +shift + +PUSH_IMAGE=false +while [[ $# -gt 0 ]]; do + case "$1" in + --push) + PUSH_IMAGE=true + shift + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IMAGE_NAME="ghcr.io/carverauto/guided/web:${TAG}" +PLATFORM="${PLATFORM:-linux/amd64}" + +declare -a EXTRA_BUILD_ARGS=() +if [[ -n "${BUILD_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + EXTRA_BUILD_ARGS=(${BUILD_ARGS}) +fi + +echo "Building ${IMAGE_NAME} for ${PLATFORM}" + +BUILD_CMD=(docker buildx build --platform "${PLATFORM}" -f "${REPO_ROOT}/Dockerfile" -t "${IMAGE_NAME}") + +if (( ${#EXTRA_BUILD_ARGS[@]} )); then + BUILD_CMD+=("${EXTRA_BUILD_ARGS[@]}") +fi + +if [[ "${PUSH_IMAGE}" == "true" ]]; then + if [[ -z "${GHCR_USER:-}" || -z "${GHCR_TOKEN:-}" ]]; then + echo "GHCR_USER and GHCR_TOKEN must be set to push images." >&2 + exit 1 + fi + + echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin + BUILD_CMD+=(--push "${REPO_ROOT}") +else + BUILD_CMD+=(--load "${REPO_ROOT}") +fi + +"${BUILD_CMD[@]}" diff --git a/scripts/k8s/seal_secrets.sh b/scripts/k8s/seal_secrets.sh new file mode 100755 index 0000000..ab6f779 --- /dev/null +++ b/scripts/k8s/seal_secrets.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/k8s/seal_secrets.sh + +Creates Bitnami SealedSecrets for the specified overlay (staging or prod). +The script prompts for the database password and guided app password, then +generates sealed manifests inside k8s//secrets/. + +Environment variables: + SEALED_NAMESPACE Namespace of the sealed-secrets controller (default: sealed-secrets) + SEALED_CONTROLLER Name of the sealed-secrets controller (default: sealed-secrets) + SEALED_CERT Path to a PEM-encoded controller certificate (optional) + KUBE_NAMESPACE Namespace for the Guided deployment (default: guided) +EOF +} + +if [[ $# -ne 1 ]]; then + usage + exit 1 +fi + +ENVIRONMENT="$1" +ALLOWED_ENVS=("staging" "prod") +if [[ ! " ${ALLOWED_ENVS[*]} " =~ " ${ENVIRONMENT} " ]]; then + echo "Environment must be one of: ${ALLOWED_ENVS[*]}" >&2 + exit 1 +fi + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd kubectl +require_cmd kubeseal +require_cmd openssl +require_cmd mix + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +OVERLAY_DIR="${REPO_ROOT}/k8s/${ENVIRONMENT}" +SECRET_DIR="${OVERLAY_DIR}/secrets" + +if [[ ! -d "${OVERLAY_DIR}" ]]; then + echo "Overlay directory ${OVERLAY_DIR} not found." >&2 + exit 1 +fi + +mkdir -p "${SECRET_DIR}" + +SEALED_NAMESPACE="${SEALED_NAMESPACE:-sealed-secrets}" +SEALED_CONTROLLER="${SEALED_CONTROLLER:-sealed-secrets}" +SEALED_CERT="${SEALED_CERT:-}" +KUBE_NAMESPACE="${KUBE_NAMESPACE:-guided}" + +read -r -s -p "PostgreSQL password: " POSTGRES_PASSWORD +echo +read -r -s -p "Guided app password (leave blank to reuse database password): " GUIDED_APP_PASSWORD +echo +if [[ -z "${GUIDED_APP_PASSWORD}" ]]; then + GUIDED_APP_PASSWORD="${POSTGRES_PASSWORD}" +fi + +pushd "${REPO_ROOT}/guided" >/dev/null +SECRET_KEY_BASE="$(mix phx.gen.secret)" +popd >/dev/null + +RELEASE_COOKIE="$(openssl rand -hex 32)" + +TMP_POSTGRES="$(mktemp)" +TMP_APP="$(mktemp)" + +cleanup() { + rm -f "${TMP_POSTGRES}" "${TMP_APP}" +} +trap cleanup EXIT + +kubectl create secret generic guided-postgres-secret \ + --namespace "${KUBE_NAMESPACE}" \ + --from-literal=POSTGRES_USER=postgres \ + --from-literal=POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + --from-literal=POSTGRES_DB=guided \ + --from-literal=GUIDED_APP_USER=guided \ + --from-literal=GUIDED_APP_PASSWORD="${GUIDED_APP_PASSWORD}" \ + --from-literal=username=postgres \ + --from-literal=password="${POSTGRES_PASSWORD}" \ + --dry-run=client -o yaml > "${TMP_POSTGRES}" + +kubectl create secret generic guided-app-secret \ + --namespace "${KUBE_NAMESPACE}" \ + --from-literal=SECRET_KEY_BASE="${SECRET_KEY_BASE}" \ + --from-literal=RELEASE_COOKIE="${RELEASE_COOKIE}" \ + --dry-run=client -o yaml > "${TMP_APP}" + +KUBESEAL_ARGS=(--format yaml) +if [[ -n "${SEALED_CERT}" ]]; then + KUBESEAL_ARGS+=(--cert "${SEALED_CERT}") +else + KUBESEAL_ARGS+=(--controller-namespace "${SEALED_NAMESPACE}" --controller-name "${SEALED_CONTROLLER}") +fi + +kubeseal "${KUBESEAL_ARGS[@]}" < "${TMP_POSTGRES}" > "${SECRET_DIR}/guided-postgres-secret.yaml" +kubeseal "${KUBESEAL_ARGS[@]}" < "${TMP_APP}" > "${SECRET_DIR}/guided-app-secret.yaml" + +echo "Sealed secrets written to ${SECRET_DIR}" diff --git a/scripts/k8s/smoke_mcp.sh b/scripts/k8s/smoke_mcp.sh new file mode 100755 index 0000000..ebdf07a --- /dev/null +++ b/scripts/k8s/smoke_mcp.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAMESPACE="guided" +SERVICE="guided-mcp" +INGRESS_HOST="" +INGRESS_SCHEME="https" +HTTP_PATH="/" + +usage() { + cat <<'EOF' +Usage: scripts/k8s/smoke_mcp.sh [options] + +Options: + -n, --namespace Kubernetes namespace (default: guided) + -s, --service LoadBalancer service exposing MCP (default: guided-mcp) + --ingress-host Optional ingress hostname to probe (e.g. staging.guided.dev) + --ingress-scheme Scheme for ingress probe (default: https) + --path HTTP path to hit on Phoenix ingress (default: /) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--namespace) + NAMESPACE="$2" + shift 2 + ;; + -s|--service) + SERVICE="$2" + shift 2 + ;; + --ingress-host) + INGRESS_HOST="$2" + shift 2 + ;; + --ingress-scheme) + INGRESS_SCHEME="$2" + shift 2 + ;; + --path) + HTTP_PATH="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +for cmd in kubectl curl jq; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Missing required command: ${cmd}" >&2 + exit 1 + fi +done + +echo "Fetching load balancer IP for service ${SERVICE} in namespace ${NAMESPACE}..." +LB_IP="$(kubectl get svc "${SERVICE}" -n "${NAMESPACE}" -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true)" + +if [[ -z "${LB_IP}" ]]; then + echo "LoadBalancer IP not available yet. Check service status with:" >&2 + echo " kubectl get svc ${SERVICE} -n ${NAMESPACE}" >&2 + exit 1 +fi + +echo "LoadBalancer IP: ${LB_IP}" + +MCP_RESPONSE="$(mktemp)" +trap 'rm -f "${MCP_RESPONSE}"' EXIT + +HTTP_CODE=$(curl -sS -o "${MCP_RESPONSE}" -w "%{http_code}" \ + "http://${LB_IP}:4000/mcp" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"smoke-test","version":"0.1.0"}}}') + +if [[ "${HTTP_CODE}" != "200" && "${HTTP_CODE}" != "202" ]]; then + echo "MCP endpoint returned HTTP ${HTTP_CODE}" >&2 + cat "${MCP_RESPONSE}" >&2 + exit 1 +fi + +echo "MCP endpoint responded with HTTP ${HTTP_CODE}:" +cat "${MCP_RESPONSE}" | jq . + +if [[ -n "${INGRESS_HOST}" ]]; then + echo + echo "Probing ingress at ${INGRESS_SCHEME}://${INGRESS_HOST}${HTTP_PATH}" + curl -Ik "${INGRESS_SCHEME}://${INGRESS_HOST}${HTTP_PATH}" +fi