dash 4.1.3 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/dash/autoscale/connections/builder.rb +51 -0
- data/lib/dash/autoscale/connections.rb +102 -0
- data/lib/dash/autoscale/controller.rb +296 -0
- data/lib/dash/autoscale/cron.rb +75 -0
- data/lib/dash/autoscale/decision.rb +89 -0
- data/lib/dash/autoscale/duration.rb +19 -0
- data/lib/dash/autoscale/evaluation.rb +52 -0
- data/lib/dash/autoscale/guard.rb +48 -0
- data/lib/dash/autoscale/lease.rb +55 -0
- data/lib/dash/autoscale/lease_held.rb +2 -0
- data/lib/dash/autoscale/lease_lost.rb +2 -0
- data/lib/dash/autoscale/member.rb +28 -0
- data/lib/dash/autoscale/metrics.rb +70 -0
- data/lib/dash/autoscale/metrics_server.rb +71 -0
- data/lib/dash/autoscale/observation.rb +41 -0
- data/lib/dash/autoscale/pause.rb +34 -0
- data/lib/dash/autoscale/policy.rb +198 -0
- data/lib/dash/autoscale/pool.rb +103 -0
- data/lib/dash/autoscale/prometheus_client.rb +76 -0
- data/lib/dash/autoscale/provider/base.rb +43 -0
- data/lib/dash/autoscale/provider/exec.rb +94 -0
- data/lib/dash/autoscale/provider/upcloud.rb +140 -0
- data/lib/dash/autoscale/provider.rb +9 -0
- data/lib/dash/autoscale/provider_error.rb +4 -0
- data/lib/dash/autoscale/read_only_query.rb +38 -0
- data/lib/dash/autoscale/reader.rb +17 -0
- data/lib/dash/autoscale/recorder.rb +13 -0
- data/lib/dash/autoscale/role_state.rb +110 -0
- data/lib/dash/autoscale/sample.rb +13 -0
- data/lib/dash/autoscale/signal/pgbus.rb +19 -0
- data/lib/dash/autoscale/signal/prometheus.rb +8 -0
- data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
- data/lib/dash/autoscale/signal.rb +31 -0
- data/lib/dash/autoscale/simulation.rb +200 -0
- data/lib/dash/autoscale/state_store.rb +132 -0
- data/lib/dash/autoscale/timestamp.rb +15 -0
- data/lib/dash/autoscale/tunnel.rb +48 -0
- data/lib/dash/cli/accessory.rb +16 -0
- data/lib/dash/cli/app/boot.rb +69 -44
- data/lib/dash/cli/app/boot_state.rb +70 -0
- data/lib/dash/cli/app/rollout_boot.rb +28 -9
- data/lib/dash/cli/app/running_targets.rb +39 -0
- data/lib/dash/cli/app/slot_version.rb +29 -0
- data/lib/dash/cli/app.rb +89 -32
- data/lib/dash/cli/autoscale.rb +312 -0
- data/lib/dash/cli/base.rb +15 -3
- data/lib/dash/cli/lock.rb +6 -1
- data/lib/dash/cli/main.rb +77 -8
- data/lib/dash/cli/proxy/inspection.rb +52 -0
- data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
- data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
- data/lib/dash/cli/proxy/reboot.rb +4 -9
- data/lib/dash/cli/proxy.rb +12 -7
- data/lib/dash/cli/prune.rb +5 -3
- data/lib/dash/cli/report.rb +4 -1
- data/lib/dash/cli/scale/host_plan.rb +63 -0
- data/lib/dash/cli/scale/join.rb +128 -0
- data/lib/dash/cli/scale/leave.rb +58 -0
- data/lib/dash/cli/scale/placement.rb +54 -0
- data/lib/dash/cli/scale/power_off.rb +26 -0
- data/lib/dash/cli/scale/replica_join.rb +74 -0
- data/lib/dash/cli/scale/replica_leave.rb +121 -0
- data/lib/dash/cli/scale.rb +276 -0
- data/lib/dash/cli/server.rb +41 -0
- data/lib/dash/cli/stats_table.rb +34 -0
- data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
- data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
- data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
- data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
- data/lib/dash/commander.rb +17 -3
- data/lib/dash/commands/accessory/proxy.rb +1 -1
- data/lib/dash/commands/accessory.rb +21 -2
- data/lib/dash/commands/app/containers.rb +28 -3
- data/lib/dash/commands/app/logging.rb +13 -2
- data/lib/dash/commands/app/proxy.rb +4 -4
- data/lib/dash/commands/app.rb +90 -10
- data/lib/dash/commands/auditor.rb +2 -2
- data/lib/dash/commands/autoscale.rb +133 -0
- data/lib/dash/commands/base.rb +4 -0
- data/lib/dash/commands/proxy/services.rb +10 -0
- data/lib/dash/commands/prune.rb +10 -2
- data/lib/dash/commands/server.rb +16 -0
- data/lib/dash/configuration/accessory.rb +2 -2
- data/lib/dash/configuration/autoscale.rb +123 -0
- data/lib/dash/configuration/docs/autoscale.yml +141 -0
- data/lib/dash/configuration/docs/configuration.yml +8 -2
- data/lib/dash/configuration/docs/proxy.yml +1 -1
- data/lib/dash/configuration/docs/role.yml +203 -0
- data/lib/dash/configuration/loadbalancer.rb +3 -2
- data/lib/dash/configuration/proxy/run.rb +1 -1
- data/lib/dash/configuration/proxy.rb +19 -7
- data/lib/dash/configuration/role/drain.rb +49 -0
- data/lib/dash/configuration/role/replicas.rb +72 -0
- data/lib/dash/configuration/role/scale/guard.rb +40 -0
- data/lib/dash/configuration/role/scale/signal.rb +93 -0
- data/lib/dash/configuration/role/scale/window.rb +37 -0
- data/lib/dash/configuration/role/scale.rb +199 -0
- data/lib/dash/configuration/role.rb +123 -6
- data/lib/dash/configuration/validator/autoscale.rb +126 -0
- data/lib/dash/configuration/validator/proxy.rb +13 -0
- data/lib/dash/configuration/validator/role.rb +26 -5
- data/lib/dash/configuration.rb +126 -15
- data/lib/dash/diagnostics/audit.rb +28 -0
- data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
- data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
- data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
- data/lib/dash/diagnostics/base.rb +53 -0
- data/lib/dash/diagnostics/config.rb +32 -0
- data/lib/dash/diagnostics/container_stats.rb +67 -0
- data/lib/dash/diagnostics/containers.rb +20 -0
- data/lib/dash/diagnostics/controller_status.rb +44 -0
- data/lib/dash/diagnostics/docker_ps.rb +40 -0
- data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
- data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
- data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
- data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
- data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
- data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
- data/lib/dash/diagnostics/doctor.rb +182 -0
- data/lib/dash/diagnostics/drift.rb +168 -0
- data/lib/dash/diagnostics/host_stats.rb +59 -0
- data/lib/dash/diagnostics/lines.rb +14 -0
- data/lib/dash/diagnostics/lock.rb +27 -0
- data/lib/dash/diagnostics/logs.rb +76 -0
- data/lib/dash/diagnostics/pool.rb +41 -0
- data/lib/dash/diagnostics/proxy_services.rb +36 -0
- data/lib/dash/diagnostics/redactor.rb +48 -0
- data/lib/dash/diagnostics/reports.rb +17 -0
- data/lib/dash/diagnostics/scale.rb +51 -0
- data/lib/dash/diagnostics/units.rb +31 -0
- data/lib/dash/mcp/base_tool.rb +43 -0
- data/lib/dash/mcp/runner.rb +28 -0
- data/lib/dash/mcp/server.rb +40 -0
- data/lib/dash/mcp/session.rb +51 -0
- data/lib/dash/mcp/tools/audit_tool.rb +11 -0
- data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
- data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
- data/lib/dash/mcp/tools/config_tool.rb +10 -0
- data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
- data/lib/dash/mcp/tools/containers_tool.rb +10 -0
- data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
- data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
- data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
- data/lib/dash/mcp/tools/drift_tool.rb +16 -0
- data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
- data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
- data/lib/dash/mcp/tools/logs_tool.rb +41 -0
- data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
- data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
- data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
- data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
- data/lib/dash/mcp/transport.rb +14 -0
- data/lib/dash/mcp.rb +56 -0
- data/lib/dash/sshkit_with_ext.rb +4 -4
- data/lib/dash/utils.rb +7 -0
- data/lib/dash/version.rb +1 -1
- data/lib/dash.rb +2 -0
- metadata +119 -5
- data/lib/dash/cli/doctor.rb +0 -113
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8fe407ebe24b10effd6d4236b4e9035f6a01362098e6b23a97d5fc120c6e9b5
|
|
4
|
+
data.tar.gz: f10f2b259bd849dde14c9bdf2982571f20cbafe201141a4a4a797eac14ce25ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dcf7d8731f2526798b9e601a9c75c81c728fd7a203a5191fad0eb61473ac2cf474c53de4c0efb83e640610ba3674969d58bc8f8033f7e46f5595bfdc43a83373
|
|
7
|
+
data.tar.gz: 00a5d2988465fdaf6b5df1e0beca380b92ab20ccc61a00cda1fdab9030c44b1c50e9e763a4736d1e649dfc99d3c2020a173ceff58df25118a0522f03b1778771
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# How each source's client is made. redis-client and pg are not dash dependencies: they are
|
|
2
|
+
# required here, on first use, and a missing one names the gem to add to the Gemfile the
|
|
3
|
+
# controller runs with.
|
|
4
|
+
module Dash::Autoscale::Connections::Builder
|
|
5
|
+
GEMS = { redis: "redis-client", postgres: "pg" }.freeze
|
|
6
|
+
CONNECT_TIMEOUT = 2
|
|
7
|
+
READ_TIMEOUT = 5
|
|
8
|
+
POSTGRES_CONNECT_TIMEOUT = 5
|
|
9
|
+
|
|
10
|
+
def self.build(source, url, tunnel, config:)
|
|
11
|
+
case source
|
|
12
|
+
when :redis
|
|
13
|
+
load_gem(source)
|
|
14
|
+
RedisClient.new(**redis_options(url, tunnel))
|
|
15
|
+
when :postgres
|
|
16
|
+
load_gem(source)
|
|
17
|
+
PG.connect(postgres_options(conninfo(url), tunnel))
|
|
18
|
+
when :prometheus
|
|
19
|
+
Dash::Autoscale::PrometheusClient.new(url: url, token: config.autoscale.prometheus_token, tunnel: tunnel)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# redis-client takes host and port over the URL's, and keeps the rest (password, db).
|
|
24
|
+
def self.redis_options(url, tunnel)
|
|
25
|
+
{ url: url, **({ host: tunnel.address, port: tunnel.port } if tunnel).to_h,
|
|
26
|
+
connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT, write_timeout: READ_TIMEOUT }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# libpq connects to `hostaddr` and verifies TLS against `host`, so a tunnel only sets
|
|
30
|
+
# hostaddr and port.
|
|
31
|
+
def self.postgres_options(conninfo, tunnel)
|
|
32
|
+
conninfo.merge(**({ hostaddr: tunnel.address, port: tunnel.port } if tunnel).to_h, connect_timeout: POSTGRES_CONNECT_TIMEOUT)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The URL as libpq reads it, so nothing in it is lost when a tunnel changes where it goes.
|
|
36
|
+
def self.conninfo(url)
|
|
37
|
+
PG::Connection.conninfo_parse(url).each_with_object({}) { |option, hash| hash[option[:keyword].to_sym] = option[:val] if option[:val] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Only the gem itself missing means "add it"; an installed one that fails to load (a
|
|
41
|
+
# native library, a dependency) says why. Both are configuration errors: a LoadError is
|
|
42
|
+
# not a StandardError, and would end the controller.
|
|
43
|
+
def self.load_gem(source)
|
|
44
|
+
require GEMS.fetch(source)
|
|
45
|
+
rescue LoadError => e
|
|
46
|
+
gem = GEMS.fetch(source)
|
|
47
|
+
raise Dash::ConfigurationError, "autoscale/#{source}: the #{gem} gem failed to load: #{e.message}" unless e.path == gem
|
|
48
|
+
|
|
49
|
+
raise Dash::ConfigurationError, %(autoscale/#{source}: needs the #{gem} gem - add gem "#{gem}" to the Gemfile dash autoscale runs with)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "net/ssh"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
4
|
+
# The controller's clients for its signals and guards - Redis, Postgres, Prometheus - one per
|
|
5
|
+
# source for the whole process, so they outlive `reconfigure!` and every tick reuses them.
|
|
6
|
+
# Each is built on first use, through a Dash::Autoscale::Tunnel when the source has `via`,
|
|
7
|
+
# and rebuilt after an error, when its URL, via or Prometheus token changes, or when its tunnel died.
|
|
8
|
+
#
|
|
9
|
+
# One read at a time per source: neither redis-client nor pg connections are thread-safe,
|
|
10
|
+
# and the drain's busy polls (inside SSHKit's `on` threads) share them with the tick.
|
|
11
|
+
class Dash::Autoscale::Connections
|
|
12
|
+
DEFAULT_PORTS = { redis: 6379, postgres: 5432 }.freeze
|
|
13
|
+
|
|
14
|
+
# Errors that leave a client (or the tunnel under it) unusable. Anything else - a query
|
|
15
|
+
# that failed, a malformed job, an HTTP error status - keeps it: the next tick reuses it.
|
|
16
|
+
# The drivers' classes are named, since their gems may not be loaded.
|
|
17
|
+
CONNECTION_ERRORS = [ IOError, SystemCallError, SocketError, Timeout::Error, Net::SSH::Exception ].freeze
|
|
18
|
+
DRIVER_CONNECTION_ERRORS = %w[ RedisClient::ConnectionError RedisClient::ProtocolError PG::ConnectionBad PG::UnableToSend ].freeze
|
|
19
|
+
|
|
20
|
+
Entry = Struct.new(:key, :client, :tunnel)
|
|
21
|
+
|
|
22
|
+
# The process's connections: the controller's, and the drain's when `dash scale set` runs
|
|
23
|
+
# inside it. Closed by the controller when it stops; a later read opens them again.
|
|
24
|
+
def self.shared
|
|
25
|
+
@shared ||= new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(config: -> { DASH.config }, builder: Builder.method(:build), tunnel: Dash::Autoscale::Tunnel.method(:open))
|
|
29
|
+
@config, @builder, @tunnel = config, builder, tunnel
|
|
30
|
+
@entries = {}
|
|
31
|
+
@locks = Hash.new { |locks, source| locks[source] = Mutex.new }
|
|
32
|
+
@locks_lock = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with(source)
|
|
36
|
+
source = source.to_sym
|
|
37
|
+
|
|
38
|
+
lock(source).synchronize do
|
|
39
|
+
entry = entry_for(source)
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
yield entry.client
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
discard(source) if connection_error?(e)
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
@entries.keys.each { |source| lock(source).synchronize { discard(source) } }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def lock(source)
|
|
56
|
+
@locks_lock.synchronize { @locks[source] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A client is kept while what it was built from - the URL, via and, for Prometheus, the
|
|
60
|
+
# token it sends - stays the same.
|
|
61
|
+
def entry_for(source)
|
|
62
|
+
autoscale = @config.call.autoscale
|
|
63
|
+
url, via = autoscale.source_url(source), autoscale.source_via(source)
|
|
64
|
+
key = [ url, via, (autoscale.prometheus_token if source == :prometheus) ]
|
|
65
|
+
entry = @entries[source]
|
|
66
|
+
return entry if entry && entry.key == key && (entry.tunnel.nil? || entry.tunnel.alive?)
|
|
67
|
+
|
|
68
|
+
discard(source)
|
|
69
|
+
@entries[source] = build(source, url, via, key)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build(source, url, via, key)
|
|
73
|
+
uri = URI(url)
|
|
74
|
+
raise Dash::ConfigurationError, "autoscale/#{source}: via needs a URL that names its host" if via && uri.host.blank?
|
|
75
|
+
|
|
76
|
+
tunnel = @tunnel.call(via, uri.host, uri.port || DEFAULT_PORTS.fetch(source)) if via
|
|
77
|
+
Entry.new(key, @builder.call(source, url, tunnel, config: @config.call), tunnel)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
tunnel&.close
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connection_error?(error)
|
|
84
|
+
CONNECTION_ERRORS.any? { |type| error.is_a?(type) } || error.class.ancestors.any? { |type| DRIVER_CONNECTION_ERRORS.include?(type.name) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def discard(source)
|
|
88
|
+
entry = @entries.delete(source)
|
|
89
|
+
return unless entry
|
|
90
|
+
|
|
91
|
+
close_quietly(entry.client)
|
|
92
|
+
close_quietly(entry.tunnel)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A client or tunnel being thrown away after an error may fail to close; the error
|
|
96
|
+
# that matters has been raised already.
|
|
97
|
+
def close_quietly(closable)
|
|
98
|
+
closable&.close
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
# `dash autoscale run`: every `autoscale.interval` seconds, for each scaled role with a
|
|
5
|
+
# `scale.schedule` or `scale.signal`, observe it, ask Dash::Autoscale::Policy, and carry the decision out
|
|
6
|
+
# through `dash scale set` (or replace an unreachable member) under the deploy lock -
|
|
7
|
+
# never by building a join itself.
|
|
8
|
+
#
|
|
9
|
+
# Its state lives on the state host (Dash::Autoscale::StateStore) and is read at the
|
|
10
|
+
# start of every tick, so a restart or a `--once` run from cron behaves like the long-lived
|
|
11
|
+
# loop. The heartbeat is the lease: a second controller refuses to start while it is
|
|
12
|
+
# alive, and this one stops when it names another controller.
|
|
13
|
+
#
|
|
14
|
+
# A failure in one role becomes that role's `action_failed` (or `lock_busy`) hold and the
|
|
15
|
+
# tick moves on. A tick that fails as a whole (the state host down) is reported and the
|
|
16
|
+
# loop goes on; only a lost lease or a deploy.yml that no longer loads stops it.
|
|
17
|
+
class Dash::Autoscale::Controller
|
|
18
|
+
include SSHKit::DSL
|
|
19
|
+
|
|
20
|
+
DECISION_LOG_LIMIT = 10_000
|
|
21
|
+
TRIM_CHECK_INTERVAL = 3600
|
|
22
|
+
|
|
23
|
+
# One scaled role on one tick: the state it is carried in, how it was evaluated, and
|
|
24
|
+
# what was decided (or what became of the decision once carried out).
|
|
25
|
+
Entry = Struct.new(:role, :state, :evaluation, :decision, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
attr_reader :id, :metrics, :cli
|
|
28
|
+
|
|
29
|
+
# `cli` reconfigures DASH, runs `dash scale set`, replaces members and prints - see
|
|
30
|
+
# Dash::Cli::Autoscale.
|
|
31
|
+
# `reader` reads the signals and guards, over connections the controller closes when it
|
|
32
|
+
# stops - see Dash::Autoscale::Reader.
|
|
33
|
+
def initialize(cli:, clock: -> { Time.now.utc }, sleeper: ->(seconds) { sleep seconds }, dry_run: false, takeover: false,
|
|
34
|
+
interval: nil, mode: "loop", recorder: nil, metrics: Dash::Autoscale::Metrics.new, reader: Dash::Autoscale::Reader.new)
|
|
35
|
+
@cli, @clock, @sleeper = cli, clock, sleeper
|
|
36
|
+
@dry_run, @takeover, @interval, @mode = dry_run, takeover, interval, mode
|
|
37
|
+
@recorder, @metrics, @reader = recorder, metrics, reader
|
|
38
|
+
@id = SecureRandom.hex(8)
|
|
39
|
+
@stopping = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dry_run?
|
|
43
|
+
@dry_run
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def interval
|
|
47
|
+
@interval || DASH.config.autoscale.interval
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The roles the controller owns the count of: scaled, with a schedule or a signal, within --roles.
|
|
51
|
+
def roles
|
|
52
|
+
(DASH.specific_roles || DASH.config.roles).select(&:controlled?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Claims the lease, unless another controller's is alive.
|
|
56
|
+
def start
|
|
57
|
+
cli.reconfigure!
|
|
58
|
+
raise ArgumentError, "No scaled role in scope has a scale/schedule or a scale/signal, so there is nothing for the controller to do" if roles.empty?
|
|
59
|
+
|
|
60
|
+
@started_at = @clock.call
|
|
61
|
+
return if dry_run?
|
|
62
|
+
|
|
63
|
+
# Raised out here: SSHKit would wrap anything raised inside `on`.
|
|
64
|
+
lease = with_store { |store| store.ensure_directory; Dash::Autoscale::Lease.new(store.heartbeat, now: @started_at) }
|
|
65
|
+
if lease.held_by_another?(id) && !@takeover
|
|
66
|
+
raise Dash::Autoscale::LeaseHeld, "Another autoscale controller is running: #{lease.describe}. Stop it, or start this one with --takeover"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@role_names = roles.map(&:name)
|
|
70
|
+
with_store { |store| store.write_heartbeat(heartbeat(@started_at)) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run
|
|
74
|
+
start
|
|
75
|
+
|
|
76
|
+
until @stopping
|
|
77
|
+
tick_started = @clock.call
|
|
78
|
+
tick_reporting_failure
|
|
79
|
+
wait_until(tick_started + interval)
|
|
80
|
+
end
|
|
81
|
+
ensure
|
|
82
|
+
release
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# From a signal handler: only a flag, the current tick (and a join in flight) finishes.
|
|
86
|
+
def stop!
|
|
87
|
+
@stopping = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stopping?
|
|
91
|
+
@stopping
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def tick
|
|
95
|
+
started = monotonic
|
|
96
|
+
now = @clock.call
|
|
97
|
+
cli.reconfigure!
|
|
98
|
+
|
|
99
|
+
heartbeat, state, pauses = read_state
|
|
100
|
+
ensure_lease(heartbeat)
|
|
101
|
+
|
|
102
|
+
entries = roles.map { |role| evaluate(role, now, state, pauses) }
|
|
103
|
+
unless dry_run?
|
|
104
|
+
acting = entries.any? { |entry| !entry.decision.hold? }
|
|
105
|
+
keeping_heartbeat { execute(entries) } if acting
|
|
106
|
+
persist(entries, state, now)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
decisions = entries.map(&:decision)
|
|
110
|
+
@metrics.record_tick(decisions: decisions, at: @clock.call, duration: monotonic - started,
|
|
111
|
+
members: entries.to_h { |entry| [ entry.role.name, entry.evaluation&.observation&.pool.to_a.map(&:state) ] })
|
|
112
|
+
@recorder&.record(now, decisions)
|
|
113
|
+
decisions.each { |decision| cli.report decision.summary, (:red if decision.error) }
|
|
114
|
+
decisions
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Closes the signal connections and marks the heartbeat stopped, so the next controller
|
|
118
|
+
# does not wait out a stale lease - unless another controller holds it by now.
|
|
119
|
+
def release
|
|
120
|
+
@reader.close
|
|
121
|
+
return if dry_run? || @started_at.nil?
|
|
122
|
+
|
|
123
|
+
with_store { |store| store.write_heartbeat_if_held(id, heartbeat(@last_tick_at || @started_at).merge(stopped_at: @clock.call)) }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
cli.report "Could not mark the autoscale controller stopped: #{e.class}: #{e.message}", :yellow
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
def tick_reporting_failure
|
|
130
|
+
tick
|
|
131
|
+
rescue Dash::Autoscale::LeaseLost, Dash::ConfigurationError
|
|
132
|
+
raise
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
cli.report "Autoscale tick failed: #{e.class}: #{e.message}", :red
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def wait_until(deadline)
|
|
138
|
+
while !@stopping && (remaining = deadline - @clock.call) > 0
|
|
139
|
+
@sleeper.call([ remaining, 1 ].min)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def read_state
|
|
144
|
+
quietly do
|
|
145
|
+
with_store { |store| [ store.heartbeat, store.state, store.pauses ] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ensure_lease(heartbeat)
|
|
150
|
+
return if dry_run? || heartbeat["controller_id"].blank? || heartbeat["controller_id"] == id
|
|
151
|
+
|
|
152
|
+
raise Dash::Autoscale::LeaseLost, "Another autoscale controller took over: #{Dash::Autoscale::Lease.new(heartbeat, now: @clock.call).describe}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def evaluate(role, now, state, pauses)
|
|
156
|
+
entry = Entry.new(role: role, state: Dash::Autoscale::RoleState.from(state.dig("roles", role.name)))
|
|
157
|
+
entry.evaluation = Dash::Autoscale::Evaluation.new(role: role, now: now, state: entry.state, pause: Dash::Autoscale::Pause.from(pauses[role.name]),
|
|
158
|
+
observation: quietly { Dash::Autoscale::Observation.take(role) }, reader: @reader)
|
|
159
|
+
entry.decision = entry.evaluation.decide
|
|
160
|
+
entry
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
entry.evaluation = nil
|
|
163
|
+
entry.decision = Dash::Autoscale::Decision.new(role: role.name, action: "hold", reasons: [ "action_failed" ], at: now, error: "#{e.class}: #{e.message}")
|
|
164
|
+
entry
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Taking the deploy lock first ensures the run directory on every host of the app, and
|
|
168
|
+
# a host that did not answer this tick would fail that for every role. The hosts this
|
|
169
|
+
# tick saw down are skipped for it (the trick Dash::Cli::Scale#replace_unreachable uses
|
|
170
|
+
# for its own member).
|
|
171
|
+
def execute(entries)
|
|
172
|
+
skipped = entries.flat_map { |entry| entry.evaluation&.observation&.unread_hosts.to_a }.uniq - DASH.run_directory_ensured_on
|
|
173
|
+
DASH.run_directory_ensured_on.concat(skipped)
|
|
174
|
+
|
|
175
|
+
entries.each { |entry| entry.decision = carry_out(entry) unless entry.decision.hold? }
|
|
176
|
+
ensure
|
|
177
|
+
skipped&.each { |host| DASH.run_directory_ensured_on.delete(host) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A join can take minutes (lock wait, boot_timeout, the app's boot). The heartbeat keeps
|
|
181
|
+
# beating meanwhile, so the lease stays alive and status does not read the controller as
|
|
182
|
+
# stuck exactly while it scales. A beat that finds another controller's id stops beating.
|
|
183
|
+
def keeping_heartbeat
|
|
184
|
+
done = false
|
|
185
|
+
beat = Thread.new do
|
|
186
|
+
until done
|
|
187
|
+
Kernel.sleep(interval)
|
|
188
|
+
break if done || !beat_heartbeat
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
yield
|
|
193
|
+
ensure
|
|
194
|
+
done = true
|
|
195
|
+
beat&.wakeup if beat&.alive?
|
|
196
|
+
# A beat still writing should land before anything after it (the tick's own heartbeat,
|
|
197
|
+
# or release's stopped_at), but a write that hangs must not hold the tick: past two
|
|
198
|
+
# intervals the beat is killed.
|
|
199
|
+
beat.kill unless beat.nil? || beat.join(2 * interval)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def beat_heartbeat
|
|
203
|
+
with_store { |store| store.write_heartbeat_if_held(id, heartbeat(@clock.call)) }
|
|
204
|
+
rescue StandardError
|
|
205
|
+
true # a missed beat is retried on the next one
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def carry_out(entry)
|
|
209
|
+
role, decision, observation = entry.role, entry.decision, entry.evaluation.observation
|
|
210
|
+
|
|
211
|
+
case decision.action
|
|
212
|
+
when "scale_out", "scale_in"
|
|
213
|
+
cli.invoke_scale_set(role, decision.to)
|
|
214
|
+
entry.state.scaled(decision.action, now: @clock.call)
|
|
215
|
+
record_joins(entry, observation.member_hosts) if decision.action == "scale_out"
|
|
216
|
+
when "replace_member"
|
|
217
|
+
member = observation.members.find { |candidate| candidate.host == decision.member_host }
|
|
218
|
+
cli.replace_member(role, member, count: decision.from)
|
|
219
|
+
entry.state.forget(member.host)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
decision
|
|
223
|
+
rescue Dash::Cli::LockError => e
|
|
224
|
+
failed(decision, "lock_busy", e.message)
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
failed(decision, "action_failed", "#{e.class}: #{e.message}")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The members a scale-out powered on warm up from now. Members someone else joined are
|
|
230
|
+
# not known to be warming, so they never hold a scale-in.
|
|
231
|
+
#
|
|
232
|
+
# The scale-out already happened, so a pool that cannot be read now is only reported:
|
|
233
|
+
# the members it joined go without a warmup rather than the action being logged failed.
|
|
234
|
+
def record_joins(entry, before)
|
|
235
|
+
DASH.config.pool.refresh!
|
|
236
|
+
entry.state.joined_at(entry.role.active_members.map(&:host) - before, now: @clock.call)
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
cli.report "#{entry.role}: scaled out, but the members it joined could not be read for their warmup (#{e.class}: #{e.message})", :yellow
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def failed(decision, reason, message)
|
|
242
|
+
Dash::Autoscale::Decision.new(role: decision.role, action: "hold", from: decision.from, to: decision.from, reasons: [ reason ],
|
|
243
|
+
inputs: decision.inputs.merge(attempted: { action: decision.action, to: decision.to, replace: decision.member_host }.compact),
|
|
244
|
+
eligible_at: decision.eligible_at, at: decision.at, error: message)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def persist(entries, state, now)
|
|
248
|
+
logged = entries.select { |entry| entry.state.log?(entry.decision) }.each { |entry| entry.state.logged(entry.decision) }.map(&:decision)
|
|
249
|
+
roles_state = state.fetch("roles", {}).merge(entries.to_h { |entry| [ entry.role.name, entry.state.to_h ] })
|
|
250
|
+
checked_at = Dash::Autoscale::Timestamp.parse(state["decisions_checked_at"])
|
|
251
|
+
check = checked_at.nil? || now - checked_at >= TRIM_CHECK_INTERVAL
|
|
252
|
+
|
|
253
|
+
# The heartbeat first, and only while it still names this controller: one command on
|
|
254
|
+
# the host checks and writes, so a takeover since the tick started (or while it acted)
|
|
255
|
+
# is never overwritten, and its state is left to the controller that took over.
|
|
256
|
+
beat_at = @clock.call
|
|
257
|
+
held = quietly { with_store { |store| store.write_heartbeat_if_held(id, heartbeat(beat_at)) } }
|
|
258
|
+
# Only a heartbeat naming another controller is a lost lease; a write that failed
|
|
259
|
+
# raised above and is this tick's failure, which the loop survives.
|
|
260
|
+
raise Dash::Autoscale::LeaseLost, "Another autoscale controller took over while this one ticked" unless held
|
|
261
|
+
|
|
262
|
+
@last_tick_at = beat_at
|
|
263
|
+
quietly do
|
|
264
|
+
with_store do |store|
|
|
265
|
+
store.append_decisions(logged)
|
|
266
|
+
trim(store) if check
|
|
267
|
+
updated = state.merge("roles" => roles_state, "decisions_checked_at" => Dash::Autoscale::Timestamp.dump(check ? now : checked_at))
|
|
268
|
+
store.write_state(updated) unless updated == state
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def trim(store)
|
|
274
|
+
store.trim_decisions(keep: DECISION_LOG_LIMIT) if store.decision_count > DECISION_LOG_LIMIT
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def heartbeat(last_tick_at)
|
|
278
|
+
{ controller_id: id, hostname: Socket.gethostname, pid: Process.pid, version: Dash::VERSION, mode: @mode,
|
|
279
|
+
started_at: @started_at, last_tick_at: last_tick_at, interval: interval, roles: @role_names }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def with_store(&block)
|
|
283
|
+
result = nil
|
|
284
|
+
on(Dash::Autoscale::StateStore.host) { result = block.call(Dash::Autoscale::StateStore.new(self)) }
|
|
285
|
+
result
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The controller reads every host every tick; those reads print only with --verbose.
|
|
289
|
+
def quietly(&block)
|
|
290
|
+
DASH.verbosity == :debug ? block.call : DASH.with_verbosity(:error, &block)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def monotonic
|
|
294
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# A five-field cron expression - `minute hour day-of-month month day-of-week` - for the
|
|
2
|
+
# schedule windows of a scaled role. Each field is `*`, a number, a range `a-b`, a list
|
|
3
|
+
# `a,b` or a step `*/n` / `a-b/n`; day-of-week 0 and 7 are both Sunday. Vixie semantics:
|
|
4
|
+
# when both day fields are restricted (neither starts with `*`), a day matches if either
|
|
5
|
+
# does. No names, `L`, `W` or `#`.
|
|
6
|
+
#
|
|
7
|
+
# `match?` takes a time already in the zone the schedule is evaluated in.
|
|
8
|
+
class Dash::Autoscale::Cron
|
|
9
|
+
class Error < ArgumentError; end
|
|
10
|
+
|
|
11
|
+
Field = Struct.new(:name, :min, :max)
|
|
12
|
+
|
|
13
|
+
FIELDS = [
|
|
14
|
+
Field.new("minute", 0, 59),
|
|
15
|
+
Field.new("hour", 0, 23),
|
|
16
|
+
Field.new("day-of-month", 1, 31),
|
|
17
|
+
Field.new("month", 1, 12),
|
|
18
|
+
Field.new("day-of-week", 0, 7)
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
ITEM = /\A(?:(\*)|(\d+)(?:-(\d+))?)(?:\/(\d+))?\z/
|
|
22
|
+
|
|
23
|
+
def self.parse(expression)
|
|
24
|
+
new(expression)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(expression)
|
|
28
|
+
@expression = expression.to_s.strip
|
|
29
|
+
parts = @expression.split
|
|
30
|
+
|
|
31
|
+
unless parts.size == FIELDS.size
|
|
32
|
+
raise Error, "#{@expression.inspect} has #{parts.size} fields, a cron has 5 (minute hour day-of-month month day-of-week)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@minutes, @hours, @days, @months, weekdays = FIELDS.zip(parts).map { |field, part| parse_field(field, part) }
|
|
36
|
+
@weekdays = weekdays.map { |day| day % 7 }.to_set
|
|
37
|
+
@days_restricted, @weekdays_restricted = !parts[2].start_with?("*"), !parts[4].start_with?("*")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def match?(time)
|
|
41
|
+
@minutes.include?(time.min) && @hours.include?(time.hour) && @months.include?(time.month) && day_match?(time)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s
|
|
45
|
+
@expression
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
def day_match?(time)
|
|
50
|
+
day, weekday = @days.include?(time.day), @weekdays.include?(time.wday)
|
|
51
|
+
|
|
52
|
+
@days_restricted && @weekdays_restricted ? day || weekday : day && weekday
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_field(field, part)
|
|
56
|
+
part.split(",", -1).flat_map { |item| parse_item(field, item) }.to_set
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_item(field, item)
|
|
60
|
+
match = ITEM.match(item)
|
|
61
|
+
unless match && (match[1] || match[3] || !match[4])
|
|
62
|
+
raise Error, "#{field.name}: #{item} is not supported, use numbers, *, ranges (a-b), lists (a,b) and steps (*/n, a-b/n)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
star, first, last, step = match.captures
|
|
66
|
+
from, to = star ? [ field.min, field.max ] : [ Integer(first, 10), Integer(last || first, 10) ]
|
|
67
|
+
step = step ? Integer(step, 10) : 1
|
|
68
|
+
|
|
69
|
+
[ from, to ].each { |value| raise Error, "#{field.name}: #{value} is outside #{field.min}-#{field.max}" unless value.between?(field.min, field.max) }
|
|
70
|
+
raise Error, "#{field.name}: #{item} is a reversed range" if from > to
|
|
71
|
+
raise Error, "#{field.name}: #{item} needs a step of at least 1" if step < 1
|
|
72
|
+
|
|
73
|
+
(from..to).step(step).to_a
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# What the controller decided for one scaled role on one tick: the action, the container
|
|
2
|
+
# count it goes from and to, the reason codes, and every input the policy used. The reason
|
|
3
|
+
# and action codes are stable - the decision log, the metrics and the MCP tools all use them.
|
|
4
|
+
#
|
|
5
|
+
# On a scale_out or scale_in the reasons name what set the target the role moves toward:
|
|
6
|
+
# `schedule_floor` (a window), `at_min` (scale min × replicas min), plus `at_max` when the
|
|
7
|
+
# target was capped. A scale_in step that stops short of the target still names the target.
|
|
8
|
+
# A signal-driven step names its signal instead: `above_threshold` or `below_threshold`.
|
|
9
|
+
Dash::Autoscale::Decision = Struct.new(:role, :action, :from, :to, :reasons, :inputs, :eligible_at, :at, :error, keyword_init: true) do
|
|
10
|
+
def initialize(reasons: [], inputs: {}, **attributes)
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Times as ISO 8601 in UTC, symbols as strings, all the way down - what JSON, the MCP
|
|
15
|
+
# tools and the decision log carry.
|
|
16
|
+
def self.json_safe(value)
|
|
17
|
+
case value
|
|
18
|
+
when Hash then value.to_h { |key, item| [ key, json_safe(item) ] }
|
|
19
|
+
when Array then value.map { |item| json_safe(item) }
|
|
20
|
+
when Time, ActiveSupport::TimeWithZone then value.utc.iso8601
|
|
21
|
+
when Symbol then value.to_s
|
|
22
|
+
when Exception then value.message
|
|
23
|
+
else value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hold?
|
|
28
|
+
action == "hold"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The member a `replace_member` decision takes out.
|
|
32
|
+
def member_host
|
|
33
|
+
inputs[:replace]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
Dash::Autoscale::Decision.json_safe(super)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One log line: `payments: scale_out 4 -> 10 (schedule_floor: window "0 22 1,15 * *" min 10)`.
|
|
41
|
+
def summary
|
|
42
|
+
counts = case action
|
|
43
|
+
when "hold" then "at #{from.nil? ? "?" : from}"
|
|
44
|
+
when "replace_member" then member_host
|
|
45
|
+
else "#{from} -> #{to}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
"#{role}: #{action} #{counts} (#{reasons.map { |reason| note(reason) }.join(", ")})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
# `above_threshold: 12.4 > 10 for 45s`, or `... until <time>` while it has not been long enough.
|
|
53
|
+
def threshold(reason)
|
|
54
|
+
above = reason == "above_threshold"
|
|
55
|
+
since = inputs[above ? :above_since : :below_since]
|
|
56
|
+
text = "#{reason}: #{inputs.dig(:signal, :value)} #{above ? ">" : "<"} #{inputs[above ? :up_above : :down_below]}"
|
|
57
|
+
|
|
58
|
+
if hold? && eligible_at
|
|
59
|
+
"#{text} until #{eligible_at.utc.iso8601}"
|
|
60
|
+
elsif since
|
|
61
|
+
"#{text} for #{(at - since).round}s"
|
|
62
|
+
else
|
|
63
|
+
text
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def note(reason)
|
|
68
|
+
case reason
|
|
69
|
+
when "schedule_floor"
|
|
70
|
+
window = inputs[:windows].to_a.max_by { |candidate| candidate[:min] }
|
|
71
|
+
window ? %(#{reason}: window "#{window[:cron]}" min #{window[:min]}) : reason
|
|
72
|
+
when "cooldown", "warming_up", "member_unreachable", "paused"
|
|
73
|
+
eligible_at ? "#{reason} until #{eligible_at.utc.iso8601}" : reason
|
|
74
|
+
when "above_threshold", "below_threshold"
|
|
75
|
+
threshold(reason)
|
|
76
|
+
when "held_by_guard"
|
|
77
|
+
"#{reason}: #{inputs[:held_by]}"
|
|
78
|
+
else
|
|
79
|
+
error ? "#{reason}: #{error}" : reason
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Dash::Autoscale::Decision::ACTIONS = %w[ scale_out scale_in replace_member hold ].freeze
|
|
85
|
+
Dash::Autoscale::Decision::REASONS = %w[
|
|
86
|
+
schedule_floor at_min at_max at_target cooldown warming_up paused lock_busy
|
|
87
|
+
member_unreachable host_unreachable pool_unreadable action_failed
|
|
88
|
+
above_threshold below_threshold held_by_guard missing_data
|
|
89
|
+
].freeze
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# A length of time as deploy.yml and the CLI write it: integer seconds, or a whole number
|
|
2
|
+
# with a unit - `90s`, `15m`, `30h`, `7d`. Returns seconds.
|
|
3
|
+
module Dash::Autoscale::Duration
|
|
4
|
+
UNITS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
5
|
+
FORMAT = /\A(\d+)([smhd]?)\z/
|
|
6
|
+
|
|
7
|
+
def self.parse(value)
|
|
8
|
+
case value
|
|
9
|
+
when Integer
|
|
10
|
+
return value unless value.negative?
|
|
11
|
+
when String
|
|
12
|
+
if (match = FORMAT.match(value))
|
|
13
|
+
return Integer(match[1], 10) * UNITS.fetch(match[2].presence || "s")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
raise ArgumentError, "#{value.inspect} should be seconds, or a number followed by s, m, h or d (30m, 2h, 1d)"
|
|
18
|
+
end
|
|
19
|
+
end
|