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,52 @@
1
+ # One policy evaluation for one scaled role: observes it (unless handed an observation),
2
+ # reads its signal and guards when it has a signal, brings the role's remembered state up to
3
+ # date with what it saw - unreachable clocks started or cleared, warmups of members that left
4
+ # dropped, breach windows moved - and asks Dash::Autoscale::Policy.
5
+ # Takes no action and writes nothing: the controller's tick and `dash autoscale explain`
6
+ # both call this, so explain answers exactly what the next tick would decide.
7
+ class Dash::Autoscale::Evaluation
8
+ attr_reader :role, :now, :state, :observation
9
+
10
+ def initialize(role:, now:, state:, pause:, observation: nil, reader: nil)
11
+ @role, @now, @state, @pause = role, now, state, pause
12
+ @observation = observation || Dash::Autoscale::Observation.take(role)
13
+ @reader = reader
14
+ end
15
+
16
+ def decide
17
+ # With the pool unreadable nothing is known about the members, so nothing is forgotten.
18
+ unless observation.pool_error
19
+ state.observe_unreachable(observation.unreachable_members.keys, now: now)
20
+ state.keep_members(observation.member_hosts)
21
+ end
22
+
23
+ signal, guards, configuration_error = read_signal
24
+
25
+ decision = Dash::Autoscale::Policy.new(role: role, now: now, time_zone: role.config.autoscale.time_zone, current: observation.current,
26
+ history: state.history, paused_until: @pause&.ends_at, warming: state.joined, unreachable: state.unreachable,
27
+ unreadable_baseline: observation.unreadable_baseline, pool_error: observation.pool_error,
28
+ signal: signal, guards: guards, breach: state.breach).decide
29
+
30
+ # A signal that cannot be configured (a missing gem, an empty secret) fails the role's
31
+ # evaluation loudly - but only where the signal decides: a floor, a pause or an
32
+ # unreachable member goes on without it.
33
+ raise configuration_error if configuration_error && decision.reasons == [ "missing_data" ]
34
+
35
+ decision
36
+ end
37
+
38
+ private
39
+ def read_signal
40
+ return [ nil, [] ] unless role.scale.signal?
41
+
42
+ signal, guards = begin
43
+ (@reader || Dash::Autoscale::Reader.new).read(role, at: now)
44
+ rescue Dash::ConfigurationError => e
45
+ error = e
46
+ [ Dash::Autoscale::Sample.new(value: nil, at: now, error: "#{e.class}: #{e.message}"), [] ]
47
+ end
48
+
49
+ state.observe_signal(signal, up_above: role.scale.up_above, down_below: role.scale.down_below)
50
+ [ signal, guards, error ]
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ # A `scale.hold_when` guard as the controller reads it. A Reading pairs the guard (its index,
2
+ # source and bound) with its Sample: it holds a signal-driven scale-out while its value is
3
+ # above the bound, or while it cannot be read.
4
+ class Dash::Autoscale::Guard
5
+ # One Reading per guard, in order. A guard that cannot be read is a Reading with an error,
6
+ # which holds; only a configuration error goes through.
7
+ def self.read(guards, connections, at: Time.now.utc)
8
+ guards.each_with_index.map do |guard, index|
9
+ Reading.new(index: index, source: guard.source, above: guard.above, sample: sample(guard, connections, at))
10
+ end
11
+ end
12
+
13
+ def self.sample(guard, connections, at)
14
+ value = connections.with(guard.connection) do |client|
15
+ if guard.source == "postgres"
16
+ Dash::Autoscale::ReadOnlyQuery.within(client) { Dash::Autoscale::ReadOnlyQuery.number(client, guard.query) }
17
+ else
18
+ client.query(guard.query)
19
+ end
20
+ end
21
+
22
+ Dash::Autoscale::Sample.new(value: value, at: at)
23
+ rescue Dash::ConfigurationError
24
+ raise
25
+ rescue StandardError => e
26
+ Dash::Autoscale::Sample.new(value: nil, at: at, error: "#{e.class}: #{e.message}")
27
+ end
28
+ private_class_method :sample
29
+
30
+ Reading = Struct.new(:index, :source, :above, :sample, keyword_init: true) do
31
+ def holding?
32
+ sample.missing? || sample.value > above
33
+ end
34
+
35
+ def name
36
+ "hold_when/#{index} #{source}"
37
+ end
38
+
39
+ # `hold_when/0 postgres 195 > 180`, or `hold_when/0 postgres: <error>`.
40
+ def describe
41
+ sample.missing? ? "#{name}: #{sample.error || "no value"}" : "#{name} #{sample.value} > #{above}"
42
+ end
43
+
44
+ def to_h
45
+ { index: index, source: source, above: above }.merge(sample.to_h)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # The controller's heartbeat read as a lease: alive while its last tick is within three of
2
+ # its own intervals and it has not stopped. A second controller refuses to start while
3
+ # another one's lease is alive, and a running one stops when the heartbeat names another
4
+ # controller. Convergent, not a hard mutex - the deploy lock still serializes every scale
5
+ # action.
6
+ class Dash::Autoscale::Lease
7
+ STALE_AFTER_INTERVALS = 3
8
+
9
+ attr_reader :heartbeat
10
+
11
+ def initialize(heartbeat, now:)
12
+ @heartbeat = heartbeat.is_a?(Hash) ? heartbeat : {}
13
+ @now = now
14
+ end
15
+
16
+ def controller_id
17
+ heartbeat["controller_id"]
18
+ end
19
+
20
+ def last_tick_at
21
+ Dash::Autoscale::Timestamp.parse(heartbeat["last_tick_at"])
22
+ end
23
+
24
+ def interval
25
+ Integer(heartbeat["interval"].to_s, 10)
26
+ rescue ArgumentError
27
+ Dash::Configuration::Autoscale::DEFAULT_INTERVAL
28
+ end
29
+
30
+ # Whole seconds since the last tick, nil without one.
31
+ def age
32
+ (@now - last_tick_at).floor if last_tick_at
33
+ end
34
+
35
+ def stopped?
36
+ heartbeat["stopped_at"].present?
37
+ end
38
+
39
+ # Ages are read on another machine's clock than they were written with, so a heartbeat
40
+ # from the future is believed only as far ahead as it would be believed old: a skewed
41
+ # clock cannot hold the lease for longer than a stale one would.
42
+ def alive?
43
+ limit = STALE_AFTER_INTERVALS * interval
44
+ !stopped? && !age.nil? && age.between?(-limit, limit)
45
+ end
46
+
47
+ def held_by_another?(id)
48
+ alive? && controller_id != id
49
+ end
50
+
51
+ def describe
52
+ tick = age ? "last tick #{age}s ago" : "no tick yet"
53
+ "controller #{controller_id} on #{heartbeat["hostname"]} (pid #{heartbeat["pid"]}, dash #{heartbeat["version"]}), #{tick}"
54
+ end
55
+ end
@@ -0,0 +1,2 @@
1
+ # Another controller's heartbeat is alive, so this one does not start (`--takeover` overrides).
2
+ class Dash::Autoscale::LeaseHeld < StandardError; end
@@ -0,0 +1,2 @@
1
+ # The heartbeat names another controller - one started with `--takeover` - so this one stops.
2
+ class Dash::Autoscale::LeaseLost < StandardError; end
@@ -0,0 +1,28 @@
1
+ # A pool member as the provider reports it: its provider id, the address dash connects to,
2
+ # the role it carries in its labels (all of them under `labels`), and its power state. `verified: false` marks a member
3
+ # named with --hosts while the provider could not be asked (see Dash::Autoscale::Pool).
4
+ Dash::Autoscale::Member = Struct.new(:id, :host, :role, :state, :labels, :created_at, :verified, keyword_init: true) do
5
+ def initialize(verified: true, **attributes)
6
+ super
7
+ end
8
+
9
+ def started?
10
+ state == "started"
11
+ end
12
+
13
+ def stopped?
14
+ state == "stopped"
15
+ end
16
+
17
+ # Neither on nor off: UpCloud reports `maintenance` while a server starts, stops or is
18
+ # changed, and `error` when it cannot.
19
+ def transitional?
20
+ !started? && !stopped?
21
+ end
22
+
23
+ def to_h
24
+ { id: id, host: host, role: role, state: state, labels: labels, created_at: created_at, verified: verified }
25
+ end
26
+ end
27
+
28
+ Dash::Autoscale::Member::STATES = %w[ started stopped maintenance error ].freeze
@@ -0,0 +1,70 @@
1
+ # What `dash autoscale run --metrics-port` serves, in the Prometheus text format: per role
2
+ # its running and target containers, its members per provider state, and its signal's and
3
+ # guards' last samples as of the last tick, decision counts since the controller started,
4
+ # and when the last tick ran and how long it took. Ticks write, the metrics server thread reads, so both go through the lock.
5
+ class Dash::Autoscale::Metrics
6
+ CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
7
+
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @containers = {}
11
+ @decisions = Hash.new(0)
12
+ @members = {}
13
+ @signals = {}
14
+ @guards = {}
15
+ @last_tick_at = @tick_duration = nil
16
+ end
17
+
18
+ # `members` is { role => [ provider state of each member ] }.
19
+ def record_tick(decisions:, members:, at:, duration:)
20
+ @mutex.synchronize do
21
+ decisions.each do |decision|
22
+ @containers[decision.role] = { "running" => decision.from, "target" => decision.inputs[:floor] }.compact
23
+ decision.reasons.each { |reason| @decisions[[ decision.role, decision.action, reason ]] += 1 }
24
+ @signals[decision.role] = decision.inputs[:signal]
25
+ @guards[decision.role] = decision.inputs[:guards].to_a
26
+ end
27
+
28
+ members.each { |role, states| @members[role] = states.tally }
29
+ @last_tick_at, @tick_duration = at, duration
30
+ end
31
+ end
32
+
33
+ def render
34
+ @mutex.synchronize do
35
+ [
36
+ family("dash_autoscale_containers", "gauge", "Containers of a scaled role: running, and the target the controller holds it to.",
37
+ @containers.sort.flat_map { |role, counts| counts.sort.map { |state, count| [ { role: role, state: state }, count ] } }),
38
+ family("dash_autoscale_decisions_total", "counter", "Decisions the controller took, per role, action and reason.",
39
+ @decisions.sort.map { |(role, action, reason), count| [ { role: role, action: action, reason: reason }, count ] }),
40
+ family("dash_autoscale_members", "gauge", "Pool members of a scaled role, per provider state.",
41
+ @members.sort.flat_map { |role, states| states.sort.map { |state, count| [ { role: role, state: state }, count ] } }),
42
+ family("dash_autoscale_signal", "gauge", "The last sample of a scaled role's signal; absent while it cannot be read.",
43
+ @signals.sort.filter_map { |role, signal| [ { role: role, source: signal[:source] }, signal[:value] ] if signal&.dig(:value) }),
44
+ family("dash_autoscale_guard", "gauge", "The last sample of each of a scaled role's guards, by its index in hold_when.",
45
+ @guards.sort.flat_map { |role, guards| guards.filter_map { |guard| [ { role: role, guard: guard[:index], source: guard[:source] }, guard[:value] ] unless guard[:value].nil? } }),
46
+ family("dash_autoscale_last_tick_timestamp_seconds", "gauge", "When the last tick finished, in Unix seconds.",
47
+ @last_tick_at ? [ [ {}, @last_tick_at.to_i ] ] : []),
48
+ family("dash_autoscale_tick_duration_seconds", "gauge", "How long the last tick took.",
49
+ @tick_duration ? [ [ {}, @tick_duration.round(3) ] ] : [])
50
+ ].join
51
+ end
52
+ end
53
+
54
+ private
55
+ def family(name, type, help, samples)
56
+ lines = [ "# HELP #{name} #{help}", "# TYPE #{name} #{type}" ]
57
+ lines += samples.map { |labels, value| "#{name}#{labels(labels)} #{value}" }
58
+ lines.map { |line| "#{line}\n" }.join
59
+ end
60
+
61
+ def labels(labels)
62
+ return "" if labels.empty?
63
+
64
+ "{#{labels.map { |key, value| %(#{key}="#{escape(value)}") }.join(",")}}"
65
+ end
66
+
67
+ def escape(value)
68
+ value.to_s.gsub("\\", "\\\\\\\\").gsub("\"", "\\\"").gsub("\n", "\\n")
69
+ end
70
+ end
@@ -0,0 +1,71 @@
1
+ require "socket"
2
+
3
+ # A one-thread HTTP endpoint for Prometheus: `GET /metrics` answers the controller's
4
+ # Dash::Autoscale::Metrics, anything else is a 404. Binds 127.0.0.1 unless told otherwise
5
+ # (`--metrics-bind`), so the metrics are not on the network by accident. No gem: a scrape
6
+ # is one short request.
7
+ class Dash::Autoscale::MetricsServer
8
+ READ_TIMEOUT = 5
9
+ MAX_HEADER_LINES = 100
10
+
11
+ def initialize(metrics, bind:, port:, timeout: READ_TIMEOUT)
12
+ @metrics, @bind, @port, @timeout = metrics, bind, port, timeout
13
+ end
14
+
15
+ def start
16
+ @server = TCPServer.new(@bind, @port)
17
+ @thread = Thread.new { serve }
18
+ self
19
+ end
20
+
21
+ def port
22
+ @server.addr[1]
23
+ end
24
+
25
+ def stop
26
+ @server&.close
27
+ @thread&.join(@timeout)
28
+ rescue IOError
29
+ nil
30
+ end
31
+
32
+ private
33
+ def serve
34
+ loop do
35
+ client = @server.accept
36
+ answer(client)
37
+ end
38
+ rescue IOError, Errno::EBADF
39
+ # Closed by #stop.
40
+ end
41
+
42
+ # One bad client never stops the endpoint: every read times out (IO::TimeoutError), and a
43
+ # request gets READ_TIMEOUT and MAX_HEADER_LINES in all, so a slow drip cannot hold it.
44
+ def answer(client)
45
+ client.timeout = @timeout
46
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
47
+ method, path = client.gets.to_s.split(" ", 3)
48
+ # The headers are read too: closing a socket with them unread resets the connection,
49
+ # and a scraper can see the reset before the body.
50
+ MAX_HEADER_LINES.times do
51
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
52
+ line = client.gets
53
+ break if line.nil? || line.strip.empty?
54
+ end
55
+
56
+ if method == "GET" && path == "/metrics"
57
+ respond client, "200 OK", @metrics.render
58
+ else
59
+ respond client, "404 Not Found", "Not found\n"
60
+ end
61
+ rescue StandardError
62
+ nil
63
+ ensure
64
+ client.close
65
+ end
66
+
67
+ def respond(client, status, body)
68
+ client.write "HTTP/1.1 #{status}\r\nContent-Type: #{Dash::Autoscale::Metrics::CONTENT_TYPE}\r\n" \
69
+ "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ # What one scaled role looks like right now: how many of its containers run, which members
2
+ # are started, which hosts did not answer, or the provider error when its pool could not be
3
+ # read. One `docker ps` per host through Dash::Diagnostics::Scale, so one unreachable host
4
+ # makes the role hold instead of failing the tick.
5
+ #
6
+ # A host that does not answer is either a started member (the controller may replace it
7
+ # after `scale.boot_timeout`) or a baseline host (never touched: the role holds).
8
+ class Dash::Autoscale::Observation
9
+ attr_reader :role, :current, :pool, :pool_error, :unreadable_baseline, :unreachable_members
10
+
11
+ def self.take(role)
12
+ status = Dash::Diagnostics::Scale.new(roles: [ role ]).to_h[:roles].first
13
+ return new(role: role, pool_error: status[:error]) if status[:error]
14
+
15
+ unread = status[:unread].to_h { |host| [ host[:host], host[:error] ] }
16
+ baseline = unread.slice(*role.baseline_hosts)
17
+
18
+ new(role: role, current: status[:total], pool: role.members, unreadable_baseline: baseline,
19
+ unreachable_members: unread.except(*baseline.keys))
20
+ end
21
+
22
+ # `pool` is every member of the role, whatever its state.
23
+ def initialize(role:, current: nil, pool: [], pool_error: nil, unreadable_baseline: {}, unreachable_members: {})
24
+ @role, @current, @pool, @pool_error = role, current, pool, pool_error
25
+ @unreadable_baseline, @unreachable_members = unreadable_baseline, unreachable_members
26
+ end
27
+
28
+ # The started members.
29
+ def members
30
+ pool.select(&:started?)
31
+ end
32
+
33
+ def member_hosts
34
+ members.map(&:host)
35
+ end
36
+
37
+ # Every host of the role that did not answer.
38
+ def unread_hosts
39
+ unreadable_baseline.keys + unreachable_members.keys
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # `dash autoscale pause ROLE [--for DURATION]`: the controller leaves the role alone until
2
+ # `ends_at` (a Time) or for good (`:indefinite`) - `resume ROLE` removes it. Kept as
3
+ # `pause/<role>.json` on the state host (Dash::Autoscale::StateStore.host), beside the
4
+ # controller's other state.
5
+ class Dash::Autoscale::Pause
6
+ INDEFINITE = "indefinite"
7
+
8
+ attr_reader :ends_at, :by, :at
9
+
10
+ def self.from(hash)
11
+ return unless hash.is_a?(Hash)
12
+
13
+ # A pause whose end cannot be read holds: letting go of a role an operator paused is
14
+ # the worse mistake.
15
+ ends_at = hash["until"] == INDEFINITE ? :indefinite : Dash::Autoscale::Timestamp.parse(hash["until"]) || :indefinite
16
+ new(ends_at: ends_at, by: hash["by"], at: Dash::Autoscale::Timestamp.parse(hash["at"]))
17
+ end
18
+
19
+ def initialize(ends_at:, by: nil, at: nil)
20
+ @ends_at, @by, @at = ends_at, by, at
21
+ end
22
+
23
+ def indefinite?
24
+ @ends_at == :indefinite
25
+ end
26
+
27
+ def active?(now)
28
+ indefinite? || now < @ends_at
29
+ end
30
+
31
+ def to_h
32
+ { "until" => indefinite? ? INDEFINITE : Dash::Autoscale::Timestamp.dump(@ends_at), "by" => by, "at" => Dash::Autoscale::Timestamp.dump(at) }
33
+ end
34
+ end
@@ -0,0 +1,198 @@
1
+ # What the controller should do with one scaled role, now. Pure: every input is handed in -
2
+ # the clock, the running count, the history of past actions, pauses, which members are
3
+ # warming up or unreachable - and `decide` returns one Dash::Autoscale::Decision. No SSH, no
4
+ # provider call, no clock of its own.
5
+ #
6
+ # The floor is scale min × replicas min, raised by any active schedule window, never above
7
+ # scale max × replicas max. Below it the role scales out straight to it, whatever else holds.
8
+ #
9
+ # A role without a signal returns to its floor: above it, it scales in by `scale.step` at
10
+ # most, once per `cooldown.down`, never while a member warms up. A role with a signal holds
11
+ # it inside its band instead: `up.for` above `up.above` scales out by a step (unless a guard
12
+ # holds, at max, or within `cooldown.up` of the last scale-out); `down.for` below
13
+ # `down.below` scales in by a step, by the same rules as a return to the floor; inside the
14
+ # band, or without a sample, it holds. Counts are containers; Dash::Cli::Scale::HostPlan
15
+ # turns them into hosts.
16
+ class Dash::Autoscale::Policy
17
+ attr_reader :role, :now, :current
18
+
19
+ # history: { last_scale_out_at:, last_scale_in_at: }
20
+ # paused_until: nil, a Time, or :indefinite
21
+ # warming: { member host => joined at }
22
+ # unreachable: { member host => unreachable since }
23
+ # unreadable_baseline: { baseline host => error (an exception or its message) }
24
+ # pool_error: the provider's error (an exception or its message) when the pool could not be read
25
+ # signal: this tick's Dash::Autoscale::Sample of the role's signal (a role with one)
26
+ # guards: this tick's Dash::Autoscale::Guard::Readings
27
+ # breach: { above_since:, below_since: } from Dash::Autoscale::RoleState#breach
28
+ def initialize(role:, now:, time_zone:, current:, history: {}, paused_until: nil, warming: {}, unreachable: {}, unreadable_baseline: {}, pool_error: nil,
29
+ signal: nil, guards: [], breach: {})
30
+ @role, @scale = role, role.scale
31
+ @now, @time_zone, @current = now, time_zone, current
32
+ @history, @paused_until, @warming, @unreachable = history, paused_until, warming, unreachable
33
+ @unreadable_baseline, @pool_error = unreadable_baseline, pool_error
34
+ @signal, @guards, @breach = signal, guards, breach
35
+ end
36
+
37
+ def decide
38
+ return hold("pool_unreadable", error: message(@pool_error)) if @pool_error
39
+ return hold("paused", eligible_at: (@paused_until unless @paused_until == :indefinite)) if paused?
40
+ return hold("host_unreachable", error: @unreadable_baseline.map { |host, error| "#{host}: #{message(error)}" }.join("; ")) if @unreadable_baseline.any?
41
+ return unreachable_member if @unreachable.any?
42
+
43
+ if current < floor
44
+ decision("scale_out", to: floor, reasons: floor_reasons)
45
+ elsif @scale.signal?
46
+ reactive
47
+ elsif current > floor
48
+ scale_in
49
+ else
50
+ hold("at_target")
51
+ end
52
+ end
53
+
54
+ def floor
55
+ @floor ||= [ [ @scale.min_count, *active_windows.map { |window| window[:min] } ].max, @scale.max_count ].min
56
+ end
57
+
58
+ private
59
+ def paused?
60
+ @paused_until == :indefinite || (@paused_until && now < @paused_until)
61
+ end
62
+
63
+ # One member per tick, the longest unreachable first, once it has had boot_timeout to
64
+ # answer and is past its warmup.
65
+ def unreachable_member
66
+ eligible_at = @unreachable.to_h { |host, since| [ host, [ since + @scale.boot_timeout, warmup_ends_at(host) ].compact.max ] }
67
+ ready = eligible_at.select { |_, at| now >= at }.keys
68
+
69
+ if ready.any?
70
+ decision("replace_member", to: current, reasons: [ "member_unreachable" ], replace: ready.min_by { |host| @unreachable[host] })
71
+ else
72
+ hold("member_unreachable", eligible_at: eligible_at.values.min)
73
+ end
74
+ end
75
+
76
+ def scale_in(reasons = floor_reasons, bottom: floor)
77
+ if warming_hosts.any?
78
+ hold("warming_up", eligible_at: warming_hosts.map { |host| warmup_ends_at(host) }.max)
79
+ elsif last_action_at && now < last_action_at + @scale.cooldown_down
80
+ hold("cooldown", eligible_at: last_action_at + @scale.cooldown_down)
81
+ else
82
+ decision("scale_in", to: step_target(bottom), reasons: reasons)
83
+ end
84
+ end
85
+
86
+ # At or above the floor, a signalled role follows its band - once it is within max: a
87
+ # role left above it (max lowered under its count) steps down to max first.
88
+ def reactive
89
+ return scale_in([ "at_max" ], bottom: @scale.max_count) if current > @scale.max_count
90
+ return hold("missing_data", error: "signal #{@scale.signal.source}: #{@signal&.error || "no value"}") if @signal.nil? || @signal.missing?
91
+
92
+ if (since = @breach[:above_since])
93
+ eligible_at = since + @scale.up_for
94
+ now >= eligible_at ? step_out : hold("above_threshold", eligible_at: eligible_at)
95
+ elsif (since = @breach[:below_since]) && current > floor
96
+ eligible_at = since + @scale.down_for
97
+ now >= eligible_at ? scale_in([ "below_threshold" ]) : hold("below_threshold", eligible_at: eligible_at)
98
+ else
99
+ hold("at_target")
100
+ end
101
+ end
102
+
103
+ def step_out
104
+ last_out = @history[:last_scale_out_at]
105
+
106
+ if (target = step_out_target).nil?
107
+ hold("at_max")
108
+ elsif (guard = @guards.find(&:holding?))
109
+ hold("held_by_guard", error: (guard.describe if guard.sample.missing?), held_by: guard.describe)
110
+ elsif last_out && now < last_out + @scale.cooldown_up
111
+ hold("cooldown", eligible_at: last_out + @scale.cooldown_up)
112
+ else
113
+ decision("scale_out", to: target, reasons: [ "above_threshold" ])
114
+ end
115
+ end
116
+
117
+ # A step up, never above max, landing on a count the hosts can split: the nearest one
118
+ # above, else the nearest one below that still adds a container. Nil at max.
119
+ def step_out_target
120
+ return if current >= @scale.max_count
121
+
122
+ target = [ current + @scale.step, @scale.max_count ].min
123
+
124
+ (target..@scale.max_count).find { |count| @scale.splittable?(count) } ||
125
+ target.downto(current + 1).find { |count| @scale.splittable?(count) }
126
+ end
127
+
128
+ # A step down, never below `bottom` (the floor), landing on a count the hosts can split:
129
+ # the nearest one above, else the nearest one below.
130
+ def step_target(bottom = floor)
131
+ target = [ bottom, current - @scale.step ].max
132
+
133
+ (target...current).find { |count| @scale.splittable?(count) } ||
134
+ target.downto(bottom).find { |count| @scale.splittable?(count) } || bottom
135
+ end
136
+
137
+ def floor_reasons
138
+ reasons = [ active_windows.any? { |window| window[:min] > @scale.min_count } ? "schedule_floor" : "at_min" ]
139
+ reasons << "at_max" if active_windows.any? { |window| window[:min] > @scale.max_count }
140
+ reasons
141
+ end
142
+
143
+ def active_windows
144
+ @active_windows ||= begin
145
+ local = now.in_time_zone(@time_zone)
146
+
147
+ @scale.schedule.filter_map do |window|
148
+ if (started_at = window.started_at(local))
149
+ window.to_h.merge(started_at: started_at.utc, ends_at: (started_at + window.duration).utc)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ def warming_hosts
156
+ @warming_hosts ||= @warming.keys.select { |host| now < warmup_ends_at(host) }
157
+ end
158
+
159
+ def warmup_ends_at(host)
160
+ @warming[host]&.+(@scale.warmup)
161
+ end
162
+
163
+ def last_action_at
164
+ @history.values_at(:last_scale_out_at, :last_scale_in_at).compact.max
165
+ end
166
+
167
+ # Errors may come in as exceptions; a decision carries their message, which JSON can.
168
+ def message(error)
169
+ error.respond_to?(:message) ? error.message : error.to_s
170
+ end
171
+
172
+ def hold(reason, eligible_at: nil, error: nil, **extra)
173
+ decision("hold", to: current, reasons: [ reason ], eligible_at: eligible_at, error: error, **extra)
174
+ end
175
+
176
+ def decision(action, to:, reasons:, eligible_at: nil, error: nil, **extra)
177
+ Dash::Autoscale::Decision.new(role: role.name.to_s, action: action, from: current, to: to, reasons: reasons,
178
+ inputs: inputs.merge(extra), eligible_at: eligible_at, at: now, error: error)
179
+ end
180
+
181
+ def inputs
182
+ {
183
+ current: current, floor: floor, min_count: @scale.min_count, max_count: @scale.max_count, step: @scale.step,
184
+ cooldown_up: @scale.cooldown_up, cooldown_down: @scale.cooldown_down, warmup: @scale.warmup, boot_timeout: @scale.boot_timeout,
185
+ windows: active_windows, last_scale_out_at: @history[:last_scale_out_at], last_scale_in_at: @history[:last_scale_in_at],
186
+ warming: warming_hosts, unreachable: @unreachable, unreadable_baseline: @unreadable_baseline, paused_until: @paused_until
187
+ }.merge(signal_inputs)
188
+ end
189
+
190
+ # Everything Dash::Autoscale::Simulation needs to replay the tick, for a role with a signal.
191
+ def signal_inputs
192
+ return {} unless @scale.signal?
193
+
194
+ { signal: { source: @scale.signal.source, value: nil, at: nil, error: nil }.merge(@signal&.to_h || {}), guards: @guards.map(&:to_h),
195
+ above_since: @breach[:above_since], below_since: @breach[:below_since],
196
+ up_above: @scale.up_above, up_for: @scale.up_for, down_below: @scale.down_below, down_for: @scale.down_for }
197
+ end
198
+ end