dash 4.1.2 → 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 +12 -9
- 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
|
@@ -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,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
|