From 276fddc46737cf9b94d7a0bd167b3f36dfc7ed22 Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Thu, 26 Feb 2026 23:51:43 -0500 Subject: [PATCH] =?utf8?q?Refactor=20customer=20=E2=86=92=20instance=20pac?= =?utf8?q?kage,=20add=20dock=20service,=20update=20provision.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 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 --- LICENSE | 7 ++ cmd/server/main.go | 4 +- internal/api/api.go | 107 +++++++++++----------- internal/compute/provision.sh | 166 +++++++++++++++++++++++++--------- internal/instance/instance.go | 113 +++++++++++++++++++++++ 5 files changed, 299 insertions(+), 98 deletions(-) create mode 100644 LICENSE create mode 100644 internal/instance/instance.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..13bee20 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/cmd/server/main.go b/cmd/server/main.go index 62c6659..ffbc4be 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/internal/api/api.go b/internal/api/api.go index f13bba0..daa29db 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 { diff --git a/internal/compute/provision.sh b/internal/compute/provision.sh index 9e66973..de54fbb 100755 --- a/internal/compute/provision.sh +++ b/internal/compute/provision.sh @@ -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 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 - - - - - - Crimata — $SLUG - - - -

Welcome, $SLUG.

-

Your Crimata hub is ready.

- - +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 @@ -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." diff --git a/internal/instance/instance.go b/internal/instance/instance.go new file mode 100644 index 0000000..e53648d --- /dev/null +++ b/internal/instance/instance.go @@ -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 +} -- 2.43.0