]> Repos - crimata-infra/commitdiff
Initial commit
authorAndrew Gundersen <adgundersen@gmail.com>
Sun, 22 Feb 2026 18:31:38 +0000 (13:31 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Sun, 22 Feb 2026 18:31:38 +0000 (13:31 -0500)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.env.example [new file with mode: 0644]
Dockerfile [new file with mode: 0644]
cmd/server/main.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
internal/api/api.go [new file with mode: 0644]
internal/compute/compute.go [new file with mode: 0644]
internal/customer/customer.go [new file with mode: 0644]
internal/dns/dns.go [new file with mode: 0644]
internal/export/export.go [new file with mode: 0644]
internal/notify/notify.go [new file with mode: 0644]

diff --git a/.env.example b/.env.example
new file mode 100644 (file)
index 0000000..76e3794
--- /dev/null
@@ -0,0 +1,24 @@
+PORT=9000
+DATABASE_URL=postgresql://localhost/crimata_infra
+
+# AWS
+AWS_REGION=us-east-1
+AWS_ACCESS_KEY_ID=...
+AWS_SECRET_ACCESS_KEY=...
+
+# EC2
+EC2_AMI=ami-0c7217cdde317cfec   # Ubuntu 24.04 LTS us-east-1
+EC2_INSTANCE_TYPE=t3.micro
+EC2_SECURITY_GROUP=sg-...
+EC2_SUBNET=subnet-...
+EC2_IAM_PROFILE=crimata-ec2-profile
+
+# DNS
+HOSTED_ZONE_ID=Z...
+BASE_DOMAIN=crimata.com
+
+# SES
+SES_FROM_EMAIL=noreply@crimata.com
+
+# S3
+S3_EXPORT_BUCKET=crimata-exports
diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..30770f8
--- /dev/null
@@ -0,0 +1,13 @@
+FROM golang:1.23-alpine AS builder
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -o infra ./cmd/server
+
+FROM alpine:3.20
+RUN apk --no-cache add ca-certificates
+WORKDIR /app
+COPY --from=builder /app/infra .
+EXPOSE 9000
+CMD ["./infra"]
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644 (file)
index 0000000..839d812
--- /dev/null
@@ -0,0 +1,83 @@
+package main
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+       "log"
+       "net/http"
+       "os"
+
+       "github.com/adgundersen/crimata-infra/internal/api"
+       "github.com/adgundersen/crimata-infra/internal/compute"
+       "github.com/adgundersen/crimata-infra/internal/customer"
+       "github.com/adgundersen/crimata-infra/internal/dns"
+       "github.com/adgundersen/crimata-infra/internal/export"
+       "github.com/adgundersen/crimata-infra/internal/notify"
+       awsconfig "github.com/aws/aws-sdk-go-v2/config"
+       _ "github.com/lib/pq"
+)
+
+func main() {
+       db, err := sql.Open("postgres", mustEnv("DATABASE_URL"))
+       if err != nil {
+               log.Fatalf("open db: %v", err)
+       }
+       defer db.Close()
+
+       store := customer.NewStore(db)
+       if err := store.Migrate(); err != nil {
+               log.Fatalf("migrate: %v", err)
+       }
+
+       awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
+               awsconfig.WithRegion(mustEnv("AWS_REGION")),
+       )
+       if err != nil {
+               log.Fatalf("load aws config: %v", err)
+       }
+
+       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"),
+       })
+
+       dnsClient := dns.NewClient(awsCfg, dns.Config{
+               HostedZoneID: mustEnv("HOSTED_ZONE_ID"),
+               BaseDomain:   getEnv("BASE_DOMAIN", "crimata.com"),
+       })
+
+       notifyClient := notify.NewClient(awsCfg, notify.Config{
+               FromEmail:  getEnv("SES_FROM_EMAIL", "noreply@crimata.com"),
+               BaseDomain: getEnv("BASE_DOMAIN", "crimata.com"),
+       })
+
+       exportClient := export.NewClient(awsCfg, export.Config{
+               S3Bucket: mustEnv("S3_EXPORT_BUCKET"),
+               Region:   mustEnv("AWS_REGION"),
+       })
+
+       handler := api.NewHandler(store, computeClient, dnsClient, notifyClient, exportClient)
+
+       port := getEnv("PORT", "9000")
+       fmt.Printf("crimata-infra listening on :%s\n", port)
+       log.Fatal(http.ListenAndServe(":"+port, handler.Routes()))
+}
+
+func mustEnv(key string) string {
+       v := os.Getenv(key)
+       if v == "" {
+               log.Fatalf("missing required env var: %s", key)
+       }
+       return v
+}
+
+func getEnv(key, fallback string) string {
+       if v := os.Getenv(key); v != "" {
+               return v
+       }
+       return fallback
+}
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..8c0f3f6
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,14 @@
+module github.com/adgundersen/crimata-infra
+
+go 1.23
+
+require (
+       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/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
+)
diff --git a/internal/api/api.go b/internal/api/api.go
new file mode 100644 (file)
index 0000000..d2937a2
--- /dev/null
@@ -0,0 +1,205 @@
+package api
+
+import (
+       "context"
+       "crypto/rand"
+       "encoding/hex"
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "regexp"
+       "strings"
+
+       "github.com/adgundersen/crimata-infra/internal/compute"
+       "github.com/adgundersen/crimata-infra/internal/customer"
+       "github.com/adgundersen/crimata-infra/internal/dns"
+       "github.com/adgundersen/crimata-infra/internal/export"
+       "github.com/adgundersen/crimata-infra/internal/notify"
+       "github.com/go-chi/chi/v5"
+)
+
+type Handler struct {
+       store   *customer.Store
+       compute *compute.Client
+       dns     *dns.Client
+       notify  *notify.Client
+       export  *export.Client
+}
+
+func NewHandler(
+       store *customer.Store,
+       compute *compute.Client,
+       dns *dns.Client,
+       notify *notify.Client,
+       export *export.Client,
+) *Handler {
+       return &Handler{store: store, compute: compute, dns: dns, notify: notify, export: export}
+}
+
+func (h *Handler) Routes() http.Handler {
+       r := chi.NewRouter()
+       r.Post("/customers", h.createCustomer)
+       r.Get("/customers/{slug}", h.getCustomer)
+       r.Delete("/customers/{slug}", h.deleteCustomer)
+       return r
+}
+
+type createRequest struct {
+       StripeCustomerID     string `json:"stripe_customer_id"`
+       StripeSubscriptionID string `json:"stripe_subscription_id"`
+       Email                string `json:"email"`
+}
+
+func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) {
+       var req createRequest
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               http.Error(w, "invalid request", http.StatusBadRequest)
+               return
+       }
+
+       // Idempotency
+       existing, _ := h.store.GetByStripeID(req.StripeCustomerID)
+       if existing != nil {
+               jsonResponse(w, existing, http.StatusOK)
+               return
+       }
+
+       slug       := uniqueSlug(h.store, req.Email)
+       password   := randomHex(12)
+       dbPassword := randomHex(24)
+
+       c := &customer.Customer{
+               StripeCustomerID:     req.StripeCustomerID,
+               StripeSubscriptionID: req.StripeSubscriptionID,
+               Email:                req.Email,
+               Slug:                 slug,
+               Status:               customer.StatusProvisioning,
+       }
+       if err := h.store.Create(c); err != nil {
+               http.Error(w, "failed to create customer record", http.StatusInternalServerError)
+               return
+       }
+
+       go h.provision(context.Background(), c, password, dbPassword)
+
+       w.WriteHeader(http.StatusAccepted)
+       jsonResponse(w, c, http.StatusAccepted)
+}
+
+func (h *Handler) getCustomer(w http.ResponseWriter, r *http.Request) {
+       slug := chi.URLParam(r, "slug")
+       c, err := h.store.GetBySlug(slug)
+       if err != nil || c == nil {
+               http.Error(w, "not found", http.StatusNotFound)
+               return
+       }
+       jsonResponse(w, c, http.StatusOK)
+}
+
+func (h *Handler) deleteCustomer(w http.ResponseWriter, r *http.Request) {
+       slug := chi.URLParam(r, "slug")
+       c, err := h.store.GetBySlug(slug)
+       if err != nil || c == nil {
+               http.Error(w, "not found", http.StatusNotFound)
+               return
+       }
+
+       go h.deprovision(context.Background(), c)
+       w.WriteHeader(http.StatusAccepted)
+}
+
+// ── Provisioning ──────────────────────────────────────────────────────────────
+
+func (h *Handler) provision(ctx context.Context, c *customer.Customer, password, dbPassword string) {
+       // 1. Launch EC2
+       instance, err := h.compute.Launch(ctx, c.Slug)
+       if err != nil {
+               fmt.Printf("provision: launch failed for %s: %v\n", c.Email, err)
+               h.store.UpdateStatus(c.ID, customer.StatusFailed)
+               return
+       }
+       h.store.UpdateEC2(c.ID, instance.InstanceID, instance.PublicIP)
+
+       // 2. Wait for instance to be ready
+       if err := h.compute.WaitUntilReady(ctx, instance.InstanceID); 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 {
+               fmt.Printf("provision: script failed for %s: %v\n", c.Email, err)
+               h.store.UpdateStatus(c.ID, customer.StatusFailed)
+               return
+       }
+
+       // 4. Create Route53 record
+       if err := h.dns.CreateRecord(ctx, c.Slug, instance.PublicIP); err != nil {
+               fmt.Printf("provision: dns failed for %s: %v\n", c.Email, err)
+       }
+
+       // 5. Send welcome email
+       if err := h.notify.SendWelcome(ctx, c.Email, c.Slug, password); err != nil {
+               fmt.Printf("provision: email failed for %s: %v\n", c.Email, err)
+       }
+
+       h.store.UpdateStatus(c.ID, customer.StatusActive)
+       fmt.Printf("provision: %s is live at %s.crimata.com\n", c.Email, c.Slug)
+}
+
+func (h *Handler) deprovision(ctx context.Context, c *customer.Customer) {
+       h.store.UpdateStatus(c.ID, customer.StatusCancelled)
+
+       // 1. Export data and email download link
+       downloadURL, err := h.export.Export(ctx, c.EC2InstanceID, c.Slug)
+       if err != nil {
+               fmt.Printf("deprovision: export failed for %s: %v\n", c.Email, err)
+       } else {
+               h.notify.SendDataExport(ctx, c.Email, downloadURL)
+       }
+
+       // 2. Terminate EC2
+       if err := h.compute.Terminate(ctx, c.EC2InstanceID); err != nil {
+               fmt.Printf("deprovision: terminate failed for %s: %v\n", c.Email, err)
+       }
+
+       // 3. Remove Route53 record
+       if err := h.dns.DeleteRecord(ctx, c.Slug, c.EC2PublicIP); err != nil {
+               fmt.Printf("deprovision: dns delete failed for %s: %v\n", c.Email, err)
+       }
+
+       fmt.Printf("deprovision: %s cleaned up\n", c.Email)
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
+
+func uniqueSlug(store *customer.Store, email string) string {
+       base := strings.Trim(slugRe.ReplaceAllString(
+               strings.ToLower(strings.Split(email, "@")[0]), "-"), "-")
+       if len(base) > 20 {
+               base = base[:20]
+       }
+       slug := base
+       for i := 2; ; i++ {
+               existing, _ := store.GetBySlug(slug)
+               if existing == nil {
+                       return slug
+               }
+               slug = fmt.Sprintf("%s-%d", base, i)
+       }
+}
+
+func randomHex(n int) string {
+       b := make([]byte, n)
+       rand.Read(b)
+       return hex.EncodeToString(b)
+}
+
+func jsonResponse(w http.ResponseWriter, v any, status int) {
+       w.Header().Set("Content-Type", "application/json")
+       w.WriteHeader(status)
+       json.NewEncoder(w).Encode(v)
+}
diff --git a/internal/compute/compute.go b/internal/compute/compute.go
new file mode 100644 (file)
index 0000000..764ee17
--- /dev/null
@@ -0,0 +1,146 @@
+package compute
+
+import (
+       "context"
+       "fmt"
+       "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"
+)
+
+type Config struct {
+       AMI             string // Ubuntu 24.04 LTS
+       InstanceType    string // t3.micro
+       SecurityGroupID string
+       SubnetID        string
+       IAMInstanceProfile string // must have SSM permissions
+       KeyName         string
+}
+
+type Instance struct {
+       InstanceID string
+       PublicIP   string
+}
+
+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.
+func (c *Client) Launch(ctx context.Context, slug string) (*Instance, error) {
+       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),
+               },
+               SecurityGroupIds: []string{c.cfg.SecurityGroupID},
+               SubnetId:         aws.String(c.cfg.SubnetID),
+               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("crimata:managed"), Value: aws.String("true")},
+                               },
+                       },
+               },
+               UserData: aws.String(bootstrapScript()),
+       })
+       if err != nil {
+               return nil, fmt.Errorf("run instances: %w", err)
+       }
+
+       instance := out.Instances[0]
+       return &Instance{
+               InstanceID: aws.ToString(instance.InstanceId),
+               PublicIP:   aws.ToString(instance.PublicIpAddress),
+       }, nil
+}
+
+// WaitUntilReady waits for the instance to be running and SSM-reachable.
+func (c *Client) WaitUntilReady(ctx context.Context, instanceID string) error {
+       waiter := ec2.NewInstanceRunningWaiter(c.ec2)
+       return 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)
+       }
+
+       // Wait for command to complete
+       commandID := aws.ToString(out.Command.CommandId)
+       return c.waitForCommand(ctx, instanceID, commandID)
+}
+
+// Terminate shuts down a customer's EC2 instance.
+func (c *Client) Terminate(ctx context.Context, instanceID string) error {
+       _, err := c.ec2.TerminateInstances(ctx, &ec2.TerminateInstancesInput{
+               InstanceIds: []string{instanceID},
+       })
+       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))
+                       }
+               }
+       }
+}
+
+// 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
+       `
+}
diff --git a/internal/customer/customer.go b/internal/customer/customer.go
new file mode 100644 (file)
index 0000000..4368908
--- /dev/null
@@ -0,0 +1,112 @@
+package customer
+
+import (
+       "database/sql"
+       "time"
+)
+
+type Status string
+
+const (
+       StatusProvisioning Status = "provisioning"
+       StatusActive       Status = "active"
+       StatusFailed       Status = "failed"
+       StatusCancelled    Status = "cancelled"
+)
+
+type Customer struct {
+       ID                   int64
+       StripeCustomerID     string
+       StripeSubscriptionID string
+       Email                string
+       Slug                 string
+       EC2InstanceID        string
+       EC2PublicIP          string
+       Status               Status
+       CreatedAt            time.Time
+}
+
+type Store struct {
+       db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+       return &Store{db: db}
+}
+
+func (s *Store) Migrate() error {
+       _, err := s.db.Exec(`
+               CREATE TABLE IF NOT EXISTS customers (
+                       id                     SERIAL PRIMARY KEY,
+                       stripe_customer_id     TEXT UNIQUE NOT NULL,
+                       stripe_subscription_id TEXT UNIQUE NOT NULL,
+                       email                  TEXT NOT NULL,
+                       slug                   TEXT UNIQUE NOT NULL,
+                       ec2_instance_id        TEXT NOT NULL DEFAULT '',
+                       ec2_public_ip          TEXT NOT NULL DEFAULT '',
+                       status                 TEXT NOT NULL DEFAULT 'provisioning',
+                       created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
+               )
+       `)
+       return err
+}
+
+func (s *Store) Create(c *Customer) error {
+       return s.db.QueryRow(`
+               INSERT INTO customers
+                       (stripe_customer_id, stripe_subscription_id, email, slug, status)
+               VALUES ($1, $2, $3, $4, $5)
+               RETURNING id, created_at`,
+               c.StripeCustomerID, c.StripeSubscriptionID,
+               c.Email, c.Slug, c.Status,
+       ).Scan(&c.ID, &c.CreatedAt)
+}
+
+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
+               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,
+       )
+       if err == sql.ErrNoRows {
+               return nil, nil
+       }
+       return c, err
+}
+
+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
+               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,
+       )
+       if err == sql.ErrNoRows {
+               return nil, nil
+       }
+       return c, 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`,
+               instanceID, publicIP, id,
+       )
+       return err
+}
+
+func (s *Store) UpdateStatus(id int64, status Status) error {
+       _, err := s.db.Exec(`UPDATE customers SET status = $1 WHERE id = $2`, status, id)
+       return err
+}
diff --git a/internal/dns/dns.go b/internal/dns/dns.go
new file mode 100644 (file)
index 0000000..46b0c87
--- /dev/null
@@ -0,0 +1,60 @@
+package dns
+
+import (
+       "context"
+       "fmt"
+
+       "github.com/aws/aws-sdk-go-v2/aws"
+       "github.com/aws/aws-sdk-go-v2/service/route53"
+       r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+type Config struct {
+       HostedZoneID string
+       BaseDomain   string
+}
+
+type Client struct {
+       r53 *route53.Client
+       cfg Config
+}
+
+func NewClient(awsCfg aws.Config, cfg Config) *Client {
+       return &Client{
+               r53: route53.NewFromConfig(awsCfg),
+               cfg: cfg,
+       }
+}
+
+// CreateRecord points slug.crimata.com at the EC2 public IP.
+func (c *Client) CreateRecord(ctx context.Context, slug, ip string) error {
+       return c.changeRecord(ctx, slug, ip, r53types.ChangeActionCreate)
+}
+
+// DeleteRecord removes the Route53 record for a customer.
+func (c *Client) DeleteRecord(ctx context.Context, slug, ip string) error {
+       return c.changeRecord(ctx, slug, ip, r53types.ChangeActionDelete)
+}
+
+func (c *Client) changeRecord(ctx context.Context, slug, ip string, action r53types.ChangeAction) error {
+       name := fmt.Sprintf("%s.%s", slug, c.cfg.BaseDomain)
+       _, err := c.r53.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+               HostedZoneId: aws.String(c.cfg.HostedZoneID),
+               ChangeBatch: &r53types.ChangeBatch{
+                       Changes: []r53types.Change{
+                               {
+                                       Action: action,
+                                       ResourceRecordSet: &r53types.ResourceRecordSet{
+                                               Name: aws.String(name),
+                                               Type: r53types.RRTypeA,
+                                               TTL:  aws.Int64(60),
+                                               ResourceRecords: []r53types.ResourceRecord{
+                                                       {Value: aws.String(ip)},
+                                               },
+                                       },
+                               },
+                       },
+               },
+       })
+       return err
+}
diff --git a/internal/export/export.go b/internal/export/export.go
new file mode 100644 (file)
index 0000000..73fa9b9
--- /dev/null
@@ -0,0 +1,64 @@
+package export
+
+import (
+       "context"
+       "fmt"
+       "time"
+
+       "github.com/aws/aws-sdk-go-v2/aws"
+       "github.com/aws/aws-sdk-go-v2/service/s3"
+       "github.com/aws/aws-sdk-go-v2/service/ssm"
+)
+
+type Config struct {
+       S3Bucket string
+       Region   string
+}
+
+type Client struct {
+       s3  *s3.Client
+       ssm *ssm.Client
+       cfg Config
+}
+
+func NewClient(awsCfg aws.Config, cfg Config) *Client {
+       return &Client{
+               s3:  s3.NewFromConfig(awsCfg),
+               ssm: ssm.NewFromConfig(awsCfg),
+               cfg: cfg,
+       }
+}
+
+// Export dumps the customer's Postgres DB and files, uploads to S3,
+// and returns a pre-signed download URL valid for 24 hours.
+func (c *Client) Export(ctx context.Context, instanceID, slug string) (string, error) {
+       key := fmt.Sprintf("exports/%s-%d.tar.gz", slug, time.Now().Unix())
+
+       // Run export script on the EC2 via SSM
+       script := fmt.Sprintf(`
+               pg_dump crimata > /tmp/crimata.sql
+               tar -czf /tmp/export.tar.gz /tmp/crimata.sql /opt/crimata/data
+               aws s3 cp /tmp/export.tar.gz s3://%s/%s
+       `, c.cfg.S3Bucket, key)
+
+       _, 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("export script: %w", err)
+       }
+
+       // Generate pre-signed URL valid for 24 hours
+       presigner := s3.NewPresignClient(c.s3)
+       req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
+               Bucket: aws.String(c.cfg.S3Bucket),
+               Key:    aws.String(key),
+       }, s3.WithPresignExpires(24*time.Hour))
+       if err != nil {
+               return "", fmt.Errorf("presign: %w", err)
+       }
+
+       return req.URL, nil
+}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
new file mode 100644 (file)
index 0000000..48bf1f1
--- /dev/null
@@ -0,0 +1,66 @@
+package notify
+
+import (
+       "context"
+       "fmt"
+
+       "github.com/aws/aws-sdk-go-v2/aws"
+       "github.com/aws/aws-sdk-go-v2/service/ses"
+       sestypes "github.com/aws/aws-sdk-go-v2/service/ses/types"
+)
+
+type Config struct {
+       FromEmail  string
+       BaseDomain string
+}
+
+type Client struct {
+       ses *ses.Client
+       cfg Config
+}
+
+func NewClient(awsCfg aws.Config, cfg Config) *Client {
+       return &Client{
+               ses: ses.NewFromConfig(awsCfg),
+               cfg: cfg,
+       }
+}
+
+func (c *Client) SendWelcome(ctx context.Context, email, slug, password string) error {
+       url := fmt.Sprintf("https://%s.%s", slug, c.cfg.BaseDomain)
+       subject := "Your Crimata hub is ready"
+       text := fmt.Sprintf("Your hub is live at %s\n\nPassword: %s\n\nChange your password in Settings after logging in.\n\n— Crimata", url, password)
+       html := fmt.Sprintf(`<p>Your hub is live at <a href="%s">%s</a></p><p><strong>Password:</strong> <code>%s</code></p><p style="color:#555">Change your password in Settings after logging in.</p><p>— Crimata</p>`, url, url, password)
+
+       _, err := c.ses.SendEmail(ctx, &ses.SendEmailInput{
+               Source:      aws.String(c.cfg.FromEmail),
+               Destination: &sestypes.Destination{ToAddresses: []string{email}},
+               Message: &sestypes.Message{
+                       Subject: &sestypes.Content{Data: aws.String(subject)},
+                       Body: &sestypes.Body{
+                               Text: &sestypes.Content{Data: aws.String(text)},
+                               Html: &sestypes.Content{Data: aws.String(html)},
+                       },
+               },
+       })
+       return err
+}
+
+func (c *Client) SendDataExport(ctx context.Context, email, downloadURL string) error {
+       subject := "Your Crimata data export is ready"
+       text := fmt.Sprintf("Your data export is ready for download:\n\n%s\n\nThis link expires in 24 hours.\n\n— Crimata", downloadURL)
+       html := fmt.Sprintf(`<p>Your data export is ready: <a href="%s">Download</a></p><p style="color:#555">This link expires in 24 hours.</p><p>— Crimata</p>`, downloadURL)
+
+       _, err := c.ses.SendEmail(ctx, &ses.SendEmailInput{
+               Source:      aws.String(c.cfg.FromEmail),
+               Destination: &sestypes.Destination{ToAddresses: []string{email}},
+               Message: &sestypes.Message{
+                       Subject: &sestypes.Content{Data: aws.String(subject)},
+                       Body: &sestypes.Body{
+                               Text: &sestypes.Content{Data: aws.String(text)},
+                               Html: &sestypes.Content{Data: aws.String(html)},
+                       },
+               },
+       })
+       return err
+}