odysseus-cli 0.3.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -58,16 +58,63 @@ ssh:
58
58
  2. Build and deploy:
59
59
 
60
60
  ```bash
61
- # Build, distribute, and deploy in one command
62
- odysseus deploy --image v1.0.0 --build
61
+ # From a git repository with everything committed — the version is the commit
62
+ odysseus deploy --build
63
+
64
+ # Anywhere else, name the version yourself
65
+ odysseus deploy --build --image v1.0.0
63
66
  ```
64
67
 
68
+ Both work. They are not equivalent — see [Naming the
69
+ version](#naming-the-version) for what the second one costs you.
70
+
71
+ ### Naming the version
72
+
73
+ Every deploy is tagged with a version, and there are two ways to get one.
74
+
75
+ **From a git commit — the proper way.** Run `deploy` in a git repository with
76
+ nothing uncommitted and omit `--image`. Odysseus tags the image with the commit
77
+ SHA and **refuses to deploy while the working tree is dirty**, which is the
78
+ point: the tag then provably identifies the code that is running. It records the
79
+ commit and your identity in each host's deploy log, so `odysseus rollback --list`
80
+ can tell you which commit a version was and who shipped it.
81
+
82
+ **With `--image TAG` — quick and dirty.** No git repository is needed, so it
83
+ works anywhere, and it is the right tool for trying something out or deploying
84
+ an image you built elsewhere. What you give up:
85
+
86
+ - **No commit is recorded.** `rollback --list` shows the version but cannot tell
87
+ you what code it was, because an arbitrary tag has no commit it honestly
88
+ identifies.
89
+ - **Nothing stops you reusing a tag.** The tag *is* the version's identity, so
90
+ deploying twice as `v1.0.0` leaves two entries in the deploy log that
91
+ odysseus cannot tell apart — and rollback and image retention cannot either.
92
+ This fails silently: you get a history that looks fine and does not say which
93
+ image is on the host.
94
+
95
+ Use `--image` to get started or to test. Use a git repository for anything you
96
+ might later need to roll back or account for.
97
+
65
98
  The `--build` flag automatically chooses how to distribute the image:
66
99
  - **Without `registry` config** → uses [pussh](https://github.com/psviderski/unregistry) to transfer images directly via SSH
67
- - **With `registry` config** → pushes to registry, hosts pull from there
100
+ - **With `registry` config** → pushes to registry, hosts pull from there — **experimental**, see [registry](#registry)
68
101
 
69
102
  ## Commands
70
103
 
104
+ ### Global options
105
+
106
+ These work with every command:
107
+
108
+ - `--config FILE` - Path to deploy.yml (default: `deploy.yml` in the working directory)
109
+ - `--debug` - Show every command sent to the host, and the connection as it opens.
110
+ `ODYSSEUS_DEBUG=1` does the same. Reach for this first when a command fails
111
+ in a way the message doesn't explain — it prints the literal shell line that
112
+ ran, which is usually where the answer is.
113
+ - `--version` - odysseus, odysseus-core and ruby versions
114
+
115
+ `-v` / `--verbose` is accepted by `deploy`, `build`, `pussh` and `setup`, and
116
+ means the same as `--debug` for those commands.
117
+
71
118
  ### deploy
72
119
 
73
120
  Deploy all roles to their configured hosts.
@@ -78,14 +125,14 @@ odysseus deploy [options]
78
125
 
79
126
  Options:
80
127
  - `--config FILE` - Path to deploy.yml (default: deploy.yml)
81
- - `--image TAG` - Docker image tag (default: latest)
128
+ - `--image TAG` - Docker image tag (default: the git commit being deployed; required outside a clean git repository). See [Naming the version](#naming-the-version) — it is the quick way, not the equivalent way.
82
129
  - `--build` - Build and distribute image before deploying
83
130
  - `--dry-run` - Show what would be deployed without doing it
84
131
  - `-v, --verbose` - Show SSH commands being executed
85
132
 
86
133
  The `--build` flag automatically chooses the distribution method based on your config:
87
134
  - **No `registry` config** → uses pussh (direct SSH transfer to each host)
88
- - **Has `registry` config** → pushes to registry (hosts pull from there)
135
+ - **Has `registry` config** → pushes to registry (hosts pull from there) — **experimental**, see [registry](#registry)
89
136
 
90
137
  Examples:
91
138
 
@@ -107,8 +154,8 @@ odysseus build [options]
107
154
 
108
155
  Options:
109
156
  - `--config FILE` - Path to deploy.yml (default: deploy.yml)
110
- - `--image TAG` - Docker image tag (default: latest)
111
- - `--push` - Push image to registry after build
157
+ - `--image TAG` - Docker image tag (default: the git commit being deployed; required outside a clean git repository). See [Naming the version](#naming-the-version) — it is the quick way, not the equivalent way.
158
+ - `--push` - Push image to registry after build (**experimental**, see [registry](#registry))
112
159
  - `--context PATH` - Build context path (default: . relative to deploy.yml)
113
160
  - `-v, --verbose` - Show build commands being executed
114
161
 
@@ -137,7 +184,7 @@ odysseus pussh [options]
137
184
 
138
185
  Options:
139
186
  - `--config FILE` - Path to deploy.yml (default: deploy.yml)
140
- - `--image TAG` - Docker image tag (default: latest)
187
+ - `--image TAG` - Docker image tag (default: the git commit being deployed; required outside a clean git repository). See [Naming the version](#naming-the-version) — it is the quick way, not the equivalent way.
141
188
  - `--build` - Build image before pushing
142
189
  - `-v, --verbose` - Show commands being executed
143
190
 
@@ -188,14 +235,36 @@ Options:
188
235
  - `-n, --lines N` - Number of lines to show (default: 100)
189
236
  - `--since TIME` - Show logs since timestamp (e.g., '10m', '2h')
190
237
 
238
+ Stopped containers are included, since the container that has just exited is
239
+ usually the one whose logs you want; when the only match is stopped, the
240
+ command says so before printing them. That notice, and the message when no
241
+ container is found at all, go to stderr, so `odysseus logs web1 > app.log`
242
+ captures the logs and nothing else. Finding no container at all — running or
243
+ stopped — exits non-zero, and the message names the role, the label it
244
+ searched for and the roles this config has.
245
+
191
246
  ### cleanup
192
247
 
193
- Clean up old containers and optionally prune images.
248
+ Tears the service down on one server. The name undersells it: this is not a
249
+ sweep of stale containers, it is a removal.
194
250
 
195
251
  ```bash
196
252
  odysseus cleanup <server> [--prune-images]
197
253
  ```
198
254
 
255
+ It stops and force-removes **every** container belonging to the service on that
256
+ server — every role, and every dependency, including databases — not only the
257
+ old ones and not excluding the container currently serving traffic. It then
258
+ removes the service's Caddy routes, and if no other service is left behind the
259
+ proxy it stops and removes the shared `odysseus-caddy` container as well, which
260
+ serves every other app on that host.
261
+
262
+ `--prune-images` additionally prunes dangling images.
263
+
264
+ There is no confirmation prompt and nothing is backed up first. Volumes survive,
265
+ so a dependency's data is still there for a later `dependency boot`, but the
266
+ containers are gone.
267
+
199
268
  ### validate
200
269
 
201
270
  Validate your deploy.yml configuration.
@@ -204,26 +273,205 @@ Validate your deploy.yml configuration.
204
273
  odysseus validate [--config FILE]
205
274
  ```
206
275
 
207
- ### accessory
276
+ This loads `plugins:`/`sails:` before checking anything else, the same as
277
+ every other command, so it also catches a plugin gem that is not installed
278
+ on this machine — see `plugins` in the configuration reference below.
279
+
280
+ ### setup
208
281
 
209
- Manage accessories (databases, Redis, etc).
282
+ Prepares every host in the config so odysseus can deploy to it as a
283
+ non-root user: creates the user `ssh.user` names, adds it to the `docker`
284
+ group, installs your public key, and creates its state directory under
285
+ that user's home (`~/.odysseus`). It only **adds** access — it never
286
+ modifies root's configuration or the bootstrap user's.
210
287
 
211
288
  ```bash
212
- # These commands use hosts from accessory config (no server argument needed)
213
- odysseus accessory boot --name db
214
- odysseus accessory boot-all
215
- odysseus accessory remove --name db
216
- odysseus accessory restart --name db
217
- odysseus accessory upgrade --name db
218
- odysseus accessory status
289
+ odysseus setup [--config FILE] [--as USER] [--key PATH]
290
+ ```
291
+
292
+ Options:
293
+ - `--as USER` - Identity to connect as while preparing the host (default:
294
+ `ubuntu`, the user Ubuntu's LTS cloud images ship, with passwordless sudo
295
+ already configured). `--as root` connects as root and needs no sudo at
296
+ all. Passwordless sudo is a hard requirement for any other identity —
297
+ odysseus cannot answer a password prompt, so a host without it is
298
+ refused before anything is changed.
299
+ - `--key PATH` - Install this public key instead of the one resolved from
300
+ `ssh.keys`. Repeatable.
301
+
302
+ It reads no new configuration keys: the user comes from `ssh.user`, the
303
+ hosts from `servers.*.hosts`, and the keys from `ssh.keys` — each entry's
304
+ `.pub` sibling if one has a valid key line in it, otherwise derived from
305
+ the private key itself with `ssh-keygen -y`. A `--key` path that doesn't
306
+ resolve to a valid public key refuses, naming the path, rather than
307
+ falling back to `ssh.keys`; the same goes for a `.pub` sibling that has
308
+ content but no line in it validates — for example a restricted
309
+ `command="..." ssh-ed25519 ...` entry, which setup deliberately does not
310
+ install. Setup installs plain login keys only, and it will not silently
311
+ substitute a different key for the one you named or the one on disk. Only
312
+ an empty or whitespace-only `.pub` sibling falls through to deriving the
313
+ key from its private half.
314
+
315
+ **Docker is installed if it isn't there.** If `docker info` doesn't answer,
316
+ setup installs it from Docker's own official apt repository, following
317
+ Docker's published instructions for Ubuntu — it does not distinguish "not
318
+ installed" from "installed but stopped" going in, since neither leaves a
319
+ usable daemon. The keyring and the apt sources file it writes are replaced
320
+ whole on every run, never appended, so a run interrupted partway through
321
+ leaves a stale file the next run overwrites rather than a corrupt one with
322
+ the repository listed twice. apt itself runs non-interactively with a
323
+ 300-second wait for the dpkg lock — long enough to outlast cloud-init or
324
+ unattended-upgrades on a host that's only minutes old — and a timeout
325
+ names the process holding it rather than failing silently. The GPG key's
326
+ fingerprint is deliberately not pinned: Docker's own instructions trust
327
+ TLS rather than pin it, and pinning here would turn Docker's routine key
328
+ rotation into an outage for everyone running this command. None of this
329
+ repairs an apt or dpkg state setup didn't create — a host with a broken
330
+ apt is reported, not fixed. Either way, success is decided by `docker
331
+ info` answering after the install runs, not by apt exiting zero. Ubuntu
332
+ 24.04 and 26.04 are the only distros it knows; anything else is refused by
333
+ name too — unlike `doctor`, which only warns on an unsupported distro,
334
+ because a deploy just needs a working Docker daemon and doesn't care which
335
+ distro provides it. Setup is stricter because it changes the host: it
336
+ stops at the first thing it can't verify rather than proceeding on a guess.
337
+
338
+ It reports what it changed separately from what was already correct, and
339
+ running it twice against an already-prepared host changes nothing on
340
+ either run.
341
+
342
+ The last thing it does is open a second connection — as the user it just
343
+ created, not the bootstrap identity — and prove Docker and the state
344
+ directory both work from there. It reports success only if that passes. A
345
+ failure at this step leaves the bootstrap path, and everything already
346
+ prepared, untouched: the host stays reachable and there is always a way
347
+ back in to try again.
348
+
349
+ **What it's for.** `setup` gets a single host ready for odysseus to deploy
350
+ to. Preparing servers at scale — many hosts, built from scratch — belongs
351
+ to OpenTofu, Terraform or an equivalent tool that can do it declaratively;
352
+ `setup` is not a substitute for that, only for getting one host going.
353
+ However a host was prepared, including one a provisioning tool built, run
354
+ [`doctor`](#doctor) afterwards to check it.
355
+
356
+ ### doctor
357
+
358
+ Read-only diagnosis of every host in the config, connecting as the user
359
+ `ssh.user` names — not root. That's the point: a host that is perfectly fine
360
+ for root can be unusable for a deploy user, and this is the identity odysseus
361
+ will actually deploy as.
362
+
363
+ ```bash
364
+ odysseus doctor [--config FILE]
365
+ ```
366
+
367
+ For each host it reports:
368
+
369
+ - **distro** — informational only. An unsupported distro is a **warning**,
370
+ not a failure: odysseus deploys to any host with a working Docker daemon,
371
+ and nothing here depends on which distro it is.
372
+ - **docker** — whether the Docker daemon answers as the deploy user.
373
+ - **docker group membership** — whether the deploy user can reach the docker
374
+ socket (skipped for root, which needs no group membership).
375
+ - **state directory** — whether odysseus's state directory, or its nearest
376
+ existing ancestor, is writable by the deploy user.
377
+ - **deploy-log location** — where `odysseus rollback` will read and write
378
+ this service's deploy history, and whether older history exists at a
379
+ location the deploy user can read but no longer write to.
380
+
381
+ Only a failing check sets a non-zero exit; a warning does not. If a check
382
+ itself blows up — a dropped connection mid-host, say — that host is reported
383
+ and the survey moves on to the rest rather than aborting.
384
+
385
+ `doctor` changes nothing on the host: no directory is created, no package is
386
+ installed, nothing is repaired. Caddy's directory is deliberately not
387
+ checked — it doesn't exist until the first deploy starts Caddy, so checking
388
+ for it would report a correctly configured, not-yet-deployed host as broken.
389
+
390
+ **What it's for.** Preparing servers at scale — users, Docker, firewall
391
+ ports, everything declaratively and repeatably — belongs to OpenTofu,
392
+ Terraform or an equivalent tool, not to odysseus. [`setup`](#setup) does a
393
+ deliberately narrow slice of that: a trial-scale bootstrap to get going, not
394
+ the declarative provisioning at scale a tool like that is for; it opens no
395
+ ports, and it is not a provisioning tool. `doctor` answers whether a host is
396
+ actually usable by odysseus as the user your config names, which is worth
397
+ asking however the host was prepared, and can serve as the acceptance test
398
+ for a tofu-built one.
399
+
400
+ ### rollback
401
+
402
+ Return every role on every host to a previously deployed version.
403
+
404
+ ```bash
405
+ odysseus rollback # to the previous version
406
+ odysseus rollback abc123def456 # to a specific version
407
+ odysseus rollback --list # what each host could roll back to
408
+ ```
409
+
410
+ The target is chosen from what the hosts have, not from your checkout: the
411
+ version must still have an image on **every** host, or the rollback refuses
412
+ without touching any of them.
413
+
414
+ A rollback re-runs the deploy path, so it starts a container and waits for
415
+ health checks — roughly the time of a normal deploy, minus build and transfer.
416
+
417
+ Only versions deployed by odysseus 0.4.2 or later can be rolled back to.
418
+ Earlier deploys were built from `:latest`, so no image identifies them.
419
+
420
+ ### dependency
421
+
422
+ Manage dependencies (databases, Redis, etc). `dep` is accepted as shorthand.
423
+
424
+ **Renamed from `accessory`.** The old command name and the old `accessories:`
425
+ key in deploy.yml both still work, and the command prints a notice when you use
426
+ the old name. They will be removed in a later release, so rename the key in your
427
+ deploy.yml when convenient:
428
+
429
+ ```yaml
430
+ dependencies: # was: accessories:
431
+ db:
432
+ image: postgres:16
433
+ ```
434
+
435
+ Nothing on your hosts changes when you rename the key. Container names and the
436
+ `odysseus.service` label are built from the service name plus the individual
437
+ dependency's name, so `db` stays `myapp-db` either way and running containers
438
+ are adopted rather than orphaned.
439
+
440
+ ```bash
441
+ # These commands use hosts from dependency config (no server argument needed)
442
+ odysseus dependency boot --name db
443
+ odysseus dependency boot-all
444
+ odysseus dependency remove --name db
445
+ odysseus dependency restart --name db
446
+ odysseus dependency upgrade --name db
447
+ odysseus dependency status
219
448
 
220
449
  # These commands require a server argument
221
- odysseus accessory logs <server> --name db [-f] [-n 100]
222
- odysseus accessory exec <server> --name db --command "psql -U postgres"
223
- odysseus accessory shell <server> --name db
450
+ odysseus dependency logs <server> --name db [-f] [-n 100]
451
+ odysseus dependency exec <server> --name db --command "psql -U postgres"
452
+ odysseus dependency shell <server> --name db
224
453
  ```
225
454
 
226
- Accessory commands like `boot`, `remove`, `restart`, `upgrade`, and `status` read the target hosts from the accessory's `hosts` configuration in deploy.yml, similar to how `deploy` works. Only `logs`, `exec`, and `shell` require a server argument since they operate on a specific host.
455
+ Dependency commands like `boot`, `remove`, `restart`, `upgrade`, and `status` read the target hosts from the dependency's `hosts` configuration in deploy.yml, similar to how `deploy` works. Only `logs`, `exec`, and `shell` require a server argument since they operate on a specific host.
456
+
457
+ **Removing one: `remove` first, then edit deploy.yml.** Every dependency command
458
+ finds its containers through that dependency's config block, so deleting the
459
+ block first strands the container — `remove` then answers `Dependency 'redis'
460
+ not found in config`, and nothing else will find it either. `boot-all` only
461
+ boots what the config lists; it never removes what the config has stopped
462
+ listing, deliberately, because a typo or a half-merged branch would otherwise
463
+ destroy a database. If you have already deleted the block, put it back, run
464
+ `remove`, then delete it.
465
+
466
+ **Volumes outlive `remove`.** It stops and removes containers and nothing else,
467
+ so a named volume — and the data in it — survives, which is what you want when
468
+ replacing a container and not what you want when you meant to be rid of it.
469
+ Removing the data is a separate, deliberate step on the host:
470
+ `docker volume rm myapp-db-data`.
471
+
472
+ A container whose config block is gone keeps running, and keeps restarting,
473
+ without appearing in `dependency status` — which lists what the config
474
+ declares, not what the host is running.
227
475
 
228
476
  ### app
229
477
 
@@ -235,6 +483,47 @@ odysseus app exec <server> --command "rails db:migrate"
235
483
  odysseus app console <server> [--cmd "rails c"]
236
484
  ```
237
485
 
486
+ Options:
487
+ - `--role ROLE` - Role whose running image to use: web, jobs, etc (default: web)
488
+
489
+ Each of these runs a new container from the image the named role is currently
490
+ running on that host. Containers are labelled per role, so `--role` is required
491
+ to reach anything but web — including on a service that has no web role at all,
492
+ where the default matches nothing on any host:
493
+
494
+ ```bash
495
+ odysseus app exec worker1.example.com --role jobs --command "rails runner …"
496
+ ```
497
+
498
+ The container is given the same environment a deploy gives it — `env.clear` and
499
+ `env.secret` both — so `odysseus app exec web1 --command "rails db:migrate"`
500
+ talks to the same database as the app running beside it. The values travel in an
501
+ env file rather than on the command line; see [env](#env) for what that means
502
+ and for the one case where the file is left behind.
503
+
504
+ `shell` and `console` print a header before handing the terminal over — the
505
+ server, the role, the image that is serving and the command being run — because
506
+ the prompt you land on tells you none of it:
507
+
508
+ ```
509
+ App Shell
510
+ Server: dedalus-prod
511
+ Role: web
512
+ Image: dedalus-production:v1.4.2
513
+ Command: /bin/sh
514
+ › New container from that image: the running app is untouched, and this one is discarded on exit.
515
+ ```
516
+
517
+ That last line is the point. These commands `docker run` the serving image; they
518
+ do not attach to the container taking traffic. Nothing you do inside reaches the
519
+ running app, and the container is removed when you leave. `dependency shell` is
520
+ the other way round — it `docker exec`s into the running dependency, so what you
521
+ do there is live.
522
+
523
+ The header goes to **stderr**, so a session whose output you are capturing —
524
+ `odysseus app console web1 --cmd "rails runner 'puts Thing.count'" > count` —
525
+ gets the session's own output on stdout and nothing else.
526
+
238
527
  ### secrets
239
528
 
240
529
  Manage encrypted secrets files.
@@ -265,6 +554,46 @@ The name of your service. Used for container naming and Caddy routing.
265
554
 
266
555
  The Docker image name (without tag). Tags are specified at deploy time.
267
556
 
557
+ ### plugins
558
+
559
+ Some features ship as separate gems ("sails") instead of being built into
560
+ Odysseus — a deploy strategy, a way of resolving a role's hosts. Having the
561
+ gem is not enough by itself: it also has to be named here, because Odysseus
562
+ never auto-discovers what happens to be installed. That is deliberate — the
563
+ same deploy.yml should behave identically on every machine, whether or not
564
+ some other gem is sitting in the local bundle.
565
+
566
+ ```yaml
567
+ plugins:
568
+ - odysseus-sail-example
569
+ ```
570
+
571
+ `sails:` is accepted as the same key under a different name; a deploy.yml
572
+ carrying both is a config error, not a silent preference of one over the
573
+ other. Each name is `require`d before the rest of deploy.yml is checked —
574
+ that ordering is what lets `servers.<role>.deploy.strategy` below resolve to
575
+ a strategy the plugin registers. A name that will not `require` (not
576
+ installed, or misspelled) stops validation with an error that names the gem,
577
+ rather than failing later at deploy time. `odysseus validate` runs this same
578
+ loading step, so it catches a missing plugin gem too — which means
579
+ `validate` can fail on a machine that lacks the gem where it used to pass,
580
+ if your deploy.yml lists one.
581
+
582
+ **No sail gem is published to RubyGems.** `odysseus-sail-example` above is a
583
+ placeholder, not a gem you can install. The sails that exist live in their
584
+ own repositories alongside this one, so a deploy.yml that names one only
585
+ works where the gem is reachable from your app's Gemfile:
586
+
587
+ ```ruby
588
+ # Gemfile — one or the other, not both
589
+ gem 'odysseus-sail-example', git: 'https://example.com/odysseus-sail-example.git'
590
+ gem 'odysseus-sail-example', path: '../odysseus-sail-example'
591
+ ```
592
+
593
+ The failure a plugin that will not load raises suggests `gem install <name>`.
594
+ That is the right advice for a published gem, and no help for a sail: the fix
595
+ is the Gemfile entry above.
596
+
268
597
  ### servers
269
598
 
270
599
  Define roles and their target hosts:
@@ -293,39 +622,69 @@ Available options:
293
622
  - `cpus` - CPU limit (e.g., `2` for 2 cores, `1.5` for 1.5 cores)
294
623
  - `cpu_shares` - Relative CPU weight (default: 1024)
295
624
 
296
- #### Dynamic hosts with AWS Auto Scaling Groups
625
+ **SSH configuration** (bastions, ProxyJump, etc.) is your responsibility. Odysseus only needs the hostnames/IPs and relies on your local SSH config.
626
+
627
+ Every role must carry a `hosts` array, or config validation rejects it with
628
+ `server role 'web' must have 'hosts' array`. A sail that resolves a role's
629
+ hosts for you — see `plugins` above — still needs the key present; it may be
630
+ empty, and the sail fills it in at deploy time.
297
631
 
298
- Instead of a static `hosts` list, you can configure Odysseus to resolve hosts dynamically from an AWS Auto Scaling Group:
632
+ #### containers
633
+
634
+ Run more than one container per host for a role — meaningful only to a
635
+ strategy that reads it. The built-in strategy always runs exactly one
636
+ container per role per host and ignores this block:
299
637
 
300
638
  ```yaml
301
639
  servers:
302
640
  web:
303
- aws:
304
- asg: my-web-asg # ASG name (required)
305
- region: us-east-1 # AWS region (required)
306
- use_private_ip: false # Use private IPs instead of public (default: false)
307
- state: InService # Instance lifecycle state filter (default: InService)
308
- options:
309
- memory: 4g
641
+ deploy:
642
+ strategy: example
643
+ containers:
644
+ count: 3 # containers per host on this role (default: 1)
645
+ name_pattern: "web-%d" # default for every role see warning below
310
646
  ```
311
647
 
312
- **AWS credentials** are loaded from the standard AWS credential chain:
313
- - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
314
- - Shared credentials file (`~/.aws/credentials`)
315
- - IAM instance profile (when running on EC2)
316
-
317
- **Prerequisites:** Install the AWS SDK gems:
318
- ```bash
319
- gem install aws-sdk-autoscaling aws-sdk-ec2
320
- ```
648
+ **`name_pattern` defaults to `"web-%d"` for every role, and a container's
649
+ name is built from the bare service name, not the role**
650
+ (`<service>-<name_pattern % slot>`). Two roles that both leave it at the
651
+ default say `web` and a multi-container `jobs`, on the same multi-container
652
+ strategy and deployed to the same host — produce identically-named
653
+ containers, and each role's deploy will see and manage the other's
654
+ containers. Give every role beyond the first its own `name_pattern` (e.g.
655
+ `"jobs-%d"`) whenever more than one role runs multi-container on a shared
656
+ host.
321
657
 
322
- Or add to your Gemfile:
323
- ```ruby
324
- gem 'aws-sdk-autoscaling'
325
- gem 'aws-sdk-ec2'
326
- ```
658
+ #### deploy
327
659
 
328
- **SSH configuration** (bastions, ProxyJump, etc.) is your responsibility. Odysseus only needs the hostnames/IPs and relies on your local SSH config.
660
+ ```yaml
661
+ servers:
662
+ web:
663
+ deploy:
664
+ strategy: example # optional — see `plugins` above; omit for the built-in strategy
665
+ drain_timeout: 30 # seconds to wait for in-flight connections before stopping the old container
666
+ stop_timeout: 10 # seconds of grace before the old container is force-removed
667
+ boot_timeout: 60 # seconds to wait for a new container to become healthy
668
+ health_check:
669
+ path: /up # default: /up
670
+ interval: 2 # seconds between checks (default: 2)
671
+ threshold: 3 # consecutive successes required (default: 3)
672
+ timeout: 5 # seconds per check (default: 5)
673
+ ```
674
+
675
+ `strategy` chooses the orchestrator for the role. Leave it out for
676
+ Odysseus's built-in zero-downtime replacement (start new, health-check via
677
+ `proxy.healthcheck`, switch traffic, drain and stop old — its own timings,
678
+ not the ones below). Name a strategy a plugin registers — currently only
679
+ `rolling` — to use that instead; it must be loaded via `plugins:` first, or
680
+ config validation refuses it with "is not registered — is the sail plugin
681
+ gem loaded?".
682
+
683
+ `drain_timeout`, `stop_timeout`, `boot_timeout` and `health_check` are parsed
684
+ and shape-checked regardless of `strategy`, but **the built-in strategy does
685
+ not read them** — it drains for a fixed 5 seconds and waits up to a fixed 60
686
+ seconds for Docker's own health check, neither of which this block changes.
687
+ They exist for a strategy that chooses to read them; `rolling` does.
329
688
 
330
689
  ### proxy
331
690
 
@@ -346,6 +705,9 @@ proxy:
346
705
  expect_status: 200 # Optional: expected HTTP status (default: 2xx)
347
706
  ```
348
707
 
708
+ A web container must report healthy before Odysseus routes traffic to it. If
709
+ you omit `healthcheck`, the container is probed with `GET /` on `app_port`.
710
+
349
711
  ### env
350
712
 
351
713
  Environment variables:
@@ -362,6 +724,27 @@ env:
362
724
  - `clear` - Plaintext values stored in deploy.yml
363
725
  - `secret` - Keys to load from encrypted secrets file or server environment
364
726
 
727
+ Both are handed to the container through an env file written under the state
728
+ directory described in [ssh](#ssh) — `/var/lib/odysseus/env` for the default
729
+ root connection — with `0600` permissions, and removed once the container has
730
+ been created, so secrets never appear in the host's process list. A value
731
+ containing a newline is rejected, since a Docker env file cannot represent one.
732
+
733
+ `app exec`, `app shell` and `app console` get the same environment the same way.
734
+ An interactive session holds its env file for as long as the session lasts and
735
+ removes it on the way out, whether the session ended cleanly, exited non-zero or
736
+ was interrupted. A session that sits idle long enough for its ssh connection to
737
+ be dropped — an idle NAT timeout, an `sshd` `ClientAlive` limit, a Tailscale
738
+ relay change — is included: the file is removed over a fresh connection.
739
+
740
+ Two cases still leave the file on the host. The `odysseus` process being killed
741
+ outright — `SIGKILL`, or the machine going down — where no cleanup can run at
742
+ all; and a host that is unreachable when the session ends, where there is
743
+ nowhere to send the removal. The file is mode `0600` inside that same env
744
+ directory, which is `0700` for every connection, so another user on the box
745
+ still cannot read it; but nothing comes back to remove it, since the next run
746
+ writes its own file rather than tidying old ones.
747
+
365
748
  ### secrets_file
366
749
 
367
750
  Path to an encrypted secrets file (relative to deploy.yml or absolute):
@@ -380,18 +763,18 @@ RAILS_MASTER_KEY: abc123def456
380
763
 
381
764
  During deploy, secrets listed in `env.secret` are loaded from the encrypted file. If a key is not found in the secrets file, it falls back to the server's environment variables.
382
765
 
383
- ### accessories
766
+ ### dependencies
384
767
 
385
768
  Long-running services like databases:
386
769
 
387
770
  ```yaml
388
- accessories:
771
+ dependencies:
389
772
  db:
390
773
  image: postgres:16
391
774
  hosts:
392
775
  - db.example.com
393
776
  volumes:
394
- - /var/lib/odysseus/myapp/postgres:/var/lib/postgresql/data
777
+ - /srv/myapp/postgres:/var/lib/postgresql/data
395
778
  env:
396
779
  clear:
397
780
  POSTGRES_USER: myapp
@@ -402,7 +785,7 @@ accessories:
402
785
  timeout: 5
403
786
  ```
404
787
 
405
- Each accessory must define `hosts` - the servers where it should run.
788
+ Each dependency must define `hosts` - the servers where it should run.
406
789
 
407
790
  ### ssh
408
791
 
@@ -415,6 +798,20 @@ ssh:
415
798
  - ~/.ssh/id_ed25519
416
799
  ```
417
800
 
801
+ `user` defaults to `root` and also decides where odysseus keeps state on the
802
+ host. A root connection writes to `/var/lib/odysseus`, exactly as always. Any
803
+ other user writes under its own `$HOME/.odysseus` instead, because it cannot
804
+ create or `chmod` a directory root owns. Caddy's certificate directory
805
+ follows the same rule as everything else: `/var/lib/odysseus/caddy` for a
806
+ root connection, `$HOME/.odysseus/caddy` for any other user.
807
+
808
+ A non-root `user` must already exist on the target host — with membership in
809
+ the `docker` group and a writable home directory — and its key must be one of
810
+ `keys` above. [`odysseus setup`](#setup) can create that user, add it to
811
+ `docker`, install the key for you, and install Docker itself if the host
812
+ doesn't have it. A host prepared by a provisioning tool instead of `setup`
813
+ still needs Docker present.
814
+
418
815
  ### builder
419
816
 
420
817
  Configuration for building Docker images:
@@ -443,6 +840,9 @@ Build strategies:
443
840
 
444
841
  ### registry
445
842
 
843
+ > **Experimental:** registry-based distribution is not officially supported yet. The
844
+ > pussh path is the supported one; treat this as untested ground and keep a way back.
845
+
446
846
  Docker registry configuration. When present, `odysseus deploy --build` will push images to the registry instead of using pussh:
447
847
 
448
848
  ```yaml
@@ -461,53 +861,37 @@ registry:
461
861
 
462
862
  For better security, you can store registry credentials in your encrypted secrets file and reference them.
463
863
 
464
- ## Charm Mode (TUI)
864
+ ### retain_versions
465
865
 
466
- Odysseus CLI supports an optional **Charm mode** for a more glamorous terminal experience with spinners, styled output, tables, and interactive confirmations.
866
+ How many distinct versions of your service's image each host keeps. Default 5.
467
867
 
468
- ### Enabling Charm Mode
469
-
470
- ```bash
471
- # Via command-line flag
472
- odysseus deploy --charm --build
473
-
474
- # Via environment variable
475
- ODYSSEUS_CHARM=1 odysseus deploy --build
868
+ ```yaml
869
+ retain_versions: 5
476
870
  ```
477
871
 
478
- ### Features in Charm Mode
479
-
480
- - **Styled headers** with rounded borders and colors
481
- - **Spinners** for long-running operations (build, deploy, pussh)
482
- - **Tables** for container and accessory status listings
483
- - **Confirmation dialogs** for destructive operations (cleanup, accessory remove)
484
-
485
- ### Installing gum
872
+ After a successful deploy, images outside that window are removed from each
873
+ host. An image is only removed if the host's own deploy log records it, no
874
+ container on the host still references it, and docker accepts the removal —
875
+ so a version you are still running is never deleted, and a failure to delete
876
+ one image never fails the deploy.
486
877
 
487
- Charm mode requires [gum](https://github.com/charmbracelet/gum) to be installed:
488
-
489
- ```bash
490
- # macOS
491
- brew install gum
492
-
493
- # Arch Linux
494
- pacman -S gum
495
-
496
- # Ubuntu/Debian (via charm tap)
497
- sudo mkdir -p /etc/apt/keyrings
498
- curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
499
- echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
500
- sudo apt update && sudo apt install gum
501
-
502
- # From source
503
- go install github.com/charmbracelet/gum@latest
504
- ```
878
+ Setting this to `1` is allowed but means the previous version's image becomes
879
+ eligible for removal as soon as you deploy, leaving `odysseus rollback` with
880
+ no candidate. Use at least 2 if you want to be able to roll back.
505
881
 
506
- If gum is not installed, Odysseus will warn you and fall back to standard output.
882
+ `latest` is never removed automatically. `odysseus cleanup --prune-images`
883
+ only removes *dangling* images, and a tagged `latest` is never dangling —
884
+ removing it means `docker image rm` by hand on the host.
507
885
 
508
886
  ## Server Requirements
509
887
 
510
- Your target servers only need **Docker** installed. Odysseus automatically deploys and manages Caddy as a container (`odysseus-caddy`) - no manual Caddy installation required.
888
+ A host odysseus **deploys** to needs a working Docker daemon and SSH access
889
+ deploys never gate on distro. [`odysseus setup`](#setup), the optional
890
+ bootstrap for a fresh host, is what requires a supported Ubuntu release
891
+ (24.04 or 26.04); a host prepared some other way — by hand, or by a
892
+ provisioning tool — just needs Docker present already. Odysseus
893
+ automatically deploys and manages Caddy as a container (`odysseus-caddy`) -
894
+ no manual Caddy installation required.
511
895
 
512
896
  ## How It Works
513
897