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,19 @@
1
+ # The age in seconds of the oldest claimable message across the role's queues. A pgbus queue
2
+ # `<name>` is the pgmq table `pgmq.q_<prefix><name>`; a message is claimable once its
3
+ # visibility time `vt` has passed, so scheduled messages do not count (pgmq.metrics()'s
4
+ # oldest_msg_age_sec does count them). pgbus reports no jobs in progress, so a pgbus role
5
+ # drains on `docker stop` alone.
6
+ class Dash::Autoscale::Signal::Pgbus < Dash::Autoscale::Signal
7
+ private
8
+ def read(_at)
9
+ connections.with(:postgres) do |postgres|
10
+ Dash::Autoscale::ReadOnlyQuery.within(postgres) do
11
+ config.queues.map { |queue| Dash::Autoscale::ReadOnlyQuery.number(postgres, claimable_age(postgres, queue)) || 0 }.max
12
+ end
13
+ end
14
+ end
15
+
16
+ def claimable_age(postgres, queue)
17
+ "SELECT EXTRACT(epoch FROM now() - min(vt)) FROM pgmq.#{postgres.quote_ident("q_#{config.prefix}#{queue}")} WHERE vt <= now()"
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ # The value of one PromQL query, the largest when it returns several series. No series, NaN
2
+ # or Inf is a missing sample (Dash::Autoscale::Sample#missing?).
3
+ class Dash::Autoscale::Signal::Prometheus < Dash::Autoscale::Signal
4
+ private
5
+ def read(_at)
6
+ connections.with(:prometheus) { |prometheus| prometheus.query(config.query) }
7
+ end
8
+ end
@@ -0,0 +1,49 @@
1
+ require "json"
2
+
3
+ # The age in seconds of the oldest job waiting across the role's queues. A Sidekiq queue is
4
+ # the Redis list `queue:<name>`, pushed on the left and fetched from the right, so its
5
+ # oldest job is `LINDEX queue:<name> -1`. Sidekiq 7 and older write `enqueued_at` as float
6
+ # epoch seconds, Sidekiq 8 as integer epoch milliseconds. Reads only: LINDEX, SMEMBERS, HMGET.
7
+ class Dash::Autoscale::Signal::Sidekiq < Dash::Autoscale::Signal
8
+ # Epoch seconds pass this in the year 5138; epoch milliseconds passed it in 1973.
9
+ MILLISECONDS_ABOVE = 1e11
10
+
11
+ def busy?
12
+ true
13
+ end
14
+
15
+ # Jobs in progress in the containers whose hostnames are given, or nil unless Sidekiq knows
16
+ # a process in every one of them: an idle process elsewhere does not speak for a container
17
+ # Sidekiq has no record of. Sidekiq reports each process's `hostname` (the container's) in its
18
+ # `info`, and writes `busy` and `quiet` on every heartbeat (every few seconds) - so a busy
19
+ # count from before the process went quiet may predate the drain signal and miss a job it
20
+ # fetched since. Until a process reports quiet it counts as at least one job busy.
21
+ def busy(hostnames)
22
+ connections.with(:redis) do |redis|
23
+ counts = Array(redis.call("SMEMBERS", "processes")).filter_map do |identity|
24
+ info, busy, quiet = redis.call("HMGET", identity, "info", "busy", "quiet")
25
+ hostname = JSON.parse(info)["hostname"] if info
26
+ next unless hostnames.include?(hostname)
27
+
28
+ [ hostname, quiet == "true" ? busy.to_i : [ busy.to_i, 1 ].max ]
29
+ end
30
+
31
+ counts.sum(&:last) if (hostnames - counts.map(&:first)).empty?
32
+ end
33
+ end
34
+
35
+ private
36
+ def read(at)
37
+ connections.with(:redis) do |redis|
38
+ config.queues.map { |queue| age(redis.call("LINDEX", "queue:#{queue}", -1), at) }.max
39
+ end
40
+ end
41
+
42
+ def age(job, at)
43
+ return 0 if job.nil?
44
+
45
+ enqueued_at = JSON.parse(job).fetch("enqueued_at")
46
+ enqueued_at /= 1000.0 if enqueued_at > MILLISECONDS_ABOVE
47
+ [ at.to_f - enqueued_at, 0 ].max
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ # A role's `scale.signal` as the controller reads it: one number per tick, from Redis
2
+ # (Sidekiq), Postgres (pgbus) or Prometheus, through the process's
3
+ # Dash::Autoscale::Connections. A reader never raises out of a tick: a failure becomes a
4
+ # Sample with its error, and the policy holds the role. Only a configuration error (a
5
+ # missing gem, a secret that resolves to nothing) goes through, for the operator to fix.
6
+ class Dash::Autoscale::Signal
7
+ attr_reader :config, :connections
8
+
9
+ def self.for(role, connections)
10
+ { "sidekiq" => Sidekiq, "pgbus" => Pgbus, "prometheus" => Prometheus }.fetch(role.scale.signal.source).new(role.scale.signal, connections)
11
+ end
12
+
13
+ # `config` is the role's Dash::Configuration::Role::Scale::Signal.
14
+ def initialize(config, connections)
15
+ @config, @connections = config, connections
16
+ end
17
+
18
+ def sample(at: Time.now.utc)
19
+ Dash::Autoscale::Sample.new(value: read(at), at: at)
20
+ rescue Dash::ConfigurationError
21
+ raise
22
+ rescue StandardError => e
23
+ Dash::Autoscale::Sample.new(value: nil, at: at, error: "#{e.class}: #{e.message}")
24
+ end
25
+
26
+ # Whether the source can say how many jobs a container is working on (only Sidekiq can):
27
+ # a drain then ends as soon as it says none.
28
+ def busy?
29
+ false
30
+ end
31
+ end
@@ -0,0 +1,200 @@
1
+ require "json"
2
+
3
+ # `dash autoscale simulate --samples FILE`: replays a `dash autoscale run --record` file
4
+ # through Dash::Autoscale::Policy with the thresholds in deploy.yml now, to try a band, a
5
+ # step or a cooldown against a day that happened before deploying it.
6
+ #
7
+ # Each role starts at the count its first tick recorded. Every tick hands the policy that
8
+ # tick's recorded signal and guard readings with the simulated count and a simulated
9
+ # RoleState: the breach windows and the last scale times are worked out again with the
10
+ # current thresholds (the recorded ones were measured against the old band), and a
11
+ # simulated action changes the count at once. A scale-out onto more hosts warms them up for
12
+ # `scale.warmup`, as joined members do. The world's own holds are replayed: a recorded
13
+ # pause, an unreadable pool, unreadable baseline hosts, unreachable members (a replacement
14
+ # the policy then decides keeps the count, as it did live).
15
+ #
16
+ # Known limits, so a replay with the same thresholds can differ from what ran:
17
+ # - the recorded signal is what the queue did with the containers that actually ran, not
18
+ # with the simulated ones;
19
+ # - a simulated action's cooldown and warmup start at its tick, where live they start once
20
+ # `dash scale set` returns (a join can take minutes), so the simulation can act again a
21
+ # tick or more early;
22
+ # - the breach windows start empty, so a recording that begins inside a breach scales
23
+ # `up.for` (or `down.for`) later than live did.
24
+ class Dash::Autoscale::Simulation
25
+ ACTIONS = %w[ scale_out scale_in ].freeze
26
+
27
+ # `roles`: role names to replay, or nil for every role in the file.
28
+ def initialize(path:, config:, roles: nil)
29
+ @path, @config, @roles = path, config, roles
30
+ end
31
+
32
+ def run
33
+ ticks, errors = read
34
+ replays, skipped = [], []
35
+
36
+ ticks.group_by { |_, decision| decision["role"] }.each do |name, role_ticks|
37
+ next if @roles && !@roles.include?(name)
38
+
39
+ role = @config.role(name)
40
+ if role&.scaled? && role.controlled?
41
+ replays << Replay.new(role, role_ticks).to_h
42
+ else
43
+ skipped << { role: name, reason: role ? "#{name} has no scale/schedule or scale/signal in deploy.yml" : "no role named #{name} in deploy.yml" }
44
+ end
45
+ end
46
+
47
+ { file: @path, roles: replays, skipped: skipped, errors: errors }
48
+ end
49
+
50
+ private
51
+ # [ [ at, recorded decision ] in file order, [ { line:, error: } ] ]
52
+ def read
53
+ ticks, errors = [], []
54
+
55
+ File.foreach(@path).with_index(1) do |line, number|
56
+ next if line.strip.empty?
57
+
58
+ tick = JSON.parse(line)
59
+ at = Dash::Autoscale::Timestamp.parse(tick["at"]) if tick.is_a?(Hash)
60
+ decisions = tick["decisions"] if tick.is_a?(Hash)
61
+ unless at && decisions.is_a?(Array) && decisions.all? { |decision| decision.is_a?(Hash) && decision["role"].is_a?(String) }
62
+ raise ArgumentError, "line is not a recorded tick: it needs an at time and a list of decisions, each naming its role"
63
+ end
64
+
65
+ decisions.each { |decision| ticks << [ at, decision ] }
66
+ rescue JSON::ParserError, ArgumentError => e
67
+ errors << { line: number, error: e.message }
68
+ end
69
+
70
+ [ ticks, errors ]
71
+ end
72
+
73
+ # One role's replay over its recorded ticks.
74
+ class Replay
75
+ def initialize(role, ticks)
76
+ @role, @scale, @ticks = role, role.scale, ticks.sort_by(&:first)
77
+ @state = Dash::Autoscale::RoleState.new
78
+ @actions, @counts, @not_replayable, @joins = [], [], 0, 0
79
+ end
80
+
81
+ def to_h
82
+ replay
83
+
84
+ { role: @role.name, ticks: @ticks.size, replayed: @ticks.size - @not_replayable, not_replayable: @not_replayable,
85
+ unrecorded_guards: unrecorded_guards, actions: @actions.map { |decision| decision.to_h.merge(summary: decision.summary) },
86
+ simulated: summary(@actions.count { |decision| decision.action == "scale_out" }, @actions.count { |decision| decision.action == "scale_in" }, @counts),
87
+ recorded: summary(*recorded_actions, recorded_counts) }.compact
88
+ end
89
+
90
+ private
91
+ def replay
92
+ @ticks.each do |at, recorded|
93
+ inputs = recorded["inputs"].is_a?(Hash) ? recorded["inputs"] : {}
94
+ seed(recorded, inputs) if @count.nil?
95
+
96
+ if @count.nil? || (@scale.signal? && !inputs["signal"].is_a?(Hash))
97
+ @not_replayable += 1
98
+ else
99
+ act(at, decide(at, recorded, inputs))
100
+ end
101
+
102
+ @counts << [ at, @count ]
103
+ end
104
+ end
105
+
106
+ # The replay starts at the first tick that recorded a count: its count, and the last
107
+ # scale times it recorded, so cooldowns hold from there.
108
+ def seed(recorded, inputs)
109
+ return unless recorded["from"].is_a?(Integer)
110
+
111
+ @count = recorded["from"]
112
+ @state = Dash::Autoscale::RoleState.new(last_scale_out_at: Dash::Autoscale::Timestamp.parse(inputs["last_scale_out_at"]),
113
+ last_scale_in_at: Dash::Autoscale::Timestamp.parse(inputs["last_scale_in_at"]))
114
+ end
115
+
116
+ def decide(at, recorded, inputs)
117
+ signal = sample(inputs["signal"], at) if @scale.signal?
118
+ @state.observe_signal(signal, up_above: @scale.up_above, down_below: @scale.down_below) if signal
119
+
120
+ Dash::Autoscale::Policy.new(role: @role, now: at, time_zone: @role.config.autoscale.time_zone, current: @count,
121
+ history: @state.history, paused_until: paused_until(inputs["paused_until"]), warming: @state.joined, unreachable: unreachable(inputs["unreachable"]),
122
+ unreadable_baseline: inputs["unreadable_baseline"].is_a?(Hash) ? inputs["unreadable_baseline"] : {},
123
+ pool_error: (recorded["error"] if recorded["reasons"] == [ "pool_unreadable" ]),
124
+ signal: signal, guards: (guards(inputs["guards"], at) if @scale.signal?).to_a, breach: @state.breach).decide
125
+ end
126
+
127
+ def act(at, decision)
128
+ return unless ACTIONS.include?(decision.action)
129
+
130
+ new_hosts = hosts(decision.to) - hosts(@count)
131
+ @state.scaled(decision.action, now: at)
132
+ @state.joined_at(new_hosts.times.map { "simulated-#{@joins += 1}" }, now: at) if decision.action == "scale_out" && new_hosts.positive?
133
+ @count = decision.to
134
+ @actions << decision
135
+ end
136
+
137
+ # As Dash::Configuration::Role::Scale#splittable? reckons it.
138
+ def hosts(count)
139
+ [ (count.to_f / @scale.replicas.max).ceil, @scale.min ].max
140
+ end
141
+
142
+ def sample(recorded, at)
143
+ Dash::Autoscale::Sample.new(value: recorded["value"], at: Dash::Autoscale::Timestamp.parse(recorded["at"]) || at, error: recorded["error"])
144
+ end
145
+
146
+ # The recorded readings against the current bounds. A guard with no recording at its
147
+ # index, or one of another source (hold_when reordered or changed), is left out.
148
+ def guards(recorded, at)
149
+ @scale.guards.each_with_index.filter_map do |guard, index|
150
+ next unless (reading = recorded_reading(recorded, guard, index))
151
+
152
+ Dash::Autoscale::Guard::Reading.new(index: index, source: guard.source, above: guard.above, sample: sample(reading, at))
153
+ end
154
+ end
155
+
156
+ def recorded_reading(recorded, guard, index)
157
+ Array(recorded).find { |reading| reading.is_a?(Hash) && reading["index"] == index && reading["source"] == guard.source }
158
+ end
159
+
160
+ # The current guards no replayed tick has a recording for.
161
+ def unrecorded_guards
162
+ return unless @scale.signal?
163
+
164
+ recorded = @ticks.filter_map { |_, decision| decision["inputs"] if decision["inputs"].is_a?(Hash) && decision["inputs"]["signal"].is_a?(Hash) }
165
+ return if recorded.empty?
166
+
167
+ missing = @scale.guards.each_with_index.count { |guard, index| recorded.none? { |inputs| recorded_reading(inputs["guards"], guard, index) } }
168
+ missing if missing.positive?
169
+ end
170
+
171
+ # { member host => unreachable since }, as recorded.
172
+ def unreachable(recorded)
173
+ return {} unless recorded.is_a?(Hash)
174
+
175
+ recorded.filter_map { |host, since| [ host, Dash::Autoscale::Timestamp.parse(since) ] if Dash::Autoscale::Timestamp.parse(since) }.to_h
176
+ end
177
+
178
+ def paused_until(recorded)
179
+ recorded == Dash::Autoscale::Pause::INDEFINITE ? :indefinite : Dash::Autoscale::Timestamp.parse(recorded)
180
+ end
181
+
182
+ def recorded_actions
183
+ ACTIONS.map { |action| @ticks.count { |_, decision| decision["action"] == action } }
184
+ end
185
+
186
+ # What ran after each recorded tick: where an action took it, else where it stood.
187
+ def recorded_counts
188
+ last = nil
189
+ @ticks.map { |at, decision| [ at, last = [ decision["to"], decision["from"] ].find { |count| count.is_a?(Integer) } || last ] }
190
+ end
191
+
192
+ # Container-seconds: each count held until the role's next tick.
193
+ def summary(scale_outs, scale_ins, counts)
194
+ counts = counts.reject { |_, count| count.nil? }
195
+ seconds = counts.each_cons(2).sum { |(at, count), (next_at, _)| count * (next_at - at) }
196
+
197
+ { scale_outs: scale_outs, scale_ins: scale_ins, peak: counts.map(&:last).max, minimum: counts.map(&:last).min, container_seconds: seconds.round }
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,132 @@
1
+ require "json"
2
+
3
+ # The controller's state, kept on `autoscale.controller` - the ops host that runs it - or,
4
+ # without that key, on the primary role's first baseline host (never a member, which comes
5
+ # and goes), so every operator's CLI and `dash mcp` read the same files: the heartbeat that
6
+ # doubles as the single-controller lease, per-role state (unreachable-since times, last
7
+ # scale-out and scale-in), the decision log, and pauses.
8
+ #
9
+ # Runs Dash::Commands::Autoscale through the SSHKit backend it is handed (call it from
10
+ # inside `on(StateStore.host)`). A missing file is empty state; a malformed one is too, with
11
+ # one warning per file, so a corrupted file never stops the controller. Reads give
12
+ # string-keyed hashes, as JSON has them.
13
+ class Dash::Autoscale::StateStore
14
+ def self.host(config = DASH.config)
15
+ config.autoscale.controller || config.primary_role.baseline_hosts.first
16
+ end
17
+
18
+ def initialize(backend, config: DASH.config)
19
+ @backend = backend
20
+ @config = config
21
+ @commands = Dash::Commands::Autoscale.new(config)
22
+ @warned = Set.new
23
+ end
24
+
25
+ def ensure_directory
26
+ execute @commands.ensure_directory
27
+ end
28
+
29
+ def heartbeat
30
+ read_object "heartbeat.json", @commands.read_heartbeat
31
+ end
32
+
33
+ # Written every tick, and during a long scale action, so it prints only with --verbose.
34
+ def write_heartbeat(heartbeat)
35
+ @backend.execute(*@commands.write_heartbeat(dump(heartbeat)), verbosity: :debug)
36
+ end
37
+
38
+ # The heartbeat, unless it names another controller: false then. A failed write raises.
39
+ def write_heartbeat_if_held(id, heartbeat)
40
+ @backend.capture(*@commands.write_heartbeat_if_held(id, dump(heartbeat)), verbosity: :debug).strip != "taken"
41
+ end
42
+
43
+ def state
44
+ read_object "state.json", @commands.read_state
45
+ end
46
+
47
+ # Whether `file` held something that is not a JSON object when last read.
48
+ def malformed?(file)
49
+ @warned.include?(file)
50
+ end
51
+
52
+ def write_state(state)
53
+ execute @commands.write_state(dump(state))
54
+ end
55
+
56
+ # The last `lines` entries of the decision log (of these roles', with `roles`), oldest first.
57
+ def decisions(lines:, roles: nil)
58
+ return [] if roles&.empty?
59
+
60
+ capture(@commands.read_decisions(lines: lines, roles: roles)).lines.filter_map { |line| parse("decisions.jsonl", line) if line.strip.present? }
61
+ end
62
+
63
+ # Decisions or hashes.
64
+ def append_decisions(decisions)
65
+ return if decisions.empty?
66
+
67
+ execute @commands.append_decisions(decisions.map { |decision| dump(decision.to_h) })
68
+ end
69
+
70
+ def decision_count
71
+ Integer(capture(@commands.count_decisions).strip.presence || "0", 10)
72
+ end
73
+
74
+ def trim_decisions(keep:)
75
+ execute @commands.trim_decisions(keep: keep)
76
+ end
77
+
78
+ # { role => pause }
79
+ def pauses
80
+ capture(@commands.read_pauses).lines.each_with_object({}) do |line, pauses|
81
+ path, json = line.chomp.split(":", 2)
82
+ # A `.json.tmp` an interrupted write left behind is no pause.
83
+ next if json.blank? || !path.end_with?(Dash::Commands::Autoscale::PAUSE_EXTENSION)
84
+
85
+ role = File.basename(path, Dash::Commands::Autoscale::PAUSE_EXTENSION)
86
+
87
+ pause = parse("pause/#{role}", json)
88
+ pauses[role] = pause if pause
89
+ end
90
+ end
91
+
92
+ def write_pause(role, pause)
93
+ execute @commands.write_pause(role, dump(pause))
94
+ end
95
+
96
+ def remove_pause(role)
97
+ execute @commands.remove_pause(role)
98
+ end
99
+
100
+ private
101
+ def read_object(file, command)
102
+ output = capture(command)
103
+ output.strip.empty? ? {} : parse(file, output) || {}
104
+ end
105
+
106
+ def parse(file, json)
107
+ value = JSON.parse(json)
108
+ value.is_a?(Hash) ? value : malformed(file)
109
+ rescue JSON::ParserError
110
+ malformed(file)
111
+ end
112
+
113
+ def malformed(file)
114
+ if @warned.add?(file)
115
+ warn "#{File.join(@config.app_directory, "autoscale", file)} on #{self.class.host(@config)} is not valid JSON, treating it as empty"
116
+ end
117
+
118
+ nil
119
+ end
120
+
121
+ def dump(value)
122
+ JSON.generate(Dash::Autoscale::Decision.json_safe(value))
123
+ end
124
+
125
+ def capture(command)
126
+ @backend.capture(*command, verbosity: :debug)
127
+ end
128
+
129
+ def execute(command)
130
+ @backend.execute(*command)
131
+ end
132
+ end
@@ -0,0 +1,15 @@
1
+ require "time"
2
+
3
+ # The times the controller's state files hold: ISO 8601 in UTC. A value that does not parse
4
+ # reads as nil, because a hand-edited or half-written file must never stop the controller.
5
+ module Dash::Autoscale::Timestamp
6
+ def self.parse(value)
7
+ Time.iso8601(value).utc if value.is_a?(String)
8
+ rescue ArgumentError
9
+ nil
10
+ end
11
+
12
+ def self.dump(time)
13
+ time&.utc&.iso8601
14
+ end
15
+ end
@@ -0,0 +1,48 @@
1
+ require "net/ssh"
2
+
3
+ # A local port forwarded over SSH through a `via` host to a database or Prometheus only the
4
+ # app servers can reach. Its own Net::SSH session and event-loop thread - never one of
5
+ # SSHKit's pooled connections, which nothing keeps looping - on 127.0.0.1 and an ephemeral
6
+ # port net-ssh picks and returns.
7
+ class Dash::Autoscale::Tunnel
8
+ ADDRESS = "127.0.0.1"
9
+ LOOP_WAIT = 0.1
10
+ CLOSE_WAIT = 2
11
+
12
+ attr_reader :port
13
+
14
+ def self.open(via, host, port, ssh: DASH.config.ssh)
15
+ new(via, host, port, user: ssh.user, ssh_options: ssh.options.except(:user))
16
+ end
17
+
18
+ def initialize(via, host, port, user:, ssh_options:, starter: ->(server, login, **options) { Net::SSH.start(server, login, options) })
19
+ @session = starter.call(via, user, **ssh_options)
20
+ begin
21
+ @port = @session.forward.local(ADDRESS, 0, host, port)
22
+ rescue StandardError
23
+ @session.close
24
+ raise
25
+ end
26
+ @closing = false
27
+ @thread = Thread.new { @session.loop(LOOP_WAIT) { !@closing } }
28
+ @thread.report_on_exception = false
29
+ end
30
+
31
+ def address
32
+ ADDRESS
33
+ end
34
+
35
+ # False once the session dropped: the client it carried has nothing to connect to.
36
+ def alive?
37
+ @thread.alive?
38
+ end
39
+
40
+ def close
41
+ @closing = true
42
+ @thread.join(CLOSE_WAIT)
43
+ @session.forward.cancel_local(port, ADDRESS)
44
+ @session.close
45
+ rescue IOError, Net::SSH::Exception
46
+ nil # the session is gone already
47
+ end
48
+ end
@@ -130,6 +130,22 @@ class Dash::Cli::Accessory < Dash::Cli::Base
130
130
  end
131
131
  end
132
132
 
133
+ desc "stats [NAME]", "Show CPU, memory, network and block I/O of an accessory (a one-second sample; NAME=all for every accessory)"
134
+ option :json, type: :boolean, default: false, desc: "Print the stats per host as JSON"
135
+ def stats(name)
136
+ return error_on_missing_accessory(name) unless name == "all" || DASH.config.accessory(name)
137
+
138
+ accessories = name == "all" ? DASH.config.accessories : [ DASH.config.accessory(name) ]
139
+ snapshot = -> { Dash::Diagnostics::ContainerStats.new(hosts: [], accessories: accessories).to_h }
140
+ return puts_json(&snapshot) if options[:json]
141
+
142
+ pre_connect_if_required
143
+ entries = snapshot.call[:accessories]
144
+ return say "No host in scope runs #{name == "all" ? "an accessory" : name}", :yellow if entries.empty?
145
+
146
+ entries.each { |host| puts Dash::Cli::StatsTable.lines("Accessory #{host[:accessory]}", host) }
147
+ end
148
+
133
149
  desc "details [NAME]", "Show details about accessory on host (use NAME=all to show all accessories)"
134
150
  def details(name)
135
151
  quiet = options[:quiet]