Skip to content

Commit e00a8bb

Browse files
authored
Merge pull request #1 from Adron/dev
Implement the Platform API (Go): all operations, workers, MCP, CI/CD, Terraform
2 parents 8a9e683 + d5a5872 commit e00a8bb

110 files changed

Lines changed: 36174 additions & 69 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Local configuration for `make run` and friends. Copy to .env; every value
2+
# here is a safe local default. Production reads the same variables from
3+
# Lambda configuration and Secrets Manager (APP_SECRET_ARN).
4+
5+
APP_ENV=local
6+
LISTEN_ADDR=:8080
7+
8+
# Database: pgx against docker-compose Postgres (`make up`). In AWS,
9+
# DATABASE_BACKEND=dataapi with DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME.
10+
DATABASE_BACKEND=pg
11+
DATABASE_URL=postgres://substratal:substratal@localhost:55432/substratal?sslmode=disable
12+
13+
# Public identity. ISSUER is the JWT iss; keep the production value so
14+
# tokens look like production ones.
15+
ISSUER=https://api.substratalapps.com
16+
PUBLIC_BASE_URL=http://localhost:8080
17+
DASHBOARD_URL=http://localhost:3000
18+
AUTHORIZE_URL=https://auth.substratalapps.com/authorize
19+
CORS_ALLOWED_ORIGINS=http://localhost:3000
20+
21+
# Signing and encryption. Empty KMS ids = local keys: an RSA key generated
22+
# into .dev/ on first run, and a fixed development AES key.
23+
KMS_SIGNING_KEY_ID=
24+
KMS_DATA_KEY_ID=
25+
LOCAL_SIGNING_KEY_FILE=.dev/signing-key.pem
26+
CURSOR_SECRET=local-development-cursor-secret
27+
28+
# Email: Mailpit catches everything; read it at http://localhost:58025.
29+
EMAIL_BACKEND=smtp
30+
SMTP_ADDR=localhost:51025
31+
EMAIL_FROM=Substratal <no-reply@mail.substratalapps.com>
32+
33+
# Webhooks: allow http://localhost receivers locally (never in production).
34+
WEBHOOKS_ALLOW_PRIVATE=true
35+
36+
# Stripe test mode (optional; billing endpoints return 503 without it).
37+
STRIPE_SECRET_KEY=
38+
STRIPE_WEBHOOK_SECRET=
39+
40+
# LocalStack, for anything pointed at AWS APIs locally.
41+
AWS_ENDPOINT_URL=http://localhost:4566
42+
AWS_REGION=us-east-1
43+
AWS_ACCESS_KEY_ID=test
44+
AWS_SECRET_ACCESS_KEY=test
45+
46+
# `make seed` / `make quickstart`
47+
ADMIN_EMAIL=admin@example.com
48+
ADMIN_PASSWORD=local admin passphrase

