dash 4.1.2 → 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 +12 -9
- 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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Changes how many containers of a role run, at runtime, at the version already running -
|
|
2
|
+
# no build and no deploy. The count is in containers across the role's hosts, like Heroku
|
|
3
|
+
# dynos, spread evenly within each host's `replicas` bounds. A later deploy keeps it,
|
|
4
|
+
# because a deploy boots as many replicas as are running.
|
|
5
|
+
#
|
|
6
|
+
# A role with `scale:` also spans hosts: when its hosts are full, pool members join (see
|
|
7
|
+
# Dash::Cli::Scale::Join), and they leave again before the baseline gives up a container
|
|
8
|
+
# (Dash::Cli::Scale::HostPlan).
|
|
9
|
+
class Dash::Cli::Scale < Dash::Cli::Base
|
|
10
|
+
desc "set ROLE COUNT", "Run COUNT containers of ROLE across its hosts, within its replicas bounds (and scale bounds, joining or removing pool members)"
|
|
11
|
+
option :keep_on_failure, type: :boolean, default: false, desc: "Leave a member that failed to join powered on, for debugging"
|
|
12
|
+
def set(role_name, count)
|
|
13
|
+
role = scalable_role(role_name)
|
|
14
|
+
count = Integer(count, 10)
|
|
15
|
+
ensure_within_bounds(role, count)
|
|
16
|
+
|
|
17
|
+
modify(lock: true) do
|
|
18
|
+
next scale_across_hosts(role, count) if role.scaled?
|
|
19
|
+
|
|
20
|
+
running = running_replicas(role)
|
|
21
|
+
placement = Dash::Cli::Scale::Placement.new(running: running.transform_values(&:keys), target: count, max: role.replicas.max)
|
|
22
|
+
|
|
23
|
+
if placement.additions.empty? && placement.removals.empty?
|
|
24
|
+
say "#{role} already runs #{count} containers", :magenta
|
|
25
|
+
else
|
|
26
|
+
scale_out(role, placement.additions, running, count) if placement.additions.any?
|
|
27
|
+
scale_in(role, placement.removals, running, count) if placement.removals.any?
|
|
28
|
+
say "#{role} now runs #{count} containers", :magenta
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "status [ROLE]", "Show the containers of each role per host (or one role)"
|
|
34
|
+
option :json, type: :boolean, default: false, desc: "Print the status as JSON"
|
|
35
|
+
def status(role_name = nil)
|
|
36
|
+
roles = role_name ? [ scalable_role(role_name) ] : DASH.config.roles
|
|
37
|
+
scale = -> { Dash::Diagnostics::Scale.new(roles: roles).to_h }
|
|
38
|
+
|
|
39
|
+
if options[:json]
|
|
40
|
+
puts_json(&scale)
|
|
41
|
+
else
|
|
42
|
+
pre_connect_if_required
|
|
43
|
+
scale.call[:roles].each { |role| print_status(role) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
no_commands do
|
|
48
|
+
# Run a sub-command on one host and role. Sub-commands read the run's scope from DASH,
|
|
49
|
+
# not from their own --hosts/--roles once DASH is configured, so the scope is set here.
|
|
50
|
+
def narrowed_to(host, role)
|
|
51
|
+
hosts, roles = DASH.specific_hosts, DASH.specific_roles&.map(&:name)
|
|
52
|
+
DASH.specific_hosts = [ host ]
|
|
53
|
+
DASH.specific_roles = [ role.name ]
|
|
54
|
+
yield
|
|
55
|
+
ensure
|
|
56
|
+
DASH.specific_roles = roles
|
|
57
|
+
DASH.specific_hosts = hosts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def invoke_narrowed(command, cli_class, **command_options)
|
|
61
|
+
invoke command, [], options.except("keep_on_failure").merge(command_options)
|
|
62
|
+
reset_invocation(cli_class)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def on_hosts(hosts, &block)
|
|
66
|
+
on(hosts, &block)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def report(message, color = nil)
|
|
70
|
+
say message, color
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Takes out a started member that no longer answers SSH, under the deploy lock, without
|
|
74
|
+
# a command reaching it (see Dash::Cli::Scale::Leave). Its run directory is never
|
|
75
|
+
# ensured: acquiring the lock would otherwise SSH to every host of the app, it included.
|
|
76
|
+
# `count` is the role's container count, for the scale-in hooks.
|
|
77
|
+
def replace_unreachable(role, member, count:)
|
|
78
|
+
skipped = !role.baseline_hosts.include?(member.host) && !DASH.run_directory_ensured_on.include?(member.host)
|
|
79
|
+
DASH.run_directory_ensured_on << member.host if skipped
|
|
80
|
+
|
|
81
|
+
modify(lock: true) do
|
|
82
|
+
Dash::Cli::Scale::Leave.new(role, member, self, running: {}, count: count, reachable: false).run
|
|
83
|
+
end
|
|
84
|
+
ensure
|
|
85
|
+
# The same host may come back as a powered-on member, whose run directory is then real.
|
|
86
|
+
DASH.run_directory_ensured_on.delete(member.host) if skipped
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def deploy_loadbalancer(except: nil)
|
|
90
|
+
targets = DASH.loadbalancer_config.target_hosts - Array(except)
|
|
91
|
+
|
|
92
|
+
on(DASH.config.proxy.effective_loadbalancer) do |host|
|
|
93
|
+
Dash::Cli::Proxy::LoadbalancerDeploy.new(host, self, targets: targets).run
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
def scalable_role(role_name)
|
|
100
|
+
DASH.config.role(role_name) || raise(ArgumentError, "No role named #{role_name}, expected one of #{DASH.config.roles.map(&:name).join(", ")}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ensure_within_bounds(role, count)
|
|
104
|
+
return ensure_within_scale_bounds(role, count) if role.scaled?
|
|
105
|
+
|
|
106
|
+
hosts = role.hosts.size
|
|
107
|
+
bounds = (hosts * role.replicas.min)..(hosts * role.replicas.max)
|
|
108
|
+
|
|
109
|
+
unless bounds.cover?(count)
|
|
110
|
+
raise ArgumentError, "#{role} runs #{bounds.min} to #{bounds.max} containers on its #{hosts} #{"host".pluralize(hosts)} " \
|
|
111
|
+
"(replicas min #{role.replicas.min}, max #{role.replicas.max} per host), not #{count}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def ensure_within_scale_bounds(role, count)
|
|
116
|
+
scale, replicas = role.scale, role.replicas
|
|
117
|
+
bounds = (scale.min * replicas.min)..(scale.max * replicas.max)
|
|
118
|
+
|
|
119
|
+
unless bounds.cover?(count)
|
|
120
|
+
raise ArgumentError, "#{role} runs #{bounds.min} to #{bounds.max} containers on #{scale.min} to #{scale.max} hosts " \
|
|
121
|
+
"(replicas min #{replicas.min}, max #{replicas.max} per host), not #{count}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Joins first, so the count never dips on the way; then the slots on the hosts that
|
|
126
|
+
# stay, adds before removals; members leave between the two, so a scale-in empties
|
|
127
|
+
# members before the baseline gives anything up.
|
|
128
|
+
def scale_across_hosts(role, count)
|
|
129
|
+
if DASH.config.pool.degraded?(role)
|
|
130
|
+
raise ArgumentError, "the #{role} pool could not be read, so dash scale set cannot count the containers it runs; " \
|
|
131
|
+
"try again once the provider answers"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
running = running_replicas(role)
|
|
135
|
+
members = role.active_members
|
|
136
|
+
plan = Dash::Cli::Scale::HostPlan.new(baseline: role.baseline_hosts, members: members.map(&:host), target: count,
|
|
137
|
+
replicas_min: role.replicas.min, replicas_max: role.replicas.max, hosts_min: role.scale.min, hosts_max: role.scale.max)
|
|
138
|
+
|
|
139
|
+
ensure_members_can_move(role, members, plan)
|
|
140
|
+
joined = join_members(role, plan, running, count)
|
|
141
|
+
running = running_replicas(role) if joined.any?
|
|
142
|
+
|
|
143
|
+
counts = plan.counts.merge(joined.zip(plan.join_counts).to_h)
|
|
144
|
+
changes = counts.to_h { |host, target| [ host, Dash::Cli::Scale::HostPlan.slot_changes(running.fetch(host, {}).keys, target) ] }
|
|
145
|
+
additions = changes.transform_values(&:first).reject { |_, slots| slots.empty? }
|
|
146
|
+
removals = changes.transform_values(&:last).reject { |_, slots| slots.empty? }
|
|
147
|
+
|
|
148
|
+
if joined.empty? && plan.leaving.empty? && additions.empty? && removals.empty?
|
|
149
|
+
return say "#{role} already runs #{count} containers", :magenta
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
scale_out(role, additions, running, count) if additions.any?
|
|
153
|
+
plan.leaving.each do |host|
|
|
154
|
+
Dash::Cli::Scale::Leave.new(role, members.find { |member| member.host == host }, self, running: running.fetch(host, {}), count: count).run
|
|
155
|
+
end
|
|
156
|
+
scale_in(role, removals, running, count) if removals.any?
|
|
157
|
+
|
|
158
|
+
say "#{role} now runs #{count} containers on #{counts.size} #{"host".pluralize(counts.size)}", :magenta
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def ensure_members_can_move(role, members, plan)
|
|
162
|
+
return if plan.joins.zero? && plan.leaving.empty?
|
|
163
|
+
|
|
164
|
+
if role.scale.power? && (stopped = role.members.count(&:stopped?)) < plan.joins
|
|
165
|
+
raise ArgumentError, "#{role} needs #{plan.joins} more #{"host".pluralize(plan.joins)} but has #{stopped} stopped " \
|
|
166
|
+
"#{"member".pluralize(stopped)} to power on (see dash doctor for the labels a member carries)"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def join_members(role, plan, running, count)
|
|
171
|
+
return [] if plan.joins.zero?
|
|
172
|
+
|
|
173
|
+
version = join_version(role, running)
|
|
174
|
+
stopped = role.members.select(&:stopped?)
|
|
175
|
+
|
|
176
|
+
plan.joins.times.map do |index|
|
|
177
|
+
Dash::Cli::Scale::Join.new(role, self, member: (stopped[index] if role.scale.power?), version: version, count: count,
|
|
178
|
+
keep_on_failure: options[:keep_on_failure]).run
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The version the role runs, baseline first - never this checkout's version.
|
|
183
|
+
def join_version(role, running)
|
|
184
|
+
replicas = role.hosts.lazy.map { |host| running.fetch(host, {}) }.find(&:any?)
|
|
185
|
+
raise Dash::Cli::BootError, "No running version of #{role} to join a member at; run dash deploy first" unless replicas
|
|
186
|
+
|
|
187
|
+
host_version(role, replicas)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# { host => { replica => version } } for every running container of the role, one
|
|
191
|
+
# `docker ps` per host. Hosts run in parallel threads, so the hash is guarded.
|
|
192
|
+
def running_replicas(role)
|
|
193
|
+
running = {}
|
|
194
|
+
mutex = Mutex.new
|
|
195
|
+
|
|
196
|
+
on(role.hosts) do |host|
|
|
197
|
+
names = capture_with_info(*DASH.app(role: role, host: host).active_containers).lines.map(&:strip)
|
|
198
|
+
replicas = names.filter_map { |name| [ role.replica_from_name(name), role.version_from_name(name) ] if role.replica_from_name(name) }.reverse.to_h
|
|
199
|
+
|
|
200
|
+
mutex.synchronize { running[host.to_s] = replicas }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
role.hosts.to_h { |host| [ host, running.fetch(host, {}) ] }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# New replicas boot at the version their host already runs. Hosts are grouped by that
|
|
207
|
+
# version because the image a container runs comes from the one shared config.
|
|
208
|
+
def scale_out(role, additions, running, count)
|
|
209
|
+
hosts = role.hosts & additions.keys
|
|
210
|
+
run_hook "pre-scale-out", role: role.name, hosts: hosts.join(","), replicas: count.to_s
|
|
211
|
+
|
|
212
|
+
hosts.group_by { |host| host_version(role, running[host]) }.each do |version, group|
|
|
213
|
+
using_version(version) do
|
|
214
|
+
on(group) do |host|
|
|
215
|
+
Dash::Cli::Scale::ReplicaJoin.new(host, role, additions.fetch(host.to_s), self, running: running[host.to_s].keys).run
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
group.each { |host| additions[host].each { |replica| say "Added #{role.replica_name(replica, version)} on #{host}" } }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
run_hook "post-scale-out", role: role.name, hosts: hosts.join(","), replicas: count.to_s
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def scale_in(role, removals, running, count)
|
|
226
|
+
hosts = role.hosts & removals.keys
|
|
227
|
+
run_hook "pre-scale-in", role: role.name, hosts: hosts.join(","), replicas: count.to_s
|
|
228
|
+
|
|
229
|
+
on(hosts) do |host|
|
|
230
|
+
leaving = removals.fetch(host.to_s).to_h { |replica| [ replica, running[host.to_s][replica] ] }
|
|
231
|
+
Dash::Cli::Scale::ReplicaLeave.new(host, role, leaving, self, running: running[host.to_s].keys).run
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
removals.each { |host, replicas| replicas.each { |replica| say "Removed #{role.replica_name(replica, running[host][replica])} on #{host}" } }
|
|
235
|
+
|
|
236
|
+
run_hook "post-scale-in", role: role.name, hosts: hosts.join(","), replicas: count.to_s
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The version names the image, so a container a boot renamed out of the way
|
|
240
|
+
# (`<version>_replaced_<hex>`) still runs `<version>`.
|
|
241
|
+
def host_version(role, replicas)
|
|
242
|
+
version = replicas[1] || replicas.values.first || raise(Dash::Cli::BootError, "Nothing of #{role} runs on a host to scale from, deploy it first")
|
|
243
|
+
version.sub(/_replaced_\h{16}\z/, "")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def using_version(version)
|
|
247
|
+
old_version = DASH.config.version
|
|
248
|
+
DASH.config.version = version
|
|
249
|
+
yield
|
|
250
|
+
ensure
|
|
251
|
+
DASH.config.version = old_version
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def print_status(role)
|
|
255
|
+
return say("#{role[:role]}: #{role[:error]}", :red) if role[:error]
|
|
256
|
+
|
|
257
|
+
hosts = " on #{role[:scale][:min]} to #{role[:scale][:max]} hosts" if role[:scale]
|
|
258
|
+
say "#{role[:role]}: #{role[:total]} #{"container".pluralize(role[:total])} (replicas min #{role[:min]}, max #{role[:max]} per host#{hosts})"
|
|
259
|
+
|
|
260
|
+
role[:hosts].each do |host, replicas|
|
|
261
|
+
say format(" %-20s %-3s %s", host, "-", "no replicas running") if replicas.empty?
|
|
262
|
+
|
|
263
|
+
replicas.each do |replica|
|
|
264
|
+
say format(" %-20s %-3s %-20s %s", host, replica[:replica], replica[:version], replica[:status])
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
role[:unread].each { |host| say format(" %-20s %-3s %s", host[:host], "-", "could not read (#{host[:error]})"), :red }
|
|
269
|
+
Array(role[:members]).each { |member| print_member(member) }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def print_member(member)
|
|
273
|
+
orphan = " - orphan: started, but runs nothing of the role" if member[:orphan]
|
|
274
|
+
say format(" member %-12s %-20s %s%s", member[:id] || "?", member[:host], member[:state], orphan), (:yellow if member[:orphan])
|
|
275
|
+
end
|
|
276
|
+
end
|
data/lib/dash/cli/server.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
class Dash::Cli::Server < Dash::Cli::Base
|
|
2
|
+
HOST_STATS_COLUMNS = "%-20s %-16s %4s %-20s %-16s %-12s %-12s %s".freeze
|
|
3
|
+
|
|
2
4
|
desc "exec", "Run a custom command on the server (use --help to show options)"
|
|
3
5
|
option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)"
|
|
4
6
|
option :raw, type: :boolean, default: false, desc: "Output raw, unmodified stdout"
|
|
@@ -34,6 +36,16 @@ class Dash::Cli::Server < Dash::Cli::Base
|
|
|
34
36
|
end
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
desc "stats", "Show load, memory, swap, disk and uptime of the servers"
|
|
40
|
+
option :json, type: :boolean, default: false, desc: "Print the stats per host as JSON"
|
|
41
|
+
def stats
|
|
42
|
+
return puts_json { Dash::Diagnostics::HostStats.new.to_h } if options[:json]
|
|
43
|
+
|
|
44
|
+
pre_connect_if_required
|
|
45
|
+
puts format(HOST_STATS_COLUMNS, "HOST", "LOAD 1/5/15", "CPUS", "MEMORY USED", "SWAP USED", "DISK /", "DISK DOCKER", "UPTIME")
|
|
46
|
+
Dash::Diagnostics::HostStats.new.to_h[:hosts].each { |host| puts host_stats_line(host) }
|
|
47
|
+
end
|
|
48
|
+
|
|
37
49
|
desc "bootstrap", "Set up Docker to run dash apps"
|
|
38
50
|
def bootstrap
|
|
39
51
|
modify(lock: true) do
|
|
@@ -67,4 +79,33 @@ class Dash::Cli::Server < Dash::Cli::Base
|
|
|
67
79
|
run_hook "docker-setup"
|
|
68
80
|
end
|
|
69
81
|
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
def host_stats_line(host)
|
|
85
|
+
return format("%-20s ERROR %s", host[:host], host[:error]) if host[:error]
|
|
86
|
+
|
|
87
|
+
load = host[:load] ? host[:load].values_at(:one, :five, :fifteen).join("/") : "-"
|
|
88
|
+
memory = host[:memory] ? "#{host[:memory][:used_percent]}% of #{Dash::Utils.human_bytes(host[:memory][:total_bytes])}" : "-"
|
|
89
|
+
format(HOST_STATS_COLUMNS, host[:host], load, host[:cpus] || "-", memory, swap_used(host[:swap]), disk_used(host[:disk][:root]), disk_used(host[:disk][:docker_root]), uptime(host[:uptime_seconds]))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A host with no swap configured reads "none" rather than a 0% that suggests headroom.
|
|
93
|
+
def swap_used(swap)
|
|
94
|
+
return "-" unless swap
|
|
95
|
+
return "none" if swap[:total_bytes].to_i.zero?
|
|
96
|
+
|
|
97
|
+
used = ((swap[:total_bytes] - swap[:free_bytes]) * 100.0 / swap[:total_bytes]).round
|
|
98
|
+
"#{used}% of #{Dash::Utils.human_bytes(swap[:total_bytes])}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def disk_used(disk)
|
|
102
|
+
disk ? "#{disk[:used_percent].round}%" : "-"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def uptime(seconds)
|
|
106
|
+
return "-" unless seconds
|
|
107
|
+
|
|
108
|
+
days, rest = seconds.divmod(86_400)
|
|
109
|
+
days > 0 ? "#{days}d #{rest / 3600}h" : "#{rest / 3600}h #{rest % 3600 / 60}m"
|
|
110
|
+
end
|
|
70
111
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# The human table for `dash app stats` and `dash accessory stats`: one block per host, a
|
|
2
|
+
# row per container. The JSON is the ContainerStats diagnostic's own `to_h`.
|
|
3
|
+
module Dash::Cli::StatsTable
|
|
4
|
+
COLUMNS = " %-28s %-10s %4s %8s %21s %21s %21s %6s".freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# `host` is one entry of ContainerStats#to_h's `hosts` or `accessories`.
|
|
9
|
+
def lines(type, host)
|
|
10
|
+
heading = "#{type} Host: #{host[:host]}"
|
|
11
|
+
return [ heading, " ERROR #{host[:error]}" ] if host[:error]
|
|
12
|
+
return [ heading, " no containers running" ] if host[:containers].empty?
|
|
13
|
+
|
|
14
|
+
[ heading, format(COLUMNS, "CONTAINER", host[:accessory] ? "ACCESSORY" : "ROLE", "SLOT", "CPU", "MEMORY / LIMIT", "NET IN / OUT", "BLOCK READ / WRITE", "PIDS"),
|
|
15
|
+
*host[:containers].map { |container| row(container) }, *(" WARN #{host[:stats_error]}" if host[:stats_error]) ]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def row(container)
|
|
19
|
+
stats = container[:stats] || {}
|
|
20
|
+
|
|
21
|
+
format COLUMNS, container[:name], container[:role] || container[:accessory], container[:replica] || "-",
|
|
22
|
+
percent(stats[:cpu_percent]), memory(stats), stats.dig(:raw, :net_io) || "-", stats.dig(:raw, :block_io) || "-", stats[:pids] || "-"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def percent(value)
|
|
26
|
+
value ? format("%.1f%%", value) : "-"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def memory(stats)
|
|
30
|
+
return stats.dig(:raw, :memory) || "-" unless stats[:memory_bytes] && stats[:memory_limit_bytes]
|
|
31
|
+
|
|
32
|
+
"#{Dash::Utils.human_bytes(stats[:memory_bytes])} / #{Dash::Utils.human_bytes(stats[:memory_limit_bytes])}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
|
|
3
|
+
# Runs before `dash scale set` removes containers of a role. $DASH_HOSTS are the hosts
|
|
4
|
+
# that lose replicas; $DASH_REPLICAS is the total the role will run afterwards.
|
|
5
|
+
|
|
6
|
+
echo "Scaling $DASH_ROLE in to $DASH_REPLICAS containers on $DASH_HOSTS..."
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
|
|
3
|
+
# Runs before `dash scale set` adds containers of a role. $DASH_HOSTS are the hosts that
|
|
4
|
+
# get new replicas; $DASH_REPLICAS is the total the role will run afterwards.
|
|
5
|
+
|
|
6
|
+
echo "Scaling $DASH_ROLE out to $DASH_REPLICAS containers on $DASH_HOSTS..."
|
data/lib/dash/commander.rb
CHANGED
|
@@ -49,6 +49,19 @@ class Dash::Commander
|
|
|
49
49
|
@config, @config_kwargs = nil, kwargs
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# A fresh read of deploy.yml for a long-lived process (`dash mcp`), one per question:
|
|
53
|
+
# the builders bound to the old Configuration and any --hosts/--roles narrowing go,
|
|
54
|
+
# `connected` stays, so the pre-connect hook does not fire again. The output loggers are
|
|
55
|
+
# closed, since loading the config broadcasts to them again.
|
|
56
|
+
def reconfigure(**kwargs)
|
|
57
|
+
configure(**kwargs)
|
|
58
|
+
@output_logger&.close
|
|
59
|
+
@output_logger = nil
|
|
60
|
+
@commands = {}
|
|
61
|
+
@loadbalancer_config = nil
|
|
62
|
+
@specifics = @specific_hosts = @specific_roles = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
52
65
|
def configured?
|
|
53
66
|
@config || @config_kwargs
|
|
54
67
|
end
|
|
@@ -91,8 +104,8 @@ class Dash::Commander
|
|
|
91
104
|
config.accessories&.collect(&:name) || []
|
|
92
105
|
end
|
|
93
106
|
|
|
94
|
-
def app(role: nil, host: nil)
|
|
95
|
-
Dash::Commands::App.new(config, role: role, host: host)
|
|
107
|
+
def app(role: nil, host: nil, replica: 1)
|
|
108
|
+
Dash::Commands::App.new(config, role: role, host: host, replica: replica)
|
|
96
109
|
end
|
|
97
110
|
|
|
98
111
|
def accessory(name)
|
|
@@ -238,7 +251,8 @@ class Dash::Commander
|
|
|
238
251
|
|
|
239
252
|
SSHKit.config.output = Dash::Output::Formatter.new($stdout, output_logger)
|
|
240
253
|
|
|
241
|
-
|
|
254
|
+
# Once per commander: a long-lived `dash mcp` loads the config once per question.
|
|
255
|
+
@output_logger_closer ||= at_exit { @output_logger&.close }
|
|
242
256
|
rescue => e
|
|
243
257
|
$stderr.puts "Output logger setup failed: #{e.class}: #{e.message}"
|
|
244
258
|
$stderr.puts e.backtrace.join("\n") if ENV["VERBOSE"]
|
|
@@ -2,7 +2,7 @@ module Dash::Commands::Accessory::Proxy
|
|
|
2
2
|
delegate :container_name, to: :"config.proxy_boot", prefix: :proxy
|
|
3
3
|
|
|
4
4
|
def deploy(target:)
|
|
5
|
-
proxy_exec :deploy, service_name, *proxy.deploy_command_args(
|
|
5
|
+
proxy_exec :deploy, service_name, *proxy.deploy_command_args(targets: [ target ])
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def remove
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
1
3
|
class Dash::Commands::Accessory < Dash::Commands::Base
|
|
2
4
|
include Proxy
|
|
3
5
|
|
|
@@ -37,14 +39,31 @@ class Dash::Commands::Accessory < Dash::Commands::Base
|
|
|
37
39
|
docker :container, :stop, service_name
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
# The accessory's container as `docker ps` JSON, running or not.
|
|
43
|
+
def list_containers_json
|
|
44
|
+
docker :ps, "--all", *service_filter, "--format", "'{{json .}}'"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Same shape as Commands::App#stats_json: the labels (or the unreadable marker), the
|
|
48
|
+
# separator, then `docker stats` behind `xargs -r`, which keeps an accessory that is not
|
|
49
|
+
# running from reporting every container on the host.
|
|
50
|
+
def stats_json
|
|
51
|
+
filters = [ *service_filter, *Dash::Commands::App::ACTIVE_DOCKER_STATUSES.flat_map { |status| [ "--filter", "status=#{status}" ] } ]
|
|
52
|
+
|
|
53
|
+
chain \
|
|
54
|
+
[ *docker(:ps, *filters, "--format", "'{{json .}}'"), "||", :echo, Dash::Commands::App::ACTIVE_CONTAINERS_UNREADABLE ],
|
|
55
|
+
[ :echo, SECTION_SEPARATOR ],
|
|
56
|
+
pipe(docker(:ps, "--quiet", *filters), [ :xargs, "-r", :docker, :stats, "--no-stream", "--format", "'{{json .}}'", "||", :echo, Dash::Diagnostics::ContainerStats::STATS_UNREADABLE ])
|
|
57
|
+
end
|
|
58
|
+
|
|
40
59
|
def info(all: false, quiet: false)
|
|
41
60
|
docker :ps, *("-a" if all), *("-q" if quiet), *service_filter
|
|
42
61
|
end
|
|
43
62
|
|
|
44
63
|
def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
|
|
45
64
|
pipe \
|
|
46
|
-
docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"),
|
|
47
|
-
("grep
|
|
65
|
+
docker(:logs, service_name, (" --since #{since.to_s.shellescape}" if since), (" --tail #{Integer(lines.to_s, 10)}" if lines), ("--timestamps" if timestamps), "2>&1"),
|
|
66
|
+
("grep #{Dash::Utils.single_quote(grep)}#{" #{grep_options}" if grep_options}" if grep)
|
|
48
67
|
end
|
|
49
68
|
|
|
50
69
|
def follow_logs(timestamps: true, grep: nil, grep_options: nil)
|
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
module Dash::Commands::App::Containers
|
|
2
|
+
# A container's `docker ps` JSON, a tab, then docker's own value of its `role` label. The
|
|
3
|
+
# JSON's `Labels` is "k=v,k=v" with no escaping, so a comma in another label's value could
|
|
4
|
+
# pass for a role there; `.Label "role"` cannot be faked that way. JSON never holds a raw tab.
|
|
5
|
+
PS_WITH_ROLE_FORMAT = %q('{{json .}}{{"\t"}}{{json (.Label "role")}}')
|
|
6
|
+
|
|
2
7
|
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
|
|
3
8
|
|
|
4
9
|
def list_containers
|
|
5
10
|
docker :container, :ls, "--all", *container_filter_args
|
|
6
11
|
end
|
|
7
12
|
|
|
13
|
+
# Every container of the service in this destination, one per line in PS_WITH_ROLE_FORMAT.
|
|
14
|
+
# Called without a role it covers every role and every replica slot on the host.
|
|
15
|
+
def list_containers_json
|
|
16
|
+
docker :container, :ls, "--all", *container_filter_args(all_replicas: true), "--format", PS_WITH_ROLE_FORMAT
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The running containers in PS_WITH_ROLE_FORMAT, then the separator,
|
|
20
|
+
# then their `docker stats`. `xargs -r` matters: `docker stats` given no container
|
|
21
|
+
# reports every container on the host, other apps' included. A docker ps that fails
|
|
22
|
+
# prints ACTIVE_CONTAINERS_UNREADABLE, since the chain's `;` hides its exit status and
|
|
23
|
+
# "could not ask" would otherwise read as "nothing runs".
|
|
24
|
+
def stats_json
|
|
25
|
+
filters = container_filter_args(statuses: Dash::Commands::App::ACTIVE_DOCKER_STATUSES, all_replicas: true)
|
|
26
|
+
|
|
27
|
+
chain \
|
|
28
|
+
[ *docker(:ps, *filters, "--format", PS_WITH_ROLE_FORMAT), "||", :echo, Dash::Commands::App::ACTIVE_CONTAINERS_UNREADABLE ],
|
|
29
|
+
[ :echo, Dash::Commands::Base::SECTION_SEPARATOR ],
|
|
30
|
+
pipe(docker(:ps, "--quiet", *filters), [ :xargs, "-r", :docker, :stats, "--no-stream", "--format", "'{{json .}}'", "||", :echo, Dash::Diagnostics::ContainerStats::STATS_UNREADABLE ])
|
|
31
|
+
end
|
|
32
|
+
|
|
8
33
|
def list_container_names
|
|
9
34
|
[ *list_containers, "--format", "'{{ .Names }}'" ]
|
|
10
35
|
end
|
|
11
36
|
|
|
12
37
|
def remove_container(version:)
|
|
13
38
|
pipe \
|
|
14
|
-
container_id_for(container_name:
|
|
39
|
+
container_id_for(container_name: container_name_pattern(version)),
|
|
15
40
|
xargs(docker(:container, :rm))
|
|
16
41
|
end
|
|
17
42
|
|
|
@@ -20,12 +45,12 @@ module Dash::Commands::App::Containers
|
|
|
20
45
|
end
|
|
21
46
|
|
|
22
47
|
def remove_containers
|
|
23
|
-
docker :container, :prune, "--force", *container_filter_args
|
|
48
|
+
docker :container, :prune, "--force", *container_filter_args(all_replicas: true)
|
|
24
49
|
end
|
|
25
50
|
|
|
26
51
|
def container_health_log(version:)
|
|
27
52
|
pipe \
|
|
28
|
-
container_id_for(container_name:
|
|
53
|
+
container_id_for(container_name: container_name_pattern(version)),
|
|
29
54
|
xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT))
|
|
30
55
|
end
|
|
31
56
|
end
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
1
3
|
module Dash::Commands::App::Logging
|
|
4
|
+
# `since` and `grep` are quoted and `lines` must be a number, so none of them can end
|
|
5
|
+
# the command they sit in. `dash mcp` hands them over from an agent, not an operator.
|
|
6
|
+
# `grep_options` is the operator's own extra flags and stays raw.
|
|
2
7
|
def logs(container_id: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
|
|
3
8
|
pipe \
|
|
4
9
|
container_id_command(container_id),
|
|
5
|
-
"xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
|
|
6
|
-
("grep
|
|
10
|
+
"xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since.to_s.shellescape}" if since}#{" --tail #{Integer(lines.to_s, 10)}" if lines} 2>&1",
|
|
11
|
+
("grep #{Dash::Utils.single_quote(grep)}#{" #{grep_options}" if grep_options}" if grep)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Every replica slot's logs in one round trip, each slot's after a `separator` line. Log
|
|
15
|
+
# lines are anyone's text, so the caller picks a separator no log line can guess.
|
|
16
|
+
def replica_logs(separator:, **options)
|
|
17
|
+
chain(*role.replica_numbers.flat_map { |replica| [ [ :echo, separator ], for_replica(replica).logs(**options) ] })
|
|
7
18
|
end
|
|
8
19
|
|
|
9
20
|
def follow_logs(host:, container_id: nil, timestamps: true, lines: nil, grep: nil, grep_options: nil)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
module Dash::Commands::App::Proxy
|
|
2
2
|
delegate :container_name, to: :"config.proxy_boot", prefix: :proxy
|
|
3
3
|
|
|
4
|
-
def deploy(
|
|
5
|
-
proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(
|
|
4
|
+
def deploy(targets:)
|
|
5
|
+
proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(targets: targets)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def remove
|
|
9
9
|
proxy_exec :remove, role.container_prefix
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def rollout_deploy(
|
|
13
|
-
proxy_exec :rollout, :deploy, role.container_prefix, *role.proxy.rollout_deploy_command_args(
|
|
12
|
+
def rollout_deploy(targets:)
|
|
13
|
+
proxy_exec :rollout, :deploy, role.container_prefix, *role.proxy.rollout_deploy_command_args(targets: targets)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def rollout_set(percent: nil, list: nil)
|