odysseus-core 0.10.0 → 0.10.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6d1b931d263332fd0a431b4a0842949f52d78f2f366f8dbf72cb98f0ea95282
4
- data.tar.gz: f3ea3a6477a88cbd38b1386f04e983bad5d7d782475f46322989568389e4fa5b
3
+ metadata.gz: cfc5413226161f5c572dfac62bc9f3a1c13374135525e023d7462b9531bea3b7
4
+ data.tar.gz: 6dcd045639190f23f0131a1b5042c5c3fc378521dcbe7151305a2983fbbb38f5
5
5
  SHA512:
6
- metadata.gz: b89f611e20800ecfbdbd30fe571b6a9979877a59f52655a0c05fb3aeadc7567f6af4325711c92eb02698bbdd23c1746f6c1d150436e20dc45318a4f0fae0f1b4
7
- data.tar.gz: 6db503709fb510a38980f0357a69f82f07b7b04e77b8b63c7286035f88425134039c06618164648876324a4a22f902341d4ca41fa4bb5a027a5addc07a4a7309
6
+ metadata.gz: a9fc554c9aa8188282f3a5d3cf1869b8df86c8145c1895ba4f837d38e896d6f90509922e31eb83150fd451f09862a1a39ef43927f48d6e1cc2ecb3cd14cd795c
7
+ data.tar.gz: 5968a74f44b8184f1c1cd53661073312cf7ca081a4c9f8eef68637624465a99ffc1f3bea626a8fedaba17a0b72a6231ed91142e11345ab034594c5bf2c76bf5e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,59 @@ gem artifacts, so they are summaries rather than contemporaneous notes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.1] - 2026-09-16
11
+
12
+ ### Fixed
13
+
14
+ - odysseus could not read an SSH key from disk unless ssh-agent happened to be
15
+ holding it. `net-ssh` needs `ed25519` and `bcrypt_pbkdf` to parse anything in
16
+ the `-----BEGIN OPENSSH PRIVATE KEY-----` container -- which is what every
17
+ modern `ssh-keygen` writes, **RSA included, not only ed25519** -- and neither
18
+ was declared, so `key_factory.rb` raised `NotImplementedError` before it ever
19
+ looked at the algorithm. Both are runtime dependencies now. Reported by a
20
+ tester whose deploys failed with a passphrase-protected key; the passphrase
21
+ was not the cause, the key format was.
22
+
23
+ - A passphrase-protected SSH key is no longer reported as "Check your SSH
24
+ keys." odysseus connects with `non_interactive: true` -- a passphrase prompt
25
+ on a deploy connection can never be answered, so prompting would hang rather
26
+ than help -- and net-ssh reports the resulting key-loading failure to a
27
+ logger rather than raising it, so with none configured the reason was
28
+ discarded and the reader was sent to inspect keys that were entirely correct.
29
+ The failure now names each locked key and gives the `ssh-add` line for it.
30
+ `Odysseus::SSHKey.encrypted?` answers that by reading the cipher named in the
31
+ key file, so no passphrase is needed to detect one.
32
+
33
+ - `docker pussh` no longer hangs silently. It ran through backticks with
34
+ everything folded into a captured string, which had three consequences: no
35
+ output reached the terminal until the push finished, so a healthy push and a
36
+ stuck one looked identical; there was no timeout; and stdin stayed attached
37
+ to the parent, so a child that asked a question waited forever on an answer
38
+ nobody could see it requesting. Output now streams as it arrives, stdin is
39
+ closed so any prompt reaches EOF and fails promptly, and a push is bounded by
40
+ `PUSSH_TIMEOUT` (30 minutes -- generous, since it exists to end a hang rather
41
+ than to police a slow link). A push that does time out now suggests what to
42
+ check, including the Tailscale steps `SSH#with_connection` already gives for
43
+ the deploy connection but which `pussh` never reached.
44
+
45
+ ### Changed
46
+
47
+ - Every container odysseus starts now has a bounded log. Containers inherited
48
+ the daemon's default logging, which on a stock Docker install is `json-file`
49
+ with no size limit, so a long-lived container filled the disk -- most visibly
50
+ `odysseus-caddy`, the one container no deploy ever replaces. Every
51
+ `docker run` now carries `--log-driver json-file --log-opt max-size=100m
52
+ --log-opt max-file=3`.
53
+
54
+ The driver is named rather than left to the daemon because `max-size` and
55
+ `max-file` belong to `json-file` and `local` only: on a host defaulting to
56
+ `journald` or `syslog`, docker refuses the run rather than ignoring them.
57
+ Naming it also makes explicit what `odysseus logs` already assumed, since it
58
+ shells out to `docker logs`.
59
+
60
+ **This overrides a deliberate daemon-wide log driver on the host.** Making the
61
+ driver and options configurable per service is tracked in TODO.md.
62
+
10
63
  ## [0.10.0] - 2026-08-28
