odysseus-core 0.3.2 → 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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +485 -1
  3. data/LICENSE.txt +25 -7
  4. data/README.md +12 -3
  5. data/lib/odysseus/builder/client.rb +15 -15
  6. data/lib/odysseus/caddy/client.rb +119 -42
  7. data/lib/odysseus/command_redaction.rb +38 -0
  8. data/lib/odysseus/config/parser.rb +43 -18
  9. data/lib/odysseus/core/deploy_versioning.rb +42 -0
  10. data/lib/odysseus/core/environment.rb +63 -0
  11. data/lib/odysseus/core/version.rb +1 -1
  12. data/lib/odysseus/core/volume_namespacer.rb +0 -0
  13. data/lib/odysseus/core.rb +2 -2
  14. data/lib/odysseus/deploy_log.rb +120 -0
  15. data/lib/odysseus/deploy_version.rb +9 -0
  16. data/lib/odysseus/deployer/dependency_manager.rb +121 -0
  17. data/lib/odysseus/deployer/executor.rb +213 -161
  18. data/lib/odysseus/deployer/retention_sweeper.rb +94 -0
  19. data/lib/odysseus/deployer/ssh.rb +92 -18
  20. data/lib/odysseus/docker/client.rb +240 -48
  21. data/lib/odysseus/docker/labels.rb +43 -0
  22. data/lib/odysseus/errors.rb +10 -0
  23. data/lib/odysseus/git.rb +70 -0
  24. data/lib/odysseus/host_paths.rb +79 -0
  25. data/lib/odysseus/host_providers/base.rb +1 -1
  26. data/lib/odysseus/host_providers/static.rb +1 -1
  27. data/lib/odysseus/host_providers.rb +3 -4
  28. data/lib/odysseus/host_verifier.rb +156 -0
  29. data/lib/odysseus/host_versions.rb +59 -0
  30. data/lib/odysseus/orchestrator/{accessory_deploy.rb → dependency_deploy.rb} +84 -88
  31. data/lib/odysseus/orchestrator/job_deploy.rb +31 -43
  32. data/lib/odysseus/orchestrator/web_deploy.rb +61 -55
  33. data/lib/odysseus/plugins.rb +72 -0
  34. data/lib/odysseus/retention_plan.rb +14 -0
  35. data/lib/odysseus/retention_planner.rb +58 -0
  36. data/lib/odysseus/rollback_plan.rb +20 -0
  37. data/lib/odysseus/rollback_planner.rb +107 -0
  38. data/lib/odysseus/sails.rb +0 -0
  39. data/lib/odysseus/secrets/encrypted_file.rb +5 -7
  40. data/lib/odysseus/secrets/loader.rb +1 -3
  41. data/lib/odysseus/setup/docker_apt.rb +178 -0
  42. data/lib/odysseus/setup/escalation.rb +73 -0
  43. data/lib/odysseus/setup/preparer.rb +372 -0
  44. data/lib/odysseus/setup/public_key.rb +127 -0
  45. data/lib/odysseus/validators/config.rb +38 -25
  46. data/lib/odysseus/version_resolver.rb +80 -0
  47. data/lib/odysseus.rb +0 -0
  48. data/sig/odysseus/core.rbs +0 -0
  49. metadata +48 -27
  50. data/Rakefile +0 -12
  51. data/lib/odysseus/version.rb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3fd4c3bfd44f78e7cdb0cbcf455d365b23e7ccd18fb9497a6f3cdd427f25b9f
4
- data.tar.gz: 2531a37231560cfe10c0c7a9f3d7e3feed5080ad932e7400953670e0dbe8029d
3
+ metadata.gz: 5fad8198958c2e3991a174b1d7821199e30c4807a48fe7bd9011f689ca430ae6
4
+ data.tar.gz: 9241a316d823c6397e3d293208bc186dcd0b6db88a3b1410e05599a43b99d686
5
5
  SHA512:
6
- metadata.gz: e7e90c0115224ab15e0d73d22504bb6f9262f9efdedbb6f0c8766a7e42008ebb3798ebab9eae3237fde1ef6221cecab997ce00181982fe7744fca857219093bc
7
- data.tar.gz: 18f19cbf04921ea7bc858f0911155e0e02156c22d54c58ce641e0d3fdd3283d5dd7bf80c7fdde887f82f93d5dfede7183045f5864b2e06335da337b940ad1507
6
+ metadata.gz: a323a1f3f08eded7052792ac13b83514a6fa045a7bcfb8bf928d28eb99ef4321ca8ec9abb2f9cd08e752467147e9cca8465bb60a3cc83d036c312cf3749c6a91
7
+ data.tar.gz: d800125c7690876145e8c7297d986d0a3ce1aba2856454df17567a209a7ae958eb79414afc0f4094390f82ad2205a61544cbc7ba31b583fd704040b15a0abcc4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,489 @@
1
+ # Changelog
2
+
3
+ All notable changes to odysseus-core are documented here.
4
+
5
+ Entries for 0.3.1 and earlier were reconstructed from git history and the built
6
+ gem artifacts, so they are summaries rather than contemporaneous notes.
7
+
1
8
  ## [Unreleased]
