"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/instance"
"github.com/adgundersen/crimata-infra/internal/notify"
"github.com/go-chi/chi/v5"
)
type Handler struct {
- store *customer.Store
+ store *instance.Store
compute *compute.Client
dns *dns.Client
notify *notify.Client
}
func NewHandler(
- store *customer.Store,
+ store *instance.Store,
compute *compute.Client,
dns *dns.Client,
notify *notify.Client,
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)
+ r.Post("/instances", h.createInstance)
+ r.Get("/instances/{slug}", h.getInstance)
+ r.Delete("/instances/{slug}", h.deleteInstance)
return r
}
Email string `json:"email"`
}
-func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) {
+func (h *Handler) createInstance(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)
password := randomHex(12)
dbPassword := randomHex(24)
- c := &customer.Customer{
- StripeCustomerID: req.StripeCustomerID,
- StripeSubscriptionID: req.StripeSubscriptionID,
- Email: req.Email,
- Slug: slug,
- Status: customer.StatusProvisioning,
+ inst := &instance.Instance{
+ StripeCustomerID: req.StripeCustomerID,
+ Slug: slug,
+ Status: instance.StatusProvisioning,
}
- if err := h.store.Create(c); err != nil {
- http.Error(w, "failed to create customer record", http.StatusInternalServerError)
+ if err := h.store.Create(inst); err != nil {
+ http.Error(w, "failed to create instance record", http.StatusInternalServerError)
return
}
- go h.provision(context.Background(), c, password, dbPassword)
+ go h.provision(context.Background(), inst, req.Email, password, dbPassword)
- w.WriteHeader(http.StatusAccepted)
- jsonResponse(w, c, http.StatusAccepted)
+ jsonResponse(w, inst, http.StatusAccepted)
}
-func (h *Handler) getCustomer(w http.ResponseWriter, r *http.Request) {
+func (h *Handler) getInstance(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
- c, err := h.store.GetBySlug(slug)
- if err != nil || c == nil {
+ inst, err := h.store.GetBySlug(slug)
+ if err != nil || inst == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
- jsonResponse(w, c, http.StatusOK)
+ jsonResponse(w, inst, http.StatusOK)
}
-func (h *Handler) deleteCustomer(w http.ResponseWriter, r *http.Request) {
+func (h *Handler) deleteInstance(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
- c, err := h.store.GetBySlug(slug)
- if err != nil || c == nil {
+ inst, err := h.store.GetBySlug(slug)
+ if err != nil || inst == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
- go h.deprovision(context.Background(), c)
+ go h.deprovision(context.Background(), inst)
w.WriteHeader(http.StatusAccepted)
}
// ── Provisioning ──────────────────────────────────────────────────────────────
-func (h *Handler) provision(ctx context.Context, c *customer.Customer, password, dbPassword string) {
+func (h *Handler) provision(ctx context.Context, inst *instance.Instance, email, password, dbPassword string) {
// 1. Launch EC2
- instance, err := h.compute.Launch(ctx, c.Slug)
+ ec2, err := h.compute.Launch(ctx, inst.Slug)
if err != nil {
- fmt.Printf("provision: launch failed for %s: %v\n", c.Email, err)
- h.store.UpdateStatus(c.ID, customer.StatusFailed)
+ fmt.Printf("provision: launch failed for %s: %v\n", inst.Slug, err)
+ h.store.UpdateStatus(inst.ID, instance.StatusFailed)
return
}
- h.store.UpdateEC2(c.ID, instance.InstanceID, instance.PublicIP)
- h.store.UpdateSSHKey(c.ID, instance.SSHPrivateKey)
+ h.store.UpdateEC2(inst.ID, ec2.InstanceID, ec2.PublicIP)
+ h.store.UpdateSSHKey(inst.ID, ec2.SSHPrivateKey)
// 2. Wait for instance to be ready
- 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)
+ if err := h.compute.WaitUntilReady(ctx, ec2.InstanceID, ec2.PublicIP); err != nil {
+ fmt.Printf("provision: wait failed for %s: %v\n", inst.Slug, err)
+ h.store.UpdateStatus(inst.ID, instance.StatusFailed)
return
}
// 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)
+ if err := h.compute.Provision(ctx, ec2.PublicIP, ec2.SSHPrivateKey, inst.Slug, password, dbPassword); err != nil {
+ fmt.Printf("provision: script failed for %s: %v\n", inst.Slug, err)
+ h.store.UpdateStatus(inst.ID, instance.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)
+ if err := h.dns.CreateRecord(ctx, inst.Slug, ec2.PublicIP); err != nil {
+ fmt.Printf("provision: dns failed for %s: %v\n", inst.Slug, 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)
+ if err := h.notify.SendWelcome(ctx, email, inst.Slug, password); err != nil {
+ fmt.Printf("provision: email failed for %s: %v\n", inst.Slug, err)
}
- h.store.UpdateStatus(c.ID, customer.StatusActive)
- fmt.Printf("provision: %s is live at %s.crimata.com\n", c.Email, c.Slug)
+ h.store.UpdateStatus(inst.ID, instance.StatusActive)
+ fmt.Printf("provision: %s is live at %s.crimata.com\n", email, inst.Slug)
}
-func (h *Handler) deprovision(ctx context.Context, c *customer.Customer) {
- h.store.UpdateStatus(c.ID, customer.StatusCancelled)
+func (h *Handler) deprovision(ctx context.Context, inst *instance.Instance) {
+ h.store.UpdateStatus(inst.ID, instance.StatusCancelled)
// 1. Export data and email download link
- downloadURL, err := h.export.Export(ctx, c.EC2InstanceID, c.Slug)
+ downloadURL, err := h.export.Export(ctx, inst.EC2InstanceID, inst.Slug)
if err != nil {
- fmt.Printf("deprovision: export failed for %s: %v\n", c.Email, err)
+ fmt.Printf("deprovision: export failed for %s: %v\n", inst.Slug, err)
} else {
- h.notify.SendDataExport(ctx, c.Email, downloadURL)
+ h.notify.SendDataExport(ctx, inst.StripeCustomerID, 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)
+ if err := h.compute.Terminate(ctx, inst.EC2InstanceID); err != nil {
+ fmt.Printf("deprovision: terminate failed for %s: %v\n", inst.Slug, 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)
+ if err := h.dns.DeleteRecord(ctx, inst.Slug, inst.EC2PublicIP); err != nil {
+ fmt.Printf("deprovision: dns delete failed for %s: %v\n", inst.Slug, err)
}
- fmt.Printf("deprovision: %s cleaned up\n", c.Email)
+ fmt.Printf("deprovision: %s cleaned up\n", inst.Slug)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
-func uniqueSlug(store *customer.Store, email string) string {
+func uniqueSlug(store *instance.Store, email string) string {
base := strings.Trim(slugRe.ReplaceAllString(
strings.ToLower(strings.Split(email, "@")[0]), "-"), "-")
if len(base) > 20 {
#!/bin/bash
-# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page
+# provision.sh — Crimata OS bootstrap
# Usage: provision.sh <slug> <password> <db_password>
set -euo pipefail
SLUG=$1
+PASSWORD=$2
+DB_PASSWORD=$3
+OS_REPO="https://github.com/adgundersen/os"
log() { echo "[crimata] $1"; }
-# ── 1. System dependencies ─────────────────────────────────────────────────────
-log "Installing nginx..."
+# ── 1. System dependencies ──────────────────────────────────────────────────
+log "Installing system dependencies..."
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>
+apt-get install -y -qq \
+ nginx \
+ postgresql postgresql-contrib \
+ gcc make \
+ libmicrohttpd-dev \
+ libpam-dev \
+ libsystemd-dev \
+ nodejs npm \
+ git
+
+# ── 2. Linux user (PAM auth uses real OS users) ─────────────────────────────
+log "Creating user $SLUG..."
+useradd --create-home --shell /bin/bash "$SLUG" || true
+echo "$SLUG:$PASSWORD" | chpasswd
+
+# ── 3. Fetch OS source ──────────────────────────────────────────────────────
+log "Fetching OS..."
+git clone --depth 1 "$OS_REPO" /tmp/crimata-os
+
+# ── 4. Build and install core binaries ─────────────────────────────────────
+log "Building crimata-auth..."
+mkdir -p /opt/crimata/bin
+make -C /tmp/crimata-os/auth OUT=/opt/crimata/bin/crimata-auth
+
+log "Building crimata-dock..."
+make -C /tmp/crimata-os/dock OUT=/opt/crimata/bin/crimata-dock
+
+# ── 5. Install web desktop UI ───────────────────────────────────────────────
+log "Installing UI..."
+mkdir -p /opt/crimata/ui
+cp -r /tmp/crimata-os/ui/* /opt/crimata/ui/
+
+# ── 6. Install apps ─────────────────────────────────────────────────────────
+log "Installing crimata-contacts..."
+mkdir -p /usr/lib/crimata-contacts
+cp -r /tmp/crimata-os/apps/contacts/* /usr/lib/crimata-contacts/
+cd /usr/lib/crimata-contacts
+npm install --silent
+npm run build
+
+# ── 7. Postgres setup ───────────────────────────────────────────────────────
+log "Configuring Postgres..."
+systemctl enable postgresql
+systemctl start postgresql
+
+su -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='crimata'\" | grep -q 1 || \
+ psql -c \"CREATE USER crimata WITH PASSWORD '$DB_PASSWORD';\"" postgres
+
+su -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='crimata_contacts'\" | grep -q 1 || \
+ psql -c \"CREATE DATABASE crimata_contacts OWNER crimata;\"" postgres
+
+DATABASE_URL="postgresql://crimata:$DB_PASSWORD@localhost/crimata_contacts" \
+ node /usr/lib/crimata-contacts/dist/migrate.js
+
+# ── 8. systemd units ────────────────────────────────────────────────────────
+log "Installing systemd units..."
+
+cat > /etc/systemd/system/crimata-auth.service << EOF
+[Unit]
+Description=Crimata Auth
+After=network.target
+
+[Service]
+ExecStart=/opt/crimata/bin/crimata-auth
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+cat > /etc/systemd/system/crimata-dock.service << EOF
+[Unit]
+Description=Crimata Dock
+After=network.target
+
+[Service]
+ExecStart=/opt/crimata/bin/crimata-dock
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+cat > /etc/systemd/system/crimata-contacts.service << EOF
+[Unit]
+Description=Crimata Contacts
+After=network.target postgresql.service
+
+[Service]
+ExecStart=node /usr/lib/crimata-contacts/dist/index.js
+Restart=always
+Environment=PORT=3001
+Environment=DATABASE_URL=postgresql://crimata:$DB_PASSWORD@localhost/crimata_contacts
+
+[Install]
+WantedBy=multi-user.target
EOF
-# ── 3. Nginx config ────────────────────────────────────────────────────────────
+systemctl daemon-reload
+systemctl enable crimata-auth crimata-dock crimata-contacts
+systemctl start crimata-auth crimata-dock crimata-contacts
+
+# ── 9. Nginx ────────────────────────────────────────────────────────────────
log "Configuring nginx..."
+
cat > /etc/nginx/sites-available/crimata << EOF
server {
listen 80;
server_name $SLUG.crimata.com;
- root /var/www/crimata;
+ root /opt/crimata/ui;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
+
+ location /auth {
+ proxy_pass http://localhost:7700;
+ }
+
+ location /dock/ {
+ proxy_pass http://localhost:7701/;
+ }
+
+ location /apps/contacts/ {
+ proxy_pass http://localhost:3001/contacts/;
+ }
}
EOF
systemctl enable nginx
systemctl restart nginx
-log "Provisioning complete. $SLUG.crimata.com is ready."
+# ── 10. Cleanup ─────────────────────────────────────────────────────────────
+rm -rf /tmp/crimata-os
+
+log "Done. $SLUG.crimata.com is live."
--- /dev/null
+package instance
+
+import (
+ "database/sql"
+ "time"
+)
+
+type Status string
+
+const (
+ StatusProvisioning Status = "provisioning"
+ StatusActive Status = "active"
+ StatusFailed Status = "failed"
+ StatusCancelled Status = "cancelled"
+)
+
+type Instance struct {
+ ID int64 `json:"id"`
+ StripeCustomerID string `json:"stripe_customer_id"`
+ Slug string `json:"slug"`
+ EC2InstanceID string `json:"ec2_instance_id"`
+ EC2PublicIP string `json:"ec2_public_ip"`
+ SSHPrivateKey string `json:"-"`
+ Status Status `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+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 instances (
+ id SERIAL PRIMARY KEY,
+ stripe_customer_id TEXT UNIQUE NOT NULL,
+ 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
+}
+
+func (s *Store) Create(inst *Instance) error {
+ return s.db.QueryRow(`
+ INSERT INTO instances (stripe_customer_id, slug, status)
+ VALUES ($1, $2, $3)
+ RETURNING id, created_at`,
+ inst.StripeCustomerID, inst.Slug, inst.Status,
+ ).Scan(&inst.ID, &inst.CreatedAt)
+}
+
+func (s *Store) GetByStripeID(stripeCustomerID string) (*Instance, error) {
+ inst := &Instance{}
+ err := s.db.QueryRow(`
+ SELECT id, stripe_customer_id, slug, ec2_instance_id, ec2_public_ip,
+ ssh_private_key, status, created_at
+ FROM instances WHERE stripe_customer_id = $1`,
+ stripeCustomerID,
+ ).Scan(
+ &inst.ID, &inst.StripeCustomerID, &inst.Slug,
+ &inst.EC2InstanceID, &inst.EC2PublicIP,
+ &inst.SSHPrivateKey, &inst.Status, &inst.CreatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return inst, err
+}
+
+func (s *Store) GetBySlug(slug string) (*Instance, error) {
+ inst := &Instance{}
+ err := s.db.QueryRow(`
+ SELECT id, stripe_customer_id, slug, ec2_instance_id, ec2_public_ip,
+ ssh_private_key, status, created_at
+ FROM instances WHERE slug = $1`,
+ slug,
+ ).Scan(
+ &inst.ID, &inst.StripeCustomerID, &inst.Slug,
+ &inst.EC2InstanceID, &inst.EC2PublicIP,
+ &inst.SSHPrivateKey, &inst.Status, &inst.CreatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return inst, err
+}
+
+func (s *Store) UpdateSSHKey(id int64, privateKey string) error {
+ _, err := s.db.Exec(`UPDATE instances 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 instances 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 instances SET status = $1 WHERE id = $2`, status, id)
+ return err
+}