11
64
 
12
65
  ### Fixed
@@ -471,6 +524,25 @@ previously reported success while failing will now stop and say so.
471
524
  - `rake` runs RSpec and RuboCop clean. Remaining Metrics offences are recorded
472
525
  in `.rubocop_todo.yml` rather than hidden in the main configuration.
473
526
 
527
+ ## [0.3.2] - 2026-08-12
528
+
529
+ Reconstructed after the fact: 0.3.2 was bumped in `version.rb` but never
530
+ tagged, and the change first reached users in 0.4.0.
531
+
532
+ ### Fixed
533
+
534
+ - Adding a hostname to `proxy.hosts` had no effect on an already-deployed
535
+ service. `Caddy::Client#add_upstream` reconciled only the upstreams of an
536
+ existing route, never its Host matcher, so the route kept whatever hosts it
537
+ was created with until it was deleted by hand. TLS was unaffected --
538
+ `enable_tls_for_hosts` always received the full list -- which made the
539
+ failure confusing: Caddy answered the new hostname over HTTPS with a valid
540
+ certificate, matched no route, and served the default handler instead of the
541
+ service. `add_upstream` now PATCHes `match/0/host` when the configured hosts
542
+ differ from the deployed ones. The repair needs one deploy: the reconcile
543
+ runs during `add_upstream`, so upgrading alone leaves an existing route as it
544
+ was.
545
+
474
546
  ## [0.3.1] - 2026-04-05
475
547
 
476
548
  ### Fixed
@@ -1,8 +1,14 @@
1
1
  # lib/odysseus/builder/client.rb
2
2
 
3
+ require 'open3'
4
+
3
5
  module Odysseus
4
6
  module Builder
5
7
  class Client
8
+ # A push moves a whole image over SSH, so this is generous -- it exists
9
+ # to end a hang, not to police a slow link.
10
+ PUSSH_TIMEOUT = 1800 # seconds
11
+
6
12
  # @param config [Hash] builder config from deploy.yml
7
13
  # @param ssh_config [Hash] SSH config (user, keys)
8
14
  # @param logger [Object] logger
@@ -85,11 +91,28 @@ module Odysseus
85
91
  cmd = "docker pussh #{image} #{target}"
86
92
  @logger.debug(cmd) if @logger.respond_to?(:debug)
87
93
 
88
- output = execute_local_command(cmd)
94
+ output = execute_local_command(cmd, timeout: PUSSH_TIMEOUT)
89
95
  { success: true, output: output, host: host }
90
96
  rescue BuildError => e
91
- @logger.error("Pussh failed: #{e.message}")
92
- { success: false, error: e.message, host: host }
97
+ message = push_failure_message(e, host)
98
+ @logger.error("Pussh failed: #{message}")
99
+ { success: false, error: message, host: host }
100
+ end
101
+
102
+ # A push that timed out almost never failed because of docker. It failed
103
+ # because the host could not be reached, and the commonest reason here is
104
+ # Tailscale -- which SSH#with_connection already explains for the deploy
105
+ # connection, but `docker pussh` shells out to the system ssh and reaches
106
+ # none of that handling.
107
+ def push_failure_message(error, host)
108
+ return error.message unless error.message.include?('timed out')
109
+
110
+ "#{error.message}\n\n" \
111
+ "The push could not reach #{host}. Worth checking:\n " \
112
+ "1. Tailscale is running: tailscale status\n " \
113
+ "2. You're authenticated: tailscale login\n " \
114
+ "3. The host is online: tailscale ping #{host}\n " \
115
+ "4. SSH works on its own: ssh #{host} true"
93
116
  end
94
117
 
95
118
  # Push image to multiple hosts via SSH
