]> Repos - crimata-infra/commitdiff
Refactor customer → instance package, add dock service, update provision.sh
authorAndrew Gundersen <adgundersen@gmail.com>
Fri, 27 Feb 2026 04:51:43 +0000 (23:51 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Fri, 27 Feb 2026 04:51:43 +0000 (23:51 -0500)
- 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>
LICENSE [new file with mode: 0644]
cmd/server/main.go
internal/api/api.go
internal/compute/provision.sh
internal/instance/instance.go [new file with mode: 0644]

diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
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.
index 62c665939142fc98425a537dd0f4b3195476481a..ffbc4beb5ef1c20d3c6487d32f7eaada3d1367c3 100644 (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)
        }
index f13bba091512f6466577c1e845b746875bf2fd71..daa29dbe0635179bd857d12bc5918634c6065ed7 100644 (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 {
index 9e6697363bb5f91c91cb16b728f7ad7088deb515..de54fbbccb66e9fb01852b3469d310d0d172215c 100755 (executable)
 #!/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
 
@@ -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 (file)
index 0000000..e53648d
--- /dev/null
@@ -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
+}