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,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
|
data/lib/dash/cli/accessory.rb
CHANGED
|
@@ -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]
|