‎.github/workflows/ci.yml‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# CI for every push to dev (and feature/*, hotfix/*) and every PR into main
2+
# (README → Branches, CI, and deployment → What runs on dev). Nothing here
3+
# touches AWS except the read-only terraform plan on PRs into main.
4+
name: CI
5+
6+
on:
7+
push:
8+
branches: [dev, "feature/**", "hotfix/**"]
9+
pull_request:
10+
branches: [main]
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: ci-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
test:
21+
runs-on: ubuntu-latest
22+
services:
23+
postgres:
24+
image: postgres:16-alpine
25+
env:
26+
POSTGRES_PASSWORD: postgres
27+
ports: ["5432:5432"]
28+
options: >-
29+
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 3s --health-retries 30
30+
env:
31+
# The API and tests connect as a non-superuser role, as they do
32+
# locally: a superuser bypasses Row-Level Security.
33+
TEST_DATABASE_URL: postgres://substratal:substratal@localhost:5432/postgres?sslmode=disable
34+
DATABASE_URL: postgres://substratal:substratal@localhost:5432/substratal?sslmode=disable
35+
steps:
36+
- uses: actions/checkout@v7
37+
38+
- uses: actions/setup-go@v7
39+
with:
40+
go-version-file: go.mod
41+
42+
- name: Create the database role
43+
env:
44+
PGPASSWORD: postgres
45+
run: psql -h localhost -U postgres -v ON_ERROR_STOP=1 -f docker/postgres-init.sql
46+
47+
- name: Format and vet
48+
run: make fmt-check && go vet ./...
49+
50+
- uses: golangci/golangci-lint-action@v9
51+
with:
52+
version: latest
53+
54+
- name: Generated code is current
55+
run: make gen-check
56+
57+
- name: Unit, integration, and contract tests
58+
run: go test -count=1 -race ./...
59+
60+
- name: Quickstart against the devserver
61+
env:
62+
EMAIL_BACKEND: log
63+
LISTEN_ADDR: ":8080"
64+
ADMIN_EMAIL: ci-admin@example.com
65+
ADMIN_PASSWORD: ci admin passphrase
66+
run: |
67+
go run ./cmd/migrate
68+
go run ./cmd/admin bootstrap -email "$ADMIN_EMAIL"
69+
go build -o /tmp/devserver ./cmd/devserver
70+
/tmp/devserver > /tmp/devserver.log 2>&1 &
71+
for i in $(seq 1 60); do curl -sf localhost:8080/v1/health && break; sleep 0.5; done
72+
SUBSTRATAL_API=http://localhost:8080/v1 scripts/quickstart.sh || (cat /tmp/devserver.log; exit 1)
73+
74+
- name: Build Lambda artifacts
75+
run: make build
76+
77+
terraform:
78+
runs-on: ubuntu-latest
79+
steps:
80+
- uses: actions/checkout@v7
81+
- uses: hashicorp/setup-terraform@v4
82+
- run: terraform fmt -check -recursive infra/terraform
83+
- run: make tf-validate
84+
85+
docs:
86+
runs-on: ubuntu-latest
87+
defaults:
88+
run:
89+
working-directory: docs
90+
steps:
91+
- uses: actions/checkout@v7
92+
- uses: ruby/setup-ruby@v1
93+
with:
94+
ruby-version: "3.2"
95+
bundler-cache: true
96+
working-directory: docs
97+
- run: bundle exec jekyll build
98+
99+
# The one thing to read before merging a release: exactly what the merge
100+
# will change in AWS. Read-only role; skipped until Phase 0 setup exists.
101+
plan:
102+
if: github.event_name == 'pull_request' && vars.AWS_PLAN_ROLE_ARN != ''
103+
needs: [test, terraform]
104+
runs-on: ubuntu-latest
105+
permissions:
106+
contents: read
107+
id-token: write
108+
pull-requests: write
109+
steps:
110+
- uses: actions/checkout@v7
111+
- uses: aws-actions/configure-aws-credentials@v6
112+
with:
113+
role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
114+
aws-region: ${{ vars.AWS_REGION }}
115+
- uses: hashicorp/setup-terraform@v4
116+
- name: terraform plan
117+
id: plan
118+
working-directory: infra/terraform/prod
119+
run: |
120+
terraform init -input=false -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}"
121+
terraform plan -input=false -no-color -lock=false \
122+
-var "artifact_bucket=${{ vars.ARTIFACT_BUCKET }}" -var "release=${{ github.event.pull_request.head.sha }}" \
123+
-var "domain_name=${{ vars.API_DOMAIN }}" > plan.txt 2>&1 || (cat plan.txt; exit 1)
124+
tail -c 60000 plan.txt > plan-short.txt
125+
- uses: actions/github-script@v9
126+
with:
127+
script: |
128+
const fs = require('fs');
129+
const plan = fs.readFileSync('infra/terraform/prod/plan-short.txt', 'utf8');
130+
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number,
131+
body: "### terraform plan (production)\n\nThis is exactly what merging will change in AWS.\n\n<details><summary>Plan</summary>\n\n```\n" + plan + "\n```\n</details>" });

