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.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dash/autoscale/connections/builder.rb +51 -0
  3. data/lib/dash/autoscale/connections.rb +102 -0
  4. data/lib/dash/autoscale/controller.rb +296 -0
  5. data/lib/dash/autoscale/cron.rb +75 -0
  6. data/lib/dash/autoscale/decision.rb +89 -0
  7. data/lib/dash/autoscale/duration.rb +19 -0
  8. data/lib/dash/autoscale/evaluation.rb +52 -0
  9. data/lib/dash/autoscale/guard.rb +48 -0
  10. data/lib/dash/autoscale/lease.rb +55 -0
  11. data/lib/dash/autoscale/lease_held.rb +2 -0
  12. data/lib/dash/autoscale/lease_lost.rb +2 -0
  13. data/lib/dash/autoscale/member.rb +28 -0
  14. data/lib/dash/autoscale/metrics.rb +70 -0
  15. data/lib/dash/autoscale/metrics_server.rb +71 -0
  16. data/lib/dash/autoscale/observation.rb +41 -0
  17. data/lib/dash/autoscale/pause.rb +34 -0
  18. data/lib/dash/autoscale/policy.rb +198 -0
  19. data/lib/dash/autoscale/pool.rb +103 -0
  20. data/lib/dash/autoscale/prometheus_client.rb +76 -0
  21. data/lib/dash/autoscale/provider/base.rb +43 -0
  22. data/lib/dash/autoscale/provider/exec.rb +94 -0
  23. data/lib/dash/autoscale/provider/upcloud.rb +140 -0
  24. data/lib/dash/autoscale/provider.rb +9 -0
  25. data/lib/dash/autoscale/provider_error.rb +4 -0
  26. data/lib/dash/autoscale/read_only_query.rb +38 -0
  27. data/lib/dash/autoscale/reader.rb +17 -0
  28. data/lib/dash/autoscale/recorder.rb +13 -0
  29. data/lib/dash/autoscale/role_state.rb +110 -0
  30. data/lib/dash/autoscale/sample.rb +13 -0
  31. data/lib/dash/autoscale/signal/pgbus.rb +19 -0
  32. data/lib/dash/autoscale/signal/prometheus.rb +8 -0
  33. data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
  34. data/lib/dash/autoscale/signal.rb +31 -0
  35. data/lib/dash/autoscale/simulation.rb +200 -0
  36. data/lib/dash/autoscale/state_store.rb +132 -0
  37. data/lib/dash/autoscale/timestamp.rb +15 -0
  38. data/lib/dash/autoscale/tunnel.rb +48 -0
  39. data/lib/dash/cli/accessory.rb +16 -0
  40. data/lib/dash/cli/app/boot.rb +69 -44
  41. data/lib/dash/cli/app/boot_state.rb +70 -0
  42. data/lib/dash/cli/app/rollout_boot.rb +28 -9
  43. data/lib/dash/cli/app/running_targets.rb +39 -0
  44. data/lib/dash/cli/app/slot_version.rb +29 -0
  45. data/lib/dash/cli/app.rb +89 -32
  46. data/lib/dash/cli/autoscale.rb +312 -0
  47. data/lib/dash/cli/base.rb +15 -3
  48. data/lib/dash/cli/lock.rb +6 -1
  49. data/lib/dash/cli/main.rb +77 -8
  50. data/lib/dash/cli/proxy/inspection.rb +52 -0
  51. data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
  52. data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
  53. data/lib/dash/cli/proxy/reboot.rb +4 -9
  54. data/lib/dash/cli/proxy.rb +12 -7
  55. data/lib/dash/cli/prune.rb +5 -3
  56. data/lib/dash/cli/report.rb +4 -1
  57. data/lib/dash/cli/scale/host_plan.rb +63 -0
  58. data/lib/dash/cli/scale/join.rb +128 -0
  59. data/lib/dash/cli/scale/leave.rb +58 -0
  60. data/lib/dash/cli/scale/placement.rb +54 -0
  61. data/lib/dash/cli/scale/power_off.rb +26 -0
  62. data/lib/dash/cli/scale/replica_join.rb +74 -0
  63. data/lib/dash/cli/scale/replica_leave.rb +121 -0
  64. data/lib/dash/cli/scale.rb +276 -0
  65. data/lib/dash/cli/server.rb +41 -0
  66. data/lib/dash/cli/stats_table.rb +34 -0
  67. data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
  68. data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
  69. data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
  70. data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
  71. data/lib/dash/commander.rb +17 -3
  72. data/lib/dash/commands/accessory/proxy.rb +1 -1
  73. data/lib/dash/commands/accessory.rb +21 -2
  74. data/lib/dash/commands/app/containers.rb +28 -3
  75. data/lib/dash/commands/app/logging.rb +13 -2
  76. data/lib/dash/commands/app/proxy.rb +4 -4
  77. data/lib/dash/commands/app.rb +90 -10
  78. data/lib/dash/commands/auditor.rb +2 -2
  79. data/lib/dash/commands/autoscale.rb +133 -0
  80. data/lib/dash/commands/base.rb +4 -0
  81. data/lib/dash/commands/proxy/services.rb +10 -0
  82. data/lib/dash/commands/prune.rb +10 -2
  83. data/lib/dash/commands/server.rb +16 -0
  84. data/lib/dash/configuration/accessory.rb +2 -2
  85. data/lib/dash/configuration/autoscale.rb +123 -0
  86. data/lib/dash/configuration/docs/autoscale.yml +141 -0
  87. data/lib/dash/configuration/docs/configuration.yml +8 -2
  88. data/lib/dash/configuration/docs/proxy.yml +12 -9
  89. data/lib/dash/configuration/docs/role.yml +203 -0
  90. data/lib/dash/configuration/loadbalancer.rb +3 -2
  91. data/lib/dash/configuration/proxy/run.rb +1 -1
  92. data/lib/dash/configuration/proxy.rb +19 -7
  93. data/lib/dash/configuration/role/drain.rb +49 -0
  94. data/lib/dash/configuration/role/replicas.rb +72 -0
  95. data/lib/dash/configuration/role/scale/guard.rb +40 -0
  96. data/lib/dash/configuration/role/scale/signal.rb +93 -0
  97. data/lib/dash/configuration/role/scale/window.rb +37 -0
  98. data/lib/dash/configuration/role/scale.rb +199 -0
  99. data/lib/dash/configuration/role.rb +123 -6
  100. data/lib/dash/configuration/validator/autoscale.rb +126 -0
  101. data/lib/dash/configuration/validator/proxy.rb +13 -0
  102. data/lib/dash/configuration/validator/role.rb +26 -5
  103. data/lib/dash/configuration.rb +126 -15
  104. data/lib/dash/diagnostics/audit.rb +28 -0
  105. data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
  106. data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
  107. data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
  108. data/lib/dash/diagnostics/base.rb +53 -0
  109. data/lib/dash/diagnostics/config.rb +32 -0
  110. data/lib/dash/diagnostics/container_stats.rb +67 -0
  111. data/lib/dash/diagnostics/containers.rb +20 -0
  112. data/lib/dash/diagnostics/controller_status.rb +44 -0
  113. data/lib/dash/diagnostics/docker_ps.rb +40 -0
  114. data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
  115. data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
  116. data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
  117. data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
  118. data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
  119. data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
  120. data/lib/dash/diagnostics/doctor.rb +182 -0
  121. data/lib/dash/diagnostics/drift.rb +168 -0
  122. data/lib/dash/diagnostics/host_stats.rb +59 -0
  123. data/lib/dash/diagnostics/lines.rb +14 -0
  124. data/lib/dash/diagnostics/lock.rb +27 -0
  125. data/lib/dash/diagnostics/logs.rb +76 -0
  126. data/lib/dash/diagnostics/pool.rb +41 -0
  127. data/lib/dash/diagnostics/proxy_services.rb +36 -0
  128. data/lib/dash/diagnostics/redactor.rb +48 -0
  129. data/lib/dash/diagnostics/reports.rb +17 -0
  130. data/lib/dash/diagnostics/scale.rb +51 -0
  131. data/lib/dash/diagnostics/units.rb +31 -0
  132. data/lib/dash/mcp/base_tool.rb +43 -0
  133. data/lib/dash/mcp/runner.rb +28 -0
  134. data/lib/dash/mcp/server.rb +40 -0
  135. data/lib/dash/mcp/session.rb +51 -0
  136. data/lib/dash/mcp/tools/audit_tool.rb +11 -0
  137. data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
  138. data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
  139. data/lib/dash/mcp/tools/config_tool.rb +10 -0
  140. data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
  141. data/lib/dash/mcp/tools/containers_tool.rb +10 -0
  142. data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
  143. data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
  144. data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
  145. data/lib/dash/mcp/tools/drift_tool.rb +16 -0
  146. data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
  147. data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
  148. data/lib/dash/mcp/tools/logs_tool.rb +41 -0
  149. data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
  150. data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
  151. data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
  152. data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
  153. data/lib/dash/mcp/transport.rb +14 -0
  154. data/lib/dash/mcp.rb +56 -0
  155. data/lib/dash/sshkit_with_ext.rb +4 -4
  156. data/lib/dash/utils.rb +7 -0
  157. data/lib/dash/version.rb +1 -1
  158. data/lib/dash.rb +2 -0
  159. metadata +119 -5
  160. 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
@@ -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,3 @@
1
+ #!/usr/bin/env sh
2
+
3
+ echo "Scaled $DASH_ROLE in to $DASH_REPLICAS containers on $DASH_HOSTS"
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+
3
+ echo "Scaled $DASH_ROLE out to $DASH_REPLICAS containers on $DASH_HOSTS"
@@ -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..."
@@ -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
- at_exit { @output_logger&.close }
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(target: target)
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 '#{grep}'#{" #{grep_options}" if grep_options}" if 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: container_name(version)),
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: container_name(version)),
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 '#{grep}'#{" #{grep_options}" if grep_options}" if 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(target:)
5
- proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(target: target)
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(target:)
13
- proxy_exec :rollout, :deploy, role.container_prefix, *role.proxy.rollout_deploy_command_args(target: target)
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)