From f1d6907f098e1fe66a6b2f9e89abfe70707b53a9 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:28:35 -0800 Subject: docs: add comprehensive technical specification --- SPEC.md | 499 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 SPEC.md (limited to 'SPEC.md') diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..50b54c2 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,499 @@ +# Ship v2 Technical Specification + +## Overview + +Ship deploys code to a VPS. Input: path to code. Output: JSON with URL. + +```bash +ship ./myproject +# {"status":"ok","name":"ship-a1b2c3","url":"https://ship-a1b2c3.example.com","type":"docker","took_ms":8200} +``` + +## CLI Interface + +### Primary Command + +``` +ship [PATH] [FLAGS] +``` + +**PATH**: File or directory to deploy. Defaults to `.` (current directory). + +**FLAGS**: +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--name` | string | auto-generated | Deploy name (becomes subdomain) | +| `--host` | string | default host | VPS host (SSH config name or user@host) | +| `--health` | string | `/` for static, none for apps | Health check endpoint | +| `--ttl` | duration | none | Auto-delete after duration (e.g., `1h`, `7d`) | +| `--env` | string[] | none | Environment variables (`KEY=VALUE`) | +| `--env-file` | string | none | Path to .env file | +| `--pretty` | bool | false | Human-readable output | + +### Other Commands + +``` +ship list [--host HOST] +ship status NAME [--host HOST] +ship logs NAME [--lines N] [--host HOST] +ship remove NAME [--host HOST] +ship host init USER@HOST --domain DOMAIN +ship host status [--host HOST] +``` + +All commands output JSON unless `--pretty` is set. + +## Output Schema + +### Success Response + +```typescript +{ + status: "ok", + name: string, + url: string, + type: "static" | "docker" | "binary", + took_ms: number, + health?: { + endpoint: string, + status: number, + latency_ms: number + }, + expires?: string // ISO 8601, only if --ttl set +} +``` + +### Error Response + +```typescript +{ + status: "error", + code: string, + message: string, + name?: string, + url?: string +} +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Deploy failed | +| 2 | Invalid arguments | +| 3 | SSH connection failed | +| 4 | Health check failed | + +## Error Codes + +| Code | Description | +|------|-------------| +| `INVALID_PATH` | Path doesn't exist or isn't readable | +| `UNKNOWN_PROJECT_TYPE` | Can't detect how to deploy | +| `SSH_CONNECT_FAILED` | Can't establish SSH connection | +| `SSH_AUTH_FAILED` | SSH key rejected | +| `UPLOAD_FAILED` | SCP/rsync failed | +| `BUILD_FAILED` | Docker build or compilation failed | +| `SERVICE_FAILED` | systemd unit failed to start | +| `CADDY_FAILED` | Caddy reload failed | +| `HEALTH_CHECK_FAILED` | App didn't respond in time | +| `HEALTH_CHECK_TIMEOUT` | Health check timed out | +| `NOT_FOUND` | Named deploy doesn't exist | +| `CONFLICT` | Name already in use (if --no-update) | +| `HOST_NOT_CONFIGURED` | No default host, --host required | +| `INVALID_TTL` | Can't parse TTL duration | + +## Auto-Detection Logic + +When given a path, ship determines deploy type: + +``` +is_file(path)? + → is_executable(path)? + → BINARY + → ERROR: "Not an executable file" + +is_directory(path)? + → has_file("Dockerfile")? + → DOCKER + → has_file("index.html") OR has_file("index.htm")? + → STATIC + → has_file("go.mod") AND NOT has_file("Dockerfile")? + → ERROR: "Go project without Dockerfile. Add a Dockerfile or build a binary." + → has_file("package.json") AND NOT has_file("Dockerfile")? + → ERROR: "Node project without Dockerfile. Add a Dockerfile." + → is_empty(path)? + → ERROR: "Directory is empty" + → ERROR: "Can't detect project type. Add a Dockerfile or index.html." +``` + +## Name Generation + +If `--name` not provided: + +``` +name = "ship-" + random_alphanumeric(6) +``` + +Names must match: `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$` + +Invalid names return `INVALID_NAME` error. + +## Port Allocation + +Ports are allocated server-side. + +### Server Files + +``` +/etc/ship/ports/ # Contains allocated port number +``` + +### Allocation Algorithm + +```python +def allocate_port(name): + port_file = f"/etc/ship/ports/{name}" + + if exists(port_file): + return int(read(port_file)) + + used_ports = [int(read(f)) for f in glob("/etc/ship/ports/*")] + next_port = 9000 + while next_port in used_ports: + next_port += 1 + + write(port_file, str(next_port)) + return next_port +``` + +### Port Range + +- Start: 9000 +- Max: 9999 +- Error if exhausted: `PORT_EXHAUSTED` + +## Server File Layout + +``` +/etc/ship/ + ports/ # Port allocation (contains port number) + env/.env # Environment variables + ttl/ # TTL expiry timestamp (Unix epoch) + +/var/www// # Static site files + +/var/lib// + src/ # Docker build context (checked out source) + data/ # Persistent data volume + +/etc/systemd/system/.service # systemd unit +/etc/caddy/sites-enabled/.caddy # Caddy config +``` + +## Deploy Flows + +### Static Site + +1. Validate path is directory with index.html +2. Generate name if needed +3. SSH: Create `/var/www//` +4. rsync: Upload directory contents to `/var/www//` +5. SSH: Set ownership to `www-data` +6. Generate Caddyfile +7. SSH: Write to `/etc/caddy/sites-enabled/.caddy` +8. SSH: Reload Caddy +9. Health check: `GET https://./` +10. If TTL: Write expiry to `/etc/ship/ttl/` +11. Return JSON + +### Docker + +1. Validate path is directory with Dockerfile +2. Generate name if needed +3. Allocate port (server-side) +4. rsync: Upload directory to `/var/lib//src/` +5. SSH: `docker build -t /var/lib//src/` +6. Generate systemd unit (runs `docker run`) +7. SSH: Write to `/etc/systemd/system/.service` +8. SSH: Write env file to `/etc/ship/env/.env` +9. SSH: `systemctl daemon-reload && systemctl restart ` +10. Generate Caddyfile (reverse proxy to port) +11. SSH: Write to `/etc/caddy/sites-enabled/.caddy` +12. SSH: Reload Caddy +13. Health check: `GET https://.` +14. If TTL: Write expiry to `/etc/ship/ttl/` +15. Return JSON + +### Binary + +1. Validate path is executable file +2. Generate name if needed +3. Allocate port (server-side) +4. SCP: Upload binary to `/usr/local/bin/` +5. SSH: `chmod +x /usr/local/bin/` +6. Generate systemd unit +7. SSH: Write to `/etc/systemd/system/.service` +8. SSH: Write env file to `/etc/ship/env/.env` +9. SSH: `systemctl daemon-reload && systemctl restart ` +10. Generate Caddyfile (reverse proxy to port) +11. SSH: Write to `/etc/caddy/sites-enabled/.caddy` +12. SSH: Reload Caddy +13. Health check: `GET https://.` +14. If TTL: Write expiry to `/etc/ship/ttl/` +15. Return JSON + +## Health Checks + +### Behavior + +After deploy, verify the app is responding: + +1. Wait 2 seconds (let app start) +2. `GET https://.` +3. If 2xx or 3xx: success +4. If error or timeout: retry after 2s +5. Max retries: 15 (total 30s) +6. If all retries fail: return `HEALTH_CHECK_FAILED` + +### Health Endpoint + +| Deploy Type | Default | Override | +|-------------|---------|----------| +| static | `/` | `--health` | +| docker | none (skip) | `--health` | +| binary | none (skip) | `--health` | + +When no health endpoint: skip health check, return success after service starts. + +## TTL (Auto-Expiry) + +### Setting TTL + +```bash +ship ./site --name preview --ttl 24h +``` + +Supported formats: `30m`, `1h`, `24h`, `7d` + +### Server-Side Cleanup + +A systemd timer runs hourly: + +```ini +# /etc/systemd/system/ship-cleanup.timer +[Timer] +OnCalendar=hourly +Persistent=true + +# /etc/systemd/system/ship-cleanup.service +[Service] +Type=oneshot +ExecStart=/usr/local/bin/ship-cleanup +``` + +Cleanup script: + +```bash +#!/bin/bash +now=$(date +%s) +for f in /etc/ship/ttl/*; do + name=$(basename "$f") + expires=$(cat "$f") + if [ "$now" -gt "$expires" ]; then + ship remove "$name" + fi +done +``` + +### TTL File Format + +``` +/etc/ship/ttl/ +``` + +Contents: Unix timestamp (seconds since epoch) + +## Templates + +### systemd Unit (Docker) + +```ini +[Unit] +Description=ship: {{.Name}} +After=docker.service +Requires=docker.service + +[Service] +Restart=always +RestartSec=5 +EnvironmentFile=/etc/ship/env/{{.Name}}.env +ExecStartPre=-/usr/bin/docker stop {{.Name}} +ExecStartPre=-/usr/bin/docker rm {{.Name}} +ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ + --env-file /etc/ship/env/{{.Name}}.env \ + -p 127.0.0.1:{{.Port}}:{{.Port}} \ + -v /var/lib/{{.Name}}/data:/data \ + {{.Name}} +ExecStop=/usr/bin/docker stop {{.Name}} + +[Install] +WantedBy=multi-user.target +``` + +### systemd Unit (Binary) + +```ini +[Unit] +Description=ship: {{.Name}} +After=network.target + +[Service] +Type=simple +Restart=always +RestartSec=5 +User={{.Name}} +EnvironmentFile=/etc/ship/env/{{.Name}}.env +ExecStart=/usr/local/bin/{{.Name}} +WorkingDirectory=/var/lib/{{.Name}} + +[Install] +WantedBy=multi-user.target +``` + +### Caddyfile (Reverse Proxy) + +``` +{{.Domain}} { + reverse_proxy localhost:{{.Port}} +} +``` + +### Caddyfile (Static) + +``` +{{.Domain}} { + root * /var/www/{{.Name}} + file_server + encode gzip +} +``` + +## Environment Variables + +Always set by ship: + +| Variable | Value | +|----------|-------| +| `PORT` | Allocated port number | +| `SHIP_NAME` | Deploy name | +| `SHIP_URL` | Full URL | + +User variables from `--env` or `--env-file` are merged. + +## Host Initialization + +```bash +ship host init user@vps.example.com --domain example.com +``` + +### Steps + +1. SSH: Check connectivity +2. SSH: Install packages (`apt install -y caddy docker.io`) +3. SSH: Enable services (`systemctl enable --now caddy docker`) +4. SSH: Create directories (`/etc/ship/ports`, `/etc/ship/env`, `/etc/ship/ttl`) +5. SSH: Install cleanup timer +6. SSH: Configure Caddy base +7. Update local state with host info + +### Output + +```json +{ + "status": "ok", + "host": "vps.example.com", + "domain": "example.com", + "installed": ["caddy", "docker"] +} +``` + +## Local State + +Stored at `~/.config/ship/state.json`: + +```json +{ + "default_host": "vps", + "hosts": { + "vps": { + "ssh": "user@vps.example.com", + "domain": "example.com" + } + } +} +``` + +Local state is minimal — server is source of truth for ports, deploys, TTLs. + +## Pretty Output + +When `--pretty` is set (or `SHIP_PRETTY=1`): + +### Deploy Success + +``` +✓ Deployed to https://myapp.example.com (4.2s) +``` + +### Deploy Failure + +``` +✗ Deploy failed: health check timed out after 30s +``` + +### List + +``` +NAME URL TYPE STATUS +api https://api.example.com docker running +preview-x7k https://preview-x7k.example.com static running (expires in 23h) +``` + +## Implementation Notes + +### SSH Execution + +Use a single SSH connection with multiplexing: + +```bash +ssh -o ControlMaster=auto -o ControlPath=/tmp/ship-%r@%h:%p -o ControlPersist=60 ... +``` + +### Concurrency + +Ship is not designed for concurrent deploys of the same name. Behavior is undefined. + +Different names can deploy concurrently. + +### Rollback (Future) + +Not in v2.0. Future consideration: keep last N versions, `ship rollback `. + +--- + +## Appendix: Example Session + +```bash +$ mkdir -p /tmp/hello && echo '

Hello

' > /tmp/hello/index.html + +$ ship /tmp/hello --name hello +{"status":"ok","name":"hello","url":"https://hello.example.com","type":"static","took_ms":3200,"health":{"endpoint":"/","status":200,"latency_ms":45}} + +$ ship list +{"status":"ok","deploys":[{"name":"hello","url":"https://hello.example.com","type":"static","running":true}]} + +$ ship remove hello +{"status":"ok","name":"hello","removed":true} +``` -- cgit v1.2.3