# 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} ```