odysseus-cli 0.2.0 → 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 +332 -0
- data/LICENSE.txt +26 -0
- data/README.md +472 -88
- data/bin/odysseus +180 -122
- data/lib/odysseus/cli/cli.rb +448 -821
- data/lib/odysseus/cli/doctor_commands.rb +93 -0
- data/lib/odysseus/cli/interactive_commands.rb +198 -0
- data/lib/odysseus/cli/rollback_commands.rb +107 -0
- data/lib/odysseus/cli/setup_commands.rb +176 -0
- data/lib/odysseus/cli/ui.rb +457 -0
- data/lib/odysseus/cli/version.rb +7 -0
- metadata +22 -53
- data/lib/odysseus/cli/gum.rb +0 -156
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# odysseus-cli/lib/odysseus/cli/doctor_commands.rb
|
|
2
|
+
#
|
|
3
|
+
# `odysseus doctor`.
|
|
4
|
+
# Split out of CLI so the command surface for it lives together and CLI
|
|
5
|
+
# itself stays under the project's class-length budget.
|
|
6
|
+
|
|
7
|
+
module Odysseus
|
|
8
|
+
module CLI
|
|
9
|
+
module DoctorCommands
|
|
10
|
+
# Read-only diagnosis of every host in the config, as the user the config
|
|
11
|
+
# names. Its own command rather than a mode of `setup`, because it lasts:
|
|
12
|
+
# "is this host usable by odysseus as my deploy user" is worth asking on
|
|
13
|
+
# any host, including one a provisioning tool built.
|
|
14
|
+
def doctor(options = {})
|
|
15
|
+
config_file = options[:config] || 'deploy.yml'
|
|
16
|
+
config = load_config(config_file)
|
|
17
|
+
|
|
18
|
+
@ui.header 'Odysseus Doctor'
|
|
19
|
+
@ui.info 'Service', config[:service]
|
|
20
|
+
@ui.info 'Deploy user', config[:ssh][:user]
|
|
21
|
+
@ui.blank
|
|
22
|
+
|
|
23
|
+
worst = :ok
|
|
24
|
+
|
|
25
|
+
executor = Odysseus::Deployer::Executor.new(config_file)
|
|
26
|
+
|
|
27
|
+
executor.host_roles.each_key do |host|
|
|
28
|
+
@ui.section host
|
|
29
|
+
ssh = connect_to_server(host, config)
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
Odysseus::HostVerifier.new(ssh: ssh, config: config).verify.each do |result|
|
|
33
|
+
worst = escalate(worst, result.status)
|
|
34
|
+
render_check(result)
|
|
35
|
+
end
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
# A check failing is a Result with status: :fail, produced by
|
|
38
|
+
# HostVerifier without raising. This rescues something else: the
|
|
39
|
+
# connection itself dying mid-survey. A drop there can surface as
|
|
40
|
+
# IOError, Net::SSH::Disconnect (a RuntimeError) or
|
|
41
|
+
# Errno::EPIPE/ECONNRESET (a SystemCallError) — three branches of
|
|
42
|
+
# StandardError with no narrower ancestor in common, so nothing
|
|
43
|
+
# tighter than StandardError could catch all of them in one
|
|
44
|
+
# rescue. A narrower rescue would also reintroduce the defect
|
|
45
|
+
# this exists to fix: one host's failure aborting the survey for
|
|
46
|
+
# everyone after it. The risk of that width is masking a genuine
|
|
47
|
+
# HostVerifier bug as "host unreachable"; naming the exception's
|
|
48
|
+
# own class and message in the detail is what keeps a
|
|
49
|
+
# NoMethodError legible as a bug rather than indistinguishable
|
|
50
|
+
# from a dropped connection.
|
|
51
|
+
worst = escalate(worst, :fail)
|
|
52
|
+
render_check(
|
|
53
|
+
Odysseus::HostVerifier::Result.new(
|
|
54
|
+
check: :reachable, status: :fail, detail: "#{host}: #{e.class}: #{e.message}"
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
ensure
|
|
58
|
+
ssh.close
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@ui.blank
|
|
63
|
+
case worst
|
|
64
|
+
when :fail then exit 1
|
|
65
|
+
when :warn then @ui.warn 'Deploys will work, but read the warnings above.'
|
|
66
|
+
else @ui.success 'This host is ready.'
|
|
67
|
+
end
|
|
68
|
+
rescue Odysseus::Error => e
|
|
69
|
+
@ui.error e.message
|
|
70
|
+
exit 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def render_check(result)
|
|
76
|
+
line = "#{result.check}: #{result.detail}"
|
|
77
|
+
|
|
78
|
+
case result.status
|
|
79
|
+
when :ok then @ui.step_ok line
|
|
80
|
+
when :warn then @ui.warn line
|
|
81
|
+
else @ui.step_fail line
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# :fail beats :warn beats :ok, so one bad check decides the exit code
|
|
86
|
+
# however many good ones surround it.
|
|
87
|
+
def escalate(current, status)
|
|
88
|
+
order = { ok: 0, warn: 1, fail: 2 }
|
|
89
|
+
order[status] > order[current] ? status : current
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# odysseus-cli/lib/odysseus/cli/interactive_commands.rb
|
|
2
|
+
#
|
|
3
|
+
# `odysseus app shell`, `odysseus app console` and `odysseus dependency shell`.
|
|
4
|
+
# These three are the commands that hand a terminal over: they build an ssh
|
|
5
|
+
# command line and run it locally rather than going through the Docker client,
|
|
6
|
+
# because the session needs the caller's own tty. Split out of CLI so they live
|
|
7
|
+
# together and CLI itself stays under the project's class-length budget.
|
|
8
|
+
#
|
|
9
|
+
# Two shells stand between this file and the container, and each needs its own
|
|
10
|
+
# quoting. Escaping for one and not the other is how these commands came to
|
|
11
|
+
# break on an env value with a space in it.
|
|
12
|
+
#
|
|
13
|
+
# Those env values are no longer in the string at all — they go in an env file
|
|
14
|
+
# on the host, and only its path is named here — but the console command, the
|
|
15
|
+
# image and the SSH key paths still are, so both layers still matter.
|
|
16
|
+
|
|
17
|
+
require 'shellwords'
|
|
18
|
+
|
|
19
|
+
module Odysseus
|
|
20
|
+
module CLI
|
|
21
|
+
module InteractiveCommands
|
|
22
|
+
# App shell
|
|
23
|
+
def app_shell(server, options = {})
|
|
24
|
+
config_file = options[:config] || 'deploy.yml'
|
|
25
|
+
role = (options[:role] || 'web').to_sym
|
|
26
|
+
config = load_config(config_file)
|
|
27
|
+
image = running_image(server, config, role)
|
|
28
|
+
session_header('App Shell', server: server, role: role, image: image, command: '/bin/sh')
|
|
29
|
+
|
|
30
|
+
with_container_env(server, config, config_file) do |env_file|
|
|
31
|
+
remote = remote_command(['docker', 'run', '-it', '--rm', '--network', 'odysseus',
|
|
32
|
+
*env_file_args(env_file), image, '/bin/sh'])
|
|
33
|
+
run_interactive!(ssh_command(config, server, remote))
|
|
34
|
+
end
|
|
35
|
+
rescue Odysseus::Error => e
|
|
36
|
+
@ui.error e.message
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# App console
|
|
41
|
+
def app_console(server, options = {})
|
|
42
|
+
config_file = options[:config] || 'deploy.yml'
|
|
43
|
+
role = (options[:role] || 'web').to_sym
|
|
44
|
+
console_cmd = options[:cmd] || '/bin/sh'
|
|
45
|
+
config = load_config(config_file)
|
|
46
|
+
image = running_image(server, config, role)
|
|
47
|
+
# Read before the env file is written: a --cmd that cannot be parsed is
|
|
48
|
+
# not worth putting a file of secrets on the host for.
|
|
49
|
+
words = console_words(console_cmd)
|
|
50
|
+
session_header('App Console', server: server, role: role, image: image, command: console_cmd)
|
|
51
|
+
|
|
52
|
+
with_container_env(server, config, config_file) do |env_file|
|
|
53
|
+
remote = remote_command(['docker', 'run', '-it', '--rm', '--network', 'odysseus',
|
|
54
|
+
*env_file_args(env_file), image, *words])
|
|
55
|
+
run_interactive!(ssh_command(config, server, remote))
|
|
56
|
+
end
|
|
57
|
+
rescue Odysseus::Error => e
|
|
58
|
+
@ui.error e.message
|
|
59
|
+
exit 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Dependency shell
|
|
63
|
+
def dependency_shell(server, options = {})
|
|
64
|
+
config_file = options[:config] || 'deploy.yml'
|
|
65
|
+
name = require_name!(options)
|
|
66
|
+
|
|
67
|
+
config = load_config(config_file)
|
|
68
|
+
service_name = "#{config[:service]}-#{name}"
|
|
69
|
+
|
|
70
|
+
ssh = connect_to_server(server, config)
|
|
71
|
+
begin
|
|
72
|
+
docker = Odysseus::Docker::Client.new(ssh)
|
|
73
|
+
containers = docker.list(service: service_name)
|
|
74
|
+
|
|
75
|
+
if containers.empty?
|
|
76
|
+
@ui.error "No running containers found for #{service_name}"
|
|
77
|
+
exit 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
container_id = containers.first['ID']
|
|
81
|
+
ensure
|
|
82
|
+
ssh.close
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
remote = remote_command(['docker', 'exec', '-it', container_id, '/bin/sh'])
|
|
86
|
+
run_interactive!(ssh_command(config, server, remote))
|
|
87
|
+
rescue Odysseus::Error => e
|
|
88
|
+
@ui.error e.message
|
|
89
|
+
exit 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# What these two print before the terminal goes away. `app shell web1`
|
|
95
|
+
# used to print nothing at all: the first thing you saw was `/app $`, from
|
|
96
|
+
# which the host, the role and the build serving it are all unreadable.
|
|
97
|
+
#
|
|
98
|
+
# The last line is the one worth the space. These commands `docker run` a
|
|
99
|
+
# new container from the image that is serving, not `docker exec` into the
|
|
100
|
+
# container taking traffic, and a shell prompt inside a container invites
|
|
101
|
+
# exactly the opposite assumption — that a file written or a process
|
|
102
|
+
# killed here lands on the running app. It does neither, and the container
|
|
103
|
+
# goes when the session does.
|
|
104
|
+
#
|
|
105
|
+
# It all goes to stderr. The session's own stdout is the caller's:
|
|
106
|
+
# `app console --cmd "rails runner 'puts Thing.count'" > count` is a
|
|
107
|
+
# reasonable way to read a value out of a deployment, and a header in that
|
|
108
|
+
# file would be a bug. Diagnostics go where diagnostics go.
|
|
109
|
+
def session_header(title, server:, role:, image:, command:)
|
|
110
|
+
@ui.header title, io: $stderr
|
|
111
|
+
@ui.info 'Server', server, io: $stderr
|
|
112
|
+
@ui.info 'Role', role, io: $stderr
|
|
113
|
+
@ui.info 'Image', image, io: $stderr
|
|
114
|
+
@ui.info 'Command', command, io: $stderr
|
|
115
|
+
@ui.step 'New container from that image: the running app is untouched, and this one is discarded on exit.',
|
|
116
|
+
io: $stderr
|
|
117
|
+
@ui.blank io: $stderr
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Layer 2, the REMOTE shell: ssh hands this string to the login shell on
|
|
121
|
+
# the host, which splits it into words. Escaping here is what keeps an
|
|
122
|
+
# argument containing a space — `--cmd "rails runner 'puts 1'"`, say —
|
|
123
|
+
# one argument, instead of docker reading part of it as the image name.
|
|
124
|
+
def remote_command(words)
|
|
125
|
+
Shellwords.join(words)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Layer 1, the LOCAL shell: `system` with a single string runs it through
|
|
129
|
+
# /bin/sh here. Every word is escaped for that shell, `remote` included —
|
|
130
|
+
# it has to survive as one word, or the local shell takes what is inside
|
|
131
|
+
# it as commands of its own.
|
|
132
|
+
def ssh_command(config, server, remote)
|
|
133
|
+
keys = config[:ssh][:keys].flat_map { |k| ['-i', File.expand_path(k)] }
|
|
134
|
+
Shellwords.join(['ssh', *keys, '-t', "#{config[:ssh][:user]}@#{server}", remote])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Holds the container's environment — env.clear and env.secret both, the
|
|
138
|
+
# same as a deploy injects — in a 0600 file on the host for as long as the
|
|
139
|
+
# session lasts, and yields its path. Docker::Client owns the file: where
|
|
140
|
+
# it lives, how it is permissioned and when it goes away.
|
|
141
|
+
#
|
|
142
|
+
# These values used to be `-e KEY=VALUE` in the command string, which is
|
|
143
|
+
# where `ps` on the deploy target reads them from. Nothing but a path goes
|
|
144
|
+
# there now, so a DATABASE_URL is no longer legible to every user on the
|
|
145
|
+
# box for the length of the session.
|
|
146
|
+
#
|
|
147
|
+
# The file is removed when the block ends, however it ends: a session that
|
|
148
|
+
# exits non-zero, an ssh that never connected, and Ctrl-C all pass back
|
|
149
|
+
# through with_env_file's ensure. What that cannot cover is this process
|
|
150
|
+
# being killed outright (SIGKILL, or the machine going down): no ensure
|
|
151
|
+
# runs, so the file stays until something else removes it. It is mode 0600
|
|
152
|
+
# inside that connection's env directory (HostPaths#env_dir —
|
|
153
|
+
# /var/lib/odysseus/env for root, $HOME/.odysseus/env otherwise), which
|
|
154
|
+
# write_env_file chmods to 0700 for every connection, so no other user on
|
|
155
|
+
# the host can read it — but it is a file of secrets that nobody is
|
|
156
|
+
# coming back for: the next deploy or one-off run writes its own rather
|
|
157
|
+
# than tidying this one.
|
|
158
|
+
def with_container_env(server, config, config_file, &)
|
|
159
|
+
ssh = connect_to_server(server, config)
|
|
160
|
+
|
|
161
|
+
begin
|
|
162
|
+
docker = Odysseus::Docker::Client.new(ssh)
|
|
163
|
+
docker.with_env_file(build_environment(config, config_file, ssh), &)
|
|
164
|
+
ensure
|
|
165
|
+
ssh.close
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# with_env_file yields nil when there is no environment to write, so a
|
|
170
|
+
# config with no env at all does not get a flag pointing at nothing.
|
|
171
|
+
def env_file_args(env_file)
|
|
172
|
+
env_file ? ['--env-file', env_file] : []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --cmd is a command line ("rails c"), so split it into words the way a
|
|
176
|
+
# shell would before each is escaped: escaping it whole would ask docker
|
|
177
|
+
# to exec a program literally named "rails c". Quoting the caller got
|
|
178
|
+
# wrong is reported rather than passed on as a command that cannot parse.
|
|
179
|
+
def console_words(console_cmd)
|
|
180
|
+
Shellwords.split(console_cmd)
|
|
181
|
+
rescue ArgumentError => e
|
|
182
|
+
@ui.error "Could not read --cmd #{console_cmd.inspect}: #{e.message}"
|
|
183
|
+
exit 1
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# `system` returns false when the command exits non-zero and nil when it
|
|
187
|
+
# could not be run at all; both were discarded here, so a refused ssh, a
|
|
188
|
+
# missing image and a failed docker run all reported success. ssh exits
|
|
189
|
+
# with the remote command's own status, so pass that on when we have it.
|
|
190
|
+
def run_interactive!(command)
|
|
191
|
+
return if system(command)
|
|
192
|
+
|
|
193
|
+
status = $?&.exitstatus
|
|
194
|
+
exit(status.nil? || status.zero? ? 1 : status)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# odysseus-cli/lib/odysseus/cli/rollback_commands.rb
|
|
2
|
+
#
|
|
3
|
+
# `odysseus rollback [VERSION]` and `odysseus rollback --list`.
|
|
4
|
+
# Split out of CLI so the command surface for rollback lives together and
|
|
5
|
+
# CLI itself stays under the project's class-length budget.
|
|
6
|
+
|
|
7
|
+
module Odysseus
|
|
8
|
+
module CLI
|
|
9
|
+
module RollbackCommands
|
|
10
|
+
# Rollback command
|
|
11
|
+
def rollback(options = {})
|
|
12
|
+
config_file = options[:config] || 'deploy.yml'
|
|
13
|
+
verbose = options[:verbose] || @ui.debug?
|
|
14
|
+
|
|
15
|
+
@ui.header 'Odysseus Rollback'
|
|
16
|
+
|
|
17
|
+
config = load_config(config_file)
|
|
18
|
+
executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
|
|
19
|
+
|
|
20
|
+
return rollback_list(executor, config) if options[:list]
|
|
21
|
+
|
|
22
|
+
plan = @ui.spin_step('Checking what every host can run') do
|
|
23
|
+
executor.rollback_plan(version: options[:version])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@ui.blank
|
|
27
|
+
@ui.info 'Service', config[:service]
|
|
28
|
+
@ui.info 'Rolling back to', "#{config[:image]}:#{plan.version}"
|
|
29
|
+
@ui.info 'Commit', plan.ref if plan.ref
|
|
30
|
+
@ui.warn rollback_approximate_warning(options[:version]) if plan.approximate
|
|
31
|
+
@ui.blank
|
|
32
|
+
|
|
33
|
+
start_time = Time.now
|
|
34
|
+
|
|
35
|
+
@ui.stream_steps(title: 'Rolling back service') do
|
|
36
|
+
executor.rollback_all(plan)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
duration = (Time.now - start_time).round(1)
|
|
40
|
+
@ui.success("Rollback complete in #{duration}s")
|
|
41
|
+
rescue Odysseus::Error => e
|
|
42
|
+
@ui.step_fail e.message
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# rollback --list: what each host has, without changing anything.
|
|
49
|
+
# Reads only the hosts, so it works without a git repository.
|
|
50
|
+
def rollback_list(executor, config)
|
|
51
|
+
survey = @ui.spin_step('Reading versions from hosts') { executor.version_survey }
|
|
52
|
+
|
|
53
|
+
@ui.blank
|
|
54
|
+
@ui.info 'Service', config[:service]
|
|
55
|
+
@ui.blank
|
|
56
|
+
|
|
57
|
+
survey.each do |host_versions|
|
|
58
|
+
@ui.section host_versions.host
|
|
59
|
+
@ui.info 'Serving', host_versions.current || '(nothing running)'
|
|
60
|
+
|
|
61
|
+
rows = rollback_rows(host_versions)
|
|
62
|
+
if rows.empty?
|
|
63
|
+
@ui.step '(no deploy history on this host)'
|
|
64
|
+
else
|
|
65
|
+
@ui.table(headers: %w[Version Deployed Ref Deployer Image], rows: rows)
|
|
66
|
+
end
|
|
67
|
+
@ui.blank
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# One row per distinct version, most recently deployed first. A version
|
|
72
|
+
# deployed repeatedly is reported once, at its latest deploy time.
|
|
73
|
+
#
|
|
74
|
+
# Ordered the same way RollbackPlanner#logged_versions ranks
|
|
75
|
+
# candidates — newest entry first, then dedupe keeping the first
|
|
76
|
+
# (newest) occurrence of each version. That is candidate order, not
|
|
77
|
+
# the planner's chosen target: RollbackPlanner#previous_version then
|
|
78
|
+
# skips candidates that are already serving somewhere or missing on
|
|
79
|
+
# some host, so a plain `odysseus rollback` can pick a version below
|
|
80
|
+
# the top row shown here. A hash keyed by version and reassigned in
|
|
81
|
+
# history order looks equivalent but is not: Ruby keeps a reassigned
|
|
82
|
+
# key at its *first* insertion position, so it orders by each
|
|
83
|
+
# version's first deploy rather than its latest.
|
|
84
|
+
def rollback_rows(host_versions)
|
|
85
|
+
host_versions.history.sort_by(&:at).reverse.uniq(&:version).map do |e|
|
|
86
|
+
[e.version, e.at, e.ref, e.deployer,
|
|
87
|
+
host_versions.available?(e.version) ? 'present' : 'missing']
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `approximate` on a RollbackPlan means only "no host had a deploy log" —
|
|
92
|
+
# not that the requested version itself is uncertain. When the operator
|
|
93
|
+
# named a version explicitly, no ordering was inferred at all, so it
|
|
94
|
+
# would be misleading to call the ordering approximate; there is simply
|
|
95
|
+
# no record of what has run here.
|
|
96
|
+
def rollback_approximate_warning(requested_version)
|
|
97
|
+
if requested_version
|
|
98
|
+
'No host has a deploy log for this service, so there is no record of what has been ' \
|
|
99
|
+
'deployed here'
|
|
100
|
+
else
|
|
101
|
+
'No host has a deploy log, so the previous version came from image creation time ' \
|
|
102
|
+
'and the ordering is approximate'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# odysseus-cli/lib/odysseus/cli/setup_commands.rb
|
|
2
|
+
#
|
|
3
|
+
# `odysseus setup`.
|
|
4
|
+
# Split out of CLI for the same reason as DoctorCommands: cli.rb is close to
|
|
5
|
+
# its Metrics/ClassLength budget.
|
|
6
|
+
|
|
7
|
+
module Odysseus
|
|
8
|
+
module CLI
|
|
9
|
+
module SetupCommands
|
|
10
|
+
# The Ubuntu cloud image's own default user, so a stock image works
|
|
11
|
+
# untouched by the time --as would otherwise be needed.
|
|
12
|
+
DEFAULT_IDENTITY = 'ubuntu'.freeze
|
|
13
|
+
|
|
14
|
+
# Prepares every host in the config so odysseus can deploy to it as a
|
|
15
|
+
# non-root user: creates the deploy user, its group, its
|
|
16
|
+
# authorized_keys and its state dir. Its own command rather than a mode
|
|
17
|
+
# of `doctor`, because doctor only reads; this one changes the host.
|
|
18
|
+
#
|
|
19
|
+
# Connects as the bootstrap identity (--as, default 'ubuntu') rather
|
|
20
|
+
# than config[:ssh][:user] -- that deploy user existing is the point of
|
|
21
|
+
# this command, so it cannot be assumed to exist yet, let alone be
|
|
22
|
+
# reachable, before setup has run.
|
|
23
|
+
#
|
|
24
|
+
# The public key to install is resolved once, before any host is
|
|
25
|
+
# touched -- before even the first connection opens. A created user
|
|
26
|
+
# with no way to log in is the worst outcome this command has, and
|
|
27
|
+
# resolving first means that failure can never happen after host 1 of
|
|
28
|
+
# 3 is already changed: the whole run refuses, or none of it does.
|
|
29
|
+
def setup(options = {})
|
|
30
|
+
config_file = options[:config] || 'deploy.yml'
|
|
31
|
+
config = load_config(config_file)
|
|
32
|
+
identity = options[:as] || DEFAULT_IDENTITY
|
|
33
|
+
# Same idiom deploy, build and pussh use: -v or --debug, either one.
|
|
34
|
+
verbose = options[:verbose] || @ui.debug?
|
|
35
|
+
|
|
36
|
+
refuse_root_deploy_user!(config)
|
|
37
|
+
keys = Odysseus::Setup::PublicKey.resolve(keys: config[:ssh][:keys], explicit: Array(options[:key]))
|
|
38
|
+
|
|
39
|
+
@ui.header 'Odysseus Setup'
|
|
40
|
+
@ui.info 'Service', config[:service]
|
|
41
|
+
@ui.info 'Bootstrap identity', identity
|
|
42
|
+
@ui.blank
|
|
43
|
+
|
|
44
|
+
worst = :ok
|
|
45
|
+
executor = Odysseus::Deployer::Executor.new(config_file)
|
|
46
|
+
|
|
47
|
+
executor.host_roles.each_key do |host|
|
|
48
|
+
@ui.section host
|
|
49
|
+
ssh = connect_as(identity, host, config, verbose: verbose)
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
escalation = Odysseus::Setup::Escalation.new(ssh: ssh, as: identity)
|
|
53
|
+
preparer = Odysseus::Setup::Preparer.new(ssh: ssh, config: config, escalation: escalation, keys: keys)
|
|
54
|
+
|
|
55
|
+
preparer.prepare.each do |result|
|
|
56
|
+
worst = escalate(worst, result.status)
|
|
57
|
+
render_result(result)
|
|
58
|
+
end
|
|
59
|
+
rescue Odysseus::SSHAuthenticationError => e
|
|
60
|
+
# The default identity is a guess -- a good one on a stock cloud
|
|
61
|
+
# image, wrong on anything else -- and the operator has no way to
|
|
62
|
+
# know which flag fixes it unless this says so. The spec requires
|
|
63
|
+
# a host with neither a usable ubuntu nor root SSH to be refused
|
|
64
|
+
# with an error naming both flags.
|
|
65
|
+
worst = escalate(worst, :fail)
|
|
66
|
+
render_result(
|
|
67
|
+
Odysseus::Setup::Preparer::Result.new(
|
|
68
|
+
step: :connection, status: :fail,
|
|
69
|
+
detail: "#{host}: #{e.message} Setup connected as #{identity}; pass --as root " \
|
|
70
|
+
'if root SSH is enabled, or --as USER for whichever identity can log in.'
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
# Mirrors DoctorCommands#doctor's rescue: a step failing is a
|
|
75
|
+
# Result with status: :fail, produced by Preparer without
|
|
76
|
+
# raising. This rescues the connection itself dying mid-run,
|
|
77
|
+
# which can surface as IOError, Net::SSH::Disconnect (a
|
|
78
|
+
# RuntimeError) or Errno::EPIPE/ECONNRESET (a SystemCallError) --
|
|
79
|
+
# three branches of StandardError with no narrower ancestor in
|
|
80
|
+
# common. Narrower would also reintroduce the defect this fixes:
|
|
81
|
+
# one host's failure aborting setup for every host after it.
|
|
82
|
+
worst = escalate(worst, :fail)
|
|
83
|
+
render_result(
|
|
84
|
+
Odysseus::Setup::Preparer::Result.new(
|
|
85
|
+
step: :connection, status: :fail, detail: "#{host}: #{e.class}: #{e.message}"
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
ensure
|
|
89
|
+
ssh.close
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@ui.blank
|
|
94
|
+
case worst
|
|
95
|
+
when :fail then exit 1
|
|
96
|
+
when :warn then @ui.warn 'Setup finished, but read the warnings above.'
|
|
97
|
+
else @ui.success 'Hosts are ready.'
|
|
98
|
+
end
|
|
99
|
+
rescue Odysseus::Error => e
|
|
100
|
+
@ui.error e.message
|
|
101
|
+
exit 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# `ssh.user: root` is not a case setup half-supports -- it is refused
|
|
107
|
+
# by name, before the key is even resolved. Deploying as root already
|
|
108
|
+
# works today with no setup at all (root needs no user, no group, no
|
|
109
|
+
# authorized_keys of its own), so every step setup would otherwise run
|
|
110
|
+
# is either a no-op mistaken for progress or, worse, a mutation of
|
|
111
|
+
# root's own account (`usermod -aG docker root`) that the command's
|
|
112
|
+
# "it never modifies root's configuration" promise forbids outright.
|
|
113
|
+
# Refusing here, rather than growing setup a root-flavoured code path,
|
|
114
|
+
# is the point: this command's job ends at getting one non-root user
|
|
115
|
+
# ready, not at supporting every identity a deploy could use.
|
|
116
|
+
#
|
|
117
|
+
# `ssh.user` (the identity being created) and `--as` (the bootstrap
|
|
118
|
+
# identity connected as, which defaults to ubuntu and may legitimately
|
|
119
|
+
# be root) name different things; only the former is refused here.
|
|
120
|
+
def refuse_root_deploy_user!(config)
|
|
121
|
+
return unless config[:ssh][:user] == 'root'
|
|
122
|
+
|
|
123
|
+
raise Odysseus::SetupError,
|
|
124
|
+
'`ssh.user` is `root`: setup exists to enable non-root deploys, root needs no ' \
|
|
125
|
+
'preparation, and deploying as root already works with no setup at all. Change ' \
|
|
126
|
+
'`ssh.user` in deploy.yml to the non-root user you want to deploy as.'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# :changed renders distinctly from :ok (step_info's copper arrow, not
|
|
130
|
+
# step_ok's mint check) so a re-run visibly reports what it did versus
|
|
131
|
+
# what was already correct -- the point of running it twice.
|
|
132
|
+
def render_result(result)
|
|
133
|
+
line = "#{result.step}: #{result.detail}"
|
|
134
|
+
|
|
135
|
+
case result.status
|
|
136
|
+
when :ok then @ui.step_ok line
|
|
137
|
+
when :changed then @ui.step_info line
|
|
138
|
+
when :warn then @ui.warn line
|
|
139
|
+
else @ui.step_fail line
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# :fail beats :warn beats :ok/:changed, so one bad step decides the
|
|
144
|
+
# exit code however many good ones surround it. :changed ranks with
|
|
145
|
+
# :ok, not above it: it is still a step that finished cleanly, and only
|
|
146
|
+
# a :fail may set a non-zero exit.
|
|
147
|
+
def escalate(current, status)
|
|
148
|
+
order = { ok: 0, changed: 0, warn: 1, fail: 2 }
|
|
149
|
+
order[status] > order[current] ? status : current
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# setup's connections differ from connect_to_server's in two ways at
|
|
153
|
+
# once: the identity (the bootstrap --as, not config[:ssh][:user] --
|
|
154
|
+
# see #setup) and use_tailscale. Both are setup's alone among the
|
|
155
|
+
# eleven callers of connect_to_server, so this stays a private helper
|
|
156
|
+
# here rather than a second keyword grafted onto a method every other
|
|
157
|
+
# command already uses correctly.
|
|
158
|
+
#
|
|
159
|
+
# use_tailscale: false because connect_to_server's hardcoded true
|
|
160
|
+
# (cli.rb) makes SSH append Tailscale troubleshooting advice to every
|
|
161
|
+
# connection timeout (deployer/ssh.rb). setup targets exactly the
|
|
162
|
+
# fresh hosts that do not have Tailscale yet, so on the one command
|
|
163
|
+
# where a timeout is most likely, that advice would be actively
|
|
164
|
+
# misleading.
|
|
165
|
+
def connect_as(identity, host, config, verbose: false)
|
|
166
|
+
Odysseus::Deployer::SSH.new(
|
|
167
|
+
host: host,
|
|
168
|
+
user: identity,
|
|
169
|
+
keys: config[:ssh][:keys],
|
|
170
|
+
use_tailscale: false,
|
|
171
|
+
verbose: verbose
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|