2
9
 
10
+ ### Fixed
11
+
12
+ - Commands echoed under `--debug`/`-v` no longer print secrets. `SSH#execute`
13
+ echoed the command verbatim, and registry login is built as
14
+ `echo '<password>' | docker login <server> -u <user> --password-stdin` --
15
+ `--password-stdin` keeps the password off the argv, and interpolating it into
16
+ `echo` put it straight back into the string being echoed. With no `-p` and no
17
+ `--password`, every pattern written to catch those missed it. New
18
+ `Odysseus::CommandRedaction` runs on the way to the terminal only; the command
19
+ executed is unchanged.
20
+
21
+ ## [0.9.0] - 2026-08-19
22
+
23
+ ### Fixed
24
+
25
+ - `Setup::Escalation`'s sudo probe no longer reports an unreachable host as a
26
+ sudo problem. It rescued `Odysseus::Error`, the shared ancestor of
27
+ `SSHCommandError` and `SSHConnectionError`, and the probe is the first
28
+ command setup sends — so a host whose name did not resolve was told to
29
+ configure passwordless sudo. It now catches only the error that means the
30
+ command ran and sudo refused.
31
+ - `SSHAuthenticationError` is a new class under `SSHError`, raised where
32
+ `SSHConnectionError` was raised for `Net::SSH::AuthenticationFailed`. "The
33
+ host refused this identity" and "the host was never reached" were the same
34
+ class, so no caller could advise on one without guessing about the other.
35
+ Nothing rescued `SSHConnectionError` specifically, so no other behaviour
36
+ changes.
37
+ - `SSH#with_connection` no longer describes a connection that never opened as
38
+ a mid-command drop. `IOError`, `Net::SSH::Disconnect`, `ECONNRESET` and
39
+ `EPIPE` arrive both while opening and while a command runs, and the rescue
40
+ wrapped both, so a host that accepted the connection and hung up before any
41
+ command existed — one still booting, or an sshd not yet up — was reported as
42
+ having dropped a command that had never been sent.
43
+ - `Docker::Client` escapes the healthcheck command instead of wrapping it in
44
+ hand-written single quotes. A cmd containing a quote, such as
45
+ `--execute='SELECT 1'`, closed the quote early and word-split into docker's
46
+ arguments, so the tail landed where the image name goes and docker reported
47
+ it could not find image `'1:latest'`. Volume specs are escaped for the same
48
+ reason. A container's `cmd` stays unescaped by design — it has to reach the
49
+ container as separate arguments.
50
+
51
+ ### Added
52
+ - `Odysseus::Setup::Escalation`, `Odysseus::Setup::PublicKey`,
53
+ `Odysseus::Setup::DockerApt` and `Odysseus::Setup::Preparer`, the classes
54
+ behind `odysseus setup` (odysseus-cli). `Escalation` gets root on the
55
+ target host via passwordless sudo, or none at all when connected as root,
56
+ and refuses up front — before anything is touched — if passwordless sudo
57
+ isn't available, since a password prompt can't be answered. `PublicKey`
58
+ resolves which key(s) to install, entirely on the local machine before
59
+ any host is touched: from `--key`, or from each `ssh.keys` entry's `.pub`
60
+ sibling, deriving one via `ssh-keygen -y` only when that sibling is empty
61
+ or missing; a key that doesn't resolve, or a sibling with content that
62
+ doesn't validate, refuses by name rather than silently substituting a
63
+ different key. `DockerApt` installs Docker from Docker's own official
64
+ apt repository, following Docker's published instructions for Ubuntu: it
65
+ installs `docker-ce docker-ce-cli containerd.io docker-buildx-plugin`
66
+ (Docker's instructions also install `docker-compose-plugin`, which is
67
+ deliberately left out — nothing in this codebase invokes `docker
68
+ compose`). The keyring and apt sources file are written whole on every
69
+ run, never appended, so an interrupted run leaves a stale file the next
70
+ run replaces rather than a corrupt one. apt runs non-interactively with a
71
+ 300-second bounded wait for the dpkg lock, long enough to outlast
72
+ cloud-init and unattended-upgrades on a minutes-old host; a timeout names
73
+ the process holding the lock. It repairs no apt or dpkg state it did not
74
+ create. The GPG key's fingerprint is deliberately not pinned, matching
75
+ Docker's own instructions, which trust TLS rather than pin it — pinning
76
+ would turn Docker's key rotation into an outage for everyone using this
77
+ command. `Preparer` runs the actual sequence against a host: installs
78
+ Docker via `DockerApt` if the host doesn't already have it, creates the
79
+ deploy user and its home, adds it to the `docker` group, installs the
80
+ resolved key(s), creates the state directory, and finishes by opening a
81
+ second connection as that new user to prove Docker and the state
82
+ directory both work before reporting success. A host whose Docker daemon
83
+ still does not answer after the install is failed, naming that the host
84
+ may need a reboot or that the daemon may have failed to start — success
85
+ is decided by the daemon answering after the install runs, not by apt
86
+ exiting zero.
87
+
88
+ ## [0.8.0] - 2026-08-17
89
+
90
+ ### Fixed
91
+ - `VersionResolver#resolve` now names the deployer for an explicitly-tagged
92
+ deploy (`odysseus deploy --image v1.2.3`), instead of recording `nil`. The
93
+ deployer comes from `git config user.email`, falling back to `$USER` — a
94
+ lookup that needs no commit and no clean working tree, so withholding it
95
+ alongside `ref` was never justified. Every explicitly-tagged deploy wrote a
96
+ host-side log line with no name on it, which emptied the "by whom" column
97
+ of `odysseus rollback --list` and left the host-side deploy history
98
+ unattributed. `ref` still stays `nil` for an explicit tag: an arbitrary tag
99
+ names no commit it honestly identifies, so there is nothing true to record
100
+ there.
101
+ - `Caddy::Client#start_caddy` now publishes the admin API on
102
+ `127.0.0.1:2019` instead of `2019:2019`, which bound it to every
103
+ interface. That API can rewrite the proxy configuration — routes,
104
+ upstreams, TLS — for every service on the host, so anyone who could reach
105
+ the port controlled the proxy. `Caddy::Client` only ever calls it via
106
+ `curl localhost` over SSH, so nothing needed the external exposure. The
107
+ in-container `CADDY_ADMIN` bind stays `0.0.0.0:2019`, which is what the
108
+ published port maps to — only where the port lands on the host changed.
109
+ **This does not close the exposure on a host where Caddy is already
110
+ running**: `ensure_running` returns early when the container is up, so
111
+ the new binding only takes effect the next time Caddy is recreated
112
+ (stopped and restarted, or removed). An already-running Caddy stays bound
113
+ to every interface until that happens.
114
+ - `JobDeploy` now ensures the `odysseus` Docker network exists before
115
+ starting a container, instead of assuming it. It ran every container with
116
+ `network: 'odysseus'` but never created that network itself, so a jobs-only
117
+ service deployed to a fresh host failed at `docker run`. The gap was
118
+ invisible on any host that had ever deployed a web role or a dependency
119
+ first: a web role's `ensure_caddy!` creates the network as a side effect of
120
+ starting Caddy, and `DependencyDeploy` has always had its own
121
+ `ensure_network!`. Found on a genuinely fresh host with the deploy user
122
+ ready but no prior deploy of either kind.
123
+ - `Caddy::Client#ensure_running` now recreates a stopped Caddy container
124
+ instead of trying to `docker run` a new one over it. Once Caddy had been
125
+ stopped, `--name odysseus-caddy` collided with the container Docker still
126
+ had by that name, and `docker run` refused every time after: every
127
+ subsequent deploy failed and the proxy stayed down until someone removed
128
+ the container by hand. Absent and already-running containers are
129
+ unaffected. The stopped container is removed rather than `docker start`ed
130
+ so it always picks up current configuration — notably the deploy-user
131
+ Caddy directory added earlier in this file — instead of resuming with
132
+ whatever it was created with.
133
+
134
+ ### Added
135
+ - `Odysseus::HostPaths`, which decides where odysseus keeps state on a host
136
+ from the connecting SSH user: `root` still gets `/var/lib/odysseus`, exactly
137
+ as before and with no extra SSH round trip; any other user gets
138
+ `$HOME/.odysseus`, resolved by asking the host once per connection.
139
+ - `Odysseus::HostVerifier`, a read-only diagnosis of one host, run as the
140
+ user the config names rather than root. `#verify` runs distro, docker
141
+ reachability, docker group membership, state-directory writability and
142
+ deploy-log location, and returns one `Result` per check with a `:ok`,
143
+ `:warn` or `:fail` status — it writes nothing to the host. `odysseus
144
+ doctor` (odysseus-cli) drives it over every host in a config.
145
+
146
+ ### Changed
147
+ - Env files (`Docker::Client`) and the deploy log (`DeployLog`) now follow
148
+ `HostPaths` instead of a fixed `/var/lib/odysseus` constant. Before this,
149
+ a non-root deploy could not work at all: `write_env_file` chmods its
150
+ directory before writing into it, and a non-owner cannot chmod a directory
151
+ root created, so the deploy died at the first container start. This is the
152
+ change that makes a non-root deploy possible, on a host you have already
153
+ configured for it.
154
+ - Caddy's certificate directory (`Caddy::Client`) now follows `HostPaths`
155
+ too, instead of staying fixed at `/var/lib/odysseus/caddy`. It was
156
+ deliberately left out of the earlier change in this release to avoid
157
+ moving issued Let's Encrypt certificates or re-issuing against rate
158
+ limits — but that risk only ever applied to root installs, which are the
159
+ only ones with certificates at the old path. Deriving the directory
160
+ protects them identically: root still resolves to
161
+ `/var/lib/odysseus/caddy`, byte-identical. Leaving it fixed meant a
162
+ non-root deploy user could never create it, so `ensure_running` failed at
163
+ `mkdir -p /var/lib/odysseus/caddy` on every non-root web deploy — found on
164
+ a real host. Root is unaffected.
165
+ - `rollback --list` keeps its history across the move: a host that deployed
166
+ as root and later switches to a deploy user still has its old log read as
167
+ a fallback when the new location is absent (or unreadable) rather than
168
+ empty — an empty file at the new location exits 0 and suppresses the
169
+ fallback. Reads fall back to the old location; appends only ever go to the
170
+ new one.
171
+ - `Executor#host_roles` is public API now, not a private helper. It answers
172
+ the same question the CLI needs answered — which hosts does this config
173
+ target, and which roles does each serve — so `odysseus doctor` can visit
174
+ every host once, however many roles it serves, without duplicating the
175
+ host-resolution logic this method already implements.
176
+ - Nothing changes for a root install.
177
+
178
+ ## [0.7.0] - 2026-08-16
179
+
180
+ A minor bump for two reasons: there is new public API, and a one-off container's
181
+ environment changes shape. Anything that ran `app exec` and relied on reading
182
+ `-e` flags out of the host's process list — or on a secret being *absent* from
183
+ one-off runs — sees different behaviour.
184
+
185
+ ### Fixed
186
+ - A one-off container — what `app exec`, `app shell` and `app console` run —
187
+ no longer has its environment inlined into the docker command as `-e
188
+ KEY=VALUE`. Every value now travels in the same 0600 env file a deployed
189
+ container's does, so a customer's database password is no longer visible in
190
+ `ps` on the deploy target, and a value containing a space or a shell
191
+ metacharacter arrives intact instead of splitting or being interpreted. The
192
+ file is named so it cannot collide with a running container's, and is
193
+ removed even when the command fails.
194
+ - The env file is removed over a fresh connection when the connection it was
195
+ written over has died in the meantime. Cleanup rescued `Odysseus::SSHError`
196
+ alone, and a connection that drops mid-session raises `IOError`,
197
+ `Net::SSH::Disconnect`, `Errno::EPIPE` or `Errno::ECONNRESET` — none of them
198
+ an `SSHError`. The cleanup's own failure therefore escaped the ensure and
199
+ replaced whatever the block was raising, so a file of secrets was left on the
200
+ host *and* an interactive session's exit status arrived as a backtrace. The
201
+ interactive paths are where this is likeliest: they hold the connection open,
202
+ idle and unpumped, for as long as the user's session lasts, which is what an
203
+ idle NAT timeout, sshd's `ClientAlive` limit or a Tailscale relay change need.
204
+ Any failure of the removal is now swallowed rather than raised — including on
205
+ the second attempt, after which the file is left behind, `0600` in a `0700`
206
+ directory.
207
+ - An upload that fails partway no longer orphans a partial env file. The path
208
+ was learned from the return value of the write, so a write that raised left
209
+ the caller with `nil` and its cleanup with nothing to remove — while scp had
210
+ already created the remote file and begun streaming secrets into it. The path
211
+ is settled before the write now, so the file removed is the file written,
212
+ whether or not the write finished.
213
+
214
+ ### Added
215
+ - `Odysseus::Core::Environment`, the environment a container starts with —
216
+ `env.clear` merged with each `env.secret` resolved from the encrypted
217
+ secrets file or the host's own environment. WebDeploy and JobDeploy each had
218
+ their own copy; one-off commands had neither, which is why `rails
219
+ db:migrate` on a one-off container started without a `DATABASE_URL`.
220
+ - `Docker::Client#with_env_file`, which writes an env file, yields its path
221
+ and removes it afterwards even on failure. `app shell` and `app console`
222
+ need an interactive TTY and so run docker themselves; this is how their
223
+ environment reaches the host as a file rather than as `-e` flags.
224
+
225
+ ## [0.6.0] - 2026-08-15
226
+
227
+ A minor bump: `plugins:` is a new key, and `odysseus validate` can now fail
228
+ where it used to pass — on a machine that does not have a named plugin gem
229
+ installed. That is the point of the change, but it is a behaviour change.
230
+
231
+ ### Added
232
+ - `plugins:` in deploy.yml, a list of gem names loaded before the config is
233
+ validated, so a sail can register its strategy in time for
234
+ `servers.<role>.deploy.strategy` to resolve. `sails:` is accepted as an
235
+ alias. Until now nothing ever loaded a sail: both registries raised "is the
236
+ gem loaded?" for every user, so `deploy.strategy` and the `aws:` host hook
237
+ were unreachable while both READMEs described them.
238
+ - `Odysseus::Core::DeployVersioning`, the shared container version identity —
239
+ `odysseus.version`, `odysseus.git_ref` and `odysseus.deployed_at` — included
240
+ by both built-in orchestrators and available to sails. `status`, `rollback`
241
+ and image retention all read these labels, so an orchestrator that invents
242
+ its own scheme is invisible to them.
243
+
244
+ ### Changed
245
+ - A deploy.yml carrying both `plugins:` and `sails:`, or both `dependencies:`
246
+ and `accessories:`, is now an error. Silently preferring one meant editing
247
+ the wrong key had no visible effect.
248
+ - `odysseus validate` loads plugins, so it now catches a plugin gem that is
249
+ not installed. It will fail on a machine without the gem, where it passed
250
+ before.
251
+ - A plugin that fails to load now reports the file that was actually missing,
252
+ and says so differently when the plugin itself was found. A sail whose own
253
+ dependency is absent — `odysseus-sail-aws-asg` without `aws-sdk-autoscaling`
254
+ — used to read as if the sail were not installed, advising an install that
255
+ could not fix it. The advice also names the Gemfile, which is what a
256
+ `bundle exec odysseus` run needs.
257
+ - Plugin errors quote the key the deploy.yml actually used. Writing `sails:`
258
+ and getting told about `plugins:` named a key that was not in the file.
259
+
260
+ ## [0.5.0] - 2026-08-13
261
+
262
+ A minor bump rather than a patch: odysseus now deletes images on your hosts.
263
+ Nothing did that before, and there is no undo, so the release number should
264
+ make you read this entry.
265
+
266
+ ### Added
267
+ - `retain_versions` in deploy.yml, default 5: how many distinct versions of a
268
+ service's image each host keeps. After a successful deploy, images beyond
269
+ that window are removed from the host. Until now SHA-tagged images
270
+ accumulated without limit — `cleanup --prune-images` only removes *dangling*
271
+ images, and a tagged image is never dangling.
272
+ - `RetentionPlanner`, `Docker::Client#remove_image` and
273
+ `Docker::Client#versions_in_use`.
274
+
275
+ ### Changed
276
+ - `deploy` prunes old images on each host once all of that host's roles are
277
+ deployed. `rollback` deliberately does not: deleting images during a
278
+ recovery is the wrong moment, and the version just rolled back from is the
279
+ most likely next thing wanted.
280
+
281
+ Three independent things must agree before an image is deleted: it must
282
+ fall outside the retain window, no container on the host may reference it
283
+ (stopped containers included, since a stopped container still references its
284
+ image and an operator may still need it), and docker must accept the
285
+ removal. Each removal is attempted on its own, so one
286
+ refusal is a logged skip rather than a failed deploy. A host with no
287
+ `deploys.log` is skipped entirely rather than pruned by image creation time,
288
+ which is build time and can be out of order. `latest` is never removed
289
+ automatically.
290
+
291
+ ## [0.4.4] - 2026-08-13
292
+
293
+ A rename, with the old names still working. Nothing on a host changes.
294
+
295
+ ### Changed
296
+ - `accessories:` in deploy.yml is now `dependencies:`. The old name implied
297
+ optional extras, when a database the app cannot boot without is not optional.
298
+ The old key is still accepted and parses identically, so existing deploy.yml
299
+ files keep working; it will be removed in a later release.
300
+ - Nothing on a host changes as a result. Container names and the
301
+ `odysseus.service` label are built from the service name plus the individual
302
+ dependency's name, so the top-level key never reaches a host and running
303
+ containers are adopted rather than orphaned. A spec runs the real parser over
304
+ a legacy fixture and asserts the resulting container name, so this cannot
305
+ regress silently.
306
+ - `Orchestrator::AccessoryDeploy` is `Orchestrator::DependencyDeploy`,
307
+ `Deployer::AccessoryManager` is `Deployer::DependencyManager`, and
308
+ `Executor`'s six accessory methods are now `deploy_dependency`,
309
+ `remove_dependency`, `restart_dependency`, `upgrade_dependency`,
310
+ `dependency_status` and `boot_dependencies`. No deprecated aliases: nothing
311
+ outside odysseus-cli consumes these, and the two gems ship in lockstep.
312
+ - The config error messages changed accordingly: `Dependency 'x' not found in
313
+ config` and `No hosts configured for dependency x`.
314
+
315
+ ### Internal
316
+ - `Config::Parser#parse_accessories` and its two helpers are now
317
+ `parse_dependencies`, `parse_dependency_healthcheck` and
318
+ `parse_dependency_proxy`. The parser gained its first coverage for this block
319
+ in the process.
320
+
321
+ ## [0.4.3] - 2026-08-13
322
+
323
+ Rollback. A previously deployed version can be put back on the whole fleet, and
324
+ the fleet refuses to move at all unless every host has the image.
325
+
326
+ ### Added
327
+ - `Executor#rollback_plan` and `#rollback_all`, which return every role on
328
+ every host to a previously deployed version by reusing the deploy path, so
329
+ health gating and proxy handling are shared with `deploy`. The target is
330
+ chosen from what the hosts report — running container labels, images present,
331
+ and each host's `deploys.log` — never from the local repository, which can
332
+ drift from what a host can actually run.
333
+ - A fleet pre-flight: the target image must be present on every host across all
334
+ roles before any host is touched. A half-rolled-back fleet is worse than a
335
+ refused command.
336
+ - `Executor#version_survey`, `HostVersions` and `RollbackPlanner`.
337
+ - `Docker::Client#image_tags`, listing the tags a host has for a repository.
338
+
339
+ ### Changed
340
+ - Deploys are recorded on the host by `Executor` rather than by each
341
+ orchestrator. A rollback now records `kind=rolled-back` with the version it
342
+ replaced, and a role deployed by a sail-provided strategy gets the same audit
343
+ trail instead of none.
344
+ - `VersionResolver#deployer` is public, so a rollback can name who ran it even
345
+ though its version came from a host rather than a commit.
346
+
347
+ ### Fixed
348
+ - `odysseus accessory status` never worked in any released version. The code
349
+ called `orchestrator.get_status(name:)`, but `Orchestrator::AccessoryDeploy`
350
+ defines `list_status` and no `get_status`, so the command always raised
351
+ `NoMethodError`. The bad call was introduced on 2025-12-27 in `343030b`,
352
+ three days before the `accessory status` subcommand itself shipped in
353
+ odysseus-cli 0.2.0 (2025-12-30) — so there was never a working version to
354
+ regress from. Fixed in `188a695`: `AccessoryManager#status_on` now calls
355
+ `list_status` and selects the requested accessory out of its results, and a
356
+ new spec exercises it through a verifying double, so a future rename of
357
+ `list_status` breaks the build instead of the command.
358
+
359
+ ### Internal
360
+ - Accessory lifecycle methods (`deploy`, `remove`, `restart`, `upgrade`,
361
+ `status`, `boot_all`) moved out of `Executor` into
362
+ `Deployer::AccessoryManager`, to keep both classes under their existing size
363
+ limits. The public API is unchanged, and `AccessoryManager` gets the first
364
+ spec coverage that code has ever had.
365
+
366
+ ## [0.4.2] - 2026-08-13
367
+
368
+ A running container can now be traced back to the commit it was built from. The
369
+ notable behaviour change is that the image tag no longer defaults to `latest`.
370
+
371
+ ### Added
372
+ - The image tag now defaults to the git commit being deployed, and containers
373
+ carry `odysseus.version`, `odysseus.deployed_at` and `odysseus.git_ref` labels,
374
+ so a running container can be traced to a commit. `odysseus.version` previously
375
+ held the deploy timestamp, which `odysseus.deployed_at` now carries.
376
+ - Each host records successful deploys in `/var/lib/odysseus/<service>/deploys.log`.
377
+
378
+ ### Changed
379
+ - `deploy`, `build` and `pussh` no longer default to the `latest` tag. Outside
380
+ a git repository, or with uncommitted changes, they stop and ask for
381
+ `--image`. The version resolves before the dry-run check, so `--dry-run`
382
+ also requires a resolvable version even though it makes no changes.
383
+
384
+ ## [0.4.1] - 2026-08-12
385
+
386
+ ### Fixed
387
+ - `Caddy::Client#enable_tls_for_hosts` updates an existing `tls` app in place
388
+ instead of trying to recreate it. Caddy's `PUT` creates and answers 409 if the
389
+ key is already there, so on any host whose Caddy already had a `tls` app this
390
+ call had always failed and the policy update had always been discarded — a
391
+ domain added to `proxy.hosts` after the first deploy never got a policy and so
392
+ never got an ACME account email. Certificates still arrived through Caddy's
393
+ implicit automation, which is why it went unnoticed. 0.4.0 did not break this;
394
+ it stopped hiding it.
395
+ - The new policy is merged onto the existing `tls` config rather than replacing
396
+ it, so sibling settings — explicit certificate loaders, `on_demand` limits —
397
+ survive a deploy.
398
+
399
+ ## [0.4.0] - 2026-08-12
400
+
401
+ Deploy reliability. `SSH#execute` and the Caddy client now raise where they used
402
+ to stay quiet, so this is a minor bump rather than a patch: a deploy that
403
+ previously reported success while failing will now stop and say so.
404
+
405
+ ### Fixed
406
+ - `Deployer::SSH#execute` reads the channel's exit status and raises
407
+ `SSHCommandError` on a non-zero exit. Previously every remote command appeared
408
+ to succeed, so a failed `docker stop`, `docker rm`, `mkdir` or network create
409
+ was invisible.
410
+ - `Deployer::SSH#execute` no longer merges stderr into its return value, so
411
+ callers parsing stdout are not handed warning text. This also repairs
412
+ `Docker::Client#health_status` for containers with no Health block.
413
+ - `Caddy::Client` raises `ProxyApiError` when a mutating admin API request
414
+ fails. `curl` exits 0 for HTTP errors, so a rejected route write was parsed as
415
+ an ordinary response and discarded — the deploy reported success while no
416
+ traffic was routed. Reads still return nil, as Caddy answers 500 for config
417
+ paths that do not exist yet.
418
+ - `Orchestrator::WebDeploy` aborts when Caddy fails to start. The result of
419
+ `ensure_running` was discarded and the guard clause that raises had become
420
+ dead code.
421
+ - A web container is always given a health command, defaulting to `GET /` on
422
+ `proxy.app_port`. Without a `healthcheck` block — the shape both READMEs
423
+ show — the container had no health command, its status stayed `none`, and every
424
+ deploy timed out after 60s and rolled back. A web role with no `app_port` now
425
+ fails fast with a config error.
426
+ - `Docker::Client#wait_healthy` honours the timeout it is passed instead of
427
+ capping every wait at 60s, so the 120s requested by `JobDeploy` and
428
+ `AccessoryDeploy` is respected.
429
+ - `Caddy::Client#add_upstream` reconciles a route's Host matcher, so editing
430
+ `proxy.hosts` takes effect instead of requiring the route to be removed by
431
+ hand.
432
+
433
+ ### Changed
434
+ - Container environment is passed through a `0600` env file under
435
+ `/var/lib/odysseus/env` and `--env-file`, instead of `-e KEY=VALUE` on the
436
+ command line. Secrets no longer appear in the host's process list, and values
437
+ containing spaces or shell metacharacters survive intact. A value containing a
438
+ newline is rejected rather than silently truncated.
439
+ - Licensed under MIT. The gemspec and LICENSE previously said LGPL-3.0-only
440
+ while the READMEs said MIT.
441
+
442
+ ### Removed
443
+ - The unused `Odysseus::VERSION` constant, stale at 0.1.0.
444
+ `Odysseus::Core::VERSION` is the single source of truth.
445
+
446
+ ### Internal
447
+ - `validators/config.rb` and `sails.rb` are covered by specs for the first time.
448
+ - `rake` runs RSpec and RuboCop clean. Remaining Metrics offences are recorded
449
+ in `.rubocop_todo.yml` rather than hidden in the main configuration.
450
+
451
+ ## [0.3.1] - 2026-04-05
452
+
453
+ ### Fixed
454
+ - Ruby 4.0 compatibility: declare the `logger` dependency explicitly.
455
+
456
+ ### Removed
457
+ - The AWS Auto Scaling Group host provider, extracted to
458
+ `odysseus-sail-aws-asg`. `HostProviders` keeps the registry and the `aws:`
459
+ config hook the sail consumes.
460
+
461
+ ## [0.3.0] - 2026-03-30
462
+
463
+ ### Added
464
+ - `Sails` plugin registry, with the rolling deploy strategy extracted to
465
+ `odysseus-sail-rolling`. `servers.<role>.deploy.strategy` resolves through it.
466
+ - Volume namespacing: named volumes are prefixed with the service name so
467
+ containers sharing a host no longer collide, reusing an existing
468
+ un-namespaced volume rather than losing data.
469
+ - `Docker::Client#ensure_network`, so accessories can boot before any service
470
+ deploy has created the network.
471
+ - Parsing for `servers.<role>.containers` and `servers.<role>.deploy`, including
472
+ timeouts and an HTTP health check with a success threshold.
473
+
474
+ ## [0.2.0] - 2025-12-30
475
+
476
+ ### Added
477
+ - Encrypted secrets files (AES-256-GCM) with `Secrets::EncryptedFile` and
478
+ `Secrets::Loader`.
479
+ - Builder with local and remote (build host) strategies, plus pussh
480
+ distribution over SSH for registry-less deploys.
481
+ - Host provider registry behind `HostProviders`, with the static provider as
482
+ the default.
483
+ - CPU and memory limits for containers.
484
+
3
485
  ## [0.1.0] - 2025-12-19
