summaryrefslogtreecommitdiffstats
path: root/SPEC.md
diff options
context:
space:
mode:
Diffstat (limited to 'SPEC.md')
-rw-r--r--SPEC.md499
1 files changed, 0 insertions, 499 deletions
diff --git a/SPEC.md b/SPEC.md
deleted file mode 100644
index 50b54c2..0000000
--- a/SPEC.md
+++ /dev/null
@@ -1,499 +0,0 @@
1# Ship v2 Technical Specification
2
3## Overview
4
5Ship deploys code to a VPS. Input: path to code. Output: JSON with URL.
6
7```bash
8ship ./myproject
9# {"status":"ok","name":"ship-a1b2c3","url":"https://ship-a1b2c3.example.com","type":"docker","took_ms":8200}
10```
11
12## CLI Interface
13
14### Primary Command
15
16```
17ship [PATH] [FLAGS]
18```
19
20**PATH**: File or directory to deploy. Defaults to `.` (current directory).
21
22**FLAGS**:
23| Flag | Type | Default | Description |
24|------|------|---------|-------------|
25| `--name` | string | auto-generated | Deploy name (becomes subdomain) |
26| `--host` | string | default host | VPS host (SSH config name or user@host) |
27| `--health` | string | `/` for static, none for apps | Health check endpoint |
28| `--ttl` | duration | none | Auto-delete after duration (e.g., `1h`, `7d`) |
29| `--env` | string[] | none | Environment variables (`KEY=VALUE`) |
30| `--env-file` | string | none | Path to .env file |
31| `--pretty` | bool | false | Human-readable output |
32
33### Other Commands
34
35```
36ship list [--host HOST]
37ship status NAME [--host HOST]
38ship logs NAME [--lines N] [--host HOST]
39ship remove NAME [--host HOST]
40ship host init USER@HOST --domain DOMAIN
41ship host status [--host HOST]
42```
43
44All commands output JSON unless `--pretty` is set.
45
46## Output Schema
47
48### Success Response
49
50```typescript
51{
52 status: "ok",
53 name: string,
54 url: string,
55 type: "static" | "docker" | "binary",
56 took_ms: number,
57 health?: {
58 endpoint: string,
59 status: number,
60 latency_ms: number
61 },
62 expires?: string // ISO 8601, only if --ttl set
63}
64```
65
66### Error Response
67
68```typescript
69{
70 status: "error",
71 code: string,
72 message: string,
73 name?: string,
74 url?: string
75}
76```
77
78### Exit Codes
79
80| Code | Meaning |
81|------|---------|
82| 0 | Success |
83| 1 | Deploy failed |
84| 2 | Invalid arguments |
85| 3 | SSH connection failed |
86| 4 | Health check failed |
87
88## Error Codes
89
90| Code | Description |
91|------|-------------|
92| `INVALID_PATH` | Path doesn't exist or isn't readable |
93| `UNKNOWN_PROJECT_TYPE` | Can't detect how to deploy |
94| `SSH_CONNECT_FAILED` | Can't establish SSH connection |
95| `SSH_AUTH_FAILED` | SSH key rejected |
96| `UPLOAD_FAILED` | SCP/rsync failed |
97| `BUILD_FAILED` | Docker build or compilation failed |
98| `SERVICE_FAILED` | systemd unit failed to start |
99| `CADDY_FAILED` | Caddy reload failed |
100| `HEALTH_CHECK_FAILED` | App didn't respond in time |
101| `HEALTH_CHECK_TIMEOUT` | Health check timed out |
102| `NOT_FOUND` | Named deploy doesn't exist |
103| `CONFLICT` | Name already in use (if --no-update) |
104| `HOST_NOT_CONFIGURED` | No default host, --host required |
105| `INVALID_TTL` | Can't parse TTL duration |
106
107## Auto-Detection Logic
108
109When given a path, ship determines deploy type:
110
111```
112is_file(path)?
113 → is_executable(path)?
114 → BINARY
115 → ERROR: "Not an executable file"
116
117is_directory(path)?
118 → has_file("Dockerfile")?
119 → DOCKER
120 → has_file("index.html") OR has_file("index.htm")?
121 → STATIC
122 → has_file("go.mod") AND NOT has_file("Dockerfile")?
123 → ERROR: "Go project without Dockerfile. Add a Dockerfile or build a binary."
124 → has_file("package.json") AND NOT has_file("Dockerfile")?
125 → ERROR: "Node project without Dockerfile. Add a Dockerfile."
126 → is_empty(path)?
127 → ERROR: "Directory is empty"
128 → ERROR: "Can't detect project type. Add a Dockerfile or index.html."
129```
130
131## Name Generation
132
133If `--name` not provided:
134
135```
136name = "ship-" + random_alphanumeric(6)
137```
138
139Names must match: `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`
140
141Invalid names return `INVALID_NAME` error.
142
143## Port Allocation
144
145Ports are allocated server-side.
146
147### Server Files
148
149```
150/etc/ship/ports/<name> # Contains allocated port number
151```
152
153### Allocation Algorithm
154
155```python
156def allocate_port(name):
157 port_file = f"/etc/ship/ports/{name}"
158
159 if exists(port_file):
160 return int(read(port_file))
161
162 used_ports = [int(read(f)) for f in glob("/etc/ship/ports/*")]
163 next_port = 9000
164 while next_port in used_ports:
165 next_port += 1
166
167 write(port_file, str(next_port))
168 return next_port
169```
170
171### Port Range
172
173- Start: 9000
174- Max: 9999
175- Error if exhausted: `PORT_EXHAUSTED`
176
177## Server File Layout
178
179```
180/etc/ship/
181 ports/<name> # Port allocation (contains port number)
182 env/<name>.env # Environment variables
183 ttl/<name> # TTL expiry timestamp (Unix epoch)
184
185/var/www/<name>/ # Static site files
186
187/var/lib/<name>/
188 src/ # Docker build context (checked out source)
189 data/ # Persistent data volume
190
191/etc/systemd/system/<name>.service # systemd unit
192/etc/caddy/sites-enabled/<name>.caddy # Caddy config
193```
194
195## Deploy Flows
196
197### Static Site
198
1991. Validate path is directory with index.html
2002. Generate name if needed
2013. SSH: Create `/var/www/<name>/`
2024. rsync: Upload directory contents to `/var/www/<name>/`
2035. SSH: Set ownership to `www-data`
2046. Generate Caddyfile
2057. SSH: Write to `/etc/caddy/sites-enabled/<name>.caddy`
2068. SSH: Reload Caddy
2079. Health check: `GET https://<name>.<domain>/`
20810. If TTL: Write expiry to `/etc/ship/ttl/<name>`
20911. Return JSON
210
211### Docker
212
2131. Validate path is directory with Dockerfile
2142. Generate name if needed
2153. Allocate port (server-side)
2164. rsync: Upload directory to `/var/lib/<name>/src/`
2175. SSH: `docker build -t <name> /var/lib/<name>/src/`
2186. Generate systemd unit (runs `docker run`)
2197. SSH: Write to `/etc/systemd/system/<name>.service`
2208. SSH: Write env file to `/etc/ship/env/<name>.env`
2219. SSH: `systemctl daemon-reload && systemctl restart <name>`
22210. Generate Caddyfile (reverse proxy to port)
22311. SSH: Write to `/etc/caddy/sites-enabled/<name>.caddy`
22412. SSH: Reload Caddy
22513. Health check: `GET https://<name>.<domain><health_endpoint>`
22614. If TTL: Write expiry to `/etc/ship/ttl/<name>`
22715. Return JSON
228
229### Binary
230
2311. Validate path is executable file
2322. Generate name if needed
2333. Allocate port (server-side)
2344. SCP: Upload binary to `/usr/local/bin/<name>`
2355. SSH: `chmod +x /usr/local/bin/<name>`
2366. Generate systemd unit
2377. SSH: Write to `/etc/systemd/system/<name>.service`
2388. SSH: Write env file to `/etc/ship/env/<name>.env`
2399. SSH: `systemctl daemon-reload && systemctl restart <name>`
24010. Generate Caddyfile (reverse proxy to port)
24111. SSH: Write to `/etc/caddy/sites-enabled/<name>.caddy`
24212. SSH: Reload Caddy
24313. Health check: `GET https://<name>.<domain><health_endpoint>`
24414. If TTL: Write expiry to `/etc/ship/ttl/<name>`
24515. Return JSON
246
247## Health Checks
248
249### Behavior
250
251After deploy, verify the app is responding:
252
2531. Wait 2 seconds (let app start)
2542. `GET https://<name>.<domain><health_endpoint>`
2553. If 2xx or 3xx: success
2564. If error or timeout: retry after 2s
2575. Max retries: 15 (total 30s)
2586. If all retries fail: return `HEALTH_CHECK_FAILED`
259
260### Health Endpoint
261
262| Deploy Type | Default | Override |
263|-------------|---------|----------|
264| static | `/` | `--health` |
265| docker | none (skip) | `--health` |
266| binary | none (skip) | `--health` |
267
268When no health endpoint: skip health check, return success after service starts.
269
270## TTL (Auto-Expiry)
271
272### Setting TTL
273
274```bash
275ship ./site --name preview --ttl 24h
276```
277
278Supported formats: `30m`, `1h`, `24h`, `7d`
279
280### Server-Side Cleanup
281
282A systemd timer runs hourly:
283
284```ini
285# /etc/systemd/system/ship-cleanup.timer
286[Timer]
287OnCalendar=hourly
288Persistent=true
289
290# /etc/systemd/system/ship-cleanup.service
291[Service]
292Type=oneshot
293ExecStart=/usr/local/bin/ship-cleanup
294```
295
296Cleanup script:
297
298```bash
299#!/bin/bash
300now=$(date +%s)
301for f in /etc/ship/ttl/*; do
302 name=$(basename "$f")
303 expires=$(cat "$f")
304 if [ "$now" -gt "$expires" ]; then
305 ship remove "$name"
306 fi
307done
308```
309
310### TTL File Format
311
312```
313/etc/ship/ttl/<name>
314```
315
316Contents: Unix timestamp (seconds since epoch)
317
318## Templates
319
320### systemd Unit (Docker)
321
322```ini
323[Unit]
324Description=ship: {{.Name}}
325After=docker.service
326Requires=docker.service
327
328[Service]
329Restart=always
330RestartSec=5
331EnvironmentFile=/etc/ship/env/{{.Name}}.env
332ExecStartPre=-/usr/bin/docker stop {{.Name}}
333ExecStartPre=-/usr/bin/docker rm {{.Name}}
334ExecStart=/usr/bin/docker run --rm --name {{.Name}} \
335 --env-file /etc/ship/env/{{.Name}}.env \
336 -p 127.0.0.1:{{.Port}}:{{.Port}} \
337 -v /var/lib/{{.Name}}/data:/data \
338 {{.Name}}
339ExecStop=/usr/bin/docker stop {{.Name}}
340
341[Install]
342WantedBy=multi-user.target
343```
344
345### systemd Unit (Binary)
346
347```ini
348[Unit]
349Description=ship: {{.Name}}
350After=network.target
351
352[Service]
353Type=simple
354Restart=always
355RestartSec=5
356User={{.Name}}
357EnvironmentFile=/etc/ship/env/{{.Name}}.env
358ExecStart=/usr/local/bin/{{.Name}}
359WorkingDirectory=/var/lib/{{.Name}}
360
361[Install]
362WantedBy=multi-user.target
363```
364
365### Caddyfile (Reverse Proxy)
366
367```
368{{.Domain}} {
369 reverse_proxy localhost:{{.Port}}
370}
371```
372
373### Caddyfile (Static)
374
375```
376{{.Domain}} {
377 root * /var/www/{{.Name}}
378 file_server
379 encode gzip
380}
381```
382
383## Environment Variables
384
385Always set by ship:
386
387| Variable | Value |
388|----------|-------|
389| `PORT` | Allocated port number |
390| `SHIP_NAME` | Deploy name |
391| `SHIP_URL` | Full URL |
392
393User variables from `--env` or `--env-file` are merged.
394
395## Host Initialization
396
397```bash
398ship host init user@vps.example.com --domain example.com
399```
400
401### Steps
402
4031. SSH: Check connectivity
4042. SSH: Install packages (`apt install -y caddy docker.io`)
4053. SSH: Enable services (`systemctl enable --now caddy docker`)
4064. SSH: Create directories (`/etc/ship/ports`, `/etc/ship/env`, `/etc/ship/ttl`)
4075. SSH: Install cleanup timer
4086. SSH: Configure Caddy base
4097. Update local state with host info
410
411### Output
412
413```json
414{
415 "status": "ok",
416 "host": "vps.example.com",
417 "domain": "example.com",
418 "installed": ["caddy", "docker"]
419}
420```
421
422## Local State
423
424Stored at `~/.config/ship/state.json`:
425
426```json
427{
428 "default_host": "vps",
429 "hosts": {
430 "vps": {
431 "ssh": "user@vps.example.com",
432 "domain": "example.com"
433 }
434 }
435}
436```
437
438Local state is minimal — server is source of truth for ports, deploys, TTLs.
439
440## Pretty Output
441
442When `--pretty` is set (or `SHIP_PRETTY=1`):
443
444### Deploy Success
445
446```
447✓ Deployed to https://myapp.example.com (4.2s)
448```
449
450### Deploy Failure
451
452```
453✗ Deploy failed: health check timed out after 30s
454```
455
456### List
457
458```
459NAME URL TYPE STATUS
460api https://api.example.com docker running
461preview-x7k https://preview-x7k.example.com static running (expires in 23h)
462```
463
464## Implementation Notes
465
466### SSH Execution
467
468Use a single SSH connection with multiplexing:
469
470```bash
471ssh -o ControlMaster=auto -o ControlPath=/tmp/ship-%r@%h:%p -o ControlPersist=60 ...
472```
473
474### Concurrency
475
476Ship is not designed for concurrent deploys of the same name. Behavior is undefined.
477
478Different names can deploy concurrently.
479
480### Rollback (Future)
481
482Not in v2.0. Future consideration: keep last N versions, `ship rollback <name>`.
483
484---
485
486## Appendix: Example Session
487
488```bash
489$ mkdir -p /tmp/hello && echo '<h1>Hello</h1>' > /tmp/hello/index.html
490
491$ ship /tmp/hello --name hello
492{"status":"ok","name":"hello","url":"https://hello.example.com","type":"static","took_ms":3200,"health":{"endpoint":"/","status":200,"latency_ms":45}}
493
494$ ship list
495{"status":"ok","deploys":[{"name":"hello","url":"https://hello.example.com","type":"static","running":true}]}
496
497$ ship remove hello
498{"status":"ok","name":"hello","removed":true}
499```