Switch to SSH provisioning, embed provision.sh, add terraform and scripts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d9619350f8
commit
17d85202f7
19 changed files with 711 additions and 87 deletions
18
.github/workflows/fly-deploy.yml
vendored
Normal file
18
.github/workflows/fly-deploy.yml
vendored
Normal file
|
|
@ -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 }}
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
37
deprovision.sh
Executable file
37
deprovision.sh
Executable file
|
|
@ -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"
|
||||
29
fly.toml
Normal file
29
fly.toml
Normal file
|
|
@ -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
|
||||
26
go.mod
26
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
|
||||
)
|
||||
|
|
|
|||
68
go.sum
Normal file
68
go.sum
Normal file
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 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},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("send command: %w", err)
|
||||
}, 5*time.Minute); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for command to complete
|
||||
commandID := aws.ToString(out.Command.CommandId)
|
||||
return c.waitForCommand(ctx, instanceID, commandID)
|
||||
// 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 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("new ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
73
internal/compute/provision.sh
Executable file
73
internal/compute/provision.sh
Executable file
|
|
@ -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."
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
73
provision.sh
Executable file
73
provision.sh
Executable file
|
|
@ -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."
|
||||
4
terraform/.gitignore
vendored
Normal file
4
terraform/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.terraform/
|
||||
terraform.tfvars
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
25
terraform/.terraform.lock.hcl
generated
Normal file
25
terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
161
terraform/main.tf
Normal file
161
terraform/main.tf
Normal file
|
|
@ -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"
|
||||
}
|
||||
25
terraform/outputs.tf
Normal file
25
terraform/outputs.tf
Normal file
|
|
@ -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
|
||||
}
|
||||
3
terraform/terraform.tfvars.example
Normal file
3
terraform/terraform.tfvars.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
aws_region = "us-east-1"
|
||||
db_password = "..."
|
||||
infra_service_ip = "..." # your DO droplet's public IP
|
||||
22
terraform/variables.tf
Normal file
22
terraform/variables.tf
Normal file
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue