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
@@ -0,0 +1,40 @@
1
+ require "json"
2
+
3
+ # One line of the app's `docker ps` in Commands::App::Containers::PS_WITH_ROLE_FORMAT - the
4
+ # container's JSON, a tab, docker's own value of its `role` label - as a container of this
5
+ # deploy. The role comes from that label value, never from the name alone (a sibling role
6
+ # whose name extends another's would be misattributed) and never from the rendered `Labels`
7
+ # string (a comma in another label's value could pass for a role there).
8
+ module Dash::Diagnostics::DockerPs
9
+ HEALTH = /\((healthy|unhealthy|health: starting)\)/
10
+
11
+ module_function
12
+
13
+ # An accessory's containers carry only its `service` label, so they are named for the
14
+ # accessory the caller asked about rather than for a role; their lines are plain JSON.
15
+ def containers(output, accessory: nil)
16
+ output.to_s.lines.filter_map do |line|
17
+ next if line.strip.empty?
18
+
19
+ ps, role = line.chomp.split("\t", 2)
20
+ accessory ? accessory_container(JSON.parse(ps), accessory) : container(JSON.parse(ps), role && JSON.parse(role))
21
+ end
22
+ end
23
+
24
+ def accessory_container(ps, accessory)
25
+ { name: ps["Names"], id: ps["ID"], accessory: accessory, state: ps["State"], status: ps["Status"], health: ps["Status"].to_s[HEALTH, 1],
26
+ image: ps["Image"], created_at: ps["CreatedAt"] }
27
+ end
28
+
29
+ def container(ps, role_name)
30
+ role = DASH.config.role(role_name) if role_name.present?
31
+ name = ps["Names"]
32
+
33
+ {
34
+ name: name, id: ps["ID"], role: role_name.presence,
35
+ replica: role&.replica_from_name(name), version: role&.version_from_name(name),
36
+ state: ps["State"], status: ps["Status"], health: ps["Status"].to_s[HEALTH, 1],
37
+ image: ps["Image"], created_at: ps["CreatedAt"]
38
+ }
39
+ end
40
+ end
@@ -1,17 +1,17 @@
1
1
  # Checks that only read the configuration — no SSH, no network. They report problems
2
2
  # that are visible in deploy.yml alone, so they still run when every host is unreachable.
3
- class Dash::Cli::Doctor::ConfigChecks
3
+ class Dash::Diagnostics::Doctor::ConfigChecks
4
4
  def run
5
5
  readiness_results + dockerfile_results
6
6
  end
7
7
 
8
8
  private
9
9
  def result(check, target, status, detail)
10
- Dash::Cli::Doctor::Result.new(check, target, status, detail)
10
+ Dash::Diagnostics::Doctor::Result.new(check, target, status, detail)
11
11
  end
12
12
 
13
13
  def readiness_results
14
- DASH.roles.map { |role| readiness_check(role) }
14
+ Dash::Diagnostics::Doctor.scoped_roles.map { |role| readiness_check(role) }
15
15
  end
16
16
 
17
17
  def readiness_check(role)
@@ -0,0 +1,46 @@
1
+ # Whether the autoscale controller is running, for apps that need one: a role with a
2
+ # `scale.schedule` or `scale.signal` changes capacity only while `dash autoscale run` ticks. A missing,
3
+ # stale or stopped heartbeat warns, and so does every pause still in force. A state host
4
+ # that does not answer is a warning, never an exception.
5
+ class Dash::Diagnostics::Doctor::ControllerChecks
6
+ def initialize(now: Time.now.utc)
7
+ @now = now
8
+ end
9
+
10
+ # Within the run's --roles, like the other checks.
11
+ def run
12
+ roles = Dash::Diagnostics::Doctor.scoped_roles
13
+ return [] if roles.none?(&:controlled?)
14
+
15
+ status = Dash::Diagnostics::ControllerStatus.new(now: @now, roles: roles).to_h
16
+ return [ result(status[:state_host], :warn, "could not read the controller heartbeat (#{status[:error]})") ] if status[:error]
17
+
18
+ [ controller_result(status), *status[:pauses].map { |pause| pause_result(pause) } ]
19
+ end
20
+
21
+ private
22
+ def result(target, status, detail)
23
+ Dash::Diagnostics::Doctor::Result.new(:controller, target, status, detail)
24
+ end
25
+
26
+ def controller_result(status)
27
+ host, controller = status[:state_host], status[:controller]
28
+ return result(host, :warn, "no controller has run; #{status[:roles].join(", ")} have a schedule or signal that only dash autoscale run carries out") unless controller
29
+
30
+ name = "controller #{controller[:controller_id]} on #{controller[:hostname]}"
31
+
32
+ case controller[:status]
33
+ when "running"
34
+ result host, :ok, "#{name} (dash #{controller[:version]}) ticked #{controller[:age_seconds]}s ago, for #{status[:roles].join(", ")}"
35
+ when "stopped"
36
+ result host, :warn, "#{name} stopped at #{controller[:stopped_at]}"
37
+ else
38
+ result host, :warn, "#{name} last ticked #{controller[:age_seconds]}s ago (interval #{controller[:interval]}s), it looks stopped or stuck"
39
+ end
40
+ end
41
+
42
+ def pause_result(pause)
43
+ until_text = pause[:until] == Dash::Autoscale::Pause::INDEFINITE ? "until resumed" : "until #{pause[:until]}"
44
+ result pause[:role], :warn, "paused by #{pause[:by] || "unknown"} #{until_text}"
45
+ end
46
+ end
@@ -4,7 +4,7 @@ require "openssl"
4
4
 
5
5
  # Local (no SSH) readiness checks for the domains dash-proxy will serve:
6
6
  # DNS resolution against the configured hosts and TLS certificate expiry.
7
- class Dash::Cli::Doctor::EndpointChecks
7
+ class Dash::Diagnostics::Doctor::EndpointChecks
8
8
  CERTIFICATE_EXPIRY_WARN_DAYS = 14
9
9
  SECONDS_PER_DAY = 86_400
10
10
  TLS_CONNECT_TIMEOUT = 5
@@ -15,22 +15,30 @@ class Dash::Cli::Doctor::EndpointChecks
15
15
 
16
16
  private
17
17
  def result(check, target, status, detail)
18
- Dash::Cli::Doctor::Result.new(check, target, status, detail)
18
+ Dash::Diagnostics::Doctor::Result.new(check, target, status, detail)
19
19
  end
20
20
 
21
21
  # Roles and accessories that run behind the proxy with custom domains.
22
22
  def proxied_units
23
- @proxied_units ||= (DASH.roles + DASH.config.proxy_accessories).select do |unit|
23
+ @proxied_units ||= (Dash::Diagnostics::Doctor.scoped_roles + DASH.config.proxy_accessories).select do |unit|
24
24
  unit.running_proxy? && unit.proxy.hosts.any?
25
25
  end
26
26
  end
27
27
 
28
28
  def dns_results
29
29
  proxied_units.flat_map do |unit|
30
- unit.proxy.hosts.map { |domain| dns_check(domain, unit.hosts, acme_issued: acme_issued?(unit)) }
30
+ unit.proxy.hosts.map { |domain| dns_check_of(unit, domain) }
31
31
  end
32
32
  end
33
33
 
34
+ # A scaled role's hosts come from the autoscale pool; when it cannot be read, its DNS
35
+ # cannot be compared, but its certificates still can.
36
+ def dns_check_of(unit, domain)
37
+ dns_check(domain, unit.hosts, acme_issued: acme_issued?(unit))
38
+ rescue Dash::Autoscale::ProviderError => e
39
+ result :dns, domain, :fail, "not compared, the hosts of #{unit.name} are unknown (#{e.message})"
40
+ end
41
+
34
42
  # Whether this unit's certificate comes from ACME rather than the operator.
35
43
  # Only then does where the domain points decide whether a certificate can
36
44
  # exist — a supplied certificate is unaffected by DNS.
@@ -3,14 +3,15 @@
3
3
  # Every check rescues StandardError at its boundary: the doctor's contract is
4
4
  # to report broken environments, not crash on them, and a failed connection can
5
5
  # raise anything from Net::SSH errors to Errno and DNS resolution errors.
6
- class Dash::Cli::Doctor::HostChecks
6
+ class Dash::Diagnostics::Doctor::HostChecks
7
7
  attr_reader :host, :sshkit, :proxy_host
8
8
  delegate :execute, :capture_with_info, to: :sshkit
9
9
 
10
- def initialize(host, sshkit, proxy_host:)
10
+ def initialize(host, sshkit, proxy_host:, registry: true)
11
11
  @host = host
12
12
  @sshkit = sshkit
13
13
  @proxy_host = proxy_host
14
+ @registry = registry
14
15
  end
15
16
 
16
17
  def run
@@ -18,7 +19,7 @@ class Dash::Cli::Doctor::HostChecks
18
19
 
19
20
  if checks[:ssh].ok?
20
21
  checks[:docker] = docker_check
21
- checks[:registry] = registry_check
22
+ checks[:registry] = registry_check if @registry
22
23
  checks.merge!(proxy_checks) if proxy_host
23
24
  end
24
25
 
@@ -27,7 +28,7 @@ class Dash::Cli::Doctor::HostChecks
27
28
 
28
29
  private
29
30
  def result(check, status, detail)
30
- Dash::Cli::Doctor::Result.new(check, host, status, detail)
31
+ Dash::Diagnostics::Doctor::Result.new(check, host, status, detail)
31
32
  end
32
33
 
33
34
  def ssh_check
@@ -0,0 +1,65 @@
1
+ # Checks on the pool members of scaled roles: whether the provider answers, whether a
2
+ # `power` role has enough members to reach its max, which members are stuck in a state
3
+ # that is neither on nor off, and whether a scaled role behind dash-proxy is load balanced.
4
+ # A provider failure is a failing result, never an exception.
5
+ class Dash::Diagnostics::Doctor::PoolChecks
6
+ def run
7
+ DASH.config.scaled_roles.flat_map { |role| role_results(role) }
8
+ end
9
+
10
+ private
11
+ def result(target, status, detail)
12
+ Dash::Diagnostics::Doctor::Result.new(:pool, target, status, detail)
13
+ end
14
+
15
+ def role_results(role)
16
+ members = role.members
17
+ return [ unverified_result(role, members), loadbalancer_result(role) ].compact if members.any? { |member| !member.verified }
18
+
19
+ [ provider_result(role, members), capacity_result(role, members), *stuck_results(role, members), loadbalancer_result(role) ].compact
20
+ rescue Dash::Autoscale::ProviderError => e
21
+ [ result(role.name, :fail, e.message), loadbalancer_result(role) ].compact
22
+ end
23
+
24
+ def provider_result(role, members)
25
+ active = members.count(&:started?)
26
+ result role.name, :ok, "#{DASH.config.autoscale.provider_name} answered: #{members.size} #{"member".pluralize(members.size)}, #{active} started"
27
+ end
28
+
29
+ # Only --hosts produces unverified members, and only when the provider did not answer.
30
+ def unverified_result(role, members)
31
+ result role.name, :warn, "#{DASH.config.autoscale.provider_name} did not answer: #{members.size} #{"member".pluralize(members.size)} " \
32
+ "taken from --hosts, unverified (#{members.map(&:host).join(", ")})"
33
+ end
34
+
35
+ def capacity_result(role, members)
36
+ if role.scale.create?
37
+ result role.name, :ok, "creates members from its template, up to #{role.scale.max} hosts"
38
+ else
39
+ needed = role.scale.max - role.baseline_hosts.size
40
+
41
+ if members.size >= needed
42
+ result role.name, :ok, "#{members.size} #{"member".pluralize(members.size)} for the #{needed} it can scale out to (max #{role.scale.max} hosts)"
43
+ else
44
+ result role.name, :warn, "#{members.size} of the #{needed} members max #{role.scale.max} needs carry its labels " \
45
+ "(#{DASH.config.pool.labels_for(role).map { |key, value| "#{key}=#{value}" }.join(", ")}); it cannot scale past #{role.baseline_hosts.size + members.size} hosts"
46
+ end
47
+ end
48
+ end
49
+
50
+ def stuck_results(role, members)
51
+ members.select(&:transitional?).map do |member|
52
+ result "#{role.name} #{member.id}", :warn, "member #{member.host || member.id} is #{member.state}, dash leaves it alone until it is started or stopped"
53
+ end
54
+ end
55
+
56
+ def loadbalancer_result(role)
57
+ return unless role.running_proxy?
58
+
59
+ if DASH.config.proxy.load_balancing?
60
+ result role.name, :ok, "load balanced by #{DASH.config.proxy.effective_loadbalancer}"
61
+ else
62
+ result role.name, :fail, "a scaled role behind dash-proxy needs the load balancer"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,54 @@
1
+ # Whether each signalled role's `scale.signal` source and `scale.hold_when` guards answer,
2
+ # through Dash::Diagnostics::AutoscaleSignal - the reads the controller makes. Each result
3
+ # names the value, the latency and the machine it was read from: a doctor run on a laptop
4
+ # reaches the sources by another path than the controller host does. A source that does
5
+ # not answer fails; one that answers without a number, and a guard holding a scale-out, warn.
6
+ class Dash::Diagnostics::Doctor::SignalChecks
7
+ # Within the run's --roles, like the other checks. No signalled role: no result.
8
+ # One set of connections (and `via:` tunnels) for every role: the sources are the app's.
9
+ def run
10
+ roles = Dash::Diagnostics::Doctor.scoped_roles.select { |role| role.scaled? && role.scale.signal? }
11
+ return [] if roles.empty?
12
+
13
+ connections = Dash::Autoscale::Connections.new
14
+ begin
15
+ roles.flat_map { |role| role_results(role, connections) }
16
+ ensure
17
+ connections.close
18
+ end
19
+ end
20
+
21
+ private
22
+ def role_results(role, connections)
23
+ sample = Dash::Diagnostics::AutoscaleSignal.new(role: role, connections: connections).to_h
24
+ from = "from this machine (#{sample[:from]})"
25
+
26
+ [ source_result(role.name, sample[:signal], from),
27
+ *sample[:guards].map { |guard| guard_result("#{role.name} hold_when/#{guard[:index]}", guard, from) } ]
28
+ end
29
+
30
+ def source_result(target, read, from)
31
+ if read[:error]
32
+ result target, :fail, "#{read[:source]} did not answer #{from}: #{read[:error]}"
33
+ elsif read[:value].nil?
34
+ result target, :warn, "#{read[:source]} answered with no value in #{read[:latency_ms]}ms, #{from}"
35
+ else
36
+ result target, :ok, "#{read[:source]} answered #{read[:value]} in #{read[:latency_ms]}ms, #{from}"
37
+ end
38
+ end
39
+
40
+ def guard_result(target, guard, from)
41
+ return source_result(target, guard, from) if guard[:error]
42
+ return result(target, :warn, "#{guard[:source]} answered with no value in #{guard[:latency_ms]}ms, #{from}; without a value it holds a scale-out") if guard[:value].nil?
43
+
44
+ if guard[:holding]
45
+ result target, :warn, "#{guard[:source]} answered #{guard[:value]} in #{guard[:latency_ms]}ms, #{from}; above #{guard[:above]}, so it holds a scale-out"
46
+ else
47
+ source_result(target, guard, from)
48
+ end
49
+ end
50
+
51
+ def result(target, status, detail)
52
+ Dash::Diagnostics::Doctor::Result.new(:signal, target, status, detail)
53
+ end
54
+ end
@@ -0,0 +1,182 @@
1
+ # `dash doctor`: deploy readiness checks, collected as results without ever raising on a
2
+ # broken environment - failures become failing results instead. `to_h` is the same as data.
3
+ #
4
+ # Every check reads, except the registry check: it runs `docker login` on every host,
5
+ # which writes the registry credentials there. `registry: false` leaves it out, and
6
+ # `dash mcp` asks for that.
7
+ class Dash::Diagnostics::Doctor < Dash::Diagnostics::Base
8
+ REGISTRY_SKIPPED = { check: "registry", reason: "runs docker login on each host, which writes credentials there; run `dash doctor` to include it" }.freeze
9
+
10
+ HOST_CHECKS = %i[ ssh docker registry proxy_image proxy_version proxy_socket ports ]
11
+
12
+ CHECK_TITLES = {
13
+ ssh: "SSH",
14
+ docker: "Docker",
15
+ registry: "Registry",
16
+ proxy_image: "Proxy image",
17
+ proxy_version: "Proxy version",
18
+ proxy_socket: "Proxy docker socket",
19
+ ports: "Ports",
20
+ dns: "DNS",
21
+ certificate: "Certificates",
22
+ readiness: "Readiness",
23
+ dockerfile: "Dockerfile",
24
+ drift: "Drift",
25
+ pool: "Pool",
26
+ controller: "Controller",
27
+ signal: "Signals"
28
+ }.freeze
29
+
30
+ STATUS_COLORS = { ok: :green, warn: :yellow, fail: :red }.freeze
31
+
32
+ Result = Struct.new(:check, :target, :status, :detail) do
33
+ def ok?
34
+ status == :ok
35
+ end
36
+
37
+ def warn?
38
+ status == :warn
39
+ end
40
+
41
+ def fail?
42
+ status == :fail
43
+ end
44
+
45
+ def title
46
+ CHECK_TITLES[check]
47
+ end
48
+
49
+ def to_s
50
+ "#{status.to_s.upcase} #{target}: #{detail}"
51
+ end
52
+ end
53
+
54
+ attr_reader :results
55
+
56
+ # The roles this run is about. DASH.roles builds the host scope, which asks the autoscale
57
+ # pool - so it is only used when --hosts narrowed the run, and the pool has been read
58
+ # already to filter by them. Otherwise --roles, or every role, straight from deploy.yml.
59
+ def self.scoped_roles
60
+ DASH.specific_hosts ? DASH.roles : (DASH.specific_roles || DASH.config.roles)
61
+ end
62
+
63
+ def initialize(registry: true)
64
+ @results = []
65
+ @registry = registry
66
+ end
67
+
68
+ # Pool checks first: when the provider cannot be asked, the member hosts are unknown, so
69
+ # the checks that need every host report that once instead of crashing on it.
70
+ def run
71
+ pool = pool_check_results
72
+ @results = without_pool(:ssh) { host_check_results } + without_pool(:dns) { endpoint_check_results } +
73
+ config_check_results + pool + controller_check_results + signal_check_results + drift_check_results
74
+ end
75
+
76
+ def failures
77
+ results.select(&:fail?)
78
+ end
79
+
80
+ def warnings
81
+ results.select(&:warn?)
82
+ end
83
+
84
+ def successful?
85
+ failures.none?
86
+ end
87
+
88
+ private
89
+ def snapshot
90
+ run
91
+
92
+ { successful: successful?, results: results.map { |result| result_h(result) }, skipped: (@registry ? [] : [ REGISTRY_SKIPPED ]) }
93
+ end
94
+
95
+ def result_h(result)
96
+ { check: result.check.to_s, target: result.target.to_s, status: result.status.to_s, detail: result.detail.to_s }
97
+ end
98
+
99
+ def host_check_results
100
+ results_by_host = collect_host_checks
101
+
102
+ HOST_CHECKS.flat_map do |check|
103
+ DASH.hosts.filter_map { |host| results_by_host.dig(host, check) }
104
+ end
105
+ end
106
+
107
+ def collect_host_checks
108
+ results_by_host = {}
109
+ mutex = Mutex.new
110
+ proxy_hosts = DASH.proxy_hosts
111
+ registry = @registry
112
+ error = nil
113
+
114
+ begin
115
+ on(DASH.hosts) do |host|
116
+ checks = Dash::Diagnostics::Doctor::HostChecks.new(host.hostname, self, proxy_host: proxy_hosts.include?(host.hostname), registry: registry).run
117
+ mutex.synchronize { results_by_host[host.hostname] = checks }
118
+ end
119
+ # Only ExecuteError: sshkit 1.25 has no MultipleExecuteError, and naming
120
+ # a constant that does not exist turns "a host's checks failed" into a
121
+ # NameError - the opposite of the doctor's never-crash contract.
122
+ rescue SSHKit::Runner::ExecuteError => e
123
+ # Per-check errors are captured inside HostChecks; getting here means a
124
+ # host's checks never completed at all. Record those as SSH failures below.
125
+ error = e
126
+ end
127
+
128
+ DASH.hosts.each do |host|
129
+ results_by_host[host] ||= { ssh: Result.new(:ssh, host, :fail, "could not run checks (#{error&.message || "connection failed"})") }
130
+ end
131
+
132
+ results_by_host
133
+ end
134
+
135
+ def endpoint_check_results
136
+ Dash::Diagnostics::Doctor::EndpointChecks.new.run
137
+ end
138
+
139
+ def config_check_results
140
+ Dash::Diagnostics::Doctor::ConfigChecks.new.run
141
+ end
142
+
143
+ def pool_check_results
144
+ Dash::Diagnostics::Doctor::PoolChecks.new.run
145
+ end
146
+
147
+ def controller_check_results
148
+ Dash::Diagnostics::Doctor::ControllerChecks.new.run
149
+ rescue StandardError => e
150
+ [ Result.new(:controller, "controller", :warn, "could not check the autoscale controller (#{e.class}: #{e.message})") ]
151
+ end
152
+
153
+ def signal_check_results
154
+ Dash::Diagnostics::Doctor::SignalChecks.new.run
155
+ rescue StandardError => e
156
+ [ Result.new(:signal, "signals", :warn, "could not check the autoscale signals (#{e.class}: #{e.message})") ]
157
+ end
158
+
159
+ def without_pool(check)
160
+ yield
161
+ rescue Dash::Autoscale::ProviderError => e
162
+ [ Result.new(check, "pool", :fail, "not run, the pool members are unknown (#{e.message})") ]
163
+ end
164
+
165
+ # Whether the proxies route to what runs. A target that is not running, or a host the
166
+ # load balancer should forward to and does not, fails; the rest warn.
167
+ def drift_check_results
168
+ drift = Dash::Diagnostics::Drift.take
169
+ unread = drift.unread.map { |host| Result.new(:drift, host[:host], :warn, "could not read #{host[:source]} (#{host[:error]}), so it was not compared") }
170
+
171
+ if drift.entries.empty? && unread.empty?
172
+ [ Result.new(:drift, "proxy", :ok, "proxy targets match the running containers") ]
173
+ else
174
+ unread + drift.entries.map do |entry|
175
+ status = Dash::Diagnostics::Drift::FAILURES.include?(entry[:code]) ? :fail : :warn
176
+ Result.new(:drift, entry[:host] || entry[:role], status, "#{entry[:code]}: #{entry[:detail]}")
177
+ end
178
+ end
179
+ rescue StandardError => e
180
+ [ Result.new(:drift, "proxy", :warn, "could not compare proxy targets with containers (#{e.class}: #{e.message})") ]
181
+ end
182
+ end
@@ -0,0 +1,168 @@
1
+ # Compares what should be running with what is: the containers on each host against the
2
+ # targets dash-proxy routes to there, against the load balancer's targets. Pure - it reads
3
+ # the Containers, ProxyServices and Lock snapshots and touches nothing; `.take` captures
4
+ # them first.
5
+ #
6
+ # Each mismatch is `{ code:, host:, role:, detail: }`, with a code an agent can branch on:
7
+ #
8
+ # proxy_target_not_running the proxy routes to a container that is not running
9
+ # running_not_targeted a running container of a proxied role is not a target
10
+ # loadbalancer_target_missing a host the load balancer should forward to is not a target
11
+ # loadbalancer_target_extra the load balancer forwards to a host it should not
12
+ # version_mismatch a role's hosts run different versions
13
+ # multiple_running_versions one host runs more than one version of a role
14
+ # member_not_targeted a started pool member the load balancer does not forward to
15
+ # (reported instead of loadbalancer_target_missing for it)
16
+ # member_orphan a started pool member runs nothing of its role
17
+ #
18
+ # A host whose snapshot is an error is not compared; it is listed under `unread` instead,
19
+ # and the fleet is not called consistent.
20
+ # While the deploy lock is held the version codes are skipped too - a deploy in flight runs
21
+ # two versions on purpose - and `lock_held` says so (nil when the lock was not read).
22
+ class Dash::Diagnostics::Drift < Dash::Diagnostics::Base
23
+ FAILURES = %w[ proxy_target_not_running loadbalancer_target_missing ].freeze
24
+ REPLACED_SUFFIX = /_replaced_\h{16}\z/
25
+ TARGET_KEYS = %w[ targets reader_targets rollout_targets ].freeze
26
+
27
+ # The lock is only read when the primary host is in scope; narrowed away from it, the
28
+ # version codes are reported whether or not a deploy is in flight.
29
+ def self.take
30
+ lock = Dash::Diagnostics::Lock.new.to_h if DASH.hosts.include?(DASH.config.primary_host)
31
+
32
+ new(containers: Dash::Diagnostics::Containers.new(accessories: []).to_h, proxy_services: Dash::Diagnostics::ProxyServices.new.to_h, lock: lock)
33
+ end
34
+
35
+ def initialize(containers:, proxy_services:, lock: nil, config: nil)
36
+ @containers = containers
37
+ @proxy_services = proxy_services
38
+ @lock = lock
39
+ @config = config
40
+ end
41
+
42
+ def entries
43
+ @entries ||= proxy_entries + loadbalancer_entries + member_entries + version_entries
44
+ end
45
+
46
+ # The hosts whose containers or routes could not be read, so were not compared. A fleet
47
+ # with any is not reported consistent: no drift found is not the same as none there.
48
+ def unread
49
+ @unread ||= [ [ "containers", Array(@containers[:hosts]) ], [ "proxy_services", [ *@proxy_services[:hosts], @proxy_services[:loadbalancer] ].compact ] ]
50
+ .flat_map { |source, hosts| hosts.select { |host| host[:error] }.map { |host| { host: host[:host], source: source, error: host[:error] } } }
51
+ end
52
+
53
+ private
54
+ def snapshot
55
+ { consistent: entries.empty? && unread.empty?, lock_held: @lock&.dig(:lock, :held), drift: entries, unread: unread }
56
+ end
57
+
58
+ def proxy_entries
59
+ Array(@proxy_services[:hosts]).reject { |host| host[:error] }.flat_map do |proxy_host|
60
+ host = proxy_host[:host]
61
+ next [] unless running.key?(host)
62
+
63
+ proxied_roles_on(host).flat_map { |role| role_proxy_entries(role, host, targets(proxy_host[:services].to_h[role.container_prefix])) }
64
+ end
65
+ end
66
+
67
+ def role_proxy_entries(role, host, targets)
68
+ containers = running[host].select { |container| container[:role] == role.name }
69
+
70
+ not_running = targets.reject { |target| containers.any? { |container| container[:id].to_s.start_with?(target) } }.map do |target|
71
+ entry "proxy_target_not_running", host, role, "#{role.container_prefix} routes to #{target}, which is not a running container"
72
+ end
73
+
74
+ not_targeted = containers.reject { |container| targets.any? { |target| container[:id].to_s.start_with?(target) } }.map do |container|
75
+ entry "running_not_targeted", host, role, "#{container[:name]} (replica #{container[:replica]}) runs but #{role.container_prefix} does not route to it"
76
+ end
77
+
78
+ not_running + not_targeted
79
+ end
80
+
81
+ def loadbalancer_entries
82
+ loadbalancer = @proxy_services[:loadbalancer]
83
+ return [] if loadbalancer.nil? || loadbalancer[:error]
84
+
85
+ expected = Dash::Configuration::Loadbalancer.new(config: config, proxy_config: config.proxy.proxy_config, secrets: config.secrets).target_hosts.uniq
86
+ actual = targets(loadbalancer[:services].to_h[config.service])
87
+ host = loadbalancer[:host]
88
+
89
+ (expected - actual).map { |target| missing_target_entry(host, target) } +
90
+ (actual - expected).map { |target| entry "loadbalancer_target_extra", host, nil, "the load balancer forwards to #{target}, which no proxied role runs on" }
91
+ end
92
+
93
+ # A member that joined but never reached the load balancer serves nobody: capacity paid
94
+ # for and wasted, not a broken route - so it is its own code, not a failure.
95
+ def missing_target_entry(host, target)
96
+ if (member = started_members.find { |candidate| candidate.host == target })
97
+ role = config.roles.find { |candidate| candidate.name == member.role }
98
+ entry "member_not_targeted", host, role, "member #{member.id} (#{target}) is started but the load balancer does not forward to it"
99
+ else
100
+ entry "loadbalancer_target_missing", host, nil, "the load balancer does not forward to #{target}"
101
+ end
102
+ end
103
+
104
+ def member_entries
105
+ config.scaled_roles.flat_map do |role|
106
+ role.active_members.filter_map do |member|
107
+ next unless running.key?(member.host)
108
+ next if running[member.host].any? { |container| container[:role] == role.name }
109
+
110
+ entry "member_orphan", member.host, role, "member #{member.id} is started but runs no #{role} container"
111
+ end
112
+ end
113
+ end
114
+
115
+ def started_members
116
+ @started_members ||= config.scaled_roles.flat_map(&:active_members)
117
+ end
118
+
119
+ def version_entries
120
+ return [] if @lock&.dig(:lock, :held)
121
+
122
+ config.roles.flat_map do |role|
123
+ by_host = running.transform_values { |containers| versions(containers.select { |container| container[:role] == role.name }) }.reject { |_, versions| versions.empty? }
124
+
125
+ multiple = by_host.select { |_, versions| versions.size > 1 }.map do |host, versions|
126
+ entry "multiple_running_versions", host, role, "#{role} runs #{versions.join(", ")} on #{host}"
127
+ end
128
+
129
+ mismatch = if by_host.values.uniq.size > 1
130
+ [ entry("version_mismatch", nil, role, "#{role} runs " + by_host.map { |host, versions| "#{versions.join("+")} on #{host}" }.join(", ")) ]
131
+ end
132
+
133
+ multiple + mismatch.to_a
134
+ end
135
+ end
136
+
137
+ def config
138
+ @config ||= DASH.config
139
+ end
140
+
141
+ def running
142
+ @running ||= Array(@containers[:hosts]).reject { |host| host[:error] }.to_h do |host|
143
+ [ host[:host], Array(host[:containers]).select { |container| container[:state] == "running" } ]
144
+ end
145
+ end
146
+
147
+ def proxied_roles_on(host)
148
+ config.roles.select { |role| role.running_proxy? && role.hosts.include?(host) }
149
+ end
150
+
151
+ # dash-proxy lists targets as "<container id or host>:<port>"; the id is the short one
152
+ # dash deployed with, the host is what the load balancer forwards to.
153
+ def targets(service)
154
+ return [] unless service
155
+
156
+ listed = TARGET_KEYS.flat_map { |key| Array(service[key]) }
157
+ listed = service["target"].to_s.split(",") if listed.empty?
158
+ listed.map { |target| target.strip.sub(/:\d+\z/, "") }.reject(&:empty?).uniq
159
+ end
160
+
161
+ def versions(containers)
162
+ containers.map { |container| container[:version].to_s.sub(REPLACED_SUFFIX, "") }.uniq.sort
163
+ end
164
+
165
+ def entry(code, host, role, detail)
166
+ { code: code, host: host, role: role&.name, detail: detail }
167
+ end
168
+ end