4
486
 
5
- - Initial release
487
+ - Initial release: config parsing, SSH and Docker clients, Caddy proxy
488
+ integration with automatic TLS, web and job orchestrators, and the accessory
489
+ lifecycle.
data/LICENSE.txt CHANGED
@@ -1,8 +1,26 @@
1
+ MIT License
2
+
1
3
  Copyright (c) Imfiny SAS / Wa Systems SAS
2
-
3
- Odysseus is an Open Source project licensed under the terms of
4
- the LGPLv3 license. Please see <http://www.gnu.org/licenses/lgpl-3.0.html>
5
- for license text.
6
-
7
- Odysseus Pro and Odysseus Enterprise have a commercial-friendly license.
8
-
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Odysseus Pro and Odysseus Enterprise are distributed under separate
26
+ commercial terms.
data/README.md CHANGED
@@ -19,6 +19,9 @@ gem 'odysseus-core'
19
19
  Odysseus Core provides the foundational components for Docker container deployment:
20
20
 
21
21
  - **Configuration parsing** - YAML-based deploy.yml configuration
22
+ - **Sail plugins** - gems named in `plugins:`/`sails:` are loaded before the
23
+ rest of deploy.yml is validated, so they can register a deploy strategy or
24
+ a host provider before anything checks whether one exists
22
25
  - **Docker client** - Container lifecycle management via SSH
