dash 4.1.3 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/dash/autoscale/connections/builder.rb +51 -0
- data/lib/dash/autoscale/connections.rb +102 -0
- data/lib/dash/autoscale/controller.rb +296 -0
- data/lib/dash/autoscale/cron.rb +75 -0
- data/lib/dash/autoscale/decision.rb +89 -0
- data/lib/dash/autoscale/duration.rb +19 -0
- data/lib/dash/autoscale/evaluation.rb +52 -0
- data/lib/dash/autoscale/guard.rb +48 -0
- data/lib/dash/autoscale/lease.rb +55 -0
- data/lib/dash/autoscale/lease_held.rb +2 -0
- data/lib/dash/autoscale/lease_lost.rb +2 -0
- data/lib/dash/autoscale/member.rb +28 -0
- data/lib/dash/autoscale/metrics.rb +70 -0
- data/lib/dash/autoscale/metrics_server.rb +71 -0
- data/lib/dash/autoscale/observation.rb +41 -0
- data/lib/dash/autoscale/pause.rb +34 -0
- data/lib/dash/autoscale/policy.rb +198 -0
- data/lib/dash/autoscale/pool.rb +103 -0
- data/lib/dash/autoscale/prometheus_client.rb +76 -0
- data/lib/dash/autoscale/provider/base.rb +43 -0
- data/lib/dash/autoscale/provider/exec.rb +94 -0
- data/lib/dash/autoscale/provider/upcloud.rb +140 -0
- data/lib/dash/autoscale/provider.rb +9 -0
- data/lib/dash/autoscale/provider_error.rb +4 -0
- data/lib/dash/autoscale/read_only_query.rb +38 -0
- data/lib/dash/autoscale/reader.rb +17 -0
- data/lib/dash/autoscale/recorder.rb +13 -0
- data/lib/dash/autoscale/role_state.rb +110 -0
- data/lib/dash/autoscale/sample.rb +13 -0
- data/lib/dash/autoscale/signal/pgbus.rb +19 -0
- data/lib/dash/autoscale/signal/prometheus.rb +8 -0
- data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
- data/lib/dash/autoscale/signal.rb +31 -0
- data/lib/dash/autoscale/simulation.rb +200 -0
- data/lib/dash/autoscale/state_store.rb +132 -0
- data/lib/dash/autoscale/timestamp.rb +15 -0
- data/lib/dash/autoscale/tunnel.rb +48 -0
- data/lib/dash/cli/accessory.rb +16 -0
- data/lib/dash/cli/app/boot.rb +69 -44
- data/lib/dash/cli/app/boot_state.rb +70 -0
- data/lib/dash/cli/app/rollout_boot.rb +28 -9
- data/lib/dash/cli/app/running_targets.rb +39 -0
- data/lib/dash/cli/app/slot_version.rb +29 -0
- data/lib/dash/cli/app.rb +89 -32
- data/lib/dash/cli/autoscale.rb +312 -0
- data/lib/dash/cli/base.rb +15 -3
- data/lib/dash/cli/lock.rb +6 -1
- data/lib/dash/cli/main.rb +77 -8
- data/lib/dash/cli/proxy/inspection.rb +52 -0
- data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
- data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
- data/lib/dash/cli/proxy/reboot.rb +4 -9
- data/lib/dash/cli/proxy.rb +12 -7
- data/lib/dash/cli/prune.rb +5 -3
- data/lib/dash/cli/report.rb +4 -1
- data/lib/dash/cli/scale/host_plan.rb +63 -0
- data/lib/dash/cli/scale/join.rb +128 -0
- data/lib/dash/cli/scale/leave.rb +58 -0
- data/lib/dash/cli/scale/placement.rb +54 -0
- data/lib/dash/cli/scale/power_off.rb +26 -0
- data/lib/dash/cli/scale/replica_join.rb +74 -0
- data/lib/dash/cli/scale/replica_leave.rb +121 -0
- data/lib/dash/cli/scale.rb +276 -0
- data/lib/dash/cli/server.rb +41 -0
- data/lib/dash/cli/stats_table.rb +34 -0
- data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
- data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
- data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
- data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
- data/lib/dash/commander.rb +17 -3
- data/lib/dash/commands/accessory/proxy.rb +1 -1
- data/lib/dash/commands/accessory.rb +21 -2
- data/lib/dash/commands/app/containers.rb +28 -3
- data/lib/dash/commands/app/logging.rb +13 -2
- data/lib/dash/commands/app/proxy.rb +4 -4
- data/lib/dash/commands/app.rb +90 -10
- data/lib/dash/commands/auditor.rb +2 -2
- data/lib/dash/commands/autoscale.rb +133 -0
- data/lib/dash/commands/base.rb +4 -0
- data/lib/dash/commands/proxy/services.rb +10 -0
- data/lib/dash/commands/prune.rb +10 -2
- data/lib/dash/commands/server.rb +16 -0
- data/lib/dash/configuration/accessory.rb +2 -2
- data/lib/dash/configuration/autoscale.rb +123 -0
- data/lib/dash/configuration/docs/autoscale.yml +141 -0
- data/lib/dash/configuration/docs/configuration.yml +8 -2
- data/lib/dash/configuration/docs/proxy.yml +1 -1
- data/lib/dash/configuration/docs/role.yml +203 -0
- data/lib/dash/configuration/loadbalancer.rb +3 -2
- data/lib/dash/configuration/proxy/run.rb +1 -1
- data/lib/dash/configuration/proxy.rb +19 -7
- data/lib/dash/configuration/role/drain.rb +49 -0
- data/lib/dash/configuration/role/replicas.rb +72 -0
- data/lib/dash/configuration/role/scale/guard.rb +40 -0
- data/lib/dash/configuration/role/scale/signal.rb +93 -0
- data/lib/dash/configuration/role/scale/window.rb +37 -0
- data/lib/dash/configuration/role/scale.rb +199 -0
- data/lib/dash/configuration/role.rb +123 -6
- data/lib/dash/configuration/validator/autoscale.rb +126 -0
- data/lib/dash/configuration/validator/proxy.rb +13 -0
- data/lib/dash/configuration/validator/role.rb +26 -5
- data/lib/dash/configuration.rb +126 -15
- data/lib/dash/diagnostics/audit.rb +28 -0
- data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
- data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
- data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
- data/lib/dash/diagnostics/base.rb +53 -0
- data/lib/dash/diagnostics/config.rb +32 -0
- data/lib/dash/diagnostics/container_stats.rb +67 -0
- data/lib/dash/diagnostics/containers.rb +20 -0
- data/lib/dash/diagnostics/controller_status.rb +44 -0
- data/lib/dash/diagnostics/docker_ps.rb +40 -0
- data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
- data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
- data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
- data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
- data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
- data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
- data/lib/dash/diagnostics/doctor.rb +182 -0
- data/lib/dash/diagnostics/drift.rb +168 -0
- data/lib/dash/diagnostics/host_stats.rb +59 -0
- data/lib/dash/diagnostics/lines.rb +14 -0
- data/lib/dash/diagnostics/lock.rb +27 -0
- data/lib/dash/diagnostics/logs.rb +76 -0
- data/lib/dash/diagnostics/pool.rb +41 -0
- data/lib/dash/diagnostics/proxy_services.rb +36 -0
- data/lib/dash/diagnostics/redactor.rb +48 -0
- data/lib/dash/diagnostics/reports.rb +17 -0
- data/lib/dash/diagnostics/scale.rb +51 -0
- data/lib/dash/diagnostics/units.rb +31 -0
- data/lib/dash/mcp/base_tool.rb +43 -0
- data/lib/dash/mcp/runner.rb +28 -0
- data/lib/dash/mcp/server.rb +40 -0
- data/lib/dash/mcp/session.rb +51 -0
- data/lib/dash/mcp/tools/audit_tool.rb +11 -0
- data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
- data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
- data/lib/dash/mcp/tools/config_tool.rb +10 -0
- data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
- data/lib/dash/mcp/tools/containers_tool.rb +10 -0
- data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
- data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
- data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
- data/lib/dash/mcp/tools/drift_tool.rb +16 -0
- data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
- data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
- data/lib/dash/mcp/tools/logs_tool.rb +41 -0
- data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
- data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
- data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
- data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
- data/lib/dash/mcp/transport.rb +14 -0
- data/lib/dash/mcp.rb +56 -0
- data/lib/dash/sshkit_with_ext.rb +4 -4
- data/lib/dash/utils.rb +7 -0
- data/lib/dash/version.rb +1 -1
- data/lib/dash.rb +2 -0
- metadata +119 -5
- data/lib/dash/cli/doctor.rb +0 -113
data/lib/dash/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
367
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|