readysite / docs / platform.md
15.0 KB
platform.md

Platform Layer

Cloud infrastructure abstraction for managing servers, volumes, and DNS across providers.

Design Pattern

The platform layer follows the same struct-embedding-interface pattern as the database layer:

Database Platform
Database struct embeds Engine Platform struct embeds Backend
engines.NewSQLite()*Database providers.NewMock()*Platform
engines.NewRemote()*Database providers.NewDigitalOcean()*Platform
// Platform provides cloud infrastructure operations.
type Platform struct {
    Backend  // Embedded interface
}

// Backend is the interface that providers implement.
type Backend interface {
    CreateServer(opts ServerOptions) (*Server, error)
    GetServer(name string) (*Server, error)
    DeleteServer(id string) error
    // ... volumes, DNS
}

Quick Start

import (
    "github.com/readysite/readysite/pkg/platform"
    "github.com/readysite/readysite/pkg/platform/providers/mock"
    "github.com/readysite/readysite/pkg/platform/providers/digitalocean"
)

// Testing
p := mock.New()

// Production
p, err := digitalocean.New(os.Getenv("DO_TOKEN"))

// Same API regardless of provider
server, err := p.CreateServer(platform.ServerOptions{
    Name:   "app-1",
    Size:   platform.Small,
    Region: platform.NYC,
    Image:  "docker-20-04",
})
server.WaitForSSH(5 * time.Minute)
server.Start(&platform.Service{
    Name:  "web",
    Image: "nginx:latest",
    Ports: []platform.Port{{Host: 80, Container: 80}},
})

Regions and Sizes

Provider-agnostic constants that map to provider-specific values internally.

Regions

Constant Location
platform.NYC New York
platform.SFO San Francisco
platform.TOR Toronto
platform.LON London
platform.AMS Amsterdam
platform.FRA Frankfurt
platform.SGP Singapore
platform.SYD Sydney
platform.BLR Bangalore

Sizes

Constant Specs
platform.Micro 1 vCPU, 1GB RAM
platform.Small 1 vCPU, 2GB RAM
platform.Medium 2 vCPU, 4GB RAM
platform.Large 4 vCPU, 8GB RAM
platform.XLarge 8 vCPU, 16GB RAM

Each provider maps these to their equivalents:

  • DigitalOcean: s-1vcpu-1gb, s-1vcpu-2gb, etc.
  • AWS: t3.micro, t3.small, etc.
  • GCP: e2-micro, e2-small, etc.

Core Types

Server

Cloud virtual machine with SSH operations.

type Server struct {
    ID     string
    Name   string
    IP     string
    Size   string  // Provider-specific size (e.g., "s-1vcpu-2gb")
    Region string  // Provider-specific region (e.g., "nyc1")
    Status string  // creating, active, off
}

SSH Operations:

Method Purpose
SSH(args...) Execute command, return output
Copy(local, remote) Upload file via SCP
Interactive() Open interactive SSH shell
WaitForSSH(timeout) Block until SSH is ready
// Run a command
output, err := server.SSH("docker", "ps")

// Upload a file
server.Copy("./app", "/usr/local/bin/app")

// Wait for server to be ready
server.WaitForSSH(5 * time.Minute)

// Interactive session (for debugging)
server.Interactive()

Service

Docker container configuration for running on servers.

type Service struct {
    Name    string
    Image   string
    Command []string
    Ports   []Port
    Volumes []Mount
    Env     map[string]string
    Network string
    Restart string  // "always", "unless-stopped", etc.
}

type Port struct {
    Host      int
    Container int
    Bind      string  // Optional: "127.0.0.1"
}

type Mount struct {
    Source string  // Host path
    Target string  // Container path
}

Container Operations (on Server):

Method Purpose
Start(service) Run container via docker run
Stop(name) Kill and remove container
server.Start(&platform.Service{
    Name:    "app",
    Image:   "myapp:latest",
    Restart: "always",
    Ports:   []platform.Port{{Host: 8080, Container: 8080}},
    Volumes: []platform.Mount{{Source: "/data", Target: "/app/data"}},
    Env:     map[string]string{"PORT": "8080"},
})

server.Stop("app")

Volume

Block storage that can be attached to servers.

type Volume struct {
    ID       string
    Name     string
    Size     int     // Gigabytes
    Region   string
    ServerID string  // Empty if detached
}

Operations (via Platform):

vol, _ := p.CreateVolume("data", 100, platform.NYC)
p.AttachVolume(vol.ID, server.ID)
p.DetachVolume(vol.ID)

DNS

DNS zone and record management (not domain registration).

type DNSZone struct {
    Name string  // e.g., "example.com"
    TTL  int
}

type DNSRecord struct {
    ID       string
    Type     string  // A, AAAA, CNAME, TXT, MX
    Name     string  // Subdomain or "@" for root
    Value    string
    TTL      int
    Priority int     // For MX records
}