23
26
  - **Caddy client** - Reverse proxy configuration and routing
24
27
  - **Deployer** - Zero-downtime deployment orchestration
@@ -48,10 +51,11 @@ executor.deploy_all(image_tag: 'v1.0.0')
48
51
 
49
52
  Parses deploy.yml configuration files with support for:
50
53
  - Server roles (web, jobs, workers)
54
+ - Sail plugins (`plugins:`, aliased `sails:`), loaded before the config below
55
+ it is validated
51
56
  - Proxy configuration (Caddy)
52
- - Accessories (databases, Redis, etc.)
57
+ - Dependencies (databases, Redis, etc.)
53
58
  - Environment variables and secrets
54
- - AWS Auto Scaling Group integration
55
59
 
56
60
  ### Odysseus::Docker::Client
57
61
 
@@ -75,6 +79,11 @@ Deployment orchestration:
75
79
  - Zero-downtime container replacement
76
80
  - Health check verification
77
81
  - Automatic rollback on failure
82
+ - Roll a service back to a previously deployed version, chosen from what the
83
+ hosts report rather than the local repository
84
+ - Prune a service's superseded image versions from each host after a
85
+ successful deploy, keeping the newest `retain_versions` and never a version
86
+ a container still references
78
87
 
79
88
  ### Odysseus::Secrets::EncryptedFile
