Refactor customer → instance package, add dock service, update provision.sh

- Rename internal/customer → internal/instance, customers table → instances
- Rename API endpoint /customers → /instances
- Add internal/instance package with clean schema (drops email, subscription ID)
- Add JSON struct tags, exclude ssh_private_key from responses
- Build out crimata-dock C service (libmicrohttpd + libsystemd)
- Update provision.sh to install full OS stack from adgundersen/os
- Add MIT License

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andrew Gundersen 2026-02-26 23:51:43 -05:00
commit 276fddc467
5 changed files with 299 additions and 98 deletions

View file

@ -11,15 +11,15 @@ import (
"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
@ -27,7 +27,7 @@ type Handler struct {
}
func NewHandler(
store *customer.Store,
store *instance.Store,
compute *compute.Client,
dns *dns.Client,
notify *notify.Client,
@ -41,9 +41,9 @@ func (h *Handler) Routes() http.Handler {
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
}
@ -53,7 +53,7 @@ type createRequest struct {
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)
@ -71,116 +71,113 @@ func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) {
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 {

View file

@ -1,66 +1,147 @@
#!/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
apt-get install -y -qq \
nginx \
postgresql postgresql-contrib \
gcc make \
libmicrohttpd-dev \
libpam-dev \
libsystemd-dev \
nodejs npm \
git
# ── 2. Welcome page ────────────────────────────────────────────────────────────
log "Creating welcome page..."
mkdir -p /var/www/crimata
# ── 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
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>
# ── 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
# ── 3. Nginx config ────────────────────────────────────────────────────────────
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
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
@ -70,4 +151,7 @@ nginx -t
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."

View file

@ -0,0 +1,113 @@
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
}