‎.github/workflows/deploy.yml‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Every merge into main deploys production, and only that way (README →
2+
# What a merge into main does). The deploy role's OIDC trust policy accepts
3+
# only this repository, the main branch, and the production environment,
4+
# so nothing else can obtain these credentials, whatever its YAML says.
5+
name: Deploy
6+
7+
on:
8+
push:
9+
branches: [main]
10+
paths-ignore: ["docs/**", "**.md", ".github/workflows/docs.yml"]
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
concurrency:
17+
group: production
18+
cancel-in-progress: false
19+
20+
jobs:
21+
test:
22+
runs-on: ubuntu-latest
23+
services:
24+
postgres:
25+
image: postgres:16-alpine
26+
env:
27+
POSTGRES_PASSWORD: postgres
28+
ports: ["5432:5432"]
29+
options: >-
30+
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 3s --health-retries 30
31+
env:
32+
TEST_DATABASE_URL: postgres://substratal:substratal@localhost:5432/postgres?sslmode=disable
33+
steps:
34+
- uses: actions/checkout@v7
35+
- uses: actions/setup-go@v7
36+
with:
37+
go-version-file: go.mod
38+
- env:
39+
PGPASSWORD: postgres
40+
run: psql -h localhost -U postgres -v ON_ERROR_STOP=1 -f docker/postgres-init.sql
41+
# The merge result itself, not just the two sides of it.
42+
- run: make fmt-check && go vet ./... && make gen-check && go test -count=1 ./...
43+
44+
deploy:
45+
needs: test
46+
runs-on: ubuntu-latest
47+
environment: production
48+
permissions:
49+
contents: read
50+
id-token: write
51+
env:
52+
RELEASE: ${{ github.sha }}
53+
steps:
54+
- name: Phase 0 setup exists
55+
id: ready
56+
run: |
57+
if [ -z "${{ vars.AWS_DEPLOY_ROLE_ARN }}" ]; then
58+
echo "::notice::AWS isn't bootstrapped yet (see README → One-time setup); skipping the production deploy."
59+
echo "ready=false" >> "$GITHUB_OUTPUT"
60+
else
61+
echo "ready=true" >> "$GITHUB_OUTPUT"
62+
fi
63+
64+
- if: steps.ready.outputs.ready == 'true'
65+
uses: actions/checkout@v7
66+
67+
- if: steps.ready.outputs.ready == 'true'
68+
uses: actions/setup-go@v7
69+
with:
70+
go-version-file: go.mod
71+
72+
- if: steps.ready.outputs.ready == 'true'
73+
uses: aws-actions/configure-aws-credentials@v6
74+
with:
75+
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
76+
aws-region: ${{ vars.AWS_REGION }}
77+
78+
- if: steps.ready.outputs.ready == 'true'
79+
uses: hashicorp/setup-terraform@v4
80+
with:
81+
terraform_wrapper: false
82+
83+
- name: Build and upload artifacts
84+
if: steps.ready.outputs.ready == 'true'
85+
run: |
86+
make build
87+
for f in api mcp stripe-webhook webhook-worker jobs; do
88+
aws s3 cp "dist/$f.zip" "s3://${{ vars.ARTIFACT_BUCKET }}/$RELEASE/$f.zip" --only-show-errors
89+
done
90+
91+
- name: terraform init
92+
if: steps.ready.outputs.ready == 'true'
93+
working-directory: infra/terraform/prod
94+
run: terraform init -input=false -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}"
95+
96+
# Migrations run before new code ships. Safe because every migration
97+
# is expand-only against the running release (README → Migrations).
98+
- name: Migrate (existing cluster)
99+
id: migrate
100+
if: steps.ready.outputs.ready == 'true'
101+
working-directory: infra/terraform/prod
102+
run: |
103+
ARN=$(terraform output -raw db_cluster_arn 2>/dev/null || true)
104+
SECRET=$(terraform output -raw db_secret_arn 2>/dev/null || true)
105+
if [ -n "$ARN" ] && [ -n "$SECRET" ]; then
106+
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate
107+
echo "done=true" >> "$GITHUB_OUTPUT"
108+
else
109+
echo "done=false" >> "$GITHUB_OUTPUT" # first deploy: the cluster doesn't exist yet
110+
fi
111+
112+
- name: terraform apply
113+
if: steps.ready.outputs.ready == 'true'
114+
working-directory: infra/terraform/prod
115+
run: |
116+
terraform apply -input=false -auto-approve \
117+
-var "artifact_bucket=${{ vars.ARTIFACT_BUCKET }}" -var "release=$RELEASE" \
118+
-var "domain_name=${{ vars.API_DOMAIN }}"
119+
120+
- name: Migrate (first deploy)
121+
if: steps.ready.outputs.ready == 'true' && steps.migrate.outputs.done == 'false'
122+
working-directory: infra/terraform/prod
123+
run: |
124+
ARN=$(terraform output -raw db_cluster_arn) SECRET=$(terraform output -raw db_secret_arn)
125+
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate
126+
127+
- name: Smoke test
128+
if: steps.ready.outputs.ready == 'true'
129+
run: |
130+
BASE="https://${{ vars.API_DOMAIN }}"
131+
for i in $(seq 1 30); do
132+
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v1/health") && [ "$code" = 200 ] && break; sleep 5
133+
done
134+
[ "$code" = 200 ] || { echo "::error::health check failed ($code)"; exit 1; }
135+
curl -sf "$BASE/.well-known/jwks.json" | grep -q '"keys"'
136+
if [ -n "${{ secrets.SMOKE_API_KEY }}" ]; then
137+
# A dedicated smoke-test User and Application seeded in production.
138+
curl -sf "$BASE/v1/users/${{ vars.SMOKE_USER_ID }}/apps/${{ vars.SMOKE_APP_ID }}/effective-permissions" \
139+
-H "Authorization: Bearer ${{ secrets.SMOKE_API_KEY }}" | grep -q '"allowed":true'
140+
fi

