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:
Andrew Gundersen 2026-02-25 15:38:58 -05:00
commit 17d85202f7
19 changed files with 711 additions and 87 deletions

18
.github/workflows/fly-deploy.yml vendored Normal file
View 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
View file

@ -0,0 +1 @@
.env

View 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

View 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{

37
deprovision.sh Executable file
View 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
View 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
View file

@ -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
View 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=

View 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

View 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)
}
// 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
View 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."

View 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`,

73
provision.sh Executable file
View 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
View file

@ -0,0 +1,4 @@
.terraform/
terraform.tfvars
*.tfstate
*.tfstate.backup

25
terraform/.terraform.lock.hcl generated Normal file
View 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
View 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
View 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
}

View 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
View 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"
}