odysseus-core 0.3.2 → 0.10.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 +508 -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 +174 -45
- 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 +93 -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
|
@@ -16,14 +16,27 @@ module Odysseus
|
|
|
16
16
|
@secrets_loader = Odysseus::Secrets::Loader.new(@config, config_dir: @config_dir)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# The identity of the deploy: version, ref and deployer.
|
|
20
|
+
#
|
|
21
|
+
# Resolved once per tag so a multi-role, multi-host deploy cannot end up
|
|
22
|
+
# with two versions, and so the git commands run once rather than per host.
|
|
23
|
+
#
|
|
24
|
+
# @param image_tag [String, nil] explicit tag, or nil to resolve from git
|
|
25
|
+
# @return [Odysseus::DeployVersion]
|
|
26
|
+
def deploy_version(image_tag = nil)
|
|
27
|
+
@deploy_versions ||= {}
|
|
28
|
+
@deploy_versions[image_tag] ||= version_resolver.resolve(image_tag: image_tag)
|
|
29
|
+
end
|
|
30
|
+
|
|
19
31
|
# Build Docker image
|
|
20
|
-
# @param image_tag [String] docker image tag (e.g., "v1.0.0")
|
|
32
|
+
# @param image_tag [String, nil] docker image tag (e.g., "v1.0.0"), or nil to resolve from git
|
|
21
33
|
# @param push [Boolean] push to registry after build
|
|
22
34
|
# @param context_path [String] path to build context (defaults to config directory)
|
|
23
35
|
# @return [Hash] build result
|
|
24
|
-
def build(image_tag
|
|
36
|
+
def build(image_tag: nil, push: false, context_path: nil)
|
|
37
|
+
resolved = deploy_version(image_tag)
|
|
25
38
|
context = context_path || resolve_build_context
|
|
26
|
-
full_image = "#{@config[:image]}:#{
|
|
39
|
+
full_image = "#{@config[:image]}:#{resolved.version}"
|
|
27
40
|
|
|
28
41
|
builder = build_builder
|
|
29
42
|
|
|
@@ -39,17 +52,15 @@ module Odysseus
|
|
|
39
52
|
end
|
|
40
53
|
|
|
41
54
|
# Build and deploy in one step
|
|
42
|
-
# @param image_tag [String] docker image tag
|
|
55
|
+
# @param image_tag [String, nil] docker image tag, or nil to resolve from git
|
|
43
56
|
# @param context_path [String] path to build context
|
|
44
57
|
# @param dry_run [Boolean] if true, don't actually deploy
|
|
45
58
|
# @return [Hash] results
|
|
46
|
-
def build_and_deploy(image_tag
|
|
59
|
+
def build_and_deploy(image_tag: nil, context_path: nil, dry_run: false)
|
|
47
60
|
# First, build the image
|
|
48
61
|
build_result = build(image_tag: image_tag, push: true, context_path: context_path)
|
|
49
62
|
|
|
50
|
-
unless build_result[:success]
|
|
51
|
-
return { build: build_result, deploy: nil, success: false }
|
|
52
|
-
end
|
|
63
|
+
return { build: build_result, deploy: nil, success: false } unless build_result[:success]
|
|
53
64
|
|
|
54
65
|
# Then deploy
|
|
55
66
|
deploy_results = deploy_all(image_tag: image_tag, dry_run: dry_run)
|
|
@@ -62,15 +73,14 @@ module Odysseus
|
|
|
62
73
|
end
|
|
63
74
|
|
|
64
75
|
# Push image to all configured hosts via SSH (using docker pussh)
|
|
65
|
-
# @param image_tag [String] docker image tag
|
|
76
|
+
# @param image_tag [String, nil] docker image tag, or nil to resolve from git
|
|
66
77
|
# @return [Hash] pussh results
|
|
67
|
-
def pussh(image_tag:)
|
|
68
|
-
|
|
78
|
+
def pussh(image_tag: nil)
|
|
79
|
+
resolved = deploy_version(image_tag)
|
|
80
|
+
full_image = "#{@config[:image]}:#{resolved.version}"
|
|
69
81
|
hosts = collect_all_hosts
|
|
70
82
|
|
|
71
|
-
if hosts.empty?
|
|
72
|
-
return { success: false, error: "No hosts configured" }
|
|
73
|
-
end
|
|
83
|
+
return { success: false, error: 'No hosts configured' } if hosts.empty?
|
|
74
84
|
|
|
75
85
|
builder = build_builder
|
|
76
86
|
builder.pussh_to_hosts(
|
|
@@ -81,16 +91,14 @@ module Odysseus
|
|
|
81
91
|
end
|
|
82
92
|
|
|
83
93
|
# Build and pussh to all hosts (no registry needed)
|
|
84
|
-
# @param image_tag [String] docker image tag
|
|
94
|
+
# @param image_tag [String, nil] docker image tag, or nil to resolve from git
|
|
85
95
|
# @param context_path [String] path to build context
|
|
86
96
|
# @return [Hash] build and pussh results
|
|
87
|
-
def build_and_pussh(image_tag
|
|
97
|
+
def build_and_pussh(image_tag: nil, context_path: nil)
|
|
88
98
|
# First, build the image locally
|
|
89
99
|
build_result = build(image_tag: image_tag, push: false, context_path: context_path)
|
|
90
100
|
|
|
91
|
-
unless build_result[:success]
|
|
92
|
-
return { build: build_result, pussh: nil, success: false }
|
|
93
|
-
end
|
|
101
|
+
return { build: build_result, pussh: nil, success: false } unless build_result[:success]
|
|
94
102
|
|
|
95
103
|
# Then pussh to all hosts
|
|
96
104
|
pussh_result = pussh(image_tag: image_tag)
|
|
@@ -104,10 +112,10 @@ module Odysseus
|
|
|
104
112
|
|
|
105
113
|
# Build and distribute image based on config
|
|
106
114
|
# Uses registry if configured, otherwise pussh to hosts
|
|
107
|
-
# @param image_tag [String] docker image tag
|
|
115
|
+
# @param image_tag [String, nil] docker image tag, or nil to resolve from git
|
|
108
116
|
# @param context_path [String] path to build context
|
|
109
117
|
# @return [Hash] build and distribution results
|
|
110
|
-
def build_and_distribute(image_tag
|
|
118
|
+
def build_and_distribute(image_tag: nil, context_path: nil)
|
|
111
119
|
if uses_registry?
|
|
112
120
|
build_and_push_to_registry(image_tag: image_tag, context_path: context_path)
|
|
113
121
|
else
|
|
@@ -122,10 +130,10 @@ module Odysseus
|
|
|
122
130
|
end
|
|
123
131
|
|
|
124
132
|
# Build and push to registry
|
|
125
|
-
# @param image_tag [String] docker image tag
|
|
133
|
+
# @param image_tag [String, nil] docker image tag, or nil to resolve from git
|
|
126
134
|
# @param context_path [String] path to build context
|
|
127
135
|
# @return [Hash] build and push results
|
|
128
|
-
def build_and_push_to_registry(image_tag
|
|
136
|
+
def build_and_push_to_registry(image_tag: nil, context_path: nil)
|
|
129
137
|
build_result = build(image_tag: image_tag, push: true, context_path: context_path)
|
|
130
138
|
|
|
131
139
|
{
|
|
@@ -136,9 +144,9 @@ module Odysseus
|
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
# Deploy all roles to their configured hosts
|
|
139
|
-
# @param image_tag [String] docker image tag
|
|
147
|
+
# @param image_tag [String, nil] docker image tag, or nil to resolve from git
|
|
140
148
|
# @param dry_run [Boolean] if true, don't actually deploy
|
|
141
|
-
def deploy_all(image_tag
|
|
149
|
+
def deploy_all(image_tag: nil, dry_run: false)
|
|
142
150
|
results = {}
|
|
143
151
|
|
|
144
152
|
@config[:servers].each do |role, role_config|
|
|
@@ -149,188 +157,239 @@ module Odysseus
|
|
|
149
157
|
end
|
|
150
158
|
end
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
end
|
|
160
|
+
prune_old_images unless dry_run
|
|
154
161
|
|
|
155
|
-
|
|
156
|
-
# @param host [String] target host (from config)
|
|
157
|
-
# @param image_tag [String] docker image tag (e.g., "v1.2.3")
|
|
158
|
-
# @param dry_run [Boolean] if true, don't actually deploy
|
|
159
|
-
# @param role [Symbol] server role
|
|
160
|
-
def deploy_role(host:, image_tag:, dry_run: false, role:)
|
|
161
|
-
if dry_run
|
|
162
|
-
puts "Dry run - would deploy #{@config[:image]}:#{image_tag} to #{host}"
|
|
163
|
-
puts "Service: #{@config[:service]}"
|
|
164
|
-
puts "Role: #{role}"
|
|
165
|
-
if role == WEB_ROLE
|
|
166
|
-
puts "Proxy hosts: #{@config[:proxy][:hosts].join(', ')}"
|
|
167
|
-
end
|
|
168
|
-
return { success: true, dry_run: true }
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
ssh = connect_to_server(host)
|
|
172
|
-
|
|
173
|
-
begin
|
|
174
|
-
orchestrator = build_orchestrator(ssh, role)
|
|
175
|
-
orchestrator.deploy(image_tag: image_tag, role: role)
|
|
176
|
-
ensure
|
|
177
|
-
ssh.close
|
|
178
|
-
end
|
|
162
|
+
results
|
|
179
163
|
end
|
|
180
164
|
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
results = {}
|
|
190
|
-
hosts.each do |host|
|
|
191
|
-
puts "Deploying accessory #{name} to #{host}..."
|
|
165
|
+
# What every host reports about this service's versions.
|
|
166
|
+
#
|
|
167
|
+
# One entry per unique host across all roles, so a host serving two roles
|
|
168
|
+
# is surveyed once. Connections are opened and closed per host.
|
|
169
|
+
#
|
|
170
|
+
# @return [Array<Odysseus::HostVersions>]
|
|
171
|
+
def version_survey
|
|
172
|
+
host_roles.map do |host, roles|
|
|
192
173
|
ssh = connect_to_server(host)
|
|
193
174
|
|
|
194
175
|
begin
|
|
195
|
-
|
|
196
|
-
|
|
176
|
+
Odysseus::HostVersions.read(
|
|
177
|
+
host: host, ssh: ssh, service: @config[:service], image: @config[:image], roles: roles
|
|
178
|
+
)
|
|
197
179
|
ensure
|
|
198
180
|
ssh.close
|
|
199
181
|
end
|
|
200
182
|
end
|
|
201
|
-
results
|
|
202
183
|
end
|
|
203
184
|
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
185
|
+
# Decide what a rollback would do, without doing it.
|
|
186
|
+
#
|
|
187
|
+
# Surveys the fleet and returns the plan, or raises RollbackError with a
|
|
188
|
+
# message naming the hosts at fault. Separate from rollback_all so the
|
|
189
|
+
# caller can show the target — and any approximate-ordering warning —
|
|
190
|
+
# before anything is touched, and so the survey runs once.
|
|
191
|
+
#
|
|
192
|
+
# @param version [String, nil] explicit target, or nil for the previous one
|
|
193
|
+
# @return [Odysseus::RollbackPlan]
|
|
194
|
+
def rollback_plan(version: nil)
|
|
195
|
+
Odysseus::RollbackPlanner.new(version_survey).plan(version: version)
|
|
196
|
+
end
|
|
211
197
|
|
|
198
|
+
# Roll every role on every host back to the planned version.
|
|
199
|
+
#
|
|
200
|
+
# Reuses the deploy path unchanged, so health gating, proxy handling and
|
|
201
|
+
# zero-downtime behaviour are shared with deploy rather than
|
|
202
|
+
# reimplemented. Sequential, and inheriting deploy's partial-failure
|
|
203
|
+
# semantics: the plan's pre-flight rules out the common cause of a
|
|
204
|
+
# half-rolled-back fleet — a missing image — but does not make the roll
|
|
205
|
+
# atomic.
|
|
206
|
+
#
|
|
207
|
+
# @param plan [Odysseus::RollbackPlan] from #rollback_plan
|
|
208
|
+
# @return [Hash] results keyed "role@host"
|
|
209
|
+
def rollback_all(plan)
|
|
210
|
+
resolved = Odysseus::DeployVersion.new(
|
|
211
|
+
version: plan.version, ref: plan.ref, deployer: version_resolver.deployer
|
|
212
|
+
)
|
|
212
213
|
results = {}
|
|
213
|
-
hosts.each do |host|
|
|
214
|
-
puts "Removing accessory #{name} from #{host}..."
|
|
215
|
-
ssh = connect_to_server(host)
|
|
216
214
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
215
|
+
@config[:servers].each do |role, role_config|
|
|
216
|
+
resolve_hosts(role_config).each do |host|
|
|
217
|
+
puts "\n=== Rolling back #{role} on #{host} to #{plan.version} ==="
|
|
218
|
+
results["#{role}@#{host}"] = run_deploy(
|
|
219
|
+
host: host, role: role, resolved: resolved,
|
|
220
|
+
kind: 'rolled-back', from: plan.from_for(host)
|
|
221
|
+
)
|
|
222
222
|
end
|
|
223
223
|
end
|
|
224
|
+
|
|
224
225
|
results
|
|
225
226
|
end
|
|
226
227
|
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
|
|
228
|
+
# Delete a service's images that no host needs any more.
|
|
229
|
+
#
|
|
230
|
+
# @return [Hash{String => Array<String>}] versions removed, keyed by host
|
|
231
|
+
def prune_old_images
|
|
232
|
+
retention_sweeper.sweep(host_roles)
|
|
233
|
+
end
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
235
|
+
# Deploy a single role to a specific host
|
|
236
|
+
# @param host [String] target host (from config)
|
|
237
|
+
# @param image_tag [String, nil] docker image tag (e.g., "v1.2.3"), or nil to resolve from git
|
|
238
|
+
# @param dry_run [Boolean] if true, don't actually deploy
|
|
239
|
+
# @param role [Symbol] server role
|
|
240
|
+
def deploy_role(host:, role:, image_tag: nil, dry_run: false)
|
|
241
|
+
resolved = deploy_version(image_tag)
|
|
239
242
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
if dry_run
|
|
244
|
+
puts "Dry run - would deploy #{@config[:image]}:#{resolved.version} to #{host}"
|
|
245
|
+
puts "Service: #{@config[:service]}"
|
|
246
|
+
puts "Role: #{role}"
|
|
247
|
+
puts "Proxy hosts: #{@config[:proxy][:hosts].join(', ')}" if role == WEB_ROLE
|
|
248
|
+
return { success: true, dry_run: true }
|
|
246
249
|
end
|
|
247
|
-
|
|
250
|
+
|
|
251
|
+
run_deploy(host: host, role: role, resolved: resolved)
|
|
248
252
|
end
|
|
249
253
|
|
|
250
|
-
#
|
|
251
|
-
# @param name [Symbol]
|
|
252
|
-
def
|
|
253
|
-
|
|
254
|
-
|
|
254
|
+
# Deploy an dependency to all its configured hosts
|
|
255
|
+
# @param name [Symbol] dependency name
|
|
256
|
+
def deploy_dependency(name:)
|
|
257
|
+
dependency_manager.deploy(name: name)
|
|
258
|
+
end
|
|
255
259
|
|
|
256
|
-
|
|
260
|
+
# Remove an dependency from all its configured hosts
|
|
261
|
+
# @param name [Symbol] dependency name
|
|
262
|
+
def remove_dependency(name:)
|
|
263
|
+
dependency_manager.remove(name: name)
|
|
264
|
+
end
|
|
257
265
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
266
|
+
# Restart an dependency on all its configured hosts
|
|
267
|
+
# @param name [Symbol] dependency name
|
|
268
|
+
def restart_dependency(name:)
|
|
269
|
+
dependency_manager.restart(name: name)
|
|
270
|
+
end
|
|
262
271
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
ssh.close
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
results
|
|
272
|
+
# Upgrade an dependency to a new image version on all its configured hosts
|
|
273
|
+
# @param name [Symbol] dependency name
|
|
274
|
+
def upgrade_dependency(name:)
|
|
275
|
+
dependency_manager.upgrade(name: name)
|
|
271
276
|
end
|
|
272
277
|
|
|
273
|
-
# List
|
|
274
|
-
def
|
|
275
|
-
|
|
278
|
+
# List dependency status on all configured hosts
|
|
279
|
+
def dependency_status
|
|
280
|
+
dependency_manager.status
|
|
281
|
+
end
|
|
276
282
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
hosts.each do |host|
|
|
281
|
-
ssh = connect_to_server(host)
|
|
282
|
-
begin
|
|
283
|
-
orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
284
|
-
status = orchestrator.get_status(name: name.to_sym)
|
|
285
|
-
status[:host] = host
|
|
286
|
-
all_statuses << status
|
|
287
|
-
ensure
|
|
288
|
-
ssh.close
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
all_statuses
|
|
283
|
+
# Boot all dependencies to their configured hosts
|
|
284
|
+
def boot_dependencies
|
|
285
|
+
dependency_manager.boot_all
|
|
293
286
|
end
|
|
294
287
|
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
288
|
+
# Every configured host mapped to the roles it serves, each in config
|
|
289
|
+
# order. A host serving two roles (e.g. web and cron on the same box)
|
|
290
|
+
# gets both, in the order its roles are declared in deploy.yml — the
|
|
291
|
+
# order #version_survey depends on to know which role's containers to
|
|
292
|
+
# look for first.
|
|
293
|
+
#
|
|
294
|
+
# Public API, not just an internal helper: the CLI asks this exact
|
|
295
|
+
# question too — which hosts does this config target, and which roles
|
|
296
|
+
# on each — to visit every host once regardless of how many roles it
|
|
297
|
+
# serves. `odysseus doctor` depends on it.
|
|
298
|
+
#
|
|
299
|
+
# @return [Hash{String => Array<Symbol>}]
|
|
300
|
+
def host_roles
|
|
301
|
+
roles_by_host = {}
|
|
298
302
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
puts "\n=== Booting accessory: #{name} ==="
|
|
302
|
-
results[name] = deploy_accessory(name: name)
|
|
303
|
+
@config[:servers].each do |role, role_config|
|
|
304
|
+
resolve_hosts(role_config).each { |host| (roles_by_host[host] ||= []) << role }
|
|
303
305
|
end
|
|
304
|
-
|
|
306
|
+
|
|
307
|
+
roles_by_host
|
|
305
308
|
end
|
|
306
309
|
|
|
307
310
|
private
|
|
308
311
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
# Dependency verbs are a distinct concern from deploy/rollback; see
|
|
313
|
+
# Odysseus::Deployer::DependencyManager.
|
|
314
|
+
def dependency_manager
|
|
315
|
+
@dependency_manager ||= Odysseus::Deployer::DependencyManager.new(
|
|
316
|
+
config: @config, secrets_loader: @secrets_loader, connector: method(:connect_to_server)
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def version_resolver
|
|
321
|
+
@version_resolver ||= Odysseus::VersionResolver.new(config_dir: @config_dir, logger: build_logger)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Image retention is a distinct concern from deploy/rollback; see
|
|
325
|
+
# Odysseus::Deployer::RetentionSweeper.
|
|
326
|
+
def retention_sweeper
|
|
327
|
+
@retention_sweeper ||= Odysseus::Deployer::RetentionSweeper.new(
|
|
328
|
+
config: @config, connector: method(:connect_to_server), logger: build_logger
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Sails are constructed with a fixed keyword set, so version metadata
|
|
333
|
+
# travels in the config hash rather than as a new keyword argument.
|
|
334
|
+
def orchestrator_config(resolved)
|
|
335
|
+
@config.merge(deploy_version: resolved)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# One role on one host: connect, hand off to the orchestrator, record the
|
|
339
|
+
# outcome on the host, close. Shared by deploy and rollback so both get
|
|
340
|
+
# identical health gating, proxy handling and audit trail.
|
|
341
|
+
#
|
|
342
|
+
# @param kind [String] 'deployed' or 'rolled-back'
|
|
343
|
+
# @param from [String, nil] the version being replaced, for a rollback
|
|
344
|
+
def run_deploy(host:, role:, resolved:, kind: 'deployed', from: nil)
|
|
345
|
+
ssh = connect_to_server(host)
|
|
346
|
+
|
|
347
|
+
begin
|
|
348
|
+
orchestrator = build_orchestrator(ssh, role, resolved)
|
|
349
|
+
result = orchestrator.deploy(image_tag: resolved.version, role: role)
|
|
350
|
+
record_deploy(ssh: ssh, host: host, role: role, resolved: resolved, kind: kind, from: from)
|
|
351
|
+
result
|
|
352
|
+
ensure
|
|
353
|
+
ssh.close
|
|
354
|
+
end
|
|
314
355
|
end
|
|
315
356
|
|
|
316
|
-
|
|
357
|
+
# The host's own record of what it is running, written only after the
|
|
358
|
+
# orchestrator reports success.
|
|
359
|
+
#
|
|
360
|
+
# Best effort, and deliberately rescuing StandardError rather than
|
|
361
|
+
# Odysseus::Error: SSH#execute can also raise Net::SSH::Disconnect,
|
|
362
|
+
# IOError or Net::SSH::ChannelOpenFailed, none of which with_connection
|
|
363
|
+
# translates. Traffic has already switched by this point, so any of them
|
|
364
|
+
# escaping here would turn a completed deploy into a reported failure.
|
|
365
|
+
def record_deploy(ssh:, host:, role:, resolved:, kind:, from:)
|
|
366
|
+
Odysseus::DeployLog.new(ssh: ssh, service: @config[:service]).append(
|
|
367
|
+
version: resolved.version, role: role, ref: resolved.ref,
|
|
368
|
+
deployer: resolved.deployer, kind: kind, from: from
|
|
369
|
+
)
|
|
370
|
+
rescue StandardError => e
|
|
371
|
+
build_logger.warn("Could not record the deploy on #{host}: #{e.message}")
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def build_orchestrator(ssh, role, resolved)
|
|
317
375
|
logger = build_logger
|
|
318
376
|
role_config = @config[:servers][role] || {}
|
|
319
377
|
strategy = role_config.dig(:deploy, :strategy)
|
|
378
|
+
config = orchestrator_config(resolved)
|
|
320
379
|
|
|
321
380
|
# Check if a sail plugin provides this strategy
|
|
322
381
|
if strategy && Odysseus::Sails.registered?(strategy)
|
|
323
382
|
sail_klass = Odysseus::Sails.resolve(strategy)
|
|
324
383
|
sail_klass.new(
|
|
325
|
-
ssh: ssh, config:
|
|
384
|
+
ssh: ssh, config: config, logger: logger, secrets_loader: @secrets_loader
|
|
326
385
|
)
|
|
327
386
|
elsif role == WEB_ROLE
|
|
328
387
|
Odysseus::Orchestrator::WebDeploy.new(
|
|
329
|
-
ssh: ssh, config:
|
|
388
|
+
ssh: ssh, config: config, logger: logger, secrets_loader: @secrets_loader
|
|
330
389
|
)
|
|
331
390
|
else
|
|
332
391
|
Odysseus::Orchestrator::JobDeploy.new(
|
|
333
|
-
ssh: ssh, config:
|
|
392
|
+
ssh: ssh, config: config, logger: logger, secrets_loader: @secrets_loader
|
|
334
393
|
)
|
|
335
394
|
end
|
|
336
395
|
end
|
|
@@ -341,7 +400,7 @@ module Odysseus
|
|
|
341
400
|
l.define_singleton_method(:info) { |msg| puts msg }
|
|
342
401
|
l.define_singleton_method(:warn) { |msg| puts "[WARN] #{msg}" }
|
|
343
402
|
l.define_singleton_method(:error) { |msg| puts "[ERROR] #{msg}" }
|
|
344
|
-
l.define_singleton_method(:debug) { |msg| puts " > #{msg}" if verbose }
|
|
403
|
+
l.define_singleton_method(:debug) { |msg| puts " > #{Odysseus::CommandRedaction.redact(msg)}" if verbose }
|
|
345
404
|
l.define_singleton_method(:verbose?) { verbose }
|
|
346
405
|
end
|
|
347
406
|
end
|
|
@@ -377,14 +436,7 @@ module Odysseus
|
|
|
377
436
|
end
|
|
378
437
|
|
|
379
438
|
def collect_all_hosts
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
@config[:servers].each do |_role, role_config|
|
|
383
|
-
role_hosts = resolve_hosts(role_config)
|
|
384
|
-
hosts.concat(role_hosts)
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
hosts.uniq
|
|
439
|
+
host_roles.keys
|
|
388
440
|
end
|
|
389
441
|
|
|
390
442
|
# Resolve hosts for a role using the appropriate provider
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# lib/odysseus/deployer/retention_sweeper.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module Deployer
|
|
5
|
+
# Removes a service's images that no host needs any more, split out of
|
|
6
|
+
# Executor for the same reason DependencyManager was: it is a distinct
|
|
7
|
+
# concern, sharing only the config and a way to open a connection.
|
|
8
|
+
#
|
|
9
|
+
# Best effort throughout. This runs after a deploy has already succeeded and
|
|
10
|
+
# switched traffic, so nothing here may raise into the caller: every removal
|
|
11
|
+
# is attempted on its own, and a host that cannot be read at all is skipped
|
|
12
|
+
# with a warning.
|
|
13
|
+
class RetentionSweeper
|
|
14
|
+
# @param config [Hash] parsed deploy.yml
|
|
15
|
+
# @param connector [#call] returns an open SSH connection for a host
|
|
16
|
+
# @param logger [Object] responds to #info and #warn
|
|
17
|
+
def initialize(config:, connector:, logger:)
|
|
18
|
+
@config = config
|
|
19
|
+
@connector = connector
|
|
20
|
+
@logger = logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param host_roles [Hash{String => Array<Symbol>}] hosts and the roles each serves
|
|
24
|
+
# @return [Hash{String => Array<String>}] versions removed, keyed by host
|
|
25
|
+
def sweep(host_roles)
|
|
26
|
+
host_roles.to_h { |host, roles| [host, sweep_host(host, roles)] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def sweep_host(host, roles)
|
|
32
|
+
ssh = @connector.call(host)
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
docker = Odysseus::Docker::Client.new(ssh)
|
|
36
|
+
plan = retention_plan(ssh, docker, host, roles)
|
|
37
|
+
return [] if plan.nil?
|
|
38
|
+
|
|
39
|
+
@logger.info(" Keeping #{plan.keep.join(', ')} on #{host}")
|
|
40
|
+
plan.remove.select { |version| prune_image(docker, host, version) }
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
@logger.warn("Could not prune images on #{host}: #{e.message}")
|
|
43
|
+
[]
|
|
44
|
+
ensure
|
|
45
|
+
ssh.close
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# nil when the host has no deploy log to authorise removals. Falling back
|
|
50
|
+
# to image creation time would risk deleting a version someone still
|
|
51
|
+
# wants: creation time is *build* time, and images can reach a host out of
|
|
52
|
+
# order.
|
|
53
|
+
def retention_plan(ssh, docker, host, roles)
|
|
54
|
+
history = Odysseus::DeployLog.new(ssh: ssh, service: @config[:service]).entries
|
|
55
|
+
|
|
56
|
+
if history.empty?
|
|
57
|
+
@logger.info(" No deploy log on #{host} yet, so nothing is pruned")
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Odysseus::RetentionPlanner.new(
|
|
62
|
+
history: history,
|
|
63
|
+
available: docker.image_tags(@config[:image]),
|
|
64
|
+
in_use: docker.versions_in_use(container_labels(roles)),
|
|
65
|
+
retain: @config[:retain_versions]
|
|
66
|
+
).plan
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The odysseus.service label values this host's containers carry — one per
|
|
70
|
+
# role. Built with Labels.service_for so reading them back cannot disagree
|
|
71
|
+
# with how WebDeploy and JobDeploy write them.
|
|
72
|
+
def container_labels(roles)
|
|
73
|
+
roles.map { |role| Odysseus::Docker::Labels.service_for(service: @config[:service], role: role) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# True when the image is gone. Named prune_image, not remove_image, so it
|
|
77
|
+
# cannot be misread as Docker::Client#remove_image, which it calls.
|
|
78
|
+
#
|
|
79
|
+
# Rescues StandardError rather than Odysseus::Error: docker refuses to
|
|
80
|
+
# remove an image a container still references, and SSH#execute can also
|
|
81
|
+
# raise Net::SSH::Disconnect, IOError or Net::SSH::ChannelOpenFailed
|
|
82
|
+
# untranslated.
|
|
83
|
+
def prune_image(docker, host, version)
|
|
84
|
+
image = "#{@config[:image]}:#{version}"
|
|
85
|
+
docker.remove_image(image)
|
|
86
|
+
@logger.info(" Pruned #{image} on #{host}")
|
|
87
|
+
true
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
@logger.info(" Kept #{image} on #{host}: #{e.message}")
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|