Operations (via Platform):

zone, _ := p.CreateDNSZone("example.com")
p.AddDNSRecord("example.com", platform.DNSRecord{
    Type:  "A",
    Name:  "@",
    Value: server.IP,
    TTL:   300,
})

Errors

var (
    ErrNotFound         = errors.New("resource not found")
    ErrInvalidState     = errors.New("invalid resource state")
    ErrTimeout          = errors.New("operation timed out")
    ErrUnsupportedRegion = errors.New("unsupported region")
    ErrUnsupportedSize   = errors.New("unsupported size")
)

Usage:

server, err := p.GetServer("app-1")
if errors.Is(err, platform.ErrNotFound) {
    // Create it
}

CLI Commands

launch

Deploy application to cloud infrastructure. Always use this tool for deployments - it handles building, uploading, and container restarts.

# Build the launch tool (once)
go build -o build/launch ./cmd/launch

# Deploy to existing servers (rebuilds binaries, uploads, restarts containers)
./build/launch

# Create new server and deploy
./build/launch --new myapp

The launch tool reads infra.json, builds binaries for the target platform, uploads via SCP, and restarts Docker containers.

connect

SSH to a deployed server.

# Build
go build -o build/connect ./cmd/connect

# Interactive SSH (auto-selects if only one server)
./build/connect

# Connect to specific server
./build/connect --server website-1

# Run command on server
./build/connect docker ps
./build/connect docker logs -f website

# Multiple servers? Interactive prompt appears
./build/connect
# > Multiple servers found. Select one:
# >   [1] website-1 (24.199.127.118)
# >   [2] website-2 (24.199.127.119)
# > Enter choice: 1

publish

Build and push Docker images to registries.

# Build
go build -o build/publish ./cmd/publish

# Build locally and push to both registries
./build/publish

# With specific tag
./build/publish --tag v1.0.0

# Private registry only
./build/publish --skip-dockerhub

# Docker Hub only
./build/publish --skip-registry

# Build on remote server (when local Docker unavailable)
./build/publish --build-server website-1

Flags:

Flag Default Description
--tag latest Image tag
--dockerhub ccutch/readysite Docker Hub repository
--skip-registry false Skip pushing to private registry
--skip-dockerhub false Skip pushing to Docker Hub
--build-server (none) Server to build on (uses launch)
--push-server readysite-org-1 Server to push from

Workflow:

  1. If local Docker available: build locally, push to both registries
  2. If no local Docker: build on --build-server via launch, push from --push-server

The private registry runs on the push server at port 5001.

infra.json

Infrastructure configuration file in project root:

{
  "platform": {
    "provider": "digitalocean",
    "token": "$DIGITAL_OCEAN_API_KEY",
    "project": "$DIGITAL_OCEAN_PROJECT",
    "region": "sfo"
  },
  "servers": {
    "app": {
      "size": "small",
      "binaries": ["website"],
      "services": ["registry"],
      "volumes": [
        {"name": "data", "size": 10, "mount": "/data", "filesystem": "ext4"}
      ]
    }
  },
  "binaries": {
    "website": {
      "source": "./website",
      "ports": [{"host": 80, "container": 5000}],
      "env": {
        "ENV": "production",
        "DB_PATH": "/data/website.db"
      },
      "env_files": {"AUTH_SECRET": "/data/secrets.env"},
      "volumes": [{"source": "/data", "target": "/data"}]
    }
  },
  "services": {
    "registry": {
      "image": "registry:2",
      "ports": [{"host": 5001, "container": 5000}],
      "volumes": [{"source": "/data/registry", "target": "/var/lib/registry"}],
      "restart": "always"
    }
  },
  "instances": {
    "app": [
      {"id": "abc123", "name": "website-1", "ip": "24.199.127.118", "region": "sfo3"}
    ]
  }
}

Sections:

Section Purpose
platform Cloud provider credentials and defaults
servers Server types with size, binaries, services, and volumes
binaries Go apps to build and deploy (need Dockerfile)
services Pre-built Docker images to run
instances Deployed server instances (auto-populated)

Binary Options:

Field Description
source Path to Go application directory (must contain Dockerfile)
ports Port mappings (host:container)
env Environment variables to set
env_files Map of env var name to server file path (e.g., {"AUTH_SECRET": "/data/secrets.env"})
volumes Volume mounts from host to container

Common Environment Variables:

Variable Description
ENV Set to production to disable HMR and development features
DB_PATH Local SQLite file path (e.g., /data/app.db)
DB_URL Remote libSQL server URL for replicas (e.g., libsql://mydb.turso.io)
DB_TOKEN Authentication token for remote libSQL server

The engines.NewAuto() database engine selects based on these variables:

  • DB_URL + DB_TOKEN → Remote replica (Turso/libSQL)
  • DB_PATH only → Local file database
  • Neither → In-memory database

Service Options:

Field Description
image Docker image to pull
ports Port mappings (host:container, optional bind for localhost-only)
volumes Volume mounts
restart Restart policy (always, unless-stopped, etc.)

Setup Scripts:

If a binary's source directory contains a setup.sh file, it runs on the server before deployment. Use this for:

  • Creating directories
  • Installing dependencies
  • Configuring services

Health Checks and Rollback:

Services with exposed ports automatically get health checks. If a container fails to start or become healthy, the launch tool:

  1. Stops the failing container
  2. Restarts the previous version
  3. Reports the failure

Notes:

  • Environment variables (prefixed with $) are expanded at runtime but preserved when saving
  • binaries require a Dockerfile in the source directory
  • instances is managed by the launch tool - don't edit manually
  • env_files are read from the server, not the local machine (for secrets)

SSHKeyProvider

Optional interface for providers that support SSH key management:

// SSHKeyProvider is an optional interface for SSH key management.
type SSHKeyProvider interface {
    // GetSSHKeyFingerprint finds or registers an SSH key, returns fingerprint.
    GetSSHKeyFingerprint(publicKey string) (string, error)
}

// Usage
if sshProvider, ok := p.Backend.(platform.SSHKeyProvider); ok {
    fingerprint, err := sshProvider.GetSSHKeyFingerprint(publicKey)
}

Keys are compared by type and data (ignoring comments) to handle duplicates gracefully.

Providers

Mock

In-memory provider for testing. No cloud calls.

import "github.com/readysite/readysite/pkg/platform/providers/mock"

p := mock.New()

DigitalOcean

Uses Droplets, Block Storage, and DNS.

import "github.com/readysite/readysite/pkg/platform/providers/digitalocean"

p, err := digitalocean.New(os.Getenv("DO_TOKEN"))

Images: docker-20-04, ubuntu-22-04-x64

Note: Implements SSHKeyProvider for fingerprint-based SSH key lookup.

AWS

Uses EC2, EBS, and Route 53.

import "github.com/readysite/readysite/pkg/platform/providers/aws"

p := aws.New(accessKey, secretKey, "us-east-1")

Note: SSH keys are uploaded to EC2 Key Pairs automatically.

GCP

Uses Compute Engine, Persistent Disks, and Cloud DNS.

import "github.com/readysite/readysite/pkg/platform/providers/gcp"

p, err := gcp.New(projectID, option.WithCredentialsFile("key.json"))

Note: SSH keys are added via instance metadata.

File Structure

pkg/platform/
├── platform.go     # Platform struct, Backend interface, ServerOptions
├── server.go       # Server struct + SSH, Copy, Interactive, WaitForSSH
├── service.go      # Service, Port, Mount + Start, Stop methods
├── volume.go       # Volume struct
├── domain.go       # DNSZone, DNSRecord
├── region.go       # Region constants
├── size.go         # Size constants
├── errors.go       # Error definitions
└── providers/
    ├── mock/
    │   └── mock.go           # Testing provider
    ├── digitalocean/
    │   └── digitalocean.go   # DigitalOcean provider
    ├── aws/
    │   └── aws.go            # AWS provider
    └── gcp/
        └── gcp.go            # GCP provider

Deployment Pattern

Typical deployment flow:

// 1. Get or create server
server, err := p.GetServer("app-1")
if errors.Is(err, platform.ErrNotFound) {
    server, err = p.CreateServer(platform.ServerOptions{
        Name:   "app-1",
        Size:   platform.Small,
        Region: platform.NYC,
        Image:  "docker-20-04",
        SSHKey: string(pubKey),
    })
}

// 2. Wait for SSH
server.WaitForSSH(5 * time.Minute)

// 3. Upload binary
server.Copy("./build/app-linux", "/usr/local/bin/app")

// 4. Run as container (or configure systemd)
server.Start(&platform.Service{
    Name:    "app",
    Image:   "caddy:latest",
    Restart: "always",
    Ports:   []platform.Port{{Host: 443, Container: 443}},
})

// 5. Configure DNS
p.AddDNSRecord("example.com", platform.DNSRecord{
    Type:  "A",
    Name:  "app",
    Value: server.IP,
    TTL:   300,
})

Network Security

Recommended firewall rules on servers:

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp   # SSH
ufw allow 80/tcp   # HTTP
ufw allow 443/tcp  # HTTPS
ufw limit 22/tcp   # Rate limit SSH
ufw enable

For internal services, use Docker networks:

// Only Caddy exposes ports
server.SSH("docker", "network", "create", "internal")

server.Start(&platform.Service{
    Name:    "app",
    Image:   "myapp:latest",
    Network: "internal",
    // No ports exposed
})

server.Start(&platform.Service{
    Name:    "caddy",
    Image:   "caddy:latest",
    Network: "internal",
    Ports:   []platform.Port{{Host: 443, Container: 443}},
})
← Back