dash 4.1.3 → 4.2.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/lib/dash/autoscale/connections/builder.rb +51 -0
- data/lib/dash/autoscale/connections.rb +102 -0
- data/lib/dash/autoscale/controller.rb +296 -0
- data/lib/dash/autoscale/cron.rb +75 -0
- data/lib/dash/autoscale/decision.rb +89 -0
- data/lib/dash/autoscale/duration.rb +19 -0
- data/lib/dash/autoscale/evaluation.rb +52 -0
- data/lib/dash/autoscale/guard.rb +48 -0
- data/lib/dash/autoscale/lease.rb +55 -0
- data/lib/dash/autoscale/lease_held.rb +2 -0
- data/lib/dash/autoscale/lease_lost.rb +2 -0
- data/lib/dash/autoscale/member.rb +28 -0
- data/lib/dash/autoscale/metrics.rb +70 -0
- data/lib/dash/autoscale/metrics_server.rb +71 -0
- data/lib/dash/autoscale/observation.rb +41 -0
- data/lib/dash/autoscale/pause.rb +34 -0
- data/lib/dash/autoscale/policy.rb +198 -0
- data/lib/dash/autoscale/pool.rb +103 -0
- data/lib/dash/autoscale/prometheus_client.rb +76 -0
- data/lib/dash/autoscale/provider/base.rb +43 -0
- data/lib/dash/autoscale/provider/exec.rb +94 -0
- data/lib/dash/autoscale/provider/upcloud.rb +140 -0
- data/lib/dash/autoscale/provider.rb +9 -0
- data/lib/dash/autoscale/provider_error.rb +4 -0
- data/lib/dash/autoscale/read_only_query.rb +38 -0
- data/lib/dash/autoscale/reader.rb +17 -0
- data/lib/dash/autoscale/recorder.rb +13 -0
- data/lib/dash/autoscale/role_state.rb +110 -0
- data/lib/dash/autoscale/sample.rb +13 -0
- data/lib/dash/autoscale/signal/pgbus.rb +19 -0
- data/lib/dash/autoscale/signal/prometheus.rb +8 -0
- data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
- data/lib/dash/autoscale/signal.rb +31 -0
- data/lib/dash/autoscale/simulation.rb +200 -0
- data/lib/dash/autoscale/state_store.rb +132 -0
- data/lib/dash/autoscale/timestamp.rb +15 -0
- data/lib/dash/autoscale/tunnel.rb +48 -0
- data/lib/dash/cli/accessory.rb +16 -0
- data/lib/dash/cli/app/boot.rb +69 -44
- data/lib/dash/cli/app/boot_state.rb +70 -0
- data/lib/dash/cli/app/rollout_boot.rb +28 -9
- data/lib/dash/cli/app/running_targets.rb +39 -0
- data/lib/dash/cli/app/slot_version.rb +29 -0
- data/lib/dash/cli/app.rb +89 -32
- data/lib/dash/cli/autoscale.rb +312 -0
- data/lib/dash/cli/base.rb +15 -3
- data/lib/dash/cli/lock.rb +6 -1
- data/lib/dash/cli/main.rb +77 -8
- data/lib/dash/cli/proxy/inspection.rb +52 -0
- data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
- data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
- data/lib/dash/cli/proxy/reboot.rb +4 -9
- data/lib/dash/cli/proxy.rb +12 -7
- data/lib/dash/cli/prune.rb +5 -3
- data/lib/dash/cli/report.rb +4 -1
- data/lib/dash/cli/scale/host_plan.rb +63 -0
- data/lib/dash/cli/scale/join.rb +128 -0
- data/lib/dash/cli/scale/leave.rb +58 -0
- data/lib/dash/cli/scale/placement.rb +54 -0
- data/lib/dash/cli/scale/power_off.rb +26 -0
- data/lib/dash/cli/scale/replica_join.rb +74 -0
- data/lib/dash/cli/scale/replica_leave.rb +121 -0
- data/lib/dash/cli/scale.rb +276 -0
- data/lib/dash/cli/server.rb +41 -0
- data/lib/dash/cli/stats_table.rb +34 -0
- data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
- data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
- data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
- data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
- data/lib/dash/commander.rb +17 -3
- data/lib/dash/commands/accessory/proxy.rb +1 -1
- data/lib/dash/commands/accessory.rb +21 -2
- data/lib/dash/commands/app/containers.rb +28 -3
- data/lib/dash/commands/app/logging.rb +13 -2
- data/lib/dash/commands/app/proxy.rb +4 -4
- data/lib/dash/commands/app.rb +90 -10
- data/lib/dash/commands/auditor.rb +2 -2
- data/lib/dash/commands/autoscale.rb +133 -0
- data/lib/dash/commands/base.rb +4 -0
- data/lib/dash/commands/proxy/services.rb +10 -0
- data/lib/dash/commands/prune.rb +10 -2
- data/lib/dash/commands/server.rb +16 -0
- data/lib/dash/configuration/accessory.rb +2 -2
- data/lib/dash/configuration/autoscale.rb +123 -0
- data/lib/dash/configuration/docs/autoscale.yml +141 -0
- data/lib/dash/configuration/docs/configuration.yml +8 -2
- data/lib/dash/configuration/docs/proxy.yml +1 -1
- data/lib/dash/configuration/docs/role.yml +203 -0
- data/lib/dash/configuration/loadbalancer.rb +3 -2
- data/lib/dash/configuration/proxy/run.rb +1 -1
- data/lib/dash/configuration/proxy.rb +19 -7
- data/lib/dash/configuration/role/drain.rb +49 -0
- data/lib/dash/configuration/role/replicas.rb +72 -0
- data/lib/dash/configuration/role/scale/guard.rb +40 -0
- data/lib/dash/configuration/role/scale/signal.rb +93 -0
- data/lib/dash/configuration/role/scale/window.rb +37 -0
- data/lib/dash/configuration/role/scale.rb +199 -0
- data/lib/dash/configuration/role.rb +123 -6
- data/lib/dash/configuration/validator/autoscale.rb +126 -0
- data/lib/dash/configuration/validator/proxy.rb +13 -0
- data/lib/dash/configuration/validator/role.rb +26 -5
- data/lib/dash/configuration.rb +126 -15
- data/lib/dash/diagnostics/audit.rb +28 -0
- data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
- data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
- data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
- data/lib/dash/diagnostics/base.rb +53 -0
- data/lib/dash/diagnostics/config.rb +32 -0
- data/lib/dash/diagnostics/container_stats.rb +67 -0
- data/lib/dash/diagnostics/containers.rb +20 -0
- data/lib/dash/diagnostics/controller_status.rb +44 -0
- data/lib/dash/diagnostics/docker_ps.rb +40 -0
- data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
- data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
- data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
- data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
- data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
- data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
- data/lib/dash/diagnostics/doctor.rb +182 -0
- data/lib/dash/diagnostics/drift.rb +168 -0
- data/lib/dash/diagnostics/host_stats.rb +59 -0
- data/lib/dash/diagnostics/lines.rb +14 -0
- data/lib/dash/diagnostics/lock.rb +27 -0
- data/lib/dash/diagnostics/logs.rb +76 -0
- data/lib/dash/diagnostics/pool.rb +41 -0
- data/lib/dash/diagnostics/proxy_services.rb +36 -0
- data/lib/dash/diagnostics/redactor.rb +48 -0
- data/lib/dash/diagnostics/reports.rb +17 -0
- data/lib/dash/diagnostics/scale.rb +51 -0
- data/lib/dash/diagnostics/units.rb +31 -0
- data/lib/dash/mcp/base_tool.rb +43 -0
- data/lib/dash/mcp/runner.rb +28 -0
- data/lib/dash/mcp/server.rb +40 -0
- data/lib/dash/mcp/session.rb +51 -0
- data/lib/dash/mcp/tools/audit_tool.rb +11 -0
- data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
- data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
- data/lib/dash/mcp/tools/config_tool.rb +10 -0
- data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
- data/lib/dash/mcp/tools/containers_tool.rb +10 -0
- data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
- data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
- data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
- data/lib/dash/mcp/tools/drift_tool.rb +16 -0
- data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
- data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
- data/lib/dash/mcp/tools/logs_tool.rb +41 -0
- data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
- data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
- data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
- data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
- data/lib/dash/mcp/transport.rb +14 -0
- data/lib/dash/mcp.rb +56 -0
- data/lib/dash/sshkit_with_ext.rb +4 -4
- data/lib/dash/utils.rb +7 -0
- data/lib/dash/version.rb +1 -1
- data/lib/dash.rb +2 -0
- metadata +119 -5
- data/lib/dash/cli/doctor.rb +0 -113
data/lib/dash/cli/app/boot.rb
CHANGED
|
@@ -18,77 +18,84 @@ class Dash::Cli::App::Boot
|
|
|
18
18
|
@cli = cli
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Every replica of the role on this host is swapped together: the old containers keep
|
|
22
|
+
# serving until all the new ones are ready, and a failure stops every new one.
|
|
21
23
|
def run
|
|
22
24
|
DASH.timings.phase("#{role} #{host}", depth: 1) do |timing|
|
|
23
25
|
@timing = timing
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
@state = capture_boot_state
|
|
28
|
+
announce_replicas if role.replicas.scalable?
|
|
29
|
+
old_versions = old_versions_renamed_if_clashing
|
|
26
30
|
|
|
27
31
|
wait_at_barrier if queuer?
|
|
28
32
|
|
|
29
33
|
begin
|
|
30
|
-
|
|
34
|
+
start_new_versions
|
|
31
35
|
rescue => e
|
|
32
36
|
close_barrier if gatekeeper?
|
|
33
|
-
|
|
37
|
+
stop_new_versions
|
|
34
38
|
raise
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
release_barrier if gatekeeper?
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
stop_old_version(old_version)
|
|
41
|
-
end
|
|
43
|
+
stop_old_versions(old_versions)
|
|
42
44
|
end
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
private
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
attr_reader :state
|
|
49
|
+
|
|
50
|
+
# Every answer comes back from one round trip, which means the running versions are
|
|
51
|
+
# read before any rename happens. When a clashing container IS the running one, the
|
|
48
52
|
# version to stop later is the name it was renamed to - the name that was read now
|
|
49
53
|
# belongs to the container this boot is about to start.
|
|
50
|
-
def
|
|
51
|
-
|
|
54
|
+
def old_versions_renamed_if_clashing
|
|
55
|
+
old_versions = state.replicas.to_h { |replica| [ replica, state.running_version(replica) ] }
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
state.replicas.select { |replica| state.clashing?(replica) }.each do |replica|
|
|
54
58
|
renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
|
|
55
|
-
info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
|
|
59
|
+
info "Renaming container #{app(replica).container_name(version)} to #{renamed_version} as already deployed on #{host}"
|
|
56
60
|
execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
|
|
57
|
-
app.rename_container(version: version, new_version: renamed_version))
|
|
61
|
+
app(replica).rename_container(version: version, new_version: renamed_version))
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
old_versions[replica] = renamed_version if old_versions[replica] == version
|
|
60
64
|
end
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
old_versions.merge(state.surplus_replicas.to_h { |replica| [ replica, state.running_version(replica) ] }).compact
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
# A slot that crashed between min and the runtime count is not running, so it is not
|
|
70
|
+
# counted - say so, rather than letting the count drop silently.
|
|
71
|
+
def announce_replicas
|
|
72
|
+
info "Booting #{state.count} #{"replica".pluralize(state.count)} of #{role} on #{host} (#{state.running_count} of #{role.replicas.max} slots running, min #{role.replicas.min})"
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
def
|
|
72
|
-
|
|
75
|
+
def capture_boot_state
|
|
76
|
+
Dash::Cli::App::BootState.new(role, capture_with_info(*app.boot_states(version), raise_on_non_zero_exit: false))
|
|
77
|
+
end
|
|
73
78
|
|
|
79
|
+
def start_new_versions
|
|
74
80
|
execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
|
|
75
81
|
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
# proxy target comes out of the run itself — asking docker for it again was a round
|
|
79
|
-
# trip spent re-reading something the host had already said.
|
|
80
|
-
container_id = capture_with_info(*app.run(hostname: hostname)).strip
|
|
83
|
+
container_ids = state.replicas.map { |replica| run_replica(replica) }
|
|
81
84
|
|
|
82
85
|
if running_proxy?
|
|
83
|
-
|
|
84
|
-
raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if
|
|
86
|
+
endpoints = container_ids.map { |container_id| container_id[0, SHORT_CONTAINER_ID_LENGTH] }
|
|
87
|
+
raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoints.any?(&:empty?)
|
|
85
88
|
|
|
86
89
|
run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
|
|
87
90
|
info "Deploying #{role} on #{host} via dash-proxy (waiting up to #{DASH.config.deploy_timeout}s for it to become healthy)..."
|
|
88
|
-
timing_healthy { execute *app.deploy(
|
|
91
|
+
timing_healthy { execute *app.deploy(targets: endpoints) }
|
|
89
92
|
run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
|
|
90
93
|
else
|
|
91
|
-
timing_healthy
|
|
94
|
+
timing_healthy do
|
|
95
|
+
state.replicas.each do |replica|
|
|
96
|
+
Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { |*args| readiness_status(replica, *args) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
92
99
|
end
|
|
93
100
|
rescue => e
|
|
94
101
|
error "Failed to boot #{role} on #{host}"
|
|
@@ -96,6 +103,14 @@ class Dash::Cli::App::Boot
|
|
|
96
103
|
raise e
|
|
97
104
|
end
|
|
98
105
|
|
|
106
|
+
# `docker run --detach` prints the id of the container it just started, so the proxy
|
|
107
|
+
# target comes out of the run itself — asking docker for it again was a round trip
|
|
108
|
+
# spent re-reading something the host had already said.
|
|
109
|
+
def run_replica(replica)
|
|
110
|
+
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
111
|
+
capture_with_info(*app(replica).run(hostname: hostname)).strip
|
|
112
|
+
end
|
|
113
|
+
|
|
99
114
|
# A role behind the proxy lets `dash-proxy deploy` block on the host until the
|
|
100
115
|
# container is healthy; a role without one now does the same, waiting in a shell loop
|
|
101
116
|
# on the host that streams its progress back rather than being polled from here once
|
|
@@ -104,11 +119,11 @@ class Dash::Cli::App::Boot
|
|
|
104
119
|
#
|
|
105
120
|
# Neither capture suppresses a non-zero exit: a status that cannot be read is a broken
|
|
106
121
|
# command, and it has always failed the boot on the spot rather than being waited out.
|
|
107
|
-
def readiness_status(mode, seconds_left = nil)
|
|
122
|
+
def readiness_status(replica, mode, seconds_left = nil)
|
|
108
123
|
if mode == :confirm
|
|
109
|
-
capture_with_info(*app.status(version: version))
|
|
124
|
+
capture_with_info(*app(replica).status(version: version))
|
|
110
125
|
else
|
|
111
|
-
capture_with_info *app.wait_for_ready(version: version, timeout: seconds_left),
|
|
126
|
+
capture_with_info *app(replica).wait_for_ready(version: version, timeout: seconds_left),
|
|
112
127
|
interaction_handler: Dash::Cli::Healthcheck::ProgressReporter.new
|
|
113
128
|
end
|
|
114
129
|
end
|
|
@@ -116,22 +131,30 @@ class Dash::Cli::App::Boot
|
|
|
116
131
|
# Every failed boot gets the container log, and the health probe history when the
|
|
117
132
|
# container declares a healthcheck — non-primary roles have no dash-proxy report to fall back on.
|
|
118
133
|
def dump_diagnostics
|
|
119
|
-
|
|
134
|
+
state.replicas.each do |replica|
|
|
135
|
+
error capture_with_info(*app(replica).logs(container_id: app(replica).container_id_for_version(version)))
|
|
120
136
|
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
health_log = capture_with_info(*app(replica).container_health_log(version: version)).strip
|
|
138
|
+
error health_log unless health_log.empty? || health_log == "null"
|
|
139
|
+
end
|
|
123
140
|
rescue SSHKit::Command::Failed
|
|
124
141
|
error "Could not fetch logs for #{version}"
|
|
125
142
|
end
|
|
126
143
|
|
|
127
|
-
def
|
|
128
|
-
|
|
144
|
+
def stop_new_versions
|
|
145
|
+
state.replicas.each do |replica|
|
|
146
|
+
execute *app(replica).stop(version: version), raise_on_non_zero_exit: false
|
|
147
|
+
end
|
|
129
148
|
end
|
|
130
149
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
def stop_old_versions(old_versions)
|
|
151
|
+
return if old_versions.empty?
|
|
152
|
+
|
|
153
|
+
old_versions.each do |replica, old_version|
|
|
154
|
+
run_stop_hook "pre-app-stop", old_version
|
|
155
|
+
execute *app(replica).stop(version: old_version), raise_on_non_zero_exit: false
|
|
156
|
+
run_stop_hook "post-app-stop", old_version
|
|
157
|
+
end
|
|
135
158
|
|
|
136
159
|
execute *app.clean_up_assets if assets?
|
|
137
160
|
execute *app.clean_up_error_pages if DASH.config.error_pages_path
|
|
@@ -172,15 +195,17 @@ class Dash::Cli::App::Boot
|
|
|
172
195
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
173
196
|
yield
|
|
174
197
|
ensure
|
|
175
|
-
|
|
198
|
+
replicas = ", #{state.count} replicas" if role.replicas.scalable?
|
|
199
|
+
@timing.detail = format("healthy after %.1fs%s", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, replicas)
|
|
176
200
|
end
|
|
177
201
|
|
|
178
202
|
def barrier_role?
|
|
179
203
|
role == DASH.primary_role
|
|
180
204
|
end
|
|
181
205
|
|
|
182
|
-
def app
|
|
183
|
-
@
|
|
206
|
+
def app(replica = 1)
|
|
207
|
+
@apps ||= {}
|
|
208
|
+
@apps[replica] ||= DASH.app(role: role, host: host, replica: replica)
|
|
184
209
|
end
|
|
185
210
|
|
|
186
211
|
def auditor
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# What one host said about a role before a boot starts anything, parsed out of the single
|
|
2
|
+
# Commands::App#boot_states capture: which slots already hold the version being deployed,
|
|
3
|
+
# which version each slot runs now, and so how many replicas to boot.
|
|
4
|
+
#
|
|
5
|
+
# The replica count is never stored. It is the number of slots with a running container,
|
|
6
|
+
# kept within the role's bounds, so `dash scale set` changes it and a deploy reads it back.
|
|
7
|
+
class Dash::Cli::App::BootState
|
|
8
|
+
attr_reader :role
|
|
9
|
+
|
|
10
|
+
def initialize(role, output)
|
|
11
|
+
@role = role
|
|
12
|
+
|
|
13
|
+
clash, running, names, *clashes = Dash::Commands::App.split_states(output).map(&:strip)
|
|
14
|
+
|
|
15
|
+
# The whole line, never a substring: a version may contain the marker's characters.
|
|
16
|
+
if names&.lines&.any? { |line| line.strip == Dash::Commands::App::ACTIVE_CONTAINERS_UNREADABLE }
|
|
17
|
+
raise Dash::Cli::BootError, "Could not list the running containers of #{role}, not booting it"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@clashes = { 1 => clash.presence }.merge(clashes.each.with_index(2).to_h { |id, replica| [ replica, id.presence ] })
|
|
21
|
+
@running = running_by_replica(running.presence, names&.lines&.map(&:strip)&.reject(&:empty?))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# How many replicas this boot starts: the slots running now, never fewer than min (so a
|
|
25
|
+
# crash cannot shrink the role below its floor) nor more than max.
|
|
26
|
+
def count
|
|
27
|
+
role.replicas.clamp(running_count)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replicas
|
|
31
|
+
(1..count).to_a
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def running_count
|
|
35
|
+
@running.size
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clashing?(replica)
|
|
39
|
+
@clashes[replica].present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def running_version(replica)
|
|
43
|
+
@running[replica]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Slots running above the count being booted - a lowered max, or a role whose
|
|
47
|
+
# `replicas` key went away. Their containers are stopped with the old version.
|
|
48
|
+
def surplus_replicas
|
|
49
|
+
@running.keys.select { |replica| replica > count }.sort
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
# Every slot's version comes out of its container's name. Slot 1's own `--latest` read
|
|
54
|
+
# wins for slot 1 (it prefers the latest image, as it always has), unless it is really
|
|
55
|
+
# another slot's whole name: on a role that was scalable and no longer is, `--latest`
|
|
56
|
+
# over the role labels can return a leftover slot's container.
|
|
57
|
+
def running_by_replica(slot_one, names)
|
|
58
|
+
by_replica = Array(names).reverse.to_h { |name| [ role.replica_from_name(name), role.version_from_name(name) ] }.except(nil)
|
|
59
|
+
|
|
60
|
+
if slot_one && !foreign?(slot_one)
|
|
61
|
+
by_replica[1] = slot_one
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
by_replica
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def foreign?(version)
|
|
68
|
+
(owner = role.replica_from_name(version)) && owner != 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -25,32 +25,51 @@ class Dash::Cli::App::RolloutBoot
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
def ensure_not_already_deployed
|
|
28
|
-
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
|
|
28
|
+
if replicas.any? { |replica| capture_with_info(*app(replica).container_id_for_version(version), raise_on_non_zero_exit: false).present? }
|
|
29
29
|
raise Dash::Cli::BootError, "Version #{version} is already deployed for #{role} on #{host}, roll out a different version"
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# The rollout target is as many replicas as the live version runs, so a canary split
|
|
34
|
+
# compares like with like.
|
|
33
35
|
def start_rollout_target
|
|
34
36
|
audit "Booted rollout target version #{version}"
|
|
35
|
-
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
36
37
|
|
|
37
38
|
execute *app.ensure_env_directory
|
|
38
39
|
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
endpoints = replicas.map do |replica|
|
|
42
|
+
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
43
|
+
execute *app(replica).run(hostname: hostname)
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
capture_with_info(*app(replica).container_id_for_version(version)).strip.tap do |endpoint|
|
|
46
|
+
raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
44
49
|
|
|
45
|
-
execute *app.rollout_deploy(
|
|
50
|
+
execute *app.rollout_deploy(targets: endpoints)
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
def stop_rollout_target
|
|
49
|
-
|
|
54
|
+
replicas.each do |replica|
|
|
55
|
+
execute *app(replica).stop(version: version), raise_on_non_zero_exit: false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A role without replicas is one slot, and asks nothing more than it always did.
|
|
60
|
+
def replicas
|
|
61
|
+
@replicas ||=
|
|
62
|
+
if role.replicas.scalable?
|
|
63
|
+
names = capture_with_info(*app.active_containers).lines.map(&:strip)
|
|
64
|
+
(1..role.replicas.clamp(names.filter_map { |name| role.replica_from_name(name) }.uniq.size)).to_a
|
|
65
|
+
else
|
|
66
|
+
[ 1 ]
|
|
67
|
+
end
|
|
50
68
|
end
|
|
51
69
|
|
|
52
|
-
def app
|
|
53
|
-
@
|
|
70
|
+
def app(replica = 1)
|
|
71
|
+
@apps ||= {}
|
|
72
|
+
@apps[replica] ||= DASH.app(role: role, host: host, replica: replica)
|
|
54
73
|
end
|
|
55
74
|
|
|
56
75
|
def audit(message)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# The dash-proxy targets of one role on one host: the running container of every slot, in
|
|
2
|
+
# slot order. A role without replicas has one slot, so this asks what re-registering a
|
|
3
|
+
# single container always asked, plus which slots run at all.
|
|
4
|
+
class Dash::Cli::App::RunningTargets
|
|
5
|
+
attr_reader :sshkit, :role, :host
|
|
6
|
+
delegate :capture_with_info, to: :sshkit
|
|
7
|
+
|
|
8
|
+
def initialize(sshkit, role:, host:)
|
|
9
|
+
@sshkit = sshkit
|
|
10
|
+
@role = role
|
|
11
|
+
@host = host
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def container_ids(only_running: false, replicas: self.replicas)
|
|
15
|
+
replicas.filter_map do |replica|
|
|
16
|
+
app = DASH.app(role: role, host: host, replica: replica)
|
|
17
|
+
|
|
18
|
+
read = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false)
|
|
19
|
+
|
|
20
|
+
if (version = Dash::Cli::App::SlotVersion.new(sshkit, app).resolve(read))
|
|
21
|
+
capture_with_info(*app.container_id_for_version(version, only_running: only_running), raise_on_non_zero_exit: false).strip.presence
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The configured slots, plus any slot still running above them - a lowered max, or a
|
|
27
|
+
# role whose `replicas` went away - until the next deploy stops it.
|
|
28
|
+
def replicas
|
|
29
|
+
@replicas ||= (role.replica_numbers | running_replicas).sort
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
# A listing that fails raises: an empty answer would read as "no surplus slots", and the
|
|
34
|
+
# caller would stop or register an incomplete set.
|
|
35
|
+
def running_replicas
|
|
36
|
+
names = capture_with_info(*DASH.app(role: role, host: host).active_containers).lines.map(&:strip)
|
|
37
|
+
names.filter_map { |name| role.replica_from_name(name) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The version one replica slot runs, from a `current_running_version` read that may have
|
|
2
|
+
# answered for another slot. A role without replicas reads slot 1 over the role labels alone,
|
|
3
|
+
# so while slots left over from when it had them still run, `--latest` can return one of
|
|
4
|
+
# theirs - an unstripped name like `app-web.2-123`. Taking that as slot 1's version would
|
|
5
|
+
# make the live container look stale, or drop it from the proxy targets. Only then is the
|
|
6
|
+
# role's running containers read, once more, to find the slot's own.
|
|
7
|
+
class Dash::Cli::App::SlotVersion
|
|
8
|
+
attr_reader :sshkit, :app
|
|
9
|
+
delegate :role, :replica, to: :app
|
|
10
|
+
delegate :capture_with_info, to: :sshkit
|
|
11
|
+
|
|
12
|
+
def initialize(sshkit, app)
|
|
13
|
+
@sshkit = sshkit
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolve(read)
|
|
18
|
+
version = read.to_s.strip.presence
|
|
19
|
+
return version unless foreign?(version)
|
|
20
|
+
|
|
21
|
+
names = capture_with_info(*app.active_containers).lines.map(&:strip)
|
|
22
|
+
names.filter_map { |name| role.version_from_name(name) if role.replica_from_name(name) == replica }.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A listed "version" that is really another slot's whole container name.
|
|
26
|
+
def foreign?(version)
|
|
27
|
+
(owner = version && role.replica_from_name(version)) && owner != replica
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/dash/cli/app.rb
CHANGED
|
@@ -55,15 +55,23 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
55
55
|
on_roles(DASH.roles, hosts: DASH.app_hosts, parallel: DASH.config.parallel_roles?, rolling: true) do |host, role|
|
|
56
56
|
app = DASH.app(role: role, host: host)
|
|
57
57
|
execute *DASH.auditor.record("Started app version #{DASH.config.version}"), verbosity: :debug
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
# Every slot's container of the version, up to max: a stopped app does not know
|
|
60
|
+
# how many replicas it ran, and a slot that never existed just fails to start.
|
|
61
|
+
role.replica_numbers.each do |replica|
|
|
62
|
+
execute *DASH.app(role: role, host: host, replica: replica).start, raise_on_non_zero_exit: false
|
|
63
|
+
end
|
|
59
64
|
|
|
60
65
|
if role.running_proxy?
|
|
61
|
-
|
|
62
|
-
endpoint
|
|
63
|
-
|
|
66
|
+
endpoints = Dash::Cli::App::RunningTargets.new(self, role: role, host: host).container_ids
|
|
67
|
+
raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoints.empty?
|
|
68
|
+
|
|
69
|
+
if endpoints.size < role.replicas.min
|
|
70
|
+
raise Dash::Cli::BootError, "Found #{endpoints.size} of the #{role.replicas.min} replicas of #{role} on #{host}, did every container start?"
|
|
71
|
+
end
|
|
64
72
|
|
|
65
73
|
cli.run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
|
|
66
|
-
execute *app.deploy(
|
|
74
|
+
execute *app.deploy(targets: endpoints)
|
|
67
75
|
cli.run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
|
|
68
76
|
end
|
|
69
77
|
end
|
|
@@ -77,15 +85,15 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
77
85
|
app = DASH.app(role: role, host: host)
|
|
78
86
|
execute *DASH.auditor.record("Stopped app", role: role), verbosity: :debug
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
execute *app.remove, raise_on_non_zero_exit: false
|
|
85
|
-
end
|
|
88
|
+
targets = Dash::Cli::App::RunningTargets.new(self, role: role, host: host)
|
|
89
|
+
|
|
90
|
+
if role.running_proxy? && targets.container_ids.any?
|
|
91
|
+
execute *app.remove, raise_on_non_zero_exit: false
|
|
86
92
|
end
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
targets.replicas.each do |replica|
|
|
95
|
+
execute *DASH.app(role: role, host: host, replica: replica).stop, raise_on_non_zero_exit: false
|
|
96
|
+
end
|
|
89
97
|
end
|
|
90
98
|
end
|
|
91
99
|
end
|
|
@@ -102,6 +110,7 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
102
110
|
desc "exec [CMD...]", "Execute a custom command on servers within the app container (use --help to show options)"
|
|
103
111
|
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
|
|
104
112
|
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
|
|
113
|
+
option :replica, type: :numeric, default: 1, desc: "Replica slot whose container --reuse runs in"
|
|
105
114
|
option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
|
|
106
115
|
option :detach, type: :boolean, default: false, desc: "Execute command in a detached container"
|
|
107
116
|
option :raw, type: :boolean, default: false, desc: "Output raw, unmodified stdout"
|
|
@@ -127,12 +136,17 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
127
136
|
env = options[:env]
|
|
128
137
|
detach = options[:detach]
|
|
129
138
|
quiet = options[:quiet]
|
|
139
|
+
replica = options[:replica]
|
|
130
140
|
case
|
|
131
141
|
when options[:interactive] && options[:reuse]
|
|
142
|
+
unless DASH.primary_role.replica_numbers.include?(replica)
|
|
143
|
+
raise ArgumentError, "#{DASH.primary_role} has no replica #{replica}, it runs at most #{DASH.primary_role.replicas.max}"
|
|
144
|
+
end
|
|
145
|
+
|
|
132
146
|
say "Get current version of running container...", :magenta unless options[:version]
|
|
133
147
|
using_version(options[:version] || current_running_version) do |version|
|
|
134
148
|
say "Launching interactive command with version #{version} via SSH from existing container on #{DASH.primary_host}...", :magenta
|
|
135
|
-
run_locally { exec DASH.app(role: DASH.primary_role, host: DASH.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) }
|
|
149
|
+
run_locally { exec DASH.app(role: DASH.primary_role, host: DASH.primary_host, replica: replica).execute_in_existing_container_over_ssh(cmd, env: env) }
|
|
136
150
|
end
|
|
137
151
|
|
|
138
152
|
when options[:interactive]
|
|
@@ -151,8 +165,10 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
151
165
|
say "Launching command with version #{version} from existing container...", :magenta
|
|
152
166
|
|
|
153
167
|
on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
|
|
168
|
+
next unless role.replica_numbers.include?(replica)
|
|
169
|
+
|
|
154
170
|
execute *DASH.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
|
|
155
|
-
puts_by_host host, capture_with_info(*DASH.app(role: role, host: host).execute_in_existing_container(cmd, env: env), strip: !raw), quiet: quiet, raw: raw
|
|
171
|
+
puts_by_host host, capture_with_info(*DASH.app(role: role, host: host, replica: replica).execute_in_existing_container(cmd, env: env), strip: !raw), quiet: quiet, raw: raw
|
|
156
172
|
end
|
|
157
173
|
end
|
|
158
174
|
|
|
@@ -172,11 +188,23 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
172
188
|
end
|
|
173
189
|
|
|
174
190
|
desc "containers", "Show app containers on servers"
|
|
191
|
+
option :json, type: :boolean, default: false, desc: "Print every container per host as JSON (role, replica, version, state, health)"
|
|
175
192
|
def containers
|
|
193
|
+
return puts_json { Dash::Diagnostics::Containers.new(accessories: []).to_h } if options[:json]
|
|
194
|
+
|
|
176
195
|
quiet = options[:quiet]
|
|
177
196
|
on(DASH.app_hosts) { |host| puts_by_host host, capture_with_info(*DASH.app.list_containers), quiet: quiet }
|
|
178
197
|
end
|
|
179
198
|
|
|
199
|
+
desc "stats", "Show CPU, memory, network and block I/O of the app containers (a one-second sample per host)"
|
|
200
|
+
option :json, type: :boolean, default: false, desc: "Print the stats per host as JSON"
|
|
201
|
+
def stats
|
|
202
|
+
return puts_json { Dash::Diagnostics::ContainerStats.new(accessories: []).to_h } if options[:json]
|
|
203
|
+
|
|
204
|
+
pre_connect_if_required
|
|
205
|
+
Dash::Diagnostics::ContainerStats.new(accessories: []).to_h[:hosts].each { |host| puts Dash::Cli::StatsTable.lines("App", host) }
|
|
206
|
+
end
|
|
207
|
+
|
|
180
208
|
desc "stale_containers", "Detect app stale containers"
|
|
181
209
|
option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
|
|
182
210
|
def stale_containers
|
|
@@ -185,16 +213,23 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
185
213
|
|
|
186
214
|
with_lock_if_stopping do
|
|
187
215
|
on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
role.replica_numbers.each do |replica|
|
|
217
|
+
app = DASH.app(role: role, host: host, replica: replica)
|
|
218
|
+
listed, running = Dash::Commands::App.split_state(capture_with_info(*app.stale_state, raise_on_non_zero_exit: false))
|
|
219
|
+
slot_version = Dash::Cli::App::SlotVersion.new(self, app)
|
|
220
|
+
running = slot_version.resolve(running)
|
|
221
|
+
next unless running # nothing of this slot runs, so nothing is stale relative to it
|
|
222
|
+
|
|
223
|
+
versions = listed.strip.split("\n").reject { |version| slot_version.foreign?(version) } - [ running ]
|
|
224
|
+
name = replica == 1 ? "role #{role}" : "role #{role} replica #{replica}"
|
|
225
|
+
|
|
226
|
+
versions.each do |version|
|
|
227
|
+
if stop
|
|
228
|
+
puts_by_host host, "Stopping stale container for #{name} with version #{version}", quiet: quiet
|
|
229
|
+
execute *app.stop(version: version), raise_on_non_zero_exit: false
|
|
230
|
+
else
|
|
231
|
+
puts_by_host host, "Detected stale container for #{name} with version #{version} (use `dash app stale_containers --stop` to stop)", quiet: quiet
|
|
232
|
+
end
|
|
198
233
|
end
|
|
199
234
|
end
|
|
200
235
|
end
|
|
@@ -215,6 +250,7 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
215
250
|
option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
|
|
216
251
|
option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
|
|
217
252
|
option :container_id, desc: "Docker container ID to fetch logs"
|
|
253
|
+
option :replica, type: :numeric, desc: "Replica slot to show (default: every replica, or replica 1 with --follow)"
|
|
218
254
|
def logs
|
|
219
255
|
# FIXME: Catch when app containers aren't running
|
|
220
256
|
|
|
@@ -225,8 +261,15 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
225
261
|
timestamps = !options[:skip_timestamps]
|
|
226
262
|
quiet = options[:quiet]
|
|
227
263
|
|
|
264
|
+
replica = options[:replica]
|
|
265
|
+
|
|
228
266
|
if options[:follow]
|
|
229
267
|
lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set
|
|
268
|
+
follow_role = DASH.specific_roles&.first || DASH.primary_role
|
|
269
|
+
|
|
270
|
+
if replica && !follow_role.replica_numbers.include?(replica)
|
|
271
|
+
raise ArgumentError, "#{follow_role} has no replica #{replica}, it runs at most #{follow_role.replicas.max}"
|
|
272
|
+
end
|
|
230
273
|
|
|
231
274
|
run_locally do
|
|
232
275
|
info "Following logs on #{DASH.primary_host}..."
|
|
@@ -234,7 +277,7 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
234
277
|
DASH.specific_roles ||= [ DASH.primary_role.name ]
|
|
235
278
|
role = DASH.roles_on(DASH.primary_host).first
|
|
236
279
|
|
|
237
|
-
app = DASH.app(role: role, host: host)
|
|
280
|
+
app = DASH.app(role: role, host: host, replica: replica || 1)
|
|
238
281
|
info app.follow_logs(host: DASH.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
|
|
239
282
|
exec app.follow_logs(host: DASH.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
|
|
240
283
|
end
|
|
@@ -242,10 +285,19 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
242
285
|
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
|
|
243
286
|
|
|
244
287
|
on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
# A --container-id names one container, whatever the slot.
|
|
289
|
+
slots = replica || container_id ? [ replica || 1 ] & role.replica_numbers : role.replica_numbers
|
|
290
|
+
|
|
291
|
+
slots.each do |slot|
|
|
292
|
+
# Each replica's lines under a header of their own, so they are not mistaken for one stream.
|
|
293
|
+
# A --container-id names a container whose slot is unknown, so claim none.
|
|
294
|
+
type = role.replicas.scalable? && (replica || !container_id) ? "App (replica #{slot})" : "App"
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
puts_by_host host, capture_with_info(*DASH.app(role: role, host: host, replica: slot).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)), type: type, quiet: quiet
|
|
298
|
+
rescue SSHKit::Command::Failed
|
|
299
|
+
puts_by_host host, "Nothing found", type: type, quiet: quiet
|
|
300
|
+
end
|
|
249
301
|
end
|
|
250
302
|
end
|
|
251
303
|
end
|
|
@@ -301,6 +353,11 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
301
353
|
on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
|
|
302
354
|
execute *DASH.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
|
|
303
355
|
execute *DASH.app(role: role, host: host).remove_container(version: version)
|
|
356
|
+
|
|
357
|
+
# A slot that never ran the version has nothing to remove.
|
|
358
|
+
role.replica_numbers.drop(1).each do |replica|
|
|
359
|
+
execute *DASH.app(role: role, host: host, replica: replica).remove_container(version: version), raise_on_non_zero_exit: false
|
|
360
|
+
end
|
|
304
361
|
end
|
|
305
362
|
end
|
|
306
363
|
end
|
|
@@ -408,10 +465,10 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
408
465
|
def current_running_version(host: DASH.primary_host)
|
|
409
466
|
version = nil
|
|
410
467
|
on(host) do
|
|
411
|
-
|
|
412
|
-
version =
|
|
468
|
+
app = DASH.app(role: DASH.roles_on(host).first, host: host)
|
|
469
|
+
version = Dash::Cli::App::SlotVersion.new(self, app).resolve(capture_with_info(*app.current_running_version))
|
|
413
470
|
end
|
|
414
|
-
version
|
|
471
|
+
version
|
|
415
472
|
end
|
|
416
473
|
|
|
417
474
|
def version_or_latest
|