odysseus-cli 0.3.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.
@@ -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