Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
d9619350f8
10 changed files with 787 additions and 0 deletions
24
.env.example
Normal file
24
.env.example
Normal file
|
|
@ -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
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
83
cmd/server/main.go
Normal file
83
cmd/server/main.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
205
internal/api/api.go
Normal file
205
internal/api/api.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
146
internal/compute/compute.go
Normal file
146
internal/compute/compute.go
Normal file
|
|
@ -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
|
||||||
|
`
|
||||||
|
}
|
||||||
112
internal/customer/customer.go
Normal file
112
internal/customer/customer.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
60
internal/dns/dns.go
Normal file
60
internal/dns/dns.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
64
internal/export/export.go
Normal file
64
internal/export/export.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
66
internal/notify/notify.go
Normal file
66
internal/notify/notify.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue