From 17d85202f7c6bf729ec2490470f3f23b07811520 Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Wed, 25 Feb 2026 15:38:58 -0500 Subject: [PATCH] Switch to SSH provisioning, embed provision.sh, add terraform and scripts Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/fly-deploy.yml | 18 +++ .gitignore | 1 + Dockerfile | 2 +- cmd/server/main.go | 9 +- deprovision.sh | 37 ++++++ fly.toml | 29 +++++ go.mod | 26 +++- go.sum | 68 +++++++++++ internal/api/api.go | 10 +- internal/compute/compute.go | 190 ++++++++++++++++++----------- internal/compute/provision.sh | 73 +++++++++++ internal/customer/customer.go | 22 +++- provision.sh | 73 +++++++++++ terraform/.gitignore | 4 + terraform/.terraform.lock.hcl | 25 ++++ terraform/main.tf | 161 ++++++++++++++++++++++++ terraform/outputs.tf | 25 ++++ terraform/terraform.tfvars.example | 3 + terraform/variables.tf | 22 ++++ 19 files changed, 711 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/fly-deploy.yml create mode 100644 .gitignore create mode 100755 deprovision.sh create mode 100644 fly.toml create mode 100644 go.sum create mode 100755 internal/compute/provision.sh create mode 100755 provision.sh create mode 100644 terraform/.gitignore create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/terraform.tfvars.example create mode 100644 terraform/variables.tf diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml new file mode 100644 index 0000000..b0c246e --- /dev/null +++ b/.github/workflows/fly-deploy.yml @@ -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 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile index 30770f8..6e731c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 839d812..62c6659 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 index 0000000..7660aaa --- /dev/null +++ b/deprovision.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# deprovision.sh — exports customer data before EC2 is terminated +# Usage: deprovision.sh + +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 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 8c0f3f6..f2ba217 100644 --- 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 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= diff --git a/internal/api/api.go b/internal/api/api.go index d2937a2..f13bba0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/compute/compute.go b/internal/compute/compute.go index 764ee17..eabc1c0 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -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 index 0000000..9e66973 --- /dev/null +++ b/internal/compute/provision.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page +# Usage: provision.sh + +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 + + + + + + Crimata — $SLUG + + + +

Welcome, $SLUG.

+

Your Crimata hub is ready.

+ + +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/internal/customer/customer.go b/internal/customer/customer.go index 4368908..47c904e 100644 --- a/internal/customer/customer.go +++ b/internal/customer/customer.go @@ -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 index 0000000..9e66973 --- /dev/null +++ b/provision.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page +# Usage: provision.sh + +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 + + + + + + Crimata — $SLUG + + + +

Welcome, $SLUG.

+

Your Crimata hub is ready.

+ + +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 index 0000000..5c07ef6 --- /dev/null +++ b/terraform/.gitignore @@ -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 index 0000000..8da922e --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -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 index 0000000..0711463 --- /dev/null +++ b/terraform/main.tf @@ -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 index 0000000..1215f5b --- /dev/null +++ b/terraform/outputs.tf @@ -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 index 0000000..842bfa8 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -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 index 0000000..2e61f01 --- /dev/null +++ b/terraform/variables.tf @@ -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" +} -- 2.43.0