From d9619350f8dca91803b6c5f2aa6d70865de4d108 Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Sun, 22 Feb 2026 13:31:38 -0500 Subject: [PATCH 1/1] Initial commit Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 24 ++++ Dockerfile | 13 +++ cmd/server/main.go | 83 ++++++++++++++ go.mod | 14 +++ internal/api/api.go | 205 ++++++++++++++++++++++++++++++++++ internal/compute/compute.go | 146 ++++++++++++++++++++++++ internal/customer/customer.go | 112 +++++++++++++++++++ internal/dns/dns.go | 60 ++++++++++ internal/export/export.go | 64 +++++++++++ internal/notify/notify.go | 66 +++++++++++ 10 files changed, 787 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/api/api.go create mode 100644 internal/compute/compute.go create mode 100644 internal/customer/customer.go create mode 100644 internal/dns/dns.go create mode 100644 internal/export/export.go create mode 100644 internal/notify/notify.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..76e3794 --- /dev/null +++ b/.env.example @@ -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 index 0000000..30770f8 --- /dev/null +++ b/Dockerfile @@ -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 index 0000000..839d812 --- /dev/null +++ b/cmd/server/main.go @@ -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 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 index 0000000..d2937a2 --- /dev/null +++ b/internal/api/api.go @@ -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 index 0000000..764ee17 --- /dev/null +++ b/internal/compute/compute.go @@ -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 index 0000000..4368908 --- /dev/null +++ b/internal/customer/customer.go @@ -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 index 0000000..46b0c87 --- /dev/null +++ b/internal/dns/dns.go @@ -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 index 0000000..73fa9b9 --- /dev/null +++ b/internal/export/export.go @@ -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 index 0000000..48bf1f1 --- /dev/null +++ b/internal/notify/notify.go @@ -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(`

Your hub is live at %s

Password: %s

Change your password in Settings after logging in.

— Crimata

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

Your data export is ready: Download

This link expires in 24 hours.

— Crimata

`, 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 +} -- 2.43.0