80
89
 
@@ -85,4 +94,4 @@ Encrypted secrets management:
85
94
 
86
95
  ## License
87
96
 
88
- LGPL-3.0-only
97
+ MIT
@@ -118,7 +118,7 @@ module Odysseus
118
118
  executor = build_executor
119
119
  output = executor.call("docker images -q #{image} 2>/dev/null || echo ''")
120
120
  !output.strip.empty?
121
- rescue
121
+ rescue StandardError
122
122
  false
123
123
  end
124
124
 
@@ -145,14 +145,18 @@ module Odysseus
145
145
  arch: config[:arch] || config['arch'],
146
146
  platforms: config[:platforms] || config['platforms'] || [],
147
147
  build_args: config[:build_args] || config['build_args'] || {},
148
- cache: config.key?(:cache) ? config[:cache] : (config.key?('cache') ? config['cache'] : true),
148
+ cache: if config.key?(:cache)
149
+ config[:cache]
150
+ else
151
+ (config.key?('cache') ? config['cache'] : true)
152
+ end,
149
153
  push: config[:push] || config['push'] || false,
150
154
  multiarch: config[:multiarch] || config['multiarch'] || false
151
155
  }
152
156
  end
153
157
 
154
158
  def build_local(context_path:, image:)
155
- @logger.info("Building locally...")
159
+ @logger.info('Building locally...')
156
160
 
