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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +485 -1
- data/LICENSE.txt +25 -7
- data/README.md +12 -3
- data/lib/odysseus/builder/client.rb +15 -15
- data/lib/odysseus/caddy/client.rb +119 -42
- data/lib/odysseus/command_redaction.rb +38 -0
- data/lib/odysseus/config/parser.rb +43 -18
- data/lib/odysseus/core/deploy_versioning.rb +42 -0
- data/lib/odysseus/core/environment.rb +63 -0
- data/lib/odysseus/core/version.rb +1 -1
- data/lib/odysseus/core/volume_namespacer.rb +0 -0
- data/lib/odysseus/core.rb +2 -2
- data/lib/odysseus/deploy_log.rb +120 -0
- data/lib/odysseus/deploy_version.rb +9 -0
- data/lib/odysseus/deployer/dependency_manager.rb +121 -0
- data/lib/odysseus/deployer/executor.rb +213 -161
- data/lib/odysseus/deployer/retention_sweeper.rb +94 -0
- data/lib/odysseus/deployer/ssh.rb +92 -18
- data/lib/odysseus/docker/client.rb +240 -48
- data/lib/odysseus/docker/labels.rb +43 -0
- data/lib/odysseus/errors.rb +10 -0
- data/lib/odysseus/git.rb +70 -0
- data/lib/odysseus/host_paths.rb +79 -0
- data/lib/odysseus/host_providers/base.rb +1 -1
- data/lib/odysseus/host_providers/static.rb +1 -1
- data/lib/odysseus/host_providers.rb +3 -4
- data/lib/odysseus/host_verifier.rb +156 -0
- data/lib/odysseus/host_versions.rb +59 -0
- data/lib/odysseus/orchestrator/{accessory_deploy.rb → dependency_deploy.rb} +84 -88
- data/lib/odysseus/orchestrator/job_deploy.rb +31 -43
- data/lib/odysseus/orchestrator/web_deploy.rb +61 -55
- data/lib/odysseus/plugins.rb +72 -0
- data/lib/odysseus/retention_plan.rb +14 -0
- data/lib/odysseus/retention_planner.rb +58 -0
- data/lib/odysseus/rollback_plan.rb +20 -0
- data/lib/odysseus/rollback_planner.rb +107 -0
- data/lib/odysseus/sails.rb +0 -0
- data/lib/odysseus/secrets/encrypted_file.rb +5 -7
- data/lib/odysseus/secrets/loader.rb +1 -3
- data/lib/odysseus/setup/docker_apt.rb +178 -0
- data/lib/odysseus/setup/escalation.rb +73 -0
- data/lib/odysseus/setup/preparer.rb +372 -0
- data/lib/odysseus/setup/public_key.rb +127 -0
- data/lib/odysseus/validators/config.rb +38 -25
- data/lib/odysseus/version_resolver.rb +80 -0
- data/lib/odysseus.rb +0 -0
- data/sig/odysseus/core.rbs +0 -0
- metadata +48 -27
- data/Rakefile +0 -12
- data/lib/odysseus/version.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fad8198958c2e3991a174b1d7821199e30c4807a48fe7bd9011f689ca430ae6
|
|
4
|
+
data.tar.gz: 9241a316d823c6397e3d293208bc186dcd0b6db88a3b1410e05599a43b99d686
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|