‎.github/workflows/rollback.yml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Emergency rollback (README → Rollback): move each function's live alias
2+
# back to its previous published version, in seconds, without Terraform.
3+
# Afterwards, land a revert of the bad merge on main so Terraform's view
4+
# and reality match again.
5+
name: Rollback
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
functions:
11+
description: Functions to roll back
12+
default: "api mcp stripe-webhook webhook-worker jobs"
13+
14+
permissions:
15+
contents: read
16+
id-token: write
17+
18+
concurrency:
19+
group: production
20+
cancel-in-progress: false
21+
22+
jobs:
23+
rollback:
24+
runs-on: ubuntu-latest
25+
environment: production
26+
steps:
27+
- uses: aws-actions/configure-aws-credentials@v6
28+
with:
29+
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
30+
aws-region: ${{ vars.AWS_REGION }}
31+
- name: Point live at the previous version
32+
run: |
33+
for f in ${{ inputs.functions }}; do
34+
name="substratal-prod-$f"
35+
current=$(aws lambda get-alias --function-name "$name" --name live --query FunctionVersion --output text)
36+
previous=$(aws lambda list-versions-by-function --function-name "$name" \
37+
--query "Versions[?Version!='\$LATEST' && to_number(Version) < \`$current\`].Version | [-1]" --output text)
38+
if [ -z "$previous" ] || [ "$previous" = "None" ]; then
39+
echo "::warning::$name has no version before $current"; continue
40+
fi
41+
aws lambda update-alias --function-name "$name" --name live --function-version "$previous" >/dev/null
42+
echo "$name: live $current → $previous"
43+
done

‎.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ docs/Gemfile.lock
99

1010
.DS_Store
1111
docs/_data/versions.yml
12+
13+
# API
14+
.env
15+
.dev/
16+
dist/
17+
*.zip
18+
infra/terraform/**/.terraform/
19+
infra/terraform/**/*.tfstate*
20+
infra/terraform/**/.terraform.lock.hcl.bak

0 commit comments

Comments
 (0)