@@ -263,13 +286,69 @@ module Odysseus
263
286
  end
264
287
  end
265
288
 
266
- def execute_local_command(cmd)
267
- output = `#{cmd} 2>&1`
268
- raise BuildError, "Command failed: #{output}" unless $?.success?
289
+ # Runs a local command, streaming its output and refusing to wait forever.
290
+ #
291
+ # This was `` `#{cmd} 2>&1` ``, and all three of its properties were
292
+ # wrong for `docker pussh`. Backticks capture everything into a string,
293
+ # so a push printed nothing until it returned and a healthy one looked
294
+ # exactly like a stuck one. There was no timeout. And stdin stayed
295
+ # attached to the parent, so a child that asked a question waited on an
296
+ # answer that could never be typed into output nobody could see -- the
297
+ # reported symptom was a push that hung with no message at all.
298
+ #
299
+ # stdin is /dev/null so that any prompt reaches EOF and the command fails
300
+ # promptly instead of hanging. Prompting is not something odysseus can
301
+ # usefully do here anyway: SSH#connect is non-interactive for the same
302
+ # reason.
303
+ #
304
+ # @param timeout [Integer, nil] seconds before the command is killed
305
+ # @raise [Odysseus::BuildError] on failure, or on timeout
306
+ def execute_local_command(cmd, timeout: nil)
307
+ output = +''
308
+
309
+ # pgroup: true is not optional. Without it the child shares this
310
+ # process's group, and terminate's kill(-pgid) would signal odysseus
311
+ # itself -- which it duly did the first time this was run.
312
+ Open3.popen2e(cmd, pgroup: true) do |stdin, out, wait_thread|
313
+ # Closed immediately, not redirected: popen2e makes its own stdin
314
+ # pipe and ignores an `in:` option. Closing it is what gives a child
315
+ # that asks a question an EOF instead of an indefinite wait.
316
+ stdin.close
317
+ reader = Thread.new do
318
+ out.each_line do |line|
319
+ output << line
320
+ @logger.info(line.chomp)
321
+ end
322
+ rescue IOError
323
+ nil # the stream is closed under us when a timeout kills the child
324
+ end
325
+
326
+ unless timeout.nil? || wait_thread.join(timeout)
327
+ terminate(wait_thread.pid)
328
+ reader.join(1)
329
+ raise BuildError, "Command timed out after #{timeout}s: #{cmd}\n#{output}"
330
+ end
331
+
332
+ wait_thread.join
333
+ reader.join
334
+ raise BuildError, "Command failed: #{output}" unless wait_thread.value.success?
335
+ end
269
336
 
270
337
  output
271
338
  end
272
339
 
340
+ # The child is usually a shell, so signal the whole process group -- else
341
+ # `sh -c 'docker pussh ...'` dies and leaves the push running.
342
+ def terminate(pid)
343
+ Process.kill('TERM', -Process.getpgid(pid))
344
+ rescue Errno::ESRCH, Errno::EPERM
345
+ begin
346
+ Process.kill('TERM', pid)
347
+ rescue Errno::ESRCH
348
+ nil
349
+ end
350
+ end
351
+
273
352
  def connect_to_build_host