157
161
  cmd = build_docker_command(context_path: context_path, image: image)
158
162
  @logger.debug(cmd) if @logger.respond_to?(:debug)
@@ -161,7 +165,7 @@ module Odysseus
161
165
  output = execute_local(cmd, context_path)
162
166
 
163
167
  { success: true, image: image, strategy: :local, output: output }
164
- rescue => e
168
+ rescue StandardError => e
165
169
  @logger.error("Local build failed: #{e.message}")
166
170
  { success: false, strategy: :local, error: e.message }
167
171
  end
@@ -169,9 +173,7 @@ module Odysseus
169
173
  def build_remote(context_path:, image:)
170
174
  @logger.info("Building on remote host: #{@config[:host]}")
171
175
 
172
- unless @config[:host]
173
- raise BuildError, "Remote build strategy requires 'host' to be configured"
174
- end
176
+ raise BuildError, "Remote build strategy requires 'host' to be configured" unless @config[:host]
175
177
 
176
178
  ssh = connect_to_build_host
177
179
 
@@ -181,7 +183,7 @@ module Odysseus
181
183
  ssh.execute("mkdir -p #{remote_dir}")
182
184
 
183
185
  # Upload build context
184
- @logger.info("Uploading build context...")
186
+ @logger.info('Uploading build context...')
185
187
  ssh.upload(context_path, remote_dir)
