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

7
LICENSE Normal file
View file

@ -0,0 +1,7 @@
Copyright (c) 2026 Andrew Gundersen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -10,9 +10,9 @@ import (
"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/instance"
"github.com/adgundersen/crimata-infra/internal/notify"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
_ "github.com/lib/pq"
@ -25,7 +25,7 @@ func main() {
}
defer db.Close()
store := customer.NewStore(db)
store := instance.NewStore(db)
if err := store.Migrate(); err != nil {
log.Fatalf("migrate: %v", err)
}

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
}