274
353
  Odysseus::Deployer::SSH.new(
275
354
  host: @config[:host],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Odysseus
4
4
  module Core
5
- VERSION = '0.10.0'
5
+ VERSION = '0.10.1'
6
6
  end
7
7
  end
@@ -167,8 +167,7 @@ module Odysseus
167
167
  rescue SocketError
168
168
  raise Odysseus::SSHConnectionError, "Could not resolve hostname '#{@host}'. Check your DNS or /etc/hosts."
169
169
  rescue Net::SSH::AuthenticationFailed
170
- raise Odysseus::SSHAuthenticationError,
171
- "SSH authentication failed for #{@user}@#{@host}. Check your SSH keys."
170
+ raise Odysseus::SSHAuthenticationError, authentication_failure_message
172
171
  rescue Errno::ETIMEDOUT, Net::SSH::ConnectionTimeout, Errno::EHOSTUNREACH
173
172
  error_msg = "Connection to #{@host} timed out."
174
173
  if @use_tailscale
@@ -190,6 +189,29 @@ module Odysseus
190
189
  raise Odysseus::SSHConnectionError, drop_message(open, e)
191
190
  end
192
191
 
192
+ # A passphrase-protected key reaches here as an ordinary authentication
193
+ # failure. net-ssh sends the real reason to a logger rather than raising
194
+ # it (key_manager.rb:319) and odysseus configures none, so without this
195
+ # the reader is told to check keys that are entirely correct.
196
+ #
197
+ # The advice is ssh-agent rather than a prompt on purpose: odysseus
198
+ # connects with non_interactive: true, and a passphrase prompt on a
199
+ # deploy connection cannot be answered -- it would hang the deploy
200
+ # instead of failing it.
201
+ def authentication_failure_message
202
+ base = "SSH authentication failed for #{@user}@#{@host}."
203
+ locked = @keys.select { |key| Odysseus::SSHKey.encrypted?(File.expand_path(key)) }
204
+
205
+ return "#{base} Check your SSH keys." if locked.empty?
206
+
207
+ "#{base}\n\n" \
208
+ "#{locked.length == 1 ? 'This key is' : 'These keys are'} passphrase-protected, and odysseus " \
209
+ "never prompts for one:\n" \
210
+ "#{locked.map { |key| " #{key}" }.join("\n")}\n\n" \
211
+ "Add #{locked.length == 1 ? 'it' : 'them'} to your agent instead:\n" \
212
+ "#{locked.map { |key| " ssh-add #{key}" }.join("\n")}"
213
+ end
214
+
193
215
  # @param open [Boolean, nil] whether the session was established
194
216
  def drop_message(open, error)
195
217
  return "Connection to #{@host} dropped mid-command rather than failing to open: #{error.message}" if open
@@ -9,6 +9,25 @@ module Odysseus
9
9
  class Client
10
10
  HEALTHCHECK_POLL_INTERVAL = 2 # seconds
11
11
 
12
+ # Applied to every container odysseus starts. Without them a container
13
+ # inherits the daemon's default logging, which on a stock Docker install
14
+ # is json-file with no size limit -- so a long-lived container fills the
15
+ # disk. Caddy is the clearest case, being the one container no deploy
16
+ # ever replaces, but a dependency that runs for months has the same
17
+ # problem, so this belongs on the shared path rather than on one caller.
18
+ #
19
+ # The driver is named rather than left to the daemon because max-size and
20
+ # max-file belong to json-file and local only: on a host defaulting to
21
+ # journald or syslog, passing them does not degrade gracefully, docker
22
+ # refuses the run. Naming it also makes explicit what the codebase
23
+ # already assumes -- `odysseus logs` shells out to `docker logs`, which
24
+ # most other drivers cannot answer.
25
+ #
26
+ # The cost, knowingly: a host with a deliberate daemon-wide log driver is
27
+ # overridden. Making these configurable is a separate TODO entry.
28
+ LOG_DRIVER = 'json-file'.freeze
29
+ LOG_OPTIONS = { 'max-size' => '100m', 'max-file' => '3' }.freeze
30
+
12
31
  # @param ssh [Odysseus::Deployer::SSH] SSH connection to server
13
32
  def initialize(ssh)
14
33
  @ssh = ssh
@@ -468,6 +487,14 @@ module Odysseus
468
487
  nil
469
488
  end
470
489
 
490
+ # Extracted from build_run_command rather than inlined there: the flags
491
+ # are fixed, and the method was already at RuboCop's complexity ceiling.
492
+ # @return [Array<String>] the --log-driver and --log-opt arguments
493
+ def log_args
494
+ ["--log-driver #{LOG_DRIVER}"] +
495
+ LOG_OPTIONS.map { |key, value| "--log-opt #{key}=#{value}" }
496
+ end
497
+
471
498
  def build_run_command(name:, image:, options:, env_file: nil)
472
499
  parts = ['docker run -d']
473
500
 
@@ -525,6 +552,10 @@ module Odysseus
525
552
  # Restart policy
526
553
  parts << "--restart #{options[:restart] || 'unless-stopped'}"
527
554
 
555
+ # Log rotation (see LOG_DRIVER/LOG_OPTIONS). Before the image, or
556
+ # docker would hand them to the container's entrypoint instead.
557
+ parts.concat(log_args)
558
+
528
559
  # Image
529
560
  parts << image
530
561
 
@@ -0,0 +1,68 @@
1
+ # lib/odysseus/ssh_key.rb
2
+
3
+ require 'base64'
4
+
5
+ module Odysseus
6
+ # Reads what can be known about a private key file without decrypting it.
7
+ #
8
+ # This exists for one message. odysseus connects with `non_interactive: true`
9
+ # -- a passphrase prompt on a deploy connection can never be answered, so
10
+ # prompting would hang rather than help. net-ssh reports the resulting
11
+ # key-loading failure to a logger rather than raising it
12
+ # (`key_manager.rb:319`), so with no logger configured the reason is
13
+ # discarded and authentication simply fails. The user is then told to check
14
+ # keys that are perfectly correct. Knowing which key is locked is what turns
15
+ # that into an answerable question.
16
+ module SSHKey
17
+ OPENSSH_HEADER = '-----BEGIN OPENSSH PRIVATE KEY-----'.freeze
18
+ OPENSSH_MAGIC = "openssh-key-v1\0".freeze
19
+ PEM_ENCRYPTED = /^Proc-Type:\s*4,ENCRYPTED/
20
+ NO_CIPHER = 'none'.freeze
21
+
22
+ class << self
23
+ # @param path [String] path to a private key file
24
+ # @return [Boolean] true only when the file is a private key that is
25
+ # passphrase-protected. False for anything unreadable, absent, public,
26
+ # or not a key: those are different failures with different messages,
27
+ # and claiming a passphrase problem for them would send the reader
28
+ # somewhere just as wrong as "check your SSH keys".
29
+ def encrypted?(path)
30
+ data = read(path)
31
+ return false unless data
32
+
33
+ data.include?(OPENSSH_HEADER) ? openssh_encrypted?(data) : PEM_ENCRYPTED.match?(data)
34
+ end
35
+
36
+ private
37
+
38
+ def read(path)
39
+ return nil unless File.file?(path)
40
+
41
+ File.read(path)
42
+ rescue SystemCallError, IOError
43
+ nil
44
+ end
45
+
46
+ # An OpenSSH-format key states its cipher in the binary body rather than
47
+ # in a header: the magic "openssh-key-v1\0", then a 4-byte big-endian
48
+ # length and that many bytes of cipher name, which is "none" when the key
49
+ # is not encrypted. Read rather than decrypted, so no passphrase is
50
+ # needed to answer the question.
51
+ def openssh_encrypted?(data)
52
+ body = data[/#{Regexp.escape(OPENSSH_HEADER)}\n(.*?)\n-----END/m, 1]
53
+ return false unless body
54
+
55
+ raw = Base64.decode64(body)
56
+ return false unless raw.start_with?(OPENSSH_MAGIC)
57
+
58
+ rest = raw.byteslice(OPENSSH_MAGIC.bytesize..) || ''
59
+ return false if rest.bytesize < 4
60
+
61
+ length = rest.byteslice(0, 4).unpack1('N')
62
+ cipher = rest.byteslice(4, length)
63
+
64
+ !cipher.nil? && cipher != NO_CIPHER
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/odysseus.rb CHANGED
@@ -9,6 +9,7 @@ module Odysseus
9
9
  loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
10
10
  loader.inflector.inflect(
11
11
  'ssh' => 'SSH',
12
+ 'ssh_key' => 'SSHKey',
12
13
  'aws_asg' => 'AwsAsg'
13
14
  )
14
15
  # errors.rb doesn't follow Zeitwerk conventions (plural, defines multiple classes)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odysseus-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas
@@ -65,6 +65,34 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '7.2'
68
+ - !ruby/object:Gem::Dependency
69
+ name: bcrypt_pbkdf
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ed25519
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.3'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.3'
68
96
  - !ruby/object:Gem::Dependency
69
97
  name: zeitwerk
70
98
  requirement: !ruby/object:Gem::Requirement
@@ -131,6 +159,7 @@ files:
131
159
  - lib/odysseus/setup/escalation.rb
132
160
  - lib/odysseus/setup/preparer.rb
133
161
  - lib/odysseus/setup/public_key.rb
162
+ - lib/odysseus/ssh_key.rb
134
163
  - lib/odysseus/validators/config.rb
135
164
  - lib/odysseus/version_resolver.rb
136
165
  - sig/odysseus/core.rbs