]> Repos - crimata-infra/commitdiff
Switch to SSH provisioning, embed provision.sh, add terraform and scripts
authorAndrew Gundersen <adgundersen@gmail.com>
Wed, 25 Feb 2026 20:38:58 +0000 (15:38 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Wed, 25 Feb 2026 20:38:58 +0000 (15:38 -0500)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 files changed:
.github/workflows/fly-deploy.yml [new file with mode: 0644]
.gitignore [new file with mode: 0644]
Dockerfile
cmd/server/main.go
deprovision.sh [new file with mode: 0755]
fly.toml [new file with mode: 0644]
go.mod
go.sum [new file with mode: 0644]
internal/api/api.go
internal/compute/compute.go
internal/compute/provision.sh [new file with mode: 0755]
internal/customer/customer.go
provision.sh [new file with mode: 0755]
terraform/.gitignore [new file with mode: 0644]
terraform/.terraform.lock.hcl [new file with mode: 0644]
terraform/main.tf [new file with mode: 0644]
terraform/outputs.tf [new file with mode: 0644]
terraform/terraform.tfvars.example [new file with mode: 0644]
terraform/variables.tf [new file with mode: 0644]

diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml
new file mode 100644 (file)
index 0000000..b0c246e
--- /dev/null
@@ -0,0 +1,18 @@
+# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
+
+name: Fly Deploy
+on:
+  push:
+    branches:
+      - main
+jobs:
+  deploy:
+    name: Deploy app
+    runs-on: ubuntu-latest
+    concurrency: deploy-group    # optional: ensure only one action runs at a time
+    steps:
+      - uses: actions/checkout@v4
+      - uses: superfly/flyctl-actions/setup-flyctl@master
+      - run: flyctl deploy --remote-only
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..4c49bd7
--- /dev/null
@@ -0,0 +1 @@
+.env
index 30770f87c38f827eb956e9bb24f42fd6b6e679e7..6e731c5687a8bd0c8d367a2727aacd71adb05d95 100644 (file)
@@ -1,4 +1,4 @@
-FROM golang:1.23-alpine AS builder
+FROM golang:1.24-alpine AS builder
 WORKDIR /app
 COPY go.mod go.sum ./
 RUN go mod download
index 839d812640714d9ee7eb570d42a5d1440f1f89e1..62c665939142fc98425a537dd0f4b3195476481a 100644 (file)
@@ -38,11 +38,10 @@ func main() {
        }
 
        computeClient := compute.NewClient(awsCfg, compute.Config{
-               AMI:                mustEnv("EC2_AMI"),
-               InstanceType:       getEnv("EC2_INSTANCE_TYPE", "t3.micro"),
-               SecurityGroupID:    mustEnv("EC2_SECURITY_GROUP"),
-               SubnetID:           mustEnv("EC2_SUBNET"),
-               IAMInstanceProfile: mustEnv("EC2_IAM_PROFILE"),
+               AMI:             mustEnv("EC2_AMI"),
+               InstanceType:    getEnv("EC2_INSTANCE_TYPE", "t3.micro"),
+               SecurityGroupID: mustEnv("EC2_SECURITY_GROUP"),
+               SubnetID:        mustEnv("EC2_SUBNET"),
        })
 
        dnsClient := dns.NewClient(awsCfg, dns.Config{
diff --git a/deprovision.sh b/deprovision.sh
new file mode 100755 (executable)
index 0000000..7660aaa
--- /dev/null
@@ -0,0 +1,37 @@
+#!/bin/bash
+# deprovision.sh — exports customer data before EC2 is terminated
+# Usage: deprovision.sh <slug> <s3_bucket>
+
+set -euo pipefail
+
+SLUG=$1
+S3_BUCKET=$2
+EXPORT_KEY="exports/$SLUG-$(date +%s).tar.gz"
+CRIMATA_DIR=/opt/crimata
+
+log() { echo "[crimata] $1"; }
+
+# ── 1. Stop all services ───────────────────────────────────────────────────────
+log "Stopping services..."
+systemctl stop crimata-auth crimata-dock || true
+
+for APP in contacts blog; do
+  systemctl stop "crimata-$APP" || true
+done
+
+# ── 2. Dump Postgres ───────────────────────────────────────────────────────────
+log "Dumping database..."
+sudo -u postgres pg_dump crimata > /tmp/crimata.sql
+
+# ── 3. Package data ────────────────────────────────────────────────────────────
+log "Packaging data..."
+tar -czf /tmp/export.tar.gz \
+  /tmp/crimata.sql \
+  $CRIMATA_DIR/data
+
+# ── 4. Upload to S3 ────────────────────────────────────────────────────────────
+log "Uploading to S3..."
+aws s3 cp /tmp/export.tar.gz "s3://$S3_BUCKET/$EXPORT_KEY"
+
+log "Export complete: s3://$S3_BUCKET/$EXPORT_KEY"
+echo "$EXPORT_KEY"
diff --git a/fly.toml b/fly.toml
new file mode 100644 (file)
index 0000000..94fa355
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,29 @@
+# fly.toml app configuration file generated for infra-shy-sea-1418 on 2026-02-22T13:41:22-05:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'infra-shy-sea-1418'
+primary_region = 'iad'
+
+[build]
+
+[http_service]
+  internal_port = 9000
+  force_https = true
+  auto_stop_machines = 'stop'
+  auto_start_machines = true
+  min_machines_running = 1
+  processes = ['app']
+
+  [[http_service.checks]]
+    interval = '15s'
+    timeout = '10s'
+    grace_period = '30s'
+    method = 'GET'
+    path = '/health'
+
+[[vm]]
+  memory = '1gb'
+  cpus = 1
+  memory_mb = 1024
diff --git a/go.mod b/go.mod
index 8c0f3f6aef22f2208c5d12a1d0c67ab83f439627..f2ba217209890795a5553780f1307ec12f901d44 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -1,14 +1,36 @@
 module github.com/adgundersen/crimata-infra
 
-go 1.23
+go 1.24.0
 
 require (
+       github.com/aws/aws-sdk-go-v2 v1.26.0
        github.com/aws/aws-sdk-go-v2/config v1.27.0
        github.com/aws/aws-sdk-go-v2/service/ec2 v1.151.0
        github.com/aws/aws-sdk-go-v2/service/route53 v1.40.0
+       github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0
        github.com/aws/aws-sdk-go-v2/service/ses v1.22.0
        github.com/aws/aws-sdk-go-v2/service/ssm v1.49.0
-       github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0
        github.com/go-chi/chi/v5 v5.1.0
        github.com/lib/pq v1.10.9
+       golang.org/x/crypto v0.48.0
+)
+
+require (
+       github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
+       github.com/aws/aws-sdk-go-v2/credentials v1.17.0 // indirect
+       github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 // indirect
+       github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 // indirect
+       github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 // indirect
+       github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
+       github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4 // indirect
+       github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect
+       github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 // indirect
+       github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 // indirect
+       github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4 // indirect
+       github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 // indirect
+       github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0 // indirect
+       github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 // indirect
+       github.com/aws/smithy-go v1.20.1 // indirect
+       github.com/jmespath/go-jmespath v0.4.0 // indirect
+       golang.org/x/sys v0.41.0 // indirect
 )
diff --git a/go.sum b/go.sum
new file mode 100644 (file)
index 0000000..42f08ff
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,68 @@
+github.com/aws/aws-sdk-go-v2 v1.26.0 h1:/Ce4OCiM3EkpW7Y+xUnfAFpchU78K7/Ug01sZni9PgA=
+github.com/aws/aws-sdk-go-v2 v1.26.0/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 h1:gTK2uhtAPtFcdRRJilZPx8uJLL2J85xK11nKtWL0wfU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1/go.mod h1:sxpLb+nZk7tIfCWChfd+h4QwHNUR57d8hA1cleTkjJo=
+github.com/aws/aws-sdk-go-v2/config v1.27.0 h1:J5sdGCAHuWKIXLeXiqr8II/adSvetkx0qdZwdbXXpb0=
+github.com/aws/aws-sdk-go-v2/config v1.27.0/go.mod h1:cfh8v69nuSUohNFMbIISP2fhmblGmYEOKs5V53HiHnk=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.0 h1:lMW2x6sKBsiAJrpi1doOXqWFyEPoE886DTb1X0wb7So=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.0/go.mod h1:uT41FIH8cCIxOdUYIL0PYyHlL1NoneDuDSCwg5VE/5o=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 h1:xWCwjjvVz2ojYTP4kBKUuUh9ZrXfcAXpflhOUUeXg1k=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0/go.mod h1:j3fACuqXg4oMTQOR2yY7m0NmJY0yBK4L4sLsRXq1Ins=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 h1:0ScVK/4qZ8CIW0k8jOeFVsyS/sAiXpYxRBLolMkuLQM=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4/go.mod h1:84KyjNZdHC6QZW08nfHI6yZgPd+qRgaWcYsyLUo3QY8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 h1:sHmMWWX5E7guWEFQ9SVo6A3S4xpPrWnd77a6y4WM6PU=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4/go.mod h1:WjpDrhWisWOIoS9n3nk67A3Ll1vfULJ9Kq6h29HTD48=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4 h1:SIkD6T4zGQ+1YIit22wi37CGNkrE7mXV1vNA5VpI3TI=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4/go.mod h1:XfeqbsG0HNedNs0GT+ju4Bs+pFAwsrlzcRdMvdNVf5s=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.151.0 h1:gH571JR1hMfIER4zK457aNjCfi1FCuVwriKx0bAyw/I=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.151.0/go.mod h1:KNJMjsbzK97hci9ev2Vl/27GgUt3ZciRP4RGujAPF2I=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 h1:EyBZibRTVAs6ECHZOw5/wlylS9OcTzwyjeQMudmREjE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 h1:NkHCgg0Ck86c5PTOzBZ0JRccI51suJDg5lgFtxBu1ek=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6/go.mod h1:mjTpxjC8v4SeINTngrnKFgm2QUi+Jm+etTbCxh8W4uU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 h1:b+E7zIUHMmcB4Dckjpkapoy47W6C9QBv/zoUP+Hn8Kc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6/go.mod h1:S2fNV0rxrP78NhPbCZeQgY8H9jdDMeGtwcfZIRxzBqU=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4 h1:uDj2K47EM1reAYU9jVlQ1M5YENI1u6a/TxJpf6AeOLA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4/go.mod h1:XKCODf4RKHppc96c2EZBGV/oCUC7OClxAo2MEyg4pIk=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.40.0 h1:MRriK+ntpKpUc8RwcYJbc5W/eLfRV8MGFTYEcZe/QbU=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.40.0/go.mod h1:n6oZO1BbhPw2X46ObAjn8ol00kujRT+Y+Q9AnbrRUe0=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0 h1:r3o2YsgW9zRcIP3Q0WCmttFVhTuugeKIvT5z9xDspc0=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0/go.mod h1:w2E4f8PUfNtyjfL6Iu+mWI96FGttE03z3UdNcUEC4tA=
+github.com/aws/aws-sdk-go-v2/service/ses v1.22.0 h1:1HJMg3WpV9L003TOhH8eEQgkPnegOwCE3/j0Iqlmt0U=
+github.com/aws/aws-sdk-go-v2/service/ses v1.22.0/go.mod h1:9MbVy83ucnbP+4D9lky3IeUBxjECR2bHq63yzkl+WKU=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.49.0 h1:EtNvvxv0m6aP4cbTyo43vBRXeTpyt8juyNPmgKSTyYs=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.49.0/go.mod h1:wzPAvA+afHPFlAMkCf80sg7bm7GbCuFX1INetlm9DAk=
+github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 h1:u6OkVDxtBPnxPkZ9/63ynEe+8kHbtS5IfaC4PzVxzWM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.19.0/go.mod h1:YqbU3RS/pkDVu+v+Nwxvn0i1WB0HkNWEePWbmODEbbs=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0 h1:6DL0qu5+315wbsAEEmzK+P9leRwNbkp+lGjPC+CEvb8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0/go.mod h1:olUAyg+FaoFaL/zFaeQQONjOZ9HXoxgvI/c7mQTYz7M=
+github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 h1:cjTRjh700H36MQ8M0LnDn33W3JmwC77mdxIIyPWCdpM=
+github.com/aws/aws-sdk-go-v2/service/sts v1.27.0/go.mod h1:nXfOBMWPokIbOY+Gi7a1psWMSvskUCemZzI+SMB7Akc=
+github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw=
+github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
index d2937a20e0657a555888ec16b9a24fb53e536c32..f13bba091512f6466577c1e845b746875bf2fd71 100644 (file)
@@ -38,6 +38,9 @@ func NewHandler(
 
 func (h *Handler) Routes() http.Handler {
        r := chi.NewRouter()
+       r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+               w.WriteHeader(http.StatusOK)
+       })
        r.Post("/customers", h.createCustomer)
        r.Get("/customers/{slug}", h.getCustomer)
        r.Delete("/customers/{slug}", h.deleteCustomer)
@@ -119,16 +122,17 @@ func (h *Handler) provision(ctx context.Context, c *customer.Customer, password,
                return
        }
        h.store.UpdateEC2(c.ID, instance.InstanceID, instance.PublicIP)
+       h.store.UpdateSSHKey(c.ID, instance.SSHPrivateKey)
 
        // 2. Wait for instance to be ready
-       if err := h.compute.WaitUntilReady(ctx, instance.InstanceID); err != nil {
+       if err := h.compute.WaitUntilReady(ctx, instance.InstanceID, instance.PublicIP); err != nil {
                fmt.Printf("provision: wait failed for %s: %v\n", c.Email, err)
                h.store.UpdateStatus(c.ID, customer.StatusFailed)
                return
        }
 
-       // 3. Run provisioning script via SSM
-       if err := h.compute.Provision(ctx, instance.InstanceID, c.Slug, password, dbPassword); err != nil {
+       // 3. Run provisioning script via SSH
+       if err := h.compute.Provision(ctx, instance.PublicIP, instance.SSHPrivateKey, c.Slug, password, dbPassword); err != nil {
                fmt.Printf("provision: script failed for %s: %v\n", c.Email, err)
                h.store.UpdateStatus(c.ID, customer.StatusFailed)
                return
index 764ee174bfd52dc8b2f3e192b917b81b588029c3..eabc1c03f70eb549588eb0ba5f339061c2499aa0 100644 (file)
@@ -1,67 +1,79 @@
 package compute
 
 import (
+       "bytes"
        "context"
+       "crypto/rand"
+       "crypto/rsa"
+       "crypto/x509"
+       _ "embed"
+       "encoding/base64"
+       "encoding/pem"
        "fmt"
+       "net"
        "time"
 
        "github.com/aws/aws-sdk-go-v2/aws"
        "github.com/aws/aws-sdk-go-v2/service/ec2"
        ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
-       "github.com/aws/aws-sdk-go-v2/service/ssm"
+       "golang.org/x/crypto/ssh"
 )
 
+//go:embed provision.sh
+var provisionScript []byte
+
 type Config struct {
-       AMI             string // Ubuntu 24.04 LTS
-       InstanceType    string // t3.micro
+       AMI             string
+       InstanceType    string
        SecurityGroupID string
        SubnetID        string
-       IAMInstanceProfile string // must have SSM permissions
-       KeyName         string
 }
 
 type Instance struct {
-       InstanceID string
-       PublicIP   string
+       InstanceID    string
+       PublicIP      string
+       SSHPrivateKey string // PEM encoded
 }
 
 type Client struct {
        ec2 *ec2.Client
-       ssm *ssm.Client
        cfg Config
 }
 
 func NewClient(awsCfg aws.Config, cfg Config) *Client {
        return &Client{
                ec2: ec2.NewFromConfig(awsCfg),
-               ssm: ssm.NewFromConfig(awsCfg),
                cfg: cfg,
        }
 }
 
-// Launch starts a new EC2 instance for a customer and returns its details.
+// Launch starts a new EC2 instance, injecting a generated SSH public key.
 func (c *Client) Launch(ctx context.Context, slug string) (*Instance, error) {
+       privateKey, publicKey, err := generateSSHKeyPair()
+       if err != nil {
+               return nil, fmt.Errorf("generate ssh key: %w", err)
+       }
+
+       userData := fmt.Sprintf("#!/bin/bash\nmkdir -p /root/.ssh\necho '%s' >> /root/.ssh/authorized_keys\nchmod 600 /root/.ssh/authorized_keys\n", publicKey)
+
        out, err := c.ec2.RunInstances(ctx, &ec2.RunInstancesInput{
-               ImageId:      aws.String(c.cfg.AMI),
-               InstanceType: ec2types.InstanceType(c.cfg.InstanceType),
-               MinCount:     aws.Int32(1),
-               MaxCount:     aws.Int32(1),
-               IamInstanceProfile: &ec2types.IamInstanceProfileSpecification{
-                       Name: aws.String(c.cfg.IAMInstanceProfile),
-               },
+               ImageId:          aws.String(c.cfg.AMI),
+               InstanceType:     ec2types.InstanceType(c.cfg.InstanceType),
+               MinCount:         aws.Int32(1),
+               MaxCount:         aws.Int32(1),
                SecurityGroupIds: []string{c.cfg.SecurityGroupID},
                SubnetId:         aws.String(c.cfg.SubnetID),
+               UserData:         aws.String(base64.StdEncoding.EncodeToString([]byte(userData))),
                TagSpecifications: []ec2types.TagSpecification{
                        {
                                ResourceType: ec2types.ResourceTypeInstance,
                                Tags: []ec2types.Tag{
-                                       {Key: aws.String("Name"),           Value: aws.String("crimata-" + slug)},
-                                       {Key: aws.String("crimata:slug"),   Value: aws.String(slug)},
+                                       {Key: aws.String("Name"), Value: aws.String("crimata-" + slug)},
+                                       {Key: aws.String("crimata:slug"), Value: aws.String(slug)},
                                        {Key: aws.String("crimata:managed"), Value: aws.String("true")},
                                },
                        },
                },
-               UserData: aws.String(bootstrapScript()),
        })
        if err != nil {
                return nil, fmt.Errorf("run instances: %w", err)
@@ -69,40 +81,85 @@ func (c *Client) Launch(ctx context.Context, slug string) (*Instance, error) {
 
        instance := out.Instances[0]
        return &Instance{
-               InstanceID: aws.ToString(instance.InstanceId),
-               PublicIP:   aws.ToString(instance.PublicIpAddress),
+               InstanceID:    aws.ToString(instance.InstanceId),
+               PublicIP:      aws.ToString(instance.PublicIpAddress),
+               SSHPrivateKey: privateKey,
        }, nil
 }
 
-// WaitUntilReady waits for the instance to be running and SSM-reachable.
-func (c *Client) WaitUntilReady(ctx context.Context, instanceID string) error {
+// WaitUntilReady waits for the instance to be running and SSH-reachable.
+func (c *Client) WaitUntilReady(ctx context.Context, instanceID, publicIP string) error {
+       // Wait for EC2 running state
        waiter := ec2.NewInstanceRunningWaiter(c.ec2)
-       return waiter.Wait(ctx, &ec2.DescribeInstancesInput{
+       if err := waiter.Wait(ctx, &ec2.DescribeInstancesInput{
                InstanceIds: []string{instanceID},
-       }, 5*time.Minute)
+       }, 5*time.Minute); err != nil {
+               return err
+       }
+
+       // Poll until SSH port is open
+       deadline := time.Now().Add(5 * time.Minute)
+       for time.Now().Before(deadline) {
+               conn, err := net.DialTimeout("tcp", publicIP+":22", 5*time.Second)
+               if err == nil {
+                       conn.Close()
+                       return nil
+               }
+               select {
+               case <-ctx.Done():
+                       return ctx.Err()
+               case <-time.After(10 * time.Second):
+               }
+       }
+       return fmt.Errorf("timed out waiting for SSH on %s", publicIP)
 }
 
-// Provision runs the crimata provisioning script on the instance via SSM.
-func (c *Client) Provision(ctx context.Context, instanceID, slug, password, dbPassword string) error {
-       script := fmt.Sprintf(`
-               curl -fsSL https://raw.githubusercontent.com/adgundersen/crimata/main/scripts/provision.sh \
-                 | bash -s -- %s %s %s
-       `, slug, password, dbPassword)
-
-       out, err := c.ssm.SendCommand(ctx, &ssm.SendCommandInput{
-               InstanceIds:  []string{instanceID},
-               DocumentName: aws.String("AWS-RunShellScript"),
-               Parameters: map[string][]string{
-                       "commands": {script},
-               },
-       })
+// Provision runs provision.sh on the instance over SSH.
+func (c *Client) Provision(ctx context.Context, publicIP, privateKeyPEM, slug, password, dbPassword string) error {
+       signer, err := parsePrivateKey(privateKeyPEM)
+       if err != nil {
+               return fmt.Errorf("parse private key: %w", err)
+       }
+
+       config := &ssh.ClientConfig{
+               User:            "ubuntu",
+               Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
+               HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+               Timeout:         10 * time.Second,
+       }
+
+       // Retry SSH connection — user data script may still be running
+       var client *ssh.Client
+       deadline := time.Now().Add(2 * time.Minute)
+       for time.Now().Before(deadline) {
+               client, err = ssh.Dial("tcp", publicIP+":22", config)
+               if err == nil {
+                       break
+               }
+               select {
+               case <-ctx.Done():
+                       return ctx.Err()
+               case <-time.After(10 * time.Second):
+               }
+       }
+       if client == nil {
+               return fmt.Errorf("could not connect via SSH: %w", err)
+       }
+       defer client.Close()
+
+       // Upload and run provision.sh
+       session, err := client.NewSession()
        if err != nil {
-               return fmt.Errorf("send command: %w", err)
+               return fmt.Errorf("new ssh session: %w", err)
        }
+       defer session.Close()
 
-       // Wait for command to complete
-       commandID := aws.ToString(out.Command.CommandId)
-       return c.waitForCommand(ctx, instanceID, commandID)
+       session.Stdin = bytes.NewReader(provisionScript)
+       cmd := fmt.Sprintf("bash -s -- %s %s %s", slug, password, dbPassword)
+       if err := session.Run(cmd); err != nil {
+               return fmt.Errorf("provision script: %w", err)
+       }
+       return nil
 }
 
 // Terminate shuts down a customer's EC2 instance.
@@ -113,34 +170,25 @@ func (c *Client) Terminate(ctx context.Context, instanceID string) error {
        return err
 }
 
-func (c *Client) waitForCommand(ctx context.Context, instanceID, commandID string) error {
-       for {
-               select {
-               case <-ctx.Done():
-                       return ctx.Err()
-               case <-time.After(10 * time.Second):
-                       out, err := c.ssm.GetCommandInvocation(ctx, &ssm.GetCommandInvocationInput{
-                               CommandId:  aws.String(commandID),
-                               InstanceId: aws.String(instanceID),
-                       })
-                       if err != nil {
-                               continue
-                       }
-                       switch out.Status {
-                       case "Success":
-                               return nil
-                       case "Failed", "Cancelled", "TimedOut":
-                               return fmt.Errorf("provisioning script failed: %s", aws.ToString(out.StandardErrorContent))
-                       }
-               }
+func generateSSHKeyPair() (privateKeyPEM string, authorizedKey string, err error) {
+       key, err := rsa.GenerateKey(rand.Reader, 4096)
+       if err != nil {
+               return "", "", err
        }
+
+       privPEM := pem.EncodeToMemory(&pem.Block{
+               Type:  "RSA PRIVATE KEY",
+               Bytes: x509.MarshalPKCS1PrivateKey(key),
+       })
+
+       pub, err := ssh.NewPublicKey(&key.PublicKey)
+       if err != nil {
+               return "", "", err
+       }
+
+       return string(privPEM), string(ssh.MarshalAuthorizedKey(pub)), nil
 }
 
-// bootstrapScript installs the SSM agent on first boot so we can run commands.
-func bootstrapScript() string {
-       return `#!/bin/bash
-               snap install amazon-ssm-agent --classic
-               systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service
-               systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service
-       `
+func parsePrivateKey(pemBytes string) (ssh.Signer, error) {
+       return ssh.ParsePrivateKey([]byte(pemBytes))
 }
diff --git a/internal/compute/provision.sh b/internal/compute/provision.sh
new file mode 100755 (executable)
index 0000000..9e66973
--- /dev/null
@@ -0,0 +1,73 @@
+#!/bin/bash
+# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page
+# Usage: provision.sh <slug> <password> <db_password>
+
+set -euo pipefail
+
+SLUG=$1
+
+log() { echo "[crimata] $1"; }
+
+# ── 1. System dependencies ─────────────────────────────────────────────────────
+log "Installing nginx..."
+apt-get update -qq
+apt-get install -y -qq nginx
+
+# ── 2. Welcome page ────────────────────────────────────────────────────────────
+log "Creating welcome page..."
+mkdir -p /var/www/crimata
+
+cat > /var/www/crimata/index.html << EOF
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Crimata — $SLUG</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+      background: #0a0a0a;
+      color: #fff;
+      height: 100vh;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-direction: column;
+      gap: 12px;
+    }
+    h1 { font-size: 2rem; font-weight: 600; }
+    p { color: #888; font-size: 1rem; }
+  </style>
+</head>
+<body>
+  <h1>Welcome, $SLUG.</h1>
+  <p>Your Crimata hub is ready.</p>
+</body>
+</html>
+EOF
+
+# ── 3. Nginx config ────────────────────────────────────────────────────────────
+log "Configuring nginx..."
+cat > /etc/nginx/sites-available/crimata << EOF
+server {
+    listen 80;
+    server_name $SLUG.crimata.com;
+
+    root /var/www/crimata;
+    index index.html;
+
+    location / {
+        try_files \$uri \$uri/ /index.html;
+    }
+}
+EOF
+
+ln -sf /etc/nginx/sites-available/crimata /etc/nginx/sites-enabled/crimata
+rm -f /etc/nginx/sites-enabled/default
+nginx -t
+systemctl enable nginx
+systemctl restart nginx
+
+log "Provisioning complete. $SLUG.crimata.com is ready."
index 43689083dba38df171950e6aa08e62de51b8550c..47c904e17425e63ba299b2fc9ae62cabe48d9da5 100644 (file)
@@ -22,6 +22,7 @@ type Customer struct {
        Slug                 string
        EC2InstanceID        string
        EC2PublicIP          string
+       SSHPrivateKey        string
        Status               Status
        CreatedAt            time.Time
 }
@@ -44,11 +45,17 @@ func (s *Store) Migrate() error {
                        slug                   TEXT UNIQUE NOT NULL,
                        ec2_instance_id        TEXT NOT NULL DEFAULT '',
                        ec2_public_ip          TEXT NOT NULL DEFAULT '',
+                       ssh_private_key        TEXT NOT NULL DEFAULT '',
                        status                 TEXT NOT NULL DEFAULT 'provisioning',
                        created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
                )
        `)
-       return err
+       if err != nil {
+               return err
+       }
+       // Add ssh_private_key column if it doesn't exist (migration for existing tables)
+       _, _ = s.db.Exec(`ALTER TABLE customers ADD COLUMN IF NOT EXISTS ssh_private_key TEXT NOT NULL DEFAULT ''`)
+       return nil
 }
 
 func (s *Store) Create(c *Customer) error {
@@ -66,13 +73,13 @@ func (s *Store) GetByStripeID(stripeCustomerID string) (*Customer, error) {
        c := &Customer{}
        err := s.db.QueryRow(`
                SELECT id, stripe_customer_id, stripe_subscription_id, email, slug,
-                      ec2_instance_id, ec2_public_ip, status, created_at
+                      ec2_instance_id, ec2_public_ip, ssh_private_key, status, created_at
                FROM customers WHERE stripe_customer_id = $1`,
                stripeCustomerID,
        ).Scan(
                &c.ID, &c.StripeCustomerID, &c.StripeSubscriptionID,
                &c.Email, &c.Slug, &c.EC2InstanceID, &c.EC2PublicIP,
-               &c.Status, &c.CreatedAt,
+               &c.SSHPrivateKey, &c.Status, &c.CreatedAt,
        )
        if err == sql.ErrNoRows {
                return nil, nil
@@ -84,13 +91,13 @@ func (s *Store) GetBySlug(slug string) (*Customer, error) {
        c := &Customer{}
        err := s.db.QueryRow(`
                SELECT id, stripe_customer_id, stripe_subscription_id, email, slug,
-                      ec2_instance_id, ec2_public_ip, status, created_at
+                      ec2_instance_id, ec2_public_ip, ssh_private_key, status, created_at
                FROM customers WHERE slug = $1`,
                slug,
        ).Scan(
                &c.ID, &c.StripeCustomerID, &c.StripeSubscriptionID,
                &c.Email, &c.Slug, &c.EC2InstanceID, &c.EC2PublicIP,
-               &c.Status, &c.CreatedAt,
+               &c.SSHPrivateKey, &c.Status, &c.CreatedAt,
        )
        if err == sql.ErrNoRows {
                return nil, nil
@@ -98,6 +105,11 @@ func (s *Store) GetBySlug(slug string) (*Customer, error) {
        return c, err
 }
 
+func (s *Store) UpdateSSHKey(id int64, privateKey string) error {
+       _, err := s.db.Exec(`UPDATE customers SET ssh_private_key = $1 WHERE id = $2`, privateKey, id)
+       return err
+}
+
 func (s *Store) UpdateEC2(id int64, instanceID, publicIP string) error {
        _, err := s.db.Exec(
                `UPDATE customers SET ec2_instance_id = $1, ec2_public_ip = $2 WHERE id = $3`,
diff --git a/provision.sh b/provision.sh
new file mode 100755 (executable)
index 0000000..9e66973
--- /dev/null
@@ -0,0 +1,73 @@
+#!/bin/bash
+# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page
+# Usage: provision.sh <slug> <password> <db_password>
+
+set -euo pipefail
+
+SLUG=$1
+
+log() { echo "[crimata] $1"; }
+
+# ── 1. System dependencies ─────────────────────────────────────────────────────
+log "Installing nginx..."
+apt-get update -qq
+apt-get install -y -qq nginx
+
+# ── 2. Welcome page ────────────────────────────────────────────────────────────
+log "Creating welcome page..."
+mkdir -p /var/www/crimata
+
+cat > /var/www/crimata/index.html << EOF
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Crimata — $SLUG</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+      background: #0a0a0a;
+      color: #fff;
+      height: 100vh;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-direction: column;
+      gap: 12px;
+    }
+    h1 { font-size: 2rem; font-weight: 600; }
+    p { color: #888; font-size: 1rem; }
+  </style>
+</head>
+<body>
+  <h1>Welcome, $SLUG.</h1>
+  <p>Your Crimata hub is ready.</p>
+</body>
+</html>
+EOF
+
+# ── 3. Nginx config ────────────────────────────────────────────────────────────
+log "Configuring nginx..."
+cat > /etc/nginx/sites-available/crimata << EOF
+server {
+    listen 80;
+    server_name $SLUG.crimata.com;
+
+    root /var/www/crimata;
+    index index.html;
+
+    location / {
+        try_files \$uri \$uri/ /index.html;
+    }
+}
+EOF
+
+ln -sf /etc/nginx/sites-available/crimata /etc/nginx/sites-enabled/crimata
+rm -f /etc/nginx/sites-enabled/default
+nginx -t
+systemctl enable nginx
+systemctl restart nginx
+
+log "Provisioning complete. $SLUG.crimata.com is ready."
diff --git a/terraform/.gitignore b/terraform/.gitignore
new file mode 100644 (file)
index 0000000..5c07ef6
--- /dev/null
@@ -0,0 +1,4 @@
+.terraform/
+terraform.tfvars
+*.tfstate
+*.tfstate.backup
diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl
new file mode 100644 (file)
index 0000000..8da922e
--- /dev/null
@@ -0,0 +1,25 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+  version     = "5.100.0"
+  constraints = "~> 5.0"
+  hashes = [
+    "h1:hd45qFU5cFuJMpFGdUniU9mVIr5LYVWP1uMeunBpYYs=",
+    "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
+    "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
+    "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
+    "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
+    "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
+    "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
+    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+    "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
+    "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
+    "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
+    "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
+    "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
+    "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
+    "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
+    "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
+  ]
+}
diff --git a/terraform/main.tf b/terraform/main.tf
new file mode 100644 (file)
index 0000000..0711463
--- /dev/null
@@ -0,0 +1,161 @@
+terraform {
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~> 5.0"
+    }
+  }
+}
+
+provider "aws" {
+  region = var.aws_region
+}
+
+# ── Data: default VPC ──────────────────────────────────────────────────────────
+data "aws_vpc" "default" {
+  default = true
+}
+
+data "aws_subnets" "default" {
+  filter {
+    name   = "vpc-id"
+    values = [data.aws_vpc.default.id]
+  }
+}
+
+# ── Security group: customer EC2 instances ─────────────────────────────────────
+resource "aws_security_group" "customer_ec2" {
+  name        = "customer"
+  description = "Customer EC2 instances"
+  vpc_id      = data.aws_vpc.default.id
+
+  ingress {
+    from_port   = 22
+    to_port     = 22
+    protocol    = "tcp"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  ingress {
+    from_port   = 80
+    to_port     = 80
+    protocol    = "tcp"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  ingress {
+    from_port   = 443
+    to_port     = 443
+    protocol    = "tcp"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  egress {
+    from_port   = 0
+    to_port     = 0
+    protocol    = "-1"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+}
+
+# ── IAM role for customer EC2 instances (SSM access) ──────────────────────────
+resource "aws_iam_role" "crimata_ec2" {
+  name = "crimata-ec2"
+
+  assume_role_policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [{
+      Effect    = "Allow"
+      Principal = { Service = "ec2.amazonaws.com" }
+      Action    = "sts:AssumeRole"
+    }]
+  })
+}
+
+resource "aws_iam_role_policy_attachment" "ssm" {
+  role       = aws_iam_role.crimata_ec2.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_role_policy_attachment" "s3" {
+  role       = aws_iam_role.crimata_ec2.name
+  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
+}
+
+resource "aws_iam_instance_profile" "crimata_ec2" {
+  name = "crimata-ec2-profile"
+  role = aws_iam_role.crimata_ec2.name
+}
+
+# ── S3 bucket for data exports ─────────────────────────────────────────────────
+resource "aws_s3_bucket" "exports" {
+  bucket = var.s3_export_bucket
+}
+
+resource "aws_s3_bucket_lifecycle_configuration" "exports" {
+  bucket = aws_s3_bucket.exports.id
+
+  rule {
+    id     = "expire-exports"
+    status = "Enabled"
+
+    filter {}
+
+    expiration {
+      days = 30
+    }
+  }
+}
+
+# ── Security group: RDS ────────────────────────────────────────────────────────
+resource "aws_security_group" "rds" {
+  name        = "crimata-rds"
+  description = "Allow Postgres from infra service"
+  vpc_id      = data.aws_vpc.default.id
+
+  ingress {
+    description = "Postgres from infra service"
+    from_port   = 5432
+    to_port     = 5432
+    protocol    = "tcp"
+    cidr_blocks = ["${var.infra_service_ip}/32"]
+  }
+
+  egress {
+    from_port   = 0
+    to_port     = 0
+    protocol    = "-1"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+}
+
+# ── RDS subnet group ───────────────────────────────────────────────────────────
+resource "aws_db_subnet_group" "crimata" {
+  name       = "crimata"
+  subnet_ids = data.aws_subnets.default.ids
+}
+
+# ── RDS instance ───────────────────────────────────────────────────────────────
+resource "aws_db_instance" "crimata" {
+  identifier        = "crimata"
+  engine            = "postgres"
+  engine_version    = "16"
+  instance_class    = "db.t3.micro"
+  allocated_storage = 20
+  storage_type      = "gp2"
+
+  db_name  = "crimata"
+  username = "crimata"
+  password = var.db_password
+
+  db_subnet_group_name   = aws_db_subnet_group.crimata.name
+  vpc_security_group_ids = [aws_security_group.rds.id]
+
+  publicly_accessible       = true
+  skip_final_snapshot       = false
+  final_snapshot_identifier = "crimata-final"
+  deletion_protection       = true
+
+  backup_retention_period = 0
+  maintenance_window      = "Mon:04:00-Mon:05:00"
+}
diff --git a/terraform/outputs.tf b/terraform/outputs.tf
new file mode 100644 (file)
index 0000000..1215f5b
--- /dev/null
@@ -0,0 +1,25 @@
+output "customer_security_group_id" {
+  description = "Security group ID for customer EC2 instances"
+  value       = aws_security_group.customer_ec2.id
+}
+
+output "ec2_iam_instance_profile" {
+  description = "IAM instance profile name for customer EC2 instances"
+  value       = aws_iam_instance_profile.crimata_ec2.name
+}
+
+output "s3_export_bucket" {
+  description = "S3 bucket for customer data exports"
+  value       = aws_s3_bucket.exports.bucket
+}
+
+output "rds_endpoint" {
+  description = "RDS connection endpoint (host:port)"
+  value       = aws_db_instance.crimata.endpoint
+}
+
+output "database_url" {
+  description = "Full DATABASE_URL for the infra service"
+  value       = "postgresql://crimata:${var.db_password}@${aws_db_instance.crimata.endpoint}/crimata"
+  sensitive   = true
+}
diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example
new file mode 100644 (file)
index 0000000..842bfa8
--- /dev/null
@@ -0,0 +1,3 @@
+aws_region       = "us-east-1"
+db_password      = "..."
+infra_service_ip = "..."   # your DO droplet's public IP
diff --git a/terraform/variables.tf b/terraform/variables.tf
new file mode 100644 (file)
index 0000000..2e61f01
--- /dev/null
@@ -0,0 +1,22 @@
+variable "aws_region" {
+  description = "AWS region"
+  type        = string
+  default     = "us-east-1"
+}
+
+variable "db_password" {
+  description = "RDS master password"
+  type        = string
+  sensitive   = true
+}
+
+variable "infra_service_ip" {
+  description = "Public IP of the infra service (Fly.io) allowed to reach RDS"
+  type        = string
+}
+
+variable "s3_export_bucket" {
+  description = "S3 bucket name for customer data exports"
+  type        = string
+  default     = "crimata-exports"
+}