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
|
@@ -6,6 +6,10 @@ module Odysseus
|
|
|
6
6
|
module Orchestrator
|
|
7
7
|
class WebDeploy
|
|
8
8
|
include Odysseus::Core::VolumeNamespacer
|
|
9
|
+
include Odysseus::Core::DeployVersioning
|
|
10
|
+
|
|
11
|
+
# Probed when a proxy is configured without an explicit healthcheck block.
|
|
12
|
+
DEFAULT_HEALTHCHECK_PATH = '/'.freeze
|
|
9
13
|
|
|
10
14
|
# @param ssh [Odysseus::Deployer::SSH] SSH connection
|
|
11
15
|
# @param config [Hash] parsed deploy config
|
|
@@ -31,13 +35,22 @@ module Odysseus
|
|
|
31
35
|
log "Deploying #{service} (role: #{role})"
|
|
32
36
|
log " Image: #{image}"
|
|
33
37
|
|
|
38
|
+
# A web role is proxied by Caddy, which needs a port to route to. Without
|
|
39
|
+
# one the container gets no health command and the deploy would sit in
|
|
40
|
+
# health checks until it timed out, so say so up front.
|
|
41
|
+
unless app_port
|
|
42
|
+
raise Odysseus::ConfigError,
|
|
43
|
+
"proxy.app_port is required to deploy the '#{role}' role — " \
|
|
44
|
+
'set it to the port your app listens on inside the container'
|
|
45
|
+
end
|
|
46
|
+
|
|
34
47
|
# Step 1: Ensure Caddy is running
|
|
35
|
-
log
|
|
48
|
+
log 'Ensuring Caddy proxy is running...'
|
|
36
49
|
if @caddy.running?
|
|
37
|
-
log
|
|
50
|
+
log ' Caddy already running'
|
|
38
51
|
else
|
|
39
|
-
|
|
40
|
-
log
|
|
52
|
+
ensure_caddy!
|
|
53
|
+
log ' Caddy started'
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
# Step 2: Find existing containers
|
|
@@ -45,7 +58,7 @@ module Odysseus
|
|
|
45
58
|
log " Found #{old_containers.size} existing container(s)"
|
|
46
59
|
|
|
47
60
|
# Step 3: Start new container
|
|
48
|
-
log
|
|
61
|
+
log 'Starting new container...'
|
|
49
62
|
new_container_id = start_new_container(image: image, role: role)
|
|
50
63
|
log " Container started: #{new_container_id[0..11]}"
|
|
51
64
|
|
|
@@ -55,21 +68,21 @@ module Odysseus
|
|
|
55
68
|
unless wait_for_healthy(new_container_id)
|
|
56
69
|
log_health_failure(new_container_id)
|
|
57
70
|
handle_failed_deploy(new_container_id, old_containers)
|
|
58
|
-
raise Odysseus::DeployError,
|
|
71
|
+
raise Odysseus::DeployError, 'Container failed health checks'
|
|
59
72
|
end
|
|
60
|
-
log
|
|
73
|
+
log ' Health check passed'
|
|
61
74
|
|
|
62
75
|
# Step 5: Add new container to Caddy
|
|
63
76
|
proxy_hosts = @config[:proxy][:hosts]&.join(', ')
|
|
64
77
|
log "Adding to Caddy proxy (hosts: #{proxy_hosts})..."
|
|
65
78
|
add_to_caddy(new_container_id)
|
|
66
|
-
log
|
|
79
|
+
log ' Caddy routing configured'
|
|
67
80
|
|
|
68
81
|
# Step 6: Remove old containers from Caddy and stop them
|
|
69
82
|
old_containers.each do |old|
|
|
70
83
|
log "Draining old container #{old['ID'][0..11]}..."
|
|
71
84
|
drain_and_remove(old['ID'])
|
|
72
|
-
log
|
|
85
|
+
log ' Old container removed'
|
|
73
86
|
end
|
|
74
87
|
|
|
75
88
|
# Step 7: Cleanup old stopped containers
|
|
@@ -96,15 +109,15 @@ module Odysseus
|
|
|
96
109
|
private
|
|
97
110
|
|
|
98
111
|
def ensure_caddy!
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
return if @caddy.ensure_running
|
|
113
|
+
|
|
114
|
+
raise Odysseus::DeployError, 'Failed to start Caddy proxy'
|
|
102
115
|
end
|
|
103
116
|
|
|
104
117
|
def start_new_container(image:, role:)
|
|
105
118
|
service = @config[:service]
|
|
106
|
-
timestamp = Time.now.strftime('%Y%m%d%H%M%S')
|
|
107
|
-
container_name = "#{service}-#{timestamp}"
|
|
119
|
+
timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
120
|
+
container_name = "#{service}-#{deploy_version_tag(image)}-#{timestamp}"
|
|
108
121
|
|
|
109
122
|
server_config = @config[:servers][role] || {}
|
|
110
123
|
options = server_config[:options] || {}
|
|
@@ -114,9 +127,7 @@ module Odysseus
|
|
|
114
127
|
log " Environment: #{env.size} variable(s) injected"
|
|
115
128
|
|
|
116
129
|
volumes = namespace_volumes(server_config[:volumes], service: service)
|
|
117
|
-
if volumes&.any?
|
|
118
|
-
log " Volumes: #{volumes.join(', ')}"
|
|
119
|
-
end
|
|
130
|
+
log " Volumes: #{volumes.join(', ')}" if volumes&.any?
|
|
120
131
|
|
|
121
132
|
if options[:memory] || options[:cpus]
|
|
122
133
|
log " Resources: memory=#{options[:memory] || 'default'}, cpus=#{options[:cpus] || 'default'}"
|
|
@@ -127,7 +138,8 @@ module Odysseus
|
|
|
127
138
|
image: image,
|
|
128
139
|
options: {
|
|
129
140
|
service: service,
|
|
130
|
-
version:
|
|
141
|
+
version: deploy_version_tag(image),
|
|
142
|
+
labels: version_labels,
|
|
131
143
|
ports: internal_port_mapping(proxy_config[:app_port]),
|
|
132
144
|
env: env,
|
|
133
145
|
volumes: volumes,
|
|
@@ -152,39 +164,27 @@ module Odysseus
|
|
|
152
164
|
nil
|
|
153
165
|
end
|
|
154
166
|
|
|
167
|
+
# Shared with JobDeploy and with the one-off containers the CLI runs, so
|
|
168
|
+
# every path injects the same environment. Kept private here: it is how
|
|
169
|
+
# this orchestrator fills in its own container's env, not a service to
|
|
170
|
+
# call on it from outside.
|
|
155
171
|
def build_environment
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# Clear env vars (hardcoded values)
|
|
159
|
-
@config[:env][:clear]&.each do |key, value|
|
|
160
|
-
env[key.to_s] = value.to_s
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Secret env vars - first try encrypted file, then server environment
|
|
164
|
-
@config[:env][:secret]&.each do |key|
|
|
165
|
-
# Try encrypted secrets file first
|
|
166
|
-
if @secrets_loader&.configured?
|
|
167
|
-
value = @secrets_loader.get(key)
|
|
168
|
-
if value
|
|
169
|
-
env[key.to_s] = value.to_s
|
|
170
|
-
next
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Fall back to server's environment
|
|
175
|
-
value = @ssh.execute("echo $#{key}").strip
|
|
176
|
-
env[key.to_s] = value unless value.empty?
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
env
|
|
172
|
+
Odysseus::Core::Environment.new(config: @config, secrets_loader: @secrets_loader, ssh: @ssh).build
|
|
180
173
|
end
|
|
181
174
|
|
|
175
|
+
# Build the container-level health check Docker polls.
|
|
176
|
+
#
|
|
177
|
+
# The deploy gates on Docker reporting the container healthy, so a web
|
|
178
|
+
# container always needs a health command — without one the status stays
|
|
179
|
+
# 'none' forever and every deploy times out. When no healthcheck block is
|
|
180
|
+
# configured we probe the app port at DEFAULT_HEALTHCHECK_PATH, matching
|
|
181
|
+
# the default Config::Parser applies to an empty healthcheck block.
|
|
182
182
|
def build_healthcheck(hc_config)
|
|
183
|
-
|
|
183
|
+
port = app_port
|
|
184
|
+
return nil unless port
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
expect_status = hc_config[:expect_status]
|
|
186
|
+
path = (hc_config && hc_config[:path]) || DEFAULT_HEALTHCHECK_PATH
|
|
187
|
+
expect_status = hc_config && hc_config[:expect_status]
|
|
188
188
|
|
|
189
189
|
# Build curl command based on expected status
|
|
190
190
|
cmd = if expect_status
|
|
@@ -254,7 +254,7 @@ module Odysseus
|
|
|
254
254
|
end
|
|
255
255
|
|
|
256
256
|
def handle_failed_deploy(new_container_id, old_containers)
|
|
257
|
-
log
|
|
257
|
+
log 'Rolling back failed deploy...', :warn
|
|
258
258
|
|
|
259
259
|
# Remove the failed new container
|
|
260
260
|
@docker.stop(new_container_id)
|
|
@@ -264,7 +264,7 @@ module Odysseus
|
|
|
264
264
|
if old_containers.any?
|
|
265
265
|
log "Rollback complete — #{old_containers.size} old container(s) still serving traffic"
|
|
266
266
|
else
|
|
267
|
-
log
|
|
267
|
+
log 'Rollback complete — no previous containers to fall back to', :warn
|
|
268
268
|
end
|
|
269
269
|
end
|
|
270
270
|
|
|
@@ -275,7 +275,7 @@ module Odysseus
|
|
|
275
275
|
begin
|
|
276
276
|
recent_logs = @docker.logs(container_id, tail: 30)
|
|
277
277
|
unless recent_logs.strip.empty?
|
|
278
|
-
log
|
|
278
|
+
log ' Container logs (last 30 lines):', :error
|
|
279
279
|
recent_logs.each_line { |line| log " #{line.rstrip}", :error }
|
|
280
280
|
end
|
|
281
281
|
rescue StandardError => e
|
|
@@ -292,17 +292,23 @@ module Odysseus
|
|
|
292
292
|
end
|
|
293
293
|
|
|
294
294
|
def describe_healthcheck(hc_config)
|
|
295
|
-
|
|
295
|
+
port = app_port
|
|
296
|
+
path = (hc_config && hc_config[:path]) || DEFAULT_HEALTHCHECK_PATH
|
|
297
|
+
interval = (hc_config && hc_config[:interval]) || 10
|
|
296
298
|
|
|
297
|
-
port
|
|
298
|
-
|
|
299
|
+
"(GET http://localhost:#{port}#{path}, interval: #{interval}s)"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Port the app listens on inside the container, nil when no proxy is configured.
|
|
303
|
+
def app_port
|
|
304
|
+
@config[:proxy] && @config[:proxy][:app_port]
|
|
299
305
|
end
|
|
300
306
|
|
|
301
307
|
def default_logger
|
|
302
308
|
@default_logger ||= Object.new.tap do |l|
|
|
303
|
-
def l.info(msg)
|
|
304
|
-
def l.warn(msg)
|
|
305
|
-
def l.error(msg)
|
|
309
|
+
def l.info(msg) = puts(msg)
|
|
310
|
+
def l.warn(msg) = puts("[WARN] #{msg}")
|
|
311
|
+
def l.error(msg) = puts("[ERROR] #{msg}")
|
|
306
312
|
end
|
|
307
313
|
end
|
|
308
314
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# lib/odysseus/plugins.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# Loads the gems named in deploy.yml's `plugins:` (or `sails:`) list, so that
|
|
5
|
+
# the sail and host-provider registries have something in them.
|
|
6
|
+
#
|
|
7
|
+
# Runs before config validation, because the validator is what asks whether a
|
|
8
|
+
# named strategy is registered. That ordering is why this validates its own
|
|
9
|
+
# shape rather than leaving it to Validators::Config like every other key.
|
|
10
|
+
#
|
|
11
|
+
# Requiring a gem name read from a config file is a real capability, and is
|
|
12
|
+
# documented as such: deploy.yml already runs arbitrary docker commands as
|
|
13
|
+
# root on the target hosts, so this widens visibility rather than trust.
|
|
14
|
+
module Plugins
|
|
15
|
+
# `sails:` matches the project's own vocabulary; `plugins:` is what someone
|
|
16
|
+
# guesses without reading the docs. Both work, but not together.
|
|
17
|
+
KEYS = %w[plugins sails].freeze
|
|
18
|
+
|
|
19
|
+
# @param raw_config [Hash] the string-keyed hash straight from YAML
|
|
20
|
+
# @raise [Odysseus::ConfigError] on an ambiguous pair, a bad shape, or a
|
|
21
|
+
# gem that will not load
|
|
22
|
+
def self.load!(raw_config)
|
|
23
|
+
key, names = names_from(raw_config)
|
|
24
|
+
return if key.nil?
|
|
25
|
+
|
|
26
|
+
unless names.is_a?(Array) && names.all?(String)
|
|
27
|
+
raise Odysseus::ConfigError,
|
|
28
|
+
"`#{key}:` must be a list of gem names, got #{names.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
names.each { |name| require_plugin(name, key) }
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the key the config actually used alongside its value, so every
|
|
36
|
+
# diagnostic can quote the key the user wrote rather than whichever of the
|
|
37
|
+
# two aliases we happen to name first.
|
|
38
|
+
#
|
|
39
|
+
# @return [Array(String, Object)] the key and its value, or [nil, nil]
|
|
40
|
+
def self.names_from(raw_config)
|
|
41
|
+
present = KEYS.select { |key| raw_config.key?(key) }
|
|
42
|
+
|
|
43
|
+
if present.length > 1
|
|
44
|
+
raise Odysseus::ConfigError,
|
|
45
|
+
'deploy.yml has both `plugins:` and `sails:` — use one; they name the same thing'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
present.empty? ? [nil, nil] : [present.first, raw_config[present.first]]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.require_plugin(name, key)
|
|
52
|
+
require name
|
|
53
|
+
rescue LoadError => e
|
|
54
|
+
raise Odysseus::ConfigError, load_failure_message(name, key, e)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A plugin whose own `require` fails is installed already, so repeating the
|
|
58
|
+
# install advice sends the user after a gem they have. The two cases read
|
|
59
|
+
# differently, and both carry the file that was actually missing — without
|
|
60
|
+
# it, a sail missing its SDK is indistinguishable from a sail missing.
|
|
61
|
+
def self.load_failure_message(name, key, error)
|
|
62
|
+
if error.path && error.path != name
|
|
63
|
+
"The plugin `#{name}` named in deploy.yml is installed but failed to load: #{error.message}."
|
|
64
|
+
else
|
|
65
|
+
"Could not load the plugin `#{name}` named in deploy.yml (#{error.message}). Add it to your " \
|
|
66
|
+
"Gemfile or run `gem install #{name}`, or remove it from `#{key}:`."
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private_class_method :names_from, :require_plugin, :load_failure_message
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# lib/odysseus/retention_plan.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# What retention decided for one host.
|
|
5
|
+
#
|
|
6
|
+
# remove versions whose images should be deleted, **oldest first**, so a
|
|
7
|
+
# partial failure leaves the newest behind
|
|
8
|
+
# keep versions retained, newest first — reported so an operator can see
|
|
9
|
+
# what the window covers without re-deriving it
|
|
10
|
+
#
|
|
11
|
+
# Both hold versions, not image references; the caller pairs them with the
|
|
12
|
+
# configured image name.
|
|
13
|
+
RetentionPlan = Data.define(:remove, :keep)
|
|
14
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# lib/odysseus/retention_planner.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# Chooses which of a service's image tags to delete from one host.
|
|
5
|
+
#
|
|
6
|
+
# Pure: handed what the host reports, returns a decision. That matters more
|
|
7
|
+
# here than anywhere else in odysseus, because the decision deletes data on a
|
|
8
|
+
# production host — so the rules are testable without a connection, and three
|
|
9
|
+
# independent things have to agree before anything is removed: the retain
|
|
10
|
+
# window, the set of versions containers still reference, and (at the caller)
|
|
11
|
+
# docker's own refusal to remove an image in use.
|
|
12
|
+
class RetentionPlanner
|
|
13
|
+
# Never removed automatically: a moving pointer, and pre-0.4.2 deploys were
|
|
14
|
+
# built from it, so something may still reference it.
|
|
15
|
+
PROTECTED_TAGS = ['latest'].freeze
|
|
16
|
+
|
|
17
|
+
# @param history [Array<Odysseus::DeployLog::Entry>] oldest first
|
|
18
|
+
# @param available [Array<String>] tags present on the host
|
|
19
|
+
# @param in_use [Array<String>] versions containers still reference
|
|
20
|
+
# @param retain [Integer] distinct versions to keep
|
|
21
|
+
def initialize(history:, available:, in_use:, retain:)
|
|
22
|
+
@history = history
|
|
23
|
+
@available = available
|
|
24
|
+
@in_use = in_use
|
|
25
|
+
@retain = retain
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Odysseus::RetentionPlan]
|
|
29
|
+
def plan
|
|
30
|
+
RetentionPlan.new(remove: removable, keep: keep)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Logged versions, newest deploy first, de-duplicated keeping the newest
|
|
36
|
+
# occurrence. Timestamps are fixed-width UTC ISO 8601, so they sort
|
|
37
|
+
# lexicographically. Same ordering RollbackPlanner ranks candidates by.
|
|
38
|
+
def ranked
|
|
39
|
+
@ranked ||= @history.sort_by(&:at).reverse.map(&:version).uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def keep
|
|
43
|
+
ranked.take(@retain)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Oldest first: if the fifth removal fails, the four already gone were the
|
|
47
|
+
# least likely to be wanted.
|
|
48
|
+
def removable
|
|
49
|
+
(ranked - keep).reverse.select { |version| removable?(version) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def removable?(version)
|
|
53
|
+
@available.include?(version) &&
|
|
54
|
+
!@in_use.include?(version) &&
|
|
55
|
+
!PROTECTED_TAGS.include?(version)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# lib/odysseus/rollback_plan.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# The decision a rollback acts on, settled before any host is touched.
|
|
5
|
+
#
|
|
6
|
+
# version the tag every host will run
|
|
7
|
+
# ref the commit that tag was built from, recovered from the host's
|
|
8
|
+
# deploy log; nil when no log recorded it
|
|
9
|
+
# approximate true when no host had a deploy log and the ordering came from
|
|
10
|
+
# image creation time, which is build time rather than deploy time
|
|
11
|
+
# replacing host name => the version it is currently running (nil if none),
|
|
12
|
+
# so each host's log records what it actually came from
|
|
13
|
+
RollbackPlan = Data.define(:version, :ref, :approximate, :replacing) do
|
|
14
|
+
# @param host [String]
|
|
15
|
+
# @return [String, nil] the version this host is rolling back from
|
|
16
|
+
def from_for(host)
|
|
17
|
+
replacing[host]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# lib/odysseus/rollback_planner.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# Chooses the version a rollback will deploy, from what the hosts report.
|
|
5
|
+
#
|
|
6
|
+
# Pure by design: it is handed surveys and returns a plan or raises. No SSH,
|
|
7
|
+
# no config, no git — so every rule below is testable with plain values, and
|
|
8
|
+
# the fleet pre-flight cannot be accidentally bypassed by a caller that
|
|
9
|
+
# already holds a connection.
|
|
10
|
+
class RollbackPlanner
|
|
11
|
+
# @param surveys [Array<Odysseus::HostVersions>] one per host, all roles
|
|
12
|
+
# @raise [Odysseus::RollbackError] when there are no hosts to act on
|
|
13
|
+
def initialize(surveys)
|
|
14
|
+
raise Odysseus::RollbackError, 'No hosts are configured, so there is nothing to roll back' if surveys.empty?
|
|
15
|
+
|
|
16
|
+
@surveys = surveys
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param version [String, nil] explicit target, or nil for the previous one
|
|
20
|
+
# @return [Odysseus::RollbackPlan]
|
|
21
|
+
# @raise [Odysseus::RollbackError] when no target can be rolled back to
|
|
22
|
+
def plan(version: nil)
|
|
23
|
+
target = version || previous_version
|
|
24
|
+
raise Odysseus::RollbackError, no_candidate_message if target.nil?
|
|
25
|
+
|
|
26
|
+
ensure_present_everywhere!(target)
|
|
27
|
+
|
|
28
|
+
RollbackPlan.new(
|
|
29
|
+
version: target,
|
|
30
|
+
ref: ref_for(target),
|
|
31
|
+
approximate: logged_versions.empty?,
|
|
32
|
+
replacing: @surveys.to_h { |survey| [survey.host, survey.current] }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The newest version every host can run and none is already running.
|
|
39
|
+
def previous_version
|
|
40
|
+
candidates.find { |version| present_everywhere?(version) && !serving_anywhere?(version) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Versions to consider, best first.
|
|
44
|
+
#
|
|
45
|
+
# The deploy log is preferred because image creation time is *build* time:
|
|
46
|
+
# images can reach a host out of order, and a rebuilt host can hold images
|
|
47
|
+
# it never served. With no log anywhere, docker's newest-first image order
|
|
48
|
+
# is the only signal available, and the plan is marked approximate.
|
|
49
|
+
def candidates
|
|
50
|
+
return logged_versions unless logged_versions.empty?
|
|
51
|
+
|
|
52
|
+
# No deploy log exists anywhere to name a specific version, so this is
|
|
53
|
+
# the fallback path. 'latest' is a moving pointer, not a version — the
|
|
54
|
+
# image tagged 'latest' today need not be the one that was running
|
|
55
|
+
# yesterday — so naming it as a rollback target is exactly the
|
|
56
|
+
# ambiguity this whole design exists to remove. Only excluded here: a
|
|
57
|
+
# version the log actually recorded is real and stays a candidate even
|
|
58
|
+
# if, unusually, it is literally tagged 'latest'.
|
|
59
|
+
@surveys.first.available.reject { |version| version == 'latest' }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Every logged version across all hosts, newest deploy first, de-duplicated.
|
|
63
|
+
# DeployLog timestamps are fixed-width UTC ISO 8601, so they sort
|
|
64
|
+
# lexicographically; uniq keeps the newest occurrence of each version.
|
|
65
|
+
def logged_versions
|
|
66
|
+
@logged_versions ||= entries.sort_by(&:at).reverse.map(&:version).uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def entries
|
|
70
|
+
@entries ||= @surveys.flat_map(&:history)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def present_everywhere?(version)
|
|
74
|
+
@surveys.all? { |survey| survey.available?(version) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def serving_anywhere?(version)
|
|
78
|
+
@surveys.any? { |survey| survey.current == version }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The commit the target was built from, per the most recent log entry that
|
|
82
|
+
# mentions it. DeployLog writes '-' for a field it had no value for, which
|
|
83
|
+
# must read back as absent rather than as a commit called '-'.
|
|
84
|
+
def ref_for(target)
|
|
85
|
+
ref = entries.select { |entry| entry.version == target }.max_by(&:at)&.ref
|
|
86
|
+
ref unless ref.nil? || ref == '-'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ensure_present_everywhere!(target)
|
|
90
|
+
missing = @surveys.reject { |survey| survey.available?(target) }.map(&:host)
|
|
91
|
+
return if missing.empty?
|
|
92
|
+
|
|
93
|
+
raise Odysseus::RollbackError,
|
|
94
|
+
"Image #{target} is missing on #{missing.join(', ')}, so rolling back would leave the " \
|
|
95
|
+
'fleet on mixed versions. Nothing was changed. Run `odysseus rollback --list` to see ' \
|
|
96
|
+
'what each host has.'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def no_candidate_message
|
|
100
|
+
running = @surveys.map { |survey| "#{survey.host} is running #{survey.current || 'nothing'}" }
|
|
101
|
+
|
|
102
|
+
"No version to roll back to (#{running.join('; ')}). A rollback needs a version that every " \
|
|
103
|
+
'host has an image for and that is not already serving. Run `odysseus rollback --list` to ' \
|
|
104
|
+
'see each host, or name a version explicitly.'
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/odysseus/sails.rb
CHANGED
|
File without changes
|
|
@@ -8,8 +8,8 @@ require 'securerandom'
|
|
|
8
8
|
module Odysseus
|
|
9
9
|
module Secrets
|
|
10
10
|
class EncryptedFile
|
|
11
|
-
CIPHER = 'aes-256-gcm'
|
|
12
|
-
KEY_ENV_VAR = 'ODYSSEUS_MASTER_KEY'
|
|
11
|
+
CIPHER = 'aes-256-gcm'.freeze
|
|
12
|
+
KEY_ENV_VAR = 'ODYSSEUS_MASTER_KEY'.freeze
|
|
13
13
|
|
|
14
14
|
class DecryptionError < Odysseus::Error; end
|
|
15
15
|
class MissingKeyError < Odysseus::Error; end
|
|
@@ -56,7 +56,7 @@ module Odysseus
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
58
|
def master_key
|
|
59
|
-
ENV
|
|
59
|
+
ENV.fetch(KEY_ENV_VAR, nil)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def encrypt(data, key)
|
|
@@ -76,9 +76,7 @@ module Odysseus
|
|
|
76
76
|
auth_tag = cipher.auth_tag
|
|
77
77
|
|
|
78
78
|
# Combine: iv + auth_tag + encrypted_data, all base64 encoded
|
|
79
|
-
combined = Base64.strict_encode64(iv)
|
|
80
|
-
Base64.strict_encode64(auth_tag) + "\n" +
|
|
81
|
-
Base64.strict_encode64(encrypted)
|
|
79
|
+
combined = "#{Base64.strict_encode64(iv)}\n#{Base64.strict_encode64(auth_tag)}\n#{Base64.strict_encode64(encrypted)}"
|
|
82
80
|
|
|
83
81
|
"# Odysseus encrypted secrets\n# Do not edit this file directly\n\n#{combined}"
|
|
84
82
|
end
|
|
@@ -87,7 +85,7 @@ module Odysseus
|
|
|
87
85
|
# Remove comments and empty lines
|
|
88
86
|
lines = content.lines.reject { |l| l.start_with?('#') || l.strip.empty? }
|
|
89
87
|
|
|
90
|
-
raise DecryptionError,
|
|
88
|
+
raise DecryptionError, 'Invalid encrypted file format' if lines.size < 3
|
|
91
89
|
|
|
92
90
|
iv = Base64.strict_decode64(lines[0].strip)
|
|
93
91
|
auth_tag = Base64.strict_decode64(lines[1].strip)
|
|
@@ -22,9 +22,7 @@ module Odysseus
|
|
|
22
22
|
path = resolve_path(secrets_file)
|
|
23
23
|
encrypted = EncryptedFile.new(path)
|
|
24
24
|
|
|
25
|
-
unless encrypted.exists?
|
|
26
|
-
raise Odysseus::ConfigError, "Secrets file not found: #{path}"
|
|
27
|
-
end
|
|
25
|
+
raise Odysseus::ConfigError, "Secrets file not found: #{path}" unless encrypted.exists?
|
|
28
26
|
|
|
29
27
|
@cached_secrets = encrypted.read
|
|
30
28
|
end
|