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.
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 +1 -1
  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
@@ -16,17 +16,19 @@ class Dash::Configuration
16
16
  delegate :argumentize, :optionize, to: Dash::Utils
17
17
 
18
18
  attr_reader :destination, :raw_config, :secrets
19
- attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :output, :proxy, :proxy_boot, :report, :servers, :ssh, :sshkit, :registry
19
+ attr_reader :accessories, :aliases, :autoscale, :boot, :builder, :env, :logging, :output, :proxy, :proxy_boot, :report, :servers, :ssh, :sshkit, :registry
20
20
 
21
21
  include Validation
22
22
 
23
23
  class << self
24
- def create_from(config_file:, destination: nil, version: nil)
24
+ # `explicit_hosts` / `explicit_roles` are --hosts and --roles, which the pool needs before
25
+ # Commander can filter by them: filtering reads all_hosts, and that asks the pool.
26
+ def create_from(config_file:, destination: nil, version: nil, explicit_hosts: nil, explicit_roles: nil)
25
27
  ENV["DASH_DESTINATION"] = ENV["KAMAL_DESTINATION"] = destination
26
28
 
27
29
  raw_config = load_raw_config(config_file: config_file, destination: destination)
28
30
 
29
- new raw_config, destination: destination, version: version
31
+ new raw_config, destination: destination, version: version, explicit_hosts: explicit_hosts, explicit_roles: explicit_roles
30
32
  end
31
33
 
32
34
  def load_raw_config(config_file:, destination: nil)
@@ -55,10 +57,12 @@ class Dash::Configuration
55
57
  end
56
58
  end
57
59
 
58
- def initialize(raw_config, destination: nil, version: nil, validate: true)
60
+ def initialize(raw_config, destination: nil, version: nil, validate: true, explicit_hosts: nil, explicit_roles: nil)
59
61
  @raw_config = ActiveSupport::InheritableOptions.new(raw_config)
60
62
  @destination = destination
61
63
  @declared_version = version
64
+ @explicit_hosts = explicit_hosts
65
+ @explicit_roles = explicit_roles
62
66
 
63
67
  validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Dash::Configuration::Validator::Configuration
64
68
 
@@ -70,6 +74,7 @@ class Dash::Configuration
70
74
 
71
75
  @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
72
76
  @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
77
+ @autoscale = Autoscale.new(config: self)
73
78
  @boot = Boot.new(config: self)
74
79
  @builder = Builder.new(config: self)
75
80
  @env = Env.new(config: @raw_config.env || {}, secrets: secrets)
@@ -102,6 +107,12 @@ class Dash::Configuration
102
107
  ensure_sleep_has_a_docker_socket
103
108
  ensure_proxy_protocol_names_its_peers
104
109
  ensure_max_idle_conns_meaningful
110
+ ensure_replicas_fit_their_roles
111
+ ensure_scaled_roles_have_a_provider
112
+ ensure_upcloud_templates_are_complete
113
+ ensure_scaled_proxied_roles_have_a_loadbalancer
114
+ ensure_signal_sources_are_configured
115
+ ensure_pgbus_signals_have_no_drain_signal
105
116
  end
106
117
 
107
118
  # Resolves every secret the deploy will need so a missing secret fails fast,
@@ -113,7 +124,9 @@ class Dash::Configuration
113
124
  builder.secrets
114
125
 
115
126
  roles.each do |role|
116
- role.secrets_io(role.hosts.first) if role.hosts.any?
127
+ # A baseline host when there is one: the same secrets, without asking the pool.
128
+ host = role.baseline_hosts.first || role.hosts.first
129
+ role.secrets_io(host) if host
117
130
 
118
131
  if role.running_proxy?
119
132
  role.proxy.run&.secrets_io
@@ -213,6 +226,16 @@ class Dash::Configuration
213
226
  roles.select(&:running_proxy?)
214
227
  end
215
228
 
229
+ def scaled_roles
230
+ roles.select(&:scaled?)
231
+ end
232
+
233
+ # Built on first use, never while the configuration loads: asking the provider is a
234
+ # network call, and loading deploy.yml must not need the network.
235
+ def pool
236
+ @pool ||= Dash::Autoscale::Pool.new(config: self, explicit_hosts: @explicit_hosts, explicit_roles: @explicit_roles)
237
+ end
238
+
216
239
  def load_balancing?
