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
|
@@ -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
|