186
188
 
187
189
  # Determine the actual context directory on remote
@@ -243,9 +245,8 @@ module Odysseus
243
245
  # Execute command locally using system
244
246
  Dir.chdir(working_dir) do
245
247
  output = `#{cmd} 2>&1`
246
- unless $?.success?
247
- raise BuildError, "Build command failed: #{output}"
248
- end
248
+ raise BuildError, "Build command failed: #{output}" unless $?.success?
249
+
249
250
  output
250
251
  end
251
252
  end
@@ -264,9 +265,8 @@ module Odysseus
264
265
 
265
266
  def execute_local_command(cmd)
266
267
  output = `#{cmd} 2>&1`
267
- unless $?.success?
268
- raise BuildError, "Command failed: #{output}"
269
- end
268
+ raise BuildError, "Command failed: #{output}" unless $?.success?
269
+
270
270
  output
271
271
  end
272
272
 
@@ -284,7 +284,7 @@ module Odysseus
284
284
  l.define_singleton_method(:info) { |msg| puts msg }
285
285
  l.define_singleton_method(:warn) { |msg| puts "[WARN] #{msg}" }
286
286
  l.define_singleton_method(:error) { |msg| puts "[ERROR] #{msg}" }
287
- l.define_singleton_method(:debug) { |msg| puts " > #{msg}" if @verbose }
287
+ l.define_singleton_method(:debug) { |msg| puts " > #{Odysseus::CommandRedaction.redact(msg)}" if @verbose }
288
288
  end
289
289
  end
290
290
  end