217
240
  proxy&.load_balancing?
218
241
  end
@@ -229,6 +252,12 @@ class Dash::Configuration
229
252
  (proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq
230
253
  end
231
254
 
255
+ # proxy_hosts without the pool members, for what must not ask the provider - where the
256
+ # load balancer runs is decided by deploy.yml alone.
257
+ def baseline_proxy_hosts
258
+ (proxy_roles.flat_map(&:baseline_hosts) + proxy_accessories.flat_map(&:hosts)).uniq
259
+ end
260
+
232
261
  def image
233
262
  name = raw_config&.image.presence
234
263
  name ||= raw_config&.service if registry.local?
@@ -363,8 +392,9 @@ class Dash::Configuration
363
392
  def to_h
364
393
  {
365
394
  roles: role_names,
366
- hosts: all_hosts,
367
- primary_host: primary_host,
395
+ hosts: baseline_hosts,
396
+ members: scaled_roles.to_h { |role| [ role.name.to_s, "#{role.scale.members} members from #{autoscale.provider_name}, #{role.scale.min}-#{role.scale.max} hosts" ] }.presence,
397
+ primary_host: primary_role&.baseline_hosts&.first,
368
398
  version: version,
369
399
  repository: repository,
370
400
  absolute_image: absolute_image,
@@ -402,13 +432,13 @@ class Dash::Configuration
402
432
  raise Dash::ConfigurationError, "The primary_role #{primary_role_name} isn't defined"
403
433
  end
404
434
 
405
- if primary_role.hosts.empty?
435
+ if primary_role.baseline_hosts.empty?
406
436
  raise Dash::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role"
407
437
  end
408
438
 
409
439
  unless allow_empty_roles?
410
440
  roles.each do |role|
411
- if role.hosts.empty?
441
+ if role.baseline_hosts.empty?
412
442
  raise Dash::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true"
413
443
  end
414
444
  end
@@ -458,7 +488,7 @@ class Dash::Configuration
458
488
  # accepts the container as ready once it is merely still running after readiness_delay,
459
489
  # then stops the old one. Warn-only for now so existing configs keep deploying.
460
490
  def ensure_unproxied_roles_are_readiness_gated
461
- offenders = roles.reject { |role| role.hosts.empty? || role.running_proxy? || role.readiness_gated? }
491
+ offenders = roles.reject { |role| role.baseline_hosts.empty? || role.running_proxy? || role.readiness_gated? }
462
492
  return true if offenders.empty?
463
493
 
464
494
  warn "Non-proxied role(s) #{offenders.map(&:name).join(", ")} have no healthcheck. " \
@@ -475,7 +505,7 @@ class Dash::Configuration
475
505
  # port hands that address to whoever asks. Warn rather than raise: a proxy on
476
506
  # a private network with no other route in is a legitimate configuration.
477
507
  def ensure_proxy_protocol_names_its_peers
478
- offenders = all_hosts.select { |host| proxy_run(host)&.proxy_protocol_unrestricted? }
508
+ offenders = baseline_hosts.select { |host| proxy_runs(host, baseline: true).first&.proxy_protocol_unrestricted? }
479
509
  return true if offenders.empty?
480
510
 
481
511
  warn "Host(s) #{offenders.sort.join(", ")}: proxy_protocol is enabled without proxy_protocol_allow_ips, " \
@@ -508,6 +538,79 @@ class Dash::Configuration
508
538
  "through the container runtime socket, and it is not mounted into the proxy without it"
509
539
  end
510
540
 
541
+ # Run once every role and the root proxy exist: a role's merged proxy reads the root
542
+ # one, which Servers.new builds roles ahead of.
543
+ def ensure_replicas_fit_their_roles
544
+ roles.each { |role| role.replicas.ensure_fits!(role, volumes: Array(raw_config.volumes)) }
545
+ end
546
+
547
+ # A scaled role's extra hosts come from the provider; without one there is nothing to
548
+ # power on, and every command would fail at the first `hosts` read instead of here.
549
+ def ensure_scaled_roles_have_a_provider
550
+ return true if autoscale.configured?
551
+
552
+ if (role = scaled_roles.first)
553
+ raise Dash::ConfigurationError, "servers/#{role.name}/scale: needs a provider for its members, set autoscale/provider (see dash docs autoscale)"
554
+ end
555
+
556
+ true
557
+ end
558
+
559
+ # UpCloud builds a server from the template; an exec `create` script reads its own.
560
+ def ensure_upcloud_templates_are_complete
561
+ return true unless autoscale.provider_name == "upcloud"
562
+
563
+ scaled_roles.select { |role| role.scale.create? }.each do |role|
564
+ missing = %w[ storage plan zone ].select { |key| role.scale.template[key].blank? }
565
+
566
+ if missing.any?
567
+ raise Dash::ConfigurationError, "servers/#{role.name}/scale/template: #{missing.join(", ")} #{missing.one? ? "is" : "are"} required to create an UpCloud server"
568
+ end
569
+ end
570
+
571
+ true
572
+ end
573
+
574
+ # A member of a proxied role reaches traffic through the load balancer's target list -
575
+ # that is the only way a host joins or leaves a dash-proxy service across hosts.
576
+ def ensure_scaled_proxied_roles_have_a_loadbalancer
577
+ return true unless proxy.loadbalancer == false
578
+
579
+ if (role = scaled_roles.find(&:running_proxy?))
580
+ raise Dash::ConfigurationError, "servers/#{role.name}/scale: a scaled role behind dash-proxy needs the load balancer, " \
581
+ "remove proxy/loadbalancer: false"
582
+ end
583
+
584
+ true
585
+ end
586
+
587
+ # A signal or guard reads through its autoscale/<connection> block; without one the
588
+ # controller would hold the role with missing_data forever instead of failing here.
589
+ def ensure_signal_sources_are_configured
590
+ scaled_roles.select { |role| role.scale.signal? }.each do |role|
591
+ readers = [ [ "signal", role.scale.signal ] ] + role.scale.guards.each_with_index.map { |guard, index| [ "hold_when/#{index}", guard ] }
592
+
593
+ readers.each do |key, reader|
594
+ next if autoscale.source?(reader.connection)
595
+
596
+ raise Dash::ConfigurationError, "servers/#{role.name}/scale/#{key}: #{reader.source} reads #{reader.connection.capitalize}, " \
597
+ "set autoscale/#{reader.connection} (see dash docs autoscale)"
598
+ end
599
+ end
600
+
601
+ true
602
+ end
603
+ # pgbus has no quiet mode: a drain signal changes nothing, and the drain wait only delays
604
+ # the stop that drains it. Legal, so warn.
605
+ def ensure_pgbus_signals_have_no_drain_signal
606
+ scaled_roles.select { |role| role.scale.signal&.source == "pgbus" && role.drain_signal }.each do |role|
607
+ warn "Role #{role.name}: a pgbus signal with drain/signal #{role.drain_signal} - pgbus has no quiet mode and ignores it. " \
608
+ "It drains its jobs on docker stop, so give the role a stop timeout of at least pgbus's drain_timeout (30s by default)."
609
+ end
610
+
611
+ true
612
+ end
613
+
511
614
  # dash-proxy resolves a zero max_idle_conns to its default of 100
512
615
  # (target_pool.go resolves defaults from zeros), so the one value an
513
616
  # operator writes to mean "keep none" is the one value that cannot mean it.
@@ -629,16 +732,24 @@ class Dash::Configuration
629
732
  end
630
733
 
631
734
  def ensure_no_conflicting_proxy_runs
632
- all_hosts.each do |host|
633
- run_configs = proxy_runs(host)
735
+ baseline_hosts.each do |host|
736
+ run_configs = proxy_runs(host, baseline: true)
634
737
  if run_configs.uniq.size > 1
635
738
  raise Dash::ConfigurationError, "Conflicting proxy run configurations for host #{host}"
636
739
  end
637
740
  end
638
741
  end
639
742
 
640
- def proxy_runs(host)
641
- (host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact
743
+ # `baseline: true` for the checks run while the configuration loads, which must not ask
744
+ # the autoscale provider. A member shares its role's proxy run, so they lose nothing.
745
+ def proxy_runs(host, baseline: false)
746
+ host_roles = baseline ? roles.select { |role| role.baseline_hosts.include?(host) } : host_roles(host)
747
+ (host_roles + host_accessories(host)).map(&:proxy).compact.map(&:run).compact
748
+ end
749
+
750
+ # all_hosts without the pool members.
751
+ def baseline_hosts
752
+ (roles.flat_map(&:baseline_hosts) + accessories.flat_map(&:hosts)).uniq
642
753
  end
643
754
 
644
755
  # `loadbalancer: true` resolves to the primary role's first host, so an
@@ -0,0 +1,28 @@
1
+ # The last lines of this deploy's audit log on each host, parsed into when, who, the tags
2
+ # the line was recorded with (destination, role, ...) and the message.
3
+ class Dash::Diagnostics::Audit < Dash::Diagnostics::Base
4
+ # "[2026-10-05T10:00:00Z] [ops@example.com] [staging] [web] Booted app version 999"
5
+ LINE = /\A\[(?<recorded_at>[^\]]*)\] \[(?<performer>[^\]]*)\](?<tags>(?: \[[^\]]*\])*) ?(?<message>.*)\z/
6
+
7
+ def initialize(hosts: DASH.hosts, lines: 50)
8
+ @hosts = hosts
9
+ @lines = Dash::Diagnostics::Lines.bounded(lines)
10
+ end
11
+
12
+ private
13
+ def snapshot
14
+ { hosts: per_host(@hosts) { |backend, _host| { entries: entries(backend.capture_with_info(*DASH.auditor.reveal(lines: @lines), raise_on_non_zero_exit: false)) } } }
15
+ end
16
+
17
+ def entries(output)
18
+ output.lines.map(&:chomp).reject(&:blank?).map { |line| entry(line) }
19
+ end
20
+
21
+ def entry(line)
22
+ if (match = LINE.match(line))
23
+ { recorded_at: match[:recorded_at], performer: match[:performer], tags: match[:tags].scan(/\[([^\]]*)\]/).flatten, message: match[:message] }
24
+ else
25
+ { message: line }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # `dash autoscale history` and the MCP `autoscale_decisions` tool: the tail of the
2
+ # controller's decision log on the state host, oldest first - of some roles, or of all.
3
+ # Each entry is what the controller decided, why, and every input it used.
4
+ class Dash::Diagnostics::AutoscaleDecisions < Dash::Diagnostics::Base
5
+ DEFAULT_LINES = 50
6
+
7
+ # `roles`: role names, or nil for every role.
8
+ def initialize(roles: nil, lines: DEFAULT_LINES)
9
+ @roles, @lines = roles, Dash::Diagnostics::Lines.bounded(lines)
10
+ end
11
+
12
+ private
13
+ def snapshot
14
+ per_host([ Dash::Autoscale::StateStore.host ]) do |backend, _host|
15
+ { decisions: Dash::Autoscale::StateStore.new(backend).decisions(lines: @lines, roles: @roles) }
16
+ end.first
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # `dash autoscale explain ROLE` and the MCP `autoscale_explain` tool: one live policy
2
+ # evaluation for a scaled role, from the same inputs the controller's next tick reads -
3
+ # the running containers, the pool, and the state and pauses on the state host - with
4
+ # every input and `eligible_at`. Decides, never acts, writes nothing, needs no running
5
+ # controller. State that cannot be read is `state_error`, and the role is evaluated as if
6
+ # the controller remembered nothing.
7
+ class Dash::Diagnostics::AutoscaleExplain < Dash::Diagnostics::Base
8
+ def initialize(role:, now: Time.now.utc)
9
+ raise ArgumentError, "#{role} has no scale, so the autoscale controller never touches it" unless role.scaled?
10
+ # Read on some of its hosts, the role's count would be too low and the decision wrong.
11
+ raise ArgumentError, "explain observes every host of #{role}; it cannot be narrowed with --hosts" if DASH.specific_hosts
12
+
13
+ @role, @now = role, now
14
+ end
15
+
16
+ private
17
+ def snapshot
18
+ host = Dash::Autoscale::StateStore.host
19
+ read = per_host([ host ]) do |backend, _host|
20
+ store = Dash::Autoscale::StateStore.new(backend)
21
+ { state: store.state, pauses: store.pauses }
22
+ end.first
23
+
24
+ state = Dash::Autoscale::RoleState.from(read.dig(:state, "roles", @role.name))
25
+ pause = Dash::Autoscale::Pause.from(read.dig(:pauses, @role.name))
26
+ decision = Dash::Autoscale::Evaluation.new(role: @role, now: @now, state: state, pause: pause).decide
27
+
28
+ { role: @role.name, controlled: @role.controlled?, state_host: host, state_error: read[:error], decision: decision.to_h }.compact
29
+ end
30
+ end
@@ -0,0 +1,82 @@
1
+ require "socket"
2
+
3
+ # `dash autoscale signal ROLE`, the MCP `signal_sample` tool and the doctor's signal check:
4
+ # one live sample of a role's `scale.signal` and of each `scale.hold_when` guard, through
5
+ # the controller's own readers (Dash::Autoscale::Signal, Dash::Autoscale::Guard), each read
6
+ # timed. The fields are those of a decision's `inputs[:signal]` and `inputs[:guards]`, so
7
+ # a sample compares directly with what the controller logged.
8
+ #
9
+ # Read from this machine: a laptop reaches the sources by another path than the controller
10
+ # host (a direct URL, or its own `via:` tunnel), so `from` names it. Read-only, as the
11
+ # readers are: Redis LINDEX, SMEMBERS and HMGET, Postgres inside a rolled-back read-only
12
+ # transaction, a Prometheus instant query. A read that fails - a configuration error
13
+ # included - is the sample's error; nothing raises.
14
+ class Dash::Diagnostics::AutoscaleSignal < Dash::Diagnostics::Base
15
+ # `connections` given are the caller's to close; without, the sample opens its own and
16
+ # closes them when done.
17
+ def initialize(role:, now: Time.now.utc, connections: nil)
18
+ @role, @now, @connections = role, now, connections
19
+ end
20
+
21
+ private
22
+ def snapshot
23
+ return { role: @role.name, configured: false, message: "#{@role} has no scale/signal, so there is nothing to sample" } unless @role.scaled? && @role.scale.signal?
24
+
25
+ with_connections do |connections|
26
+ signal = timed { Dash::Autoscale::Signal.for(@role, connections).sample(at: @now) }
27
+ guards = @role.scale.guards.each_with_index.map { |guard, index| guard_reading(guard, index, connections) }
28
+
29
+ Dash::Autoscale::Decision.json_safe(role: @role.name, configured: true, from: Socket.gethostname,
30
+ signal: { source: scale.signal.source }.merge(signal[:result].to_h, latency_ms: signal[:latency_ms]),
31
+ position: position(signal[:result]), guards: guards, band: band)
32
+ end
33
+ end
34
+
35
+ def scale
36
+ @role.scale
37
+ end
38
+
39
+ def with_connections
40
+ return yield @connections if @connections
41
+
42
+ connections = Dash::Autoscale::Connections.new
43
+ begin
44
+ yield connections
45
+ ensure
46
+ connections.close
47
+ end
48
+ end
49
+
50
+ # A guard read on its own, so it has its own latency.
51
+ def guard_reading(guard, index, connections)
52
+ read = timed { Dash::Autoscale::Guard.read([ guard ], connections, at: @now).first.sample }
53
+ reading = Dash::Autoscale::Guard::Reading.new(index: index, source: guard.source, above: guard.above, sample: read[:result])
54
+
55
+ reading.to_h.merge(holding: reading.holding?, latency_ms: read[:latency_ms])
56
+ end
57
+
58
+ def position(sample)
59
+ return if sample.missing?
60
+
61
+ if sample.value > scale.up_above then "above"
62
+ elsif sample.value < scale.down_below then "below"
63
+ else "inside"
64
+ end
65
+ end
66
+
67
+ def band
68
+ { up_above: scale.up_above, up_for: scale.up_for, down_below: scale.down_below, down_for: scale.down_for }
69
+ end
70
+
71
+ # { result: the Sample, latency_ms: }; a configuration error becomes the Sample's error.
72
+ def timed
73
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ result = begin
75
+ yield
76
+ rescue Dash::ConfigurationError => e
77
+ Dash::Autoscale::Sample.new(value: nil, at: @now, error: "#{e.class}: #{e.message}")
78
+ end
79
+
80
+ { result: result, latency_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1) }
81
+ end
82
+ end
@@ -0,0 +1,53 @@
1
+ require "dash/sshkit_with_ext"
2
+
3
+ # What every diagnostic shares: a JSON-safe `to_h` stamped with when it was taken, and
4
+ # per-host isolation. A host that cannot be reached, or answers with something that does
5
+ # not parse, becomes `{ host:, error: }` in the snapshot - a debugging tool that dies on
6
+ # the broken host is useless exactly when it is needed (same contract as Dash::Diagnostics::Doctor).
7
+ #
8
+ # Read-only by contract: a diagnostic only ever captures, never takes the deploy lock,
9
+ # never writes an audit line, never fires a hook. The pre-connect hook is the caller's
10
+ # (the CLI command or `dash mcp` at boot), as with Doctor.
11
+ class Dash::Diagnostics::Base
12
+ include SSHKit::DSL
13
+
14
+ def to_h
15
+ { generated_at: Time.now.utc.iso8601 }.merge(snapshot)
16
+ end
17
+
18
+ private
19
+ def snapshot
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # [ { host:, **yield } | { host:, error: } ] in the order the hosts were given. The
24
+ # block runs inside each host's SSHKit backend and gets it as its argument.
25
+ #
26
+ # StandardError rather than the SSHKit errors alone, deliberately: a failed connection
27
+ # raises anything from Net::SSH errors to Errno and DNS resolution errors, and none of
28
+ # them may take the other hosts' answers down with them.
29
+ def per_host(hosts, &block)
30
+ results = {}
31
+ mutex = Mutex.new
32
+
33
+ on(hosts) do |host|
34
+ result = begin
35
+ block.call(self, host.to_s)
36
+ rescue StandardError => e
37
+ { error: "#{e.class}: #{e.message}" }
38
+ end
39
+
40
+ mutex.synchronize { results[host.to_s] = result }
41
+ end
42
+
43
+ hosts.map { |host| { host: host.to_s }.merge(results.fetch(host.to_s) { { error: "no answer" } }) }
44
+ end
45
+
46
+ # per_host for every accessory on its own hosts within scope: [ { host:, accessory:, ... } ].
47
+ def per_accessory(accessories)
48
+ accessories.flat_map do |accessory|
49
+ per_host(accessory.hosts & DASH.accessory_hosts) { |backend, host| yield accessory, backend, host }
50
+ .map { |entry| { host: entry[:host], accessory: accessory.name }.merge(entry.except(:host)) }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ # The effective configuration as `dash config` prints it (secrets as their redaction),
2
+ # plus the topology an agent would otherwise have to derive: which hosts each role runs
3
+ # on, which roles are proxied, and where the proxies and the load balancer are.
4
+ class Dash::Diagnostics::Config < Dash::Diagnostics::Base
5
+ private
6
+ def snapshot
7
+ config = DASH.config
8
+
9
+ { service: config.service, destination: config.destination, roles: config.roles.map { |role| role_topology(role) },
10
+ proxy: { hosts: config.proxy_hosts, loadbalancer: (config.proxy.effective_loadbalancer.presence if config.proxy.load_balancing?) },
11
+ accessories: config.accessories.map { |accessory| { name: accessory.name, hosts: accessory.hosts } },
12
+ config: json_safe(Dash::Utils.redacted(config.to_h)) }
13
+ end
14
+
15
+ # config.to_h is built for YAML: symbols, and an SSH jump host as a Net::SSH object.
16
+ def json_safe(value)
17
+ case value
18
+ when Hash then value.transform_values { |nested| json_safe(nested) }
19
+ when Array then value.map { |element| json_safe(element) }
20
+ when String, Numeric, true, false, nil then value
21
+ when Symbol then value.to_s
22
+ when Net::SSH::Proxy::Jump then "jump #{value.jump_proxies}"
23
+ when Net::SSH::Proxy::Command then "command #{value.command_line_template}"
24
+ else value.class.name
25
+ end
26
+ end
27
+
28
+ def role_topology(role)
29
+ { name: role.name, hosts: role.hosts, primary: role.primary?, proxied: role.running_proxy?,
30
+ replicas: { min: role.replicas.min, max: role.replicas.max } }
31
+ end
32
+ end
@@ -0,0 +1,67 @@
1
+ require "json"
2
+
3
+ # Per app host, the resource use of every running container of this service: CPU, memory,
4
+ # network and block I/O and PIDs, with its role, replica slot and version - then each
5
+ # accessory's container on its own hosts. A point-in-time
6
+ # sample - `docker stats --no-stream` measures over about a second, per host, in parallel.
7
+ #
8
+ # Every number keeps its raw string under `raw:`; a value docker printed in a form Units
9
+ # cannot read becomes nil there, never a wrong number.
10
+ class Dash::Diagnostics::ContainerStats < Dash::Diagnostics::Base
11
+ # Printed when `docker stats` fails after `docker ps` answered (a container removed in
12
+ # between makes the whole sample fail): the containers stay, `stats_error` says why.
13
+ STATS_UNREADABLE = "--stats-unreadable--"
14
+
15
+ # Raised inside per_host, which turns it into the host's error entry.
16
+ class DockerUnreadable < StandardError
17
+ def message = "docker ps failed: the docker daemon could not be asked"
18
+ end
19
+
20
+ # `accessories: []` for the app alone, `hosts: []` for accessories alone.
21
+ def initialize(hosts: DASH.app_hosts, accessories: DASH.config.accessories)
22
+ @hosts = hosts
23
+ @accessories = accessories
24
+ end
25
+
26
+ private
27
+ def snapshot
28
+ { hosts: per_host(@hosts) { |backend, _host| sample(backend.capture_with_info(*DASH.app.stats_json, raise_on_non_zero_exit: false)) },
29
+ accessories: per_accessory(@accessories) do |accessory, backend, _host|
30
+ sample(backend.capture_with_info(*DASH.accessory(accessory.name).stats_json, raise_on_non_zero_exit: false), accessory: accessory.name)
31
+ end }
32
+ end
33
+
34
+ # { containers: } for one host, plus `stats_error:` when docker stats itself failed.
35
+ def sample(output, accessory: nil)
36
+ ps, stats = output.to_s.split(/^#{Regexp.escape(Dash::Commands::Base::SECTION_SEPARATOR)}$/, 2)
37
+ raise DockerUnreadable if ps.to_s.lines.map(&:strip).include?(Dash::Commands::App::ACTIVE_CONTAINERS_UNREADABLE)
38
+
39
+ stats_lines = stats.to_s.lines.map(&:strip)
40
+ by_id = stats_lines.filter_map { |line| JSON.parse(line) if line.start_with?("{") }.index_by { |sample| sample["ID"].to_s[0, 12] }
41
+
42
+ containers = Dash::Diagnostics::DockerPs.containers(ps, accessory: accessory).map do |container|
43
+ sample = by_id[container[:id].to_s[0, 12]]
44
+ container.merge(stats: sample && stats_of(sample))
45
+ end
46
+
47
+ # Containers listed but not one sample: docker stats failed in a way its exit status
48
+ # hid (the second docker ps failing hands xargs -r nothing, and xargs exits 0).
49
+ failed = stats_lines.include?(STATS_UNREADABLE) || (containers.any? && by_id.empty?)
50
+
51
+ { containers: containers }.tap do |entry|
52
+ entry[:stats_error] = "docker stats failed, so these containers have no sample" if failed
53
+ end
54
+ end
55
+
56
+ def stats_of(sample)
57
+ memory, memory_limit = Dash::Diagnostics::Units.pair(sample["MemUsage"])
58
+ net_rx, net_tx = Dash::Diagnostics::Units.pair(sample["NetIO"])
59
+ block_read, block_write = Dash::Diagnostics::Units.pair(sample["BlockIO"])
60
+
61
+ { cpu_percent: Dash::Diagnostics::Units.percent(sample["CPUPerc"]),
62
+ memory_bytes: memory, memory_limit_bytes: memory_limit, memory_percent: Dash::Diagnostics::Units.percent(sample["MemPerc"]),
63
+ net_rx_bytes: net_rx, net_tx_bytes: net_tx, block_read_bytes: block_read, block_write_bytes: block_write,
64
+ pids: Integer(sample["PIDs"].to_s, 10, exception: false),
65
+ raw: { cpu: sample["CPUPerc"], memory: sample["MemUsage"], memory_percent: sample["MemPerc"], net_io: sample["NetIO"], block_io: sample["BlockIO"], pids: sample["PIDs"] } }
66
+ end
67
+ end
@@ -0,0 +1,20 @@
1
+ # Per app host, every container of this service and destination: role, replica slot,
2
+ # version, state and health. One `docker ps` per host, whatever the roles and slots. Then
3
+ # each accessory's container on its own hosts.
4
+ class Dash::Diagnostics::Containers < Dash::Diagnostics::Base
5
+ # `accessories: []` for the app alone, `hosts: []` for accessories alone.
6
+ def initialize(hosts: DASH.app_hosts, accessories: DASH.config.accessories)
7
+ @hosts = hosts
8
+ @accessories = accessories
9
+ end
10
+
11
+ private
12
+ def snapshot
13
+ { hosts: per_host(@hosts) { |backend, _host| { containers: Dash::Diagnostics::DockerPs.containers(backend.capture_with_info(*DASH.app.list_containers_json)) } },
14
+ accessories: per_accessory(@accessories) { |accessory, backend, _host| { containers: accessory_containers(accessory, backend) } } }
15
+ end
16
+
17
+ def accessory_containers(accessory, backend)
18
+ Dash::Diagnostics::DockerPs.containers(backend.capture_with_info(*DASH.accessory(accessory.name).list_containers_json), accessory: accessory.name)
19
+ end
20
+ end
@@ -0,0 +1,44 @@
1
+ # `dash autoscale status`, the MCP `controller_status` tool and the doctor's controller
2
+ # check: the heartbeat on the state host - which controller, where, which dash, how long
3
+ # since its last tick - read as `running`, `stale` (no tick for three of its intervals) or
4
+ # `stopped`, plus the pauses still in force. `controller` is nil when none ever ran.
5
+ class Dash::Diagnostics::ControllerStatus < Dash::Diagnostics::Base
6
+ # `roles`: the roles this run is about (the doctor's scope); the controlled ones and their pauses.
7
+ def initialize(now: Time.now.utc, roles: DASH.specific_roles || DASH.config.roles)
8
+ @now, @roles = now, roles
9
+ end
10
+
11
+ private
12
+ def snapshot
13
+ host = Dash::Autoscale::StateStore.host
14
+ roles = @roles.select(&:controlled?).map(&:name)
15
+ read = per_host([ host ]) do |backend, _host|
16
+ store = Dash::Autoscale::StateStore.new(backend)
17
+ heartbeat = store.heartbeat
18
+ # A corrupted heartbeat is not the same as none: say so instead of "never ran".
19
+ next { error: "heartbeat.json is not valid JSON" } if store.malformed?("heartbeat.json")
20
+
21
+ { heartbeat: heartbeat, pauses: store.pauses }
22
+ end.first
23
+
24
+ return { state_host: host, roles: roles, error: read[:error] } if read[:error]
25
+
26
+ { state_host: host, roles: roles, controller: controller(read[:heartbeat]), pauses: pauses(read[:pauses].slice(*@roles.map(&:name))) }
27
+ end
28
+
29
+ def controller(heartbeat)
30
+ return if heartbeat.empty?
31
+
32
+ lease = Dash::Autoscale::Lease.new(heartbeat, now: @now)
33
+ status = if lease.stopped? then "stopped" elsif lease.alive? then "running" else "stale" end
34
+
35
+ heartbeat.symbolize_keys.merge(status: status, age_seconds: lease.age)
36
+ end
37
+
38
+ def pauses(pauses)
39
+ pauses.filter_map do |role, hash|
40
+ pause = Dash::Autoscale::Pause.from(hash)
41
+ { role: role }.merge(pause.to_h.symbolize_keys) if pause.active?(@now)
42
+ end.sort_by { |pause| pause[:role] }
43
+ end
44
+ end