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
@@ -0,0 +1,178 @@
1
+ # lib/odysseus/setup/docker_apt.rb
2
+
3
+ require 'shellwords'
4
+
5
+ module Odysseus
6
+ module Setup
7
+ # Installs Docker from Docker's official apt repository, following Docker's
8
+ # published PROCEDURE for Ubuntu -- the keyring, the sources line, the apt
9
+ # install. Not its package list: see PACKAGES below, which deliberately
10
+ # omits docker-compose-plugin. Saying "following Docker's instructions"
11
+ # unqualified is what put the same overstatement on the docs site, where a
12
+ # reader deciding whether to run this was not told what would be missing.
13
+ #
14
+ # Every file this writes on the host is written WHOLE rather than appended,
15
+ # so a run interrupted anywhere leaves a stale file that the next run
16
+ # overwrites -- never a corrupt one with the repository listed twice.
17
+ #
18
+ # It repairs nothing it did not create: a broken dpkg state, a held lock, a
19
+ # third-party repository that fails to refresh are all reported and raised,
20
+ # not worked around. Guessing at someone else's apt state is how a bootstrap
21
+ # leaves a machine worse than it found it.
22
+ #
23
+ # The GPG key's fingerprint is deliberately not pinned. Pinning defends
24
+ # against a CA-level compromise of download.docker.com, but turns Docker's
25
+ # key rotation into an outage for everyone using this command; Docker's own
26
+ # instructions trust TLS, and so does this.
27
+ class DockerApt
28
+ KEYRING = '/etc/apt/keyrings/docker.asc'.freeze
29
+ SOURCES = '/etc/apt/sources.list.d/docker.list'.freeze
30
+ GPG_URL = 'https://download.docker.com/linux/ubuntu/gpg'.freeze
31
+ REPO_URL = 'https://download.docker.com/linux/ubuntu'.freeze
32
+ LOCK_FILE = '/var/lib/dpkg/lock-frontend'.freeze
33
+
34
+ # Long enough to outlast cloud-init and unattended-upgrades on a
35
+ # minutes-old host, which is the normal state of a machine someone is
36
+ # running setup against; short enough that a genuinely stuck lock is an
37
+ # error rather than a session that never returns.
38
+ LOCK_TIMEOUT = 300
39
+
40
+ # Deliberately short of Docker's published instructions, which also
41
+ # install docker-compose-plugin: nothing in this codebase invokes
42
+ # `docker compose`, odysseus runs containers through `docker run` /
43
+ # `docker create` (odysseus-core/lib/odysseus/docker/client.rb). Do not
44
+ # add it back on the strength of Docker's docs alone -- `setup` installs
45
+ # only what odysseus actually uses. docker-buildx-plugin stays: `docker
46
+ # buildx build` (odysseus-core/lib/odysseus/builder/client.rb:216) runs
47
+ # on the operator's own machine for the `:local` strategy, or on
48
+ # `builder.host` over SSH for `:remote` -- never on a deploy target as
49
+ # such, and setup's hosts come only from `servers.*`
50
+ # (odysseus-core/lib/odysseus/deployer/executor.rb:300-308). So on a
51
+ # host setup prepares, buildx is only reachable where `builder.host`
52
+ # happens to name one of those deploy hosts with multiarch on -- a
53
+ # coincidence plausible precisely in setup's single-host-trial niche,
54
+ # and worth keeping regardless: a modern `docker build` without the
55
+ # plugin falls back to Docker's deprecated legacy builder.
56
+ PACKAGES = %w[docker-ce docker-ce-cli containerd.io docker-buildx-plugin].freeze
57
+
58
+ # @param ssh [Odysseus::Deployer::SSH] connection as the bootstrap identity
59
+ # @param escalation [Odysseus::Setup::Escalation] how root is reached
60
+ # @param codename [String] Ubuntu's VERSION_CODENAME, e.g. "noble"
61
+ def initialize(ssh:, escalation:, codename:)
62
+ @ssh = ssh
63
+ @escalation = escalation
64
+ @codename = codename.to_s.strip
65
+ end
66
+
67
+ # @return [nil]
68
+ # @raise [Odysseus::SetupError] on any failure, with the apt output's
69
+ # first line and, for a lock timeout, the process holding it
70
+ def install!
71
+ if @codename.empty?
72
+ raise Odysseus::SetupError,
73
+ '/etc/os-release reported no VERSION_CODENAME, so the apt repository line ' \
74
+ 'cannot name a release. Refusing to guess one.'
75
+ end
76
+
77
+ apt('update')
78
+ apt('install -y ca-certificates curl')
79
+ make_keyring_dir
80
+ fetch_keyring
81
+ make_keyring_readable
82
+ write_sources
83
+ # The repository is only visible to apt after a refresh that follows
84
+ # the sources file, so this update is not the same as the one above.
85
+ apt('update')
86
+ apt("install -y #{PACKAGES.join(' ')}")
87
+
88
+ nil
89
+ end
90
+
91
+ private
92
+
93
+ def arch
94
+ @arch ||= @escalation.run('dpkg --print-architecture').to_s.strip
95
+ end
96
+
97
+ def sources_line
98
+ "deb [arch=#{arch} signed-by=#{KEYRING}] #{REPO_URL} #{@codename} stable"
99
+ end
100
+
101
+ # Every non-apt host call below is wrapped the same way #apt already
102
+ # is: a raw Odysseus::SSHCommandError from any of these used to escape
103
+ # #install! untouched, past Preparer#run_step's `rescue
104
+ # Odysseus::SetupError` and out of #prepare entirely -- reported by the
105
+ # CLI's per-host `rescue StandardError` as a step named :connection,
106
+ # discarding every step result that had already succeeded on that
107
+ # host. Wrapping here, at the point each command actually runs, is what
108
+ # keeps #install!'s own `@raise [Odysseus::SetupError] on any failure`
109
+ # true.
110
+
111
+ def make_keyring_dir
112
+ @escalation.run('install -m 0755 -d /etc/apt/keyrings')
113
+ rescue Odysseus::Error => e
114
+ raise Odysseus::SetupError,
115
+ "could not create /etc/apt/keyrings: #{e.message.lines.first.to_s.strip}"
116
+ end
117
+
118
+ # curl -o, not a redirect: the file is opened by the process sudo
119
+ # elevated, not by the bootstrap identity's own shell.
120
+ def fetch_keyring
121
+ @escalation.run("curl -fsSL #{GPG_URL} -o #{KEYRING}")
122
+ rescue Odysseus::Error => e
123
+ raise Odysseus::SetupError,
124
+ "could not download Docker's signing key from #{GPG_URL}: " \
125
+ "#{e.message.lines.first.to_s.strip}"
126
+ end
127
+
128
+ def make_keyring_readable
129
+ @escalation.run("chmod a+r #{KEYRING}")
130
+ rescue Odysseus::Error => e
131
+ raise Odysseus::SetupError,
132
+ "could not make #{KEYRING} readable: #{e.message.lines.first.to_s.strip}"
133
+ end
134
+
135
+ # `tee`, not `tee -a`: this file is replaced, not added to. The prefix
136
+ # goes on the writer alone via Escalation#elevate -- prefixing the whole
137
+ # pipeline would elevate printf and leave tee unprivileged, which is the
138
+ # defect that made setup's first authorized_keys append fail under the
139
+ # documented default identity.
140
+ def write_sources
141
+ writer = @escalation.elevate("tee #{Shellwords.escape(SOURCES)}")
142
+ @ssh.execute("printf '%s\n' #{Shellwords.escape(sources_line)} | #{writer} >/dev/null")
143
+ rescue Odysseus::Error => e
144
+ raise Odysseus::SetupError,
145
+ "could not write the apt sources file #{SOURCES}: #{e.message.lines.first.to_s.strip}"
146
+ end
147
+
148
+ # `env DEBIAN_FRONTEND=noninteractive` rather than a bare assignment:
149
+ # sudoers may refuse to pass an environment variable through, and a
150
+ # prompt cannot be answered on a non-interactive connection at all.
151
+ def apt(args)
152
+ @escalation.run(
153
+ "env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=#{LOCK_TIMEOUT} #{args}"
154
+ )
155
+ rescue Odysseus::Error => e
156
+ raise Odysseus::SetupError,
157
+ "apt-get #{args.split.first} failed#{lock_holder_note}: " \
158
+ "#{e.message.lines.first.to_s.strip}"
159
+ end
160
+
161
+ # Best-effort attribution, never a second failure: if fuser or ps is
162
+ # missing the message simply says less.
163
+ def lock_holder_note
164
+ # fuser can report more than one holder as space-separated PIDs on
165
+ # one line. `tr -d ' '` would glue them into one fabricated number --
166
+ # "1234 5678" becoming "12345678" -- so take the first token instead
167
+ # of deleting every space.
168
+ pid = @escalation.run("fuser #{LOCK_FILE} 2>/dev/null").to_s.strip.split.first.to_s
169
+ return '' if pid.empty?
170
+
171
+ name = @escalation.run("ps -o comm= -p #{Shellwords.escape(pid)} 2>/dev/null || true").to_s.strip
172
+ name.empty? ? " (pid #{pid} holds #{LOCK_FILE})" : " (#{name}, pid #{pid}, holds #{LOCK_FILE})"
173
+ rescue Odysseus::Error
174
+ ''
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,73 @@
1
+ # lib/odysseus/setup/escalation.rb
2
+
3
+ module Odysseus
4
+ module Setup
5
+ # How `odysseus setup` gets root on the host it is preparing.
6
+ #
7
+ # The bootstrap identity is named by --as and defaults to `ubuntu`, so
8
+ # needing sudo is the common path rather than the exception: Ubuntu's LTS
9
+ # cloud images ship that user with passwordless sudo already configured.
10
+ # A root bootstrap must not touch sudo at all — minimal images often do not
11
+ # have it installed.
12
+ #
13
+ # The probe exists because a sudo password prompt cannot be answered:
14
+ # Net::SSH runs with non_interactive: true, so a prompt hangs and then
15
+ # fails with nothing useful said. Asking up front turns that into one
16
+ # sentence before anything has been changed.
17
+ class Escalation
18
+ ROOT = 'root'.freeze
19
+
20
+ # @param ssh [Odysseus::Deployer::SSH] connection as the bootstrap identity
21
+ # @param as [String] that identity's username
22
+ def initialize(ssh:, as:)
23
+ @ssh = ssh
24
+ @as = as
25
+ end
26
+
27
+ # @return [Boolean] whether commands need a sudo prefix
28
+ def sudo?
29
+ @as != ROOT
30
+ end
31
+
32
+ # @raise [Odysseus::SetupError] when escalation is not available
33
+ def probe!
34
+ return unless sudo?
35
+
36
+ @ssh.execute('sudo -n true')
37
+ # SSHCommandError only, deliberately: it is the one that means the
38
+ # command ran and sudo said no. Its sibling SSHConnectionError -- and
39
+ # everything else under Odysseus::Error -- means the host was never
40
+ # reached, and this is the FIRST command setup sends, so an unreachable
41
+ # host arrives here before anywhere else. Rescuing the shared ancestor
42
+ # told an operator whose DNS was wrong, or whose host was down, to go
43
+ # configure NOPASSWD sudo. A connection failure travels on untouched,
44
+ # to be reported against the connection by the caller that owns it.
45
+ rescue Odysseus::SSHCommandError => e
46
+ raise Odysseus::SetupError,
47
+ "#{@as} cannot escalate with passwordless sudo, which odysseus setup requires: " \
48
+ "#{e.message.lines.first.to_s.strip}. Odysseus cannot answer a password prompt. " \
49
+ 'Use --as root on a host where root can log in, or give this user NOPASSWD sudo.'
50
+ end
51
+
52
+ # The one place that decides whether a command needs a sudo prefix.
53
+ # Exposed rather than kept inside #run because a command whose
54
+ # privileged half is only *part* of a pipeline cannot go through #run:
55
+ # `sudo -n printf ... | tee file` would elevate printf and leave tee
56
+ # unprivileged, which is the defect that made the first authorized_keys
57
+ # append fail under the documented default identity. Callers in that
58
+ # position build the pipeline themselves and elevate the writer alone.
59
+ #
60
+ # @param command [String] a command that needs root
61
+ # @return [String] it, prefixed when a prefix is needed
62
+ def elevate(command)
63
+ sudo? ? "sudo -n #{command}" : command
64
+ end
65
+
66
+ # @param command [String] a command that needs root
67
+ # @return [String] its output
68
+ def run(command)
69
+ @ssh.execute(elevate(command))
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,372 @@
1
+ # lib/odysseus/setup/preparer.rb
2
+
3
+ require 'shellwords'
4
+
5
+ module Odysseus
6
+ module Setup
7
+ # Prepares a fresh host so odysseus can deploy to it as a non-root user.
8
+ #
9
+ # Runs over the connection made as the bootstrap identity (--as, root or
10
+ # otherwise) and escalates through `escalation` for anything that touches
11
+ # host state. Each step checks before it acts, so a second run against an
12
+ # already-prepared host changes nothing and every step reports :ok — the
13
+ # first run, mid-preparation, is the one that should say :changed.
14
+ #
15
+ # Docker is installed from Docker's own apt repository when the host does
16
+ # not have it (see DockerApt); a host whose daemon still does not answer
17
+ # afterwards fails the step rather than being handed back half-prepared.
18
+ class Preparer
19
+ SUPPORTED_UBUNTU = %w[24.04 26.04].freeze
20
+
21
+ # @param step [Symbol] which step this is
22
+ # @param status [Symbol] :ok, :changed, :warn or :fail
23
+ # @param detail [String] one line a reader can act on
24
+ Result = Data.define(:step, :status, :detail)
25
+
26
+ # @param ssh [Odysseus::Deployer::SSH] connection as the bootstrap identity
27
+ # @param config [Hash] parsed deploy.yml; only ssh.user is read here
28
+ # @param escalation [Odysseus::Setup::Escalation] how root is reached
29
+ # @param keys [Array<String>] resolved authorized_keys lines (see
30
+ # Odysseus::Setup::PublicKey.resolve, which the caller has already run)
31
+ def initialize(ssh:, config:, escalation:, keys:)
32
+ @ssh = ssh
33
+ @config = config
34
+ @escalation = escalation
35
+ @keys = keys
36
+ @user = config.dig(:ssh, :user)
37
+ @home_dir = nil
38
+ end
39
+
40
+ # @return [Array<Result>] one per step that ran, in a stable order.
41
+ # A :fail halts the sequence — nothing after a failed gate is safe to
42
+ # attempt, whether that's an unsupported distro or a missing daemon.
43
+ def prepare
44
+ results = [escalation_step]
45
+ return results if results.last.status == :fail
46
+
47
+ %i[distro docker user group keys state_dir self_test].each do |step|
48
+ result = run_step(step)
49
+ results << result
50
+ break if result.status == :fail
51
+ end
52
+
53
+ results
54
+ end
55
+
56
+ private
57
+
58
+ # Any step from :user onward can raise Odysseus::SetupError now:
59
+ # #resolve_home_dir refuses to guess an unresolved home rather than
60
+ # silently building a path out of it (see #home_dir). That refusal
61
+ # needs to be attributed to the step that hit it, the same way
62
+ # escalation_step already converts its own SetupError into a Result
63
+ # rather than letting a raise crash #prepare outright -- this is that
64
+ # same conversion, applied once here instead of duplicated in every
65
+ # step that touches home_dir.
66
+ def run_step(step)
67
+ send(:"#{step}_step")
68
+ rescue Odysseus::SetupError => e
69
+ Result.new(step: step, status: :fail, detail: e.message)
70
+ end
71
+
72
+ def escalation_step
73
+ @escalation.probe!
74
+ detail = @escalation.sudo? ? 'passwordless sudo confirmed' : 'connected as root'
75
+ Result.new(step: :escalation, status: :ok, detail: detail)
76
+ rescue Odysseus::SetupError => e
77
+ Result.new(step: :escalation, status: :fail, detail: e.message)
78
+ end
79
+
80
+ # No escalation: /etc/os-release is world-readable, and the gate itself
81
+ # must hold even on a host escalation cannot yet be proven on.
82
+ def distro_step
83
+ os = read_os_release
84
+ id = os['ID']
85
+ version = os['VERSION_ID']
86
+
87
+ if id == 'ubuntu' && SUPPORTED_UBUNTU.include?(version)
88
+ Result.new(step: :distro, status: :ok, detail: "ubuntu #{version}")
89
+ else
90
+ Result.new(
91
+ step: :distro, status: :fail,
92
+ detail: "#{id || 'unknown'} #{version} — odysseus setup only knows " \
93
+ "ubuntu #{SUPPORTED_UBUNTU.join(' or ')}"
94
+ )
95
+ end
96
+ end
97
+
98
+ # Check, then install, then check again. The second probe is the only
99
+ # thing that decides success: apt exiting 0 says a package was unpacked,
100
+ # not that a daemon answers, and it is the daemon every deploy needs.
101
+ def docker_step
102
+ version = docker_version
103
+ return Result.new(step: :docker, status: :ok, detail: "docker #{version}") if version
104
+
105
+ DockerApt.new(ssh: @ssh, escalation: @escalation, codename: read_os_release['VERSION_CODENAME']).install!
106
+
107
+ version = docker_version
108
+ unless version
109
+ return Result.new(
110
+ step: :docker, status: :fail,
111
+ detail: 'installed docker from apt, but its daemon still does not answer. ' \
112
+ 'The host may need a reboot, or the daemon may have failed to start.'
113
+ )
114
+ end
115
+
116
+ Result.new(step: :docker, status: :changed, detail: "installed docker #{version}")
117
+ end
118
+
119
+ # @return [String, nil] the running daemon's version, or nil if it does
120
+ # not answer -- which does not distinguish "not installed" from
121
+ # "installed, stopped", and does not need to: both are a host without
122
+ # a usable Docker until this step has run.
123
+ def docker_version
124
+ output = @escalation.run("docker info --format '{{.ServerVersion}}' 2>&1 || true").to_s.strip
125
+ output.match?(/\A\d+\./) ? output : nil
126
+ end
127
+
128
+ def user_step
129
+ if user_exists?
130
+ verify_home_ownership
131
+ else
132
+ @escalation.run("useradd --create-home --shell /bin/bash #{shell_user}")
133
+ Result.new(step: :user, status: :changed, detail: "created #{@user}, home #{home_dir}")
134
+ end
135
+ end
136
+
137
+ def user_exists?
138
+ !@escalation.run("id -u #{shell_user} 2>/dev/null || true").to_s.strip.empty?
139
+ end
140
+
141
+ # Repairs ownership, and only ownership: this never touches the shell,
142
+ # the password, or anything else about a user odysseus did not create.
143
+ #
144
+ # The user existing does not guarantee the home does: useradd normally
145
+ # creates both together, but a user made some other way, or one whose
146
+ # home was removed after the fact, can exist without one. `chown` on a
147
+ # path that isn't there raises rather than repairing anything, so the
148
+ # directory is created first -- a no-op when it already exists -- which
149
+ # is the actual "half-created-user recovery" the brief names, not just
150
+ # the ownership half of it.
151
+ def verify_home_ownership
152
+ @escalation.run("mkdir -p #{Shellwords.escape(home_dir)}") unless dir_present?(home_dir)
153
+ owner, group = @escalation.run("stat -c '%U %G' #{Shellwords.escape(home_dir)} 2>/dev/null || true").to_s.split
154
+
155
+ if owner == @user && group == @user
156
+ Result.new(step: :user, status: :ok, detail: "#{@user} exists, home owned correctly")
157
+ else
158
+ @escalation.run("chown #{shell_user}:#{shell_user} #{Shellwords.escape(home_dir)}")
159
+ detail = "repaired #{home_dir} (was #{owner || 'missing'} #{group || 'missing'})"
160
+ Result.new(step: :user, status: :changed, detail: detail)
161
+ end
162
+ end
163
+
164
+ def group_step
165
+ groups = @escalation.run("id -nG #{shell_user} 2>/dev/null || true").to_s.split
166
+
167
+ if groups.include?('docker')
168
+ Result.new(step: :group, status: :ok, detail: "#{@user} is in the docker group")
169
+ else
170
+ @escalation.run("usermod -aG docker #{shell_user}")
171
+ Result.new(step: :group, status: :changed, detail: "added #{@user} to the docker group")
172
+ end
173
+ end
174
+
175
+ # Never overwrites: a second operator's key installed by an earlier run
176
+ # must survive this one. Only the keys actually missing are appended,
177
+ # and the directory is only touched (mkdir/chmod/chown) when something
178
+ # needs to change — a healthy re-run issues none of those commands.
179
+ def keys_step
180
+ missing = @keys.reject { |line| key_present?(line) }
181
+ return Result.new(step: :keys, status: :ok, detail: "#{@keys.size} key(s) already present") if missing.empty?
182
+
183
+ @escalation.run("mkdir -p #{Shellwords.escape(ssh_dir)}")
184
+ @escalation.run("touch #{Shellwords.escape(authorized_keys)}")
185
+ missing.each { |line| append_key(line) }
186
+ # 700/600 are reapplied every time a key is appended, so a directory
187
+ # left loose is corrected as a side effect of appending a key — sshd
188
+ # ignores both silently, with no error worth finding. Only a side
189
+ # effect, though: this step early-returns above when every key is
190
+ # already present, so a pre-existing world-writable ~/.ssh on an
191
+ # otherwise fully up-to-date host is never revisited or repaired.
192
+ @escalation.run("chmod 700 #{Shellwords.escape(ssh_dir)}")
193
+ @escalation.run("chmod 600 #{Shellwords.escape(authorized_keys)}")
194
+ @escalation.run("chown -R #{shell_user}:#{shell_user} #{Shellwords.escape(ssh_dir)}")
195
+
196
+ Result.new(step: :keys, status: :changed, detail: "added #{missing.size} key(s)")
197
+ end
198
+
199
+ # A `>>` redirect is set up by the CONNECTION's own shell before sudo
200
+ # ever runs -- sudo only elevates the command it's given, not the
201
+ # shell that's about to open a file for it. `sudo -n echo line >>file`
202
+ # therefore tries to open `file` as the *bootstrap* identity, which
203
+ # cannot write into another user's authorized_keys and fails outright
204
+ # under the documented default of --as ubuntu. Piping into `tee -a`
205
+ # instead keeps the open() inside the process that's actually
206
+ # escalated, which is the standard idiom for exactly this reason.
207
+ # Nothing here needs escalation at all under --as root, so the prefix is
208
+ # applied to the writer alone and only when needed -- via
209
+ # Escalation#elevate, so this is not a second opinion about when sudo is
210
+ # required.
211
+ def append_key(line)
212
+ writer = @escalation.elevate("tee -a #{Shellwords.escape(authorized_keys)}")
213
+ @ssh.execute("printf '%s\n' #{Shellwords.escape(line)} | #{writer} >/dev/null")
214
+ end
215
+
216
+ def key_present?(line)
217
+ cmd = "grep -qxF #{Shellwords.escape(line)} #{Shellwords.escape(authorized_keys)} 2>/dev/null && echo present || echo absent"
218
+ @escalation.run(cmd).to_s.strip == 'present'
219
+ end
220
+
221
+ def state_dir_step
222
+ dir = state_dir
223
+ exists = dir_present?(dir)
224
+ owner = exists ? @escalation.run("stat -c '%U' #{Shellwords.escape(dir)} 2>/dev/null || true").to_s.strip.split.first : nil
225
+
226
+ if exists && owner == @user
227
+ Result.new(step: :state_dir, status: :ok, detail: "#{dir} exists")
228
+ else
229
+ @escalation.run("mkdir -p #{Shellwords.escape(dir)}")
230
+ @escalation.run("chown #{shell_user}:#{shell_user} #{Shellwords.escape(dir)}")
231
+ Result.new(step: :state_dir, status: :changed, detail: "created #{dir}")
232
+ end
233
+ end
234
+
235
+ # A genuinely fresh connection, authenticated as the deploy user rather
236
+ # than the bootstrap identity -- reusing @ssh would only prove @user's
237
+ # own access, over a session that may predate the docker-group change
238
+ # (usermod -aG only takes effect for new logins) and against a home
239
+ # directory @ssh's identity typically cannot even read into. no sudo
240
+ # here either way: this is the proof the host is usable with no help
241
+ # from escalation, which is the whole safety argument for the command.
242
+ # Because it never writes anything, a failure here leaves the bootstrap
243
+ # path -- and everything already prepared -- untouched and the host
244
+ # still reachable. A failure to even log in is reported by name rather
245
+ # than left to propagate, since that is the bricked-host case this step
246
+ # exists to catch before odysseus hands the host back.
247
+ def self_test_step
248
+ dir = state_dir
249
+ fresh = Odysseus::Deployer::SSH.new(
250
+ host: @ssh.host,
251
+ user: @user,
252
+ port: @ssh.port,
253
+ keys: @config.dig(:ssh, :keys) || [],
254
+ use_tailscale: false
255
+ )
256
+
257
+ begin
258
+ docker_output = fresh.execute("docker info --format '{{.ServerVersion}}' 2>&1 || true").to_s.strip
259
+ docker_ok = docker_output.match?(/\A\d+\./)
260
+ writable = fresh.execute(
261
+ "test -d #{Shellwords.escape(dir)} && test -w #{Shellwords.escape(dir)} && echo present || echo absent"
262
+ ).to_s.strip == 'present'
263
+
264
+ if docker_ok && writable
265
+ Result.new(step: :self_test, status: :ok, detail: "docker and #{dir} reachable as #{@user}")
266
+ else
267
+ problems = []
268
+ problems << 'docker did not answer' unless docker_ok
269
+ problems << "#{dir} is not writable" unless writable
270
+ Result.new(step: :self_test, status: :fail, detail: problems.join('; '))
271
+ end
272
+ rescue Odysseus::Error => e
273
+ Result.new(
274
+ step: :self_test, status: :fail,
275
+ detail: "could not log in as #{@user}: #{e.message.lines.first.to_s.strip}"
276
+ )
277
+ ensure
278
+ fresh.close
279
+ end
280
+ end
281
+
282
+ def dir_present?(dir)
283
+ @escalation.run("test -d #{Shellwords.escape(dir)} && echo present || echo absent").to_s.strip == 'present'
284
+ end
285
+
286
+ def read_os_release
287
+ @read_os_release ||= begin
288
+ raw = @ssh.execute('cat /etc/os-release 2>/dev/null || true').to_s
289
+
290
+ raw.lines.each_with_object({}) do |line, acc|
291
+ key, value = line.strip.split('=', 2)
292
+ next if key.nil? || value.nil?
293
+
294
+ acc[key] = value.delete('"')
295
+ end
296
+ end
297
+ end
298
+
299
+ # Ubuntu's useradd defaults HOME=/home, but that only holds for a user
300
+ # this class creates itself. A pre-existing user can have any home at
301
+ # all -- assuming /home/<user> for one installs keys and a state
302
+ # directory where sshd never reads them, while still reporting
303
+ # success. Read it from the host instead, once the user is known to
304
+ # exist, and memoize it: a second call must not re-ask a question
305
+ # that cannot have changed mid-run.
306
+ def home_dir
307
+ @home_dir ||= resolve_home_dir
308
+ end
309
+
310
+ # No pipe: a pipeline's exit status is its LAST command's, so
311
+ # `getent passwd <user> | cut -d: -f6` used to exit 0 with empty
312
+ # output no matter how badly getent itself failed -- the third
313
+ # instance on this branch of the exit status being observed not
314
+ # being the one that mattered. getent's own exit status decides now,
315
+ # via @escalation.run's ordinary raise-on-failure; field 6 is split
316
+ # out in Ruby instead of by a second process.
317
+ #
318
+ # The result is validated before it is ever used to build a path.
319
+ # File.join("", ".ssh") is "/.ssh" -- a blank answer would let
320
+ # keys_step and state_dir_step succeed at the filesystem root while
321
+ # /home/<user> is never touched, reporting a host ready when
322
+ # authorized_keys was never written there. An answer of exactly "/"
323
+ # is worse: verify_home_ownership would find it "owned wrong" and
324
+ # `chown` it straight to the deploy user, seizing a directory setup
325
+ # never created -- the same invariant Finding 1 exists to protect,
326
+ # lost the moment an arbitrary passwd field replaced the old,
327
+ # effectively sandboxed /home/<user> join. There is no safe
328
+ # fallback to substitute: a guess is what caused this, so an
329
+ # unresolved home fails the step that needed it, by name, instead.
330
+ def resolve_home_dir
331
+ candidate = @escalation.run("getent passwd #{shell_user}").to_s.strip.split(':')[5].to_s.strip
332
+
333
+ if candidate.start_with?('/')
334
+ # Squeeze BEFORE expanding, not after: POSIX makes a path that
335
+ # starts with exactly two slashes implementation-defined, so
336
+ # File.expand_path leaves "//" as "//" rather than folding it to
337
+ # "/" -- expand_path alone would still accept the very spelling
338
+ # that reached verify_home_ownership's `chown` unrejected. Once
339
+ # collapsed to a single leading slash, expand_path also resolves
340
+ # "/." and "/.." (both the filesystem root by another spelling)
341
+ # down to "/", where the check below catches them too. This is
342
+ # pure string manipulation on an already-absolute path -- no
343
+ # symlink resolution, nothing touches the machine running
344
+ # odysseus, only the string describing a path on the remote host.
345
+ canonical = File.expand_path(candidate.sub(%r{\A/+}, '/'))
346
+ return canonical unless canonical == '/'
347
+ end
348
+
349
+ raise Odysseus::SetupError,
350
+ "could not resolve a home directory for #{@user}: getent passwd reported " \
351
+ "#{candidate.empty? ? 'nothing usable' : candidate.inspect} for it. Refusing " \
352
+ 'to guess /home/<user> in its place.'
353
+ end
354
+
355
+ def ssh_dir
356
+ File.join(home_dir, '.ssh')
357
+ end
358
+
359
+ def authorized_keys
360
+ File.join(ssh_dir, 'authorized_keys')
361
+ end
362
+
363
+ def state_dir
364
+ File.join(home_dir, Odysseus::HostPaths::USER_DIRNAME)
365
+ end
366
+
367
+ def shell_user
368
+ Shellwords.escape(@user)
369
+ end
370
+ end
371
+ end
372
+ end