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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# The pool members of every scaled role, as the provider reports them. The configuration
|
|
2
|
+
# owns one and Role#hosts reads it, so members are asked for on the first `hosts` read and
|
|
3
|
+
# then once per process per role - `refresh!` asks again.
|
|
4
|
+
#
|
|
5
|
+
# Degraded mode lives here and only here. When the provider cannot be asked and the command
|
|
6
|
+
# named hosts with --hosts, the named hosts that are not baseline hosts are taken as
|
|
7
|
+
# started members, unverified, of the one scaled role in scope (--roles narrows it), with a
|
|
8
|
+
# warning. Without --hosts, or with more than one scaled role it could mean, the provider
|
|
9
|
+
# error stands: dash never guesses which hosts are running.
|
|
10
|
+
class Dash::Autoscale::Pool
|
|
11
|
+
attr_reader :config, :explicit_hosts, :explicit_roles
|
|
12
|
+
|
|
13
|
+
def initialize(config:, explicit_hosts: nil, explicit_roles: nil)
|
|
14
|
+
@config = config
|
|
15
|
+
@explicit_hosts = Array(explicit_hosts).presence
|
|
16
|
+
@explicit_roles = Array(explicit_roles).presence
|
|
17
|
+
@members = {}
|
|
18
|
+
@degraded = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `on` runs host blocks in threads, and any of them may be the first to read a role's
|
|
23
|
+
# hosts; the lock keeps that to one provider call. A failure is kept too, so a provider
|
|
24
|
+
# that is down costs one timeout per process, not one per `hosts` read.
|
|
25
|
+
def members_for(role)
|
|
26
|
+
members = @mutex.synchronize do
|
|
27
|
+
@members[role.name] ||= begin
|
|
28
|
+
resolve(role)
|
|
29
|
+
rescue Dash::Autoscale::ProviderError => e
|
|
30
|
+
e
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raise members if members.is_a?(Exception)
|
|
35
|
+
members
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether the role's members came from --hosts because the provider did not answer, so
|
|
39
|
+
# the role's full host list is unknown.
|
|
40
|
+
def degraded?(role)
|
|
41
|
+
members_for(role)
|
|
42
|
+
@degraded.fetch(role.name, false)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def active_for(role)
|
|
46
|
+
members_for(role).select(&:started?)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def refresh!
|
|
50
|
+
@mutex.synchronize { @members.clear; @degraded.clear }
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def provider
|
|
55
|
+
@provider ||= Dash::Autoscale::Provider.for(config.autoscale)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def labels_for(role)
|
|
59
|
+
{ "dash.service" => config.service, "dash.destination" => config.destination || "-", "dash.role" => role.name }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
def resolve(role)
|
|
64
|
+
provider.members(labels: labels_for(role), address: role.scale.address)
|
|
65
|
+
# A credential that resolves to nothing only surfaces here, on the first request.
|
|
66
|
+
rescue Dash::Autoscale::ProviderError, Dash::ConfigurationError => e
|
|
67
|
+
degraded(role, e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def degraded(role, error)
|
|
71
|
+
raise Dash::Autoscale::ProviderError, "Could not read the #{role} pool: #{error.message}" unless explicit_hosts
|
|
72
|
+
|
|
73
|
+
@degraded[role.name] = true
|
|
74
|
+
|
|
75
|
+
scoped = config.scaled_roles.select { |candidate| explicit_roles.nil? || Dash::Utils.filter_specific_items(explicit_roles, [ candidate ]).any? }
|
|
76
|
+
return [] unless scoped.include?(role)
|
|
77
|
+
|
|
78
|
+
if scoped.many?
|
|
79
|
+
raise Dash::Autoscale::ProviderError, "Could not read the pool (#{error.message}), " \
|
|
80
|
+
"and --hosts could mean members of #{scoped.map(&:name).join(" or ")}; name one with --roles"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unverified(role, error)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def unverified(role, error)
|
|
87
|
+
baseline = config.roles.flat_map(&:baseline_hosts) + config.accessories.flat_map(&:hosts)
|
|
88
|
+
# A wildcard names no host; only literal --hosts entries can stand in for members.
|
|
89
|
+
hosts = explicit_hosts.grep_v(/[*?\[{]/) - baseline
|
|
90
|
+
|
|
91
|
+
warn_once "Could not read the #{role} pool (#{error.message}); " \
|
|
92
|
+
"taking #{hosts.any? ? hosts.join(", ") : "no hosts"} from --hosts as started members, unverified"
|
|
93
|
+
|
|
94
|
+
hosts.map { |host| Dash::Autoscale::Member.new(id: nil, host: host, role: role.name, state: "started", labels: labels_for(role), verified: false) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def warn_once(message)
|
|
98
|
+
return if @warned
|
|
99
|
+
|
|
100
|
+
@warned = true
|
|
101
|
+
warn message
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
# Prometheus's instant query API over Net::HTTP, as the gem's other HTTP client
|
|
6
|
+
# (Dash::OtelShipper) uses it. A vector reads as its largest value, a scalar as its value;
|
|
7
|
+
# no series is nil, and a NaN or infinite series NaN - both a missing sample. Through a
|
|
8
|
+
# tunnel it connects to the tunnel (`ipaddr`) while TLS still names the URL's host.
|
|
9
|
+
class Dash::Autoscale::PrometheusClient
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
OPEN_TIMEOUT = 2
|
|
13
|
+
READ_TIMEOUT = 5
|
|
14
|
+
|
|
15
|
+
def initialize(url:, token: nil, tunnel: nil)
|
|
16
|
+
@uri = URI(url.end_with?("/") ? url : "#{url}/")
|
|
17
|
+
@token, @tunnel = token, tunnel
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def query(promql)
|
|
21
|
+
uri = URI.join(@uri, "api/v1/query")
|
|
22
|
+
uri.query = URI.encode_www_form(query: promql)
|
|
23
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
24
|
+
request["Authorization"] = "Bearer #{@token}" if @token
|
|
25
|
+
|
|
26
|
+
value(data(http.request(request)))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Nothing held open between queries.
|
|
30
|
+
def close; end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
def http
|
|
34
|
+
Net::HTTP.new(@uri.host, @tunnel ? @tunnel.port : @uri.port).tap do |http|
|
|
35
|
+
http.ipaddr = @tunnel.address if @tunnel
|
|
36
|
+
http.use_ssl = @uri.scheme == "https"
|
|
37
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
38
|
+
http.read_timeout = READ_TIMEOUT
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def data(response)
|
|
43
|
+
body = begin
|
|
44
|
+
JSON.parse(response.body.to_s)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return body["data"] if response.is_a?(Net::HTTPSuccess) && body.is_a?(Hash) && body["status"] == "success"
|
|
50
|
+
|
|
51
|
+
raise Error, "Prometheus answered HTTP #{response.code}#{": #{body["error"]}" if body.is_a?(Hash) && body["error"]}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def value(data)
|
|
55
|
+
case data["resultType"]
|
|
56
|
+
when "vector"
|
|
57
|
+
values = data["result"].map { |series| number(series["value"][1]) }
|
|
58
|
+
return if values.empty?
|
|
59
|
+
|
|
60
|
+
values.all?(&:finite?) ? values.max : Float::NAN
|
|
61
|
+
when "scalar"
|
|
62
|
+
number(data["result"][1])
|
|
63
|
+
else
|
|
64
|
+
raise Error, "Prometheus returned a #{data["resultType"]}, not a vector or a scalar: make the query return one number"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def number(text)
|
|
69
|
+
case text
|
|
70
|
+
when "NaN" then Float::NAN
|
|
71
|
+
when "+Inf" then Float::INFINITY
|
|
72
|
+
when "-Inf" then -Float::INFINITY
|
|
73
|
+
else Float(text)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# What every provider answers. `labels` is the role's membership:
|
|
2
|
+
# { "dash.service" => ..., "dash.destination" => ..., "dash.role" => ... }.
|
|
3
|
+
#
|
|
4
|
+
# members(labels:, address:) -> [ Member ] carrying exactly those labels
|
|
5
|
+
# start(member) -> powers it on
|
|
6
|
+
# stop(member, timeout:) -> powers it off, soft, within timeout seconds
|
|
7
|
+
# create(labels:, template:, address:) -> Member, the new server
|
|
8
|
+
# destroy(member) -> deletes it and its storage
|
|
9
|
+
# state(member) -> "started" | "stopped" | "maintenance" | "error"
|
|
10
|
+
#
|
|
11
|
+
# Every failure raises Dash::Autoscale::ProviderError.
|
|
12
|
+
class Dash::Autoscale::Provider::Base
|
|
13
|
+
POLL_INTERVAL = 5
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
self.class.name.demodulize.downcase
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
%i[ members start stop create destroy state ].each do |action|
|
|
20
|
+
define_method(action) { |*, **| raise NotImplementedError, "#{self.class.name}##{action}" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Polls until the member reaches `state`, or raises once `timeout` seconds have passed.
|
|
24
|
+
def wait_until(member, state:, timeout:, interval: POLL_INTERVAL)
|
|
25
|
+
deadline = monotonic_now + timeout
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
current = state(member)
|
|
29
|
+
return current if current == state
|
|
30
|
+
|
|
31
|
+
if monotonic_now >= deadline
|
|
32
|
+
raise Dash::Autoscale::ProviderError, "#{name}: #{member.id} is #{current}, not #{state}, after #{timeout}s"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sleep interval
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
def monotonic_now
|
|
41
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
# The operator's own scripts, for any cloud dash has no client for. Each runs locally with
|
|
5
|
+
# the role's labels as DASH_SERVICE / DASH_DESTINATION / DASH_ROLE (and their KAMAL_
|
|
6
|
+
# twins, as hooks get), plus DASH_MEMBER_ID and DASH_HOST for the member it acts on.
|
|
7
|
+
# `members` and `create` print members as JSON, one object per line.
|
|
8
|
+
class Dash::Autoscale::Provider::Exec < Dash::Autoscale::Provider::Base
|
|
9
|
+
def initialize(scripts:)
|
|
10
|
+
@scripts = scripts
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# `address` is the script's business: it prints the host dash should connect to.
|
|
14
|
+
def members(labels:, address:)
|
|
15
|
+
run(:members, labels).lines.filter_map { |line| parse_member(line, labels) if line.strip.present? }
|
|
16
|
+
.select { |member| member.role == labels["dash.role"] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start(member)
|
|
20
|
+
run(:start, labels_of(member), member)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stop(member, timeout:)
|
|
24
|
+
run(:stop, labels_of(member), member, "DASH_STOP_TIMEOUT" => timeout.to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create(labels:, template:, address:)
|
|
28
|
+
parse_member(run(:create, labels).lines.find { |line| line.strip.present? }, labels) ||
|
|
29
|
+
raise(Dash::Autoscale::ProviderError, "exec: create printed no member")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def destroy(member)
|
|
33
|
+
run(:destroy, labels_of(member), member)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The members script is the only source of state there is.
|
|
37
|
+
def state(member)
|
|
38
|
+
members(labels: labels_of(member), address: nil).find { |candidate| candidate.id == member.id }&.state ||
|
|
39
|
+
raise(Dash::Autoscale::ProviderError, "exec: members no longer lists #{member.id}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
def run(action, labels, member = nil, extra_env = {})
|
|
44
|
+
script = @scripts.call(action) || raise(Dash::Autoscale::ProviderError, "exec: no #{action} script, set autoscale/provider/exec/#{action}")
|
|
45
|
+
output, error, status = Open3.capture3(env(labels, member).merge(extra_env), script)
|
|
46
|
+
|
|
47
|
+
unless status.success?
|
|
48
|
+
raise Dash::Autoscale::ProviderError, "exec: #{script} exited #{status.exitstatus}#{": #{last_line(error)}" if error.present?}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
output
|
|
52
|
+
rescue SystemCallError => e
|
|
53
|
+
raise Dash::Autoscale::ProviderError, "exec: #{script} could not run (#{e.message})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def env(labels, member)
|
|
57
|
+
Dash::Tags.new(
|
|
58
|
+
service: labels["dash.service"], destination: labels["dash.destination"], role: labels["dash.role"],
|
|
59
|
+
member_id: member&.id, host: member&.host
|
|
60
|
+
).env
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_member(line, labels)
|
|
64
|
+
return if line.nil?
|
|
65
|
+
|
|
66
|
+
data = JSON.parse(line)
|
|
67
|
+
raise JSON::ParserError unless data.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
if data["id"].to_s.blank? || data["host"].to_s.blank?
|
|
70
|
+
raise Dash::Autoscale::ProviderError, "exec: a member needs an id and a host, got #{line.strip.truncate(80).inspect}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
state = data["state"].to_s
|
|
74
|
+
|
|
75
|
+
unless Dash::Autoscale::Member::STATES.include?(state)
|
|
76
|
+
raise Dash::Autoscale::ProviderError, "exec: member #{data["id"]} has state #{state.inspect}, expected one of #{Dash::Autoscale::Member::STATES.join(", ")}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Dash::Autoscale::Member.new(id: data["id"].to_s, host: data["host"], role: data["role"] || labels["dash.role"], state: state,
|
|
80
|
+
labels: labels.merge("dash.role" => data["role"] || labels["dash.role"]), created_at: data["created_at"])
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
raise Dash::Autoscale::ProviderError, "exec: expected one JSON object per line, got #{line.strip.truncate(80).inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The last line says why a script failed; anything above it is the script's own chatter,
|
|
86
|
+
# and the message ends up in errors and saved reports.
|
|
87
|
+
def last_line(output)
|
|
88
|
+
output.lines.map(&:strip).reject(&:blank?).last.to_s.truncate(200)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def labels_of(member)
|
|
92
|
+
member.labels || raise(Dash::Autoscale::ProviderError, "exec: member #{member.id} carries no labels")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
# UpCloud API 1.3 (https://developers.upcloud.com/1.3/8-servers/) over Net::HTTP. The
|
|
7
|
+
# credentials are lambdas so the secrets are read on the first request, never earlier, and
|
|
8
|
+
# they only ever travel as basic auth - error messages carry the path and the status.
|
|
9
|
+
class Dash::Autoscale::Provider::Upcloud < Dash::Autoscale::Provider::Base
|
|
10
|
+
API = URI("https://api.upcloud.com")
|
|
11
|
+
OPEN_TIMEOUT = 10
|
|
12
|
+
READ_TIMEOUT = 60
|
|
13
|
+
|
|
14
|
+
NETWORK_ERRORS = [ SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError ]
|
|
15
|
+
|
|
16
|
+
def initialize(username:, password:)
|
|
17
|
+
@username = username
|
|
18
|
+
@password = password
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The list is filtered by label on the server and again here: a member is only ever a
|
|
22
|
+
# server that carries every one of the role's labels. The list has no addresses, so each
|
|
23
|
+
# match is read once more for them.
|
|
24
|
+
def members(labels:, address:)
|
|
25
|
+
query = URI.encode_www_form(labels.map { |key, value| [ "label", "#{key}=#{value}" ] })
|
|
26
|
+
servers = request(:get, "/1.3/server?#{query}").dig("servers", "server") || []
|
|
27
|
+
|
|
28
|
+
servers.select { |server| labelled?(server, labels) }.map do |server|
|
|
29
|
+
member_from(request(:get, "/1.3/server/#{server["uuid"]}").fetch("server"), labels: labels, address: address)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start(member)
|
|
34
|
+
request(:post, "/1.3/server/#{member.id}/start", {})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop(member, timeout:)
|
|
38
|
+
request(:post, "/1.3/server/#{member.id}/stop", { stop_server: { stop_type: "soft", timeout: timeout.to_s } })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create(labels:, template:, address:)
|
|
42
|
+
server = request(:post, "/1.3/server", { server: create_body(labels: labels, template: template) }).fetch("server")
|
|
43
|
+
member_from(server, labels: labels, address: address)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def destroy(member)
|
|
47
|
+
request(:delete, "/1.3/server/#{member.id}?storages=1")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def state(member)
|
|
51
|
+
request(:get, "/1.3/server/#{member.id}").dig("server", "state")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def labelled?(server, labels)
|
|
56
|
+
present = Array(server.dig("labels", "label")).to_h { |label| [ label["key"], label["value"] ] }
|
|
57
|
+
labels.all? { |key, value| present[key] == value }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def member_from(server, labels:, address:)
|
|
61
|
+
Dash::Autoscale::Member.new \
|
|
62
|
+
id: server["uuid"],
|
|
63
|
+
host: address_of(server, address) || raise(Dash::Autoscale::ProviderError, "upcloud: server #{server["uuid"]} has no #{address} IPv4 address, set scale/address to one it has"),
|
|
64
|
+
role: labels["dash.role"],
|
|
65
|
+
state: server["state"],
|
|
66
|
+
labels: labels,
|
|
67
|
+
created_at: server["created"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# IPv4 only: an IPv6 host would need brackets everywhere dash builds `host:port`.
|
|
71
|
+
def address_of(server, access)
|
|
72
|
+
Array(server.dig("ip_addresses", "ip_address")).find { |ip| ip["access"] == access && ip["family"] == "IPv4" }&.fetch("address")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Public and utility interfaces always, so a member can pull its image and reach the
|
|
76
|
+
# zone's private services; the SDN interface when the template names one.
|
|
77
|
+
def create_body(labels:, template:)
|
|
78
|
+
name = [ labels["dash.service"], labels["dash.role"], SecureRandom.hex(4) ].join("-")
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
zone: template["zone"],
|
|
82
|
+
title: name,
|
|
83
|
+
hostname: name,
|
|
84
|
+
plan: template["plan"],
|
|
85
|
+
metadata: "yes",
|
|
86
|
+
labels: { label: labels.map { |key, value| { key: key, value: value } } },
|
|
87
|
+
storage_devices: { storage_device: [ { action: "clone", storage: template["storage"], title: "#{name}-disk" } ] },
|
|
88
|
+
networking: { interfaces: { interface: interfaces(template["network"]) } },
|
|
89
|
+
login_user: login_user(template),
|
|
90
|
+
user_data: template["user_data"]
|
|
91
|
+
}.compact
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def interfaces(network)
|
|
95
|
+
ipv4 = { ip_address: [ { family: "IPv4" } ] }
|
|
96
|
+
|
|
97
|
+
[ { index: 1, type: "public", ip_addresses: ipv4 }, { index: 2, type: "utility", ip_addresses: ipv4 } ].tap do |interfaces|
|
|
98
|
+
interfaces << { index: 3, type: "private", network: network, ip_addresses: ipv4 } if network.present?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def login_user(template)
|
|
103
|
+
if template["login_user"].present?
|
|
104
|
+
{ username: template["login_user"], create_password: "no", ssh_keys: { ssh_key: Array(template["ssh_keys"]) } }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def request(method, path, body = nil)
|
|
109
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(path, "Accept" => "application/json")
|
|
110
|
+
request.basic_auth(@username.call, @password.call)
|
|
111
|
+
|
|
112
|
+
if body
|
|
113
|
+
request["Content-Type"] = "application/json"
|
|
114
|
+
request.body = JSON.generate(body)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
response = nil
|
|
118
|
+
Net::HTTP.start(API.host, API.port, use_ssl: true, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) { |http| response = http.request(request) }
|
|
119
|
+
parse(method, path, response)
|
|
120
|
+
rescue *NETWORK_ERRORS => e
|
|
121
|
+
raise Dash::Autoscale::ProviderError, "upcloud: #{method.upcase} #{path.split("?").first} failed (#{e.class}: #{e.message})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse(method, path, response)
|
|
125
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
126
|
+
raise Dash::Autoscale::ProviderError, "upcloud: #{method.upcase} #{path.split("?").first} answered #{response.code} #{api_error(response)}".strip
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
response.body.present? ? JSON.parse(response.body) : {}
|
|
130
|
+
rescue JSON::ParserError
|
|
131
|
+
raise Dash::Autoscale::ProviderError, "upcloud: #{method.upcase} #{path.split("?").first} answered something that is not JSON"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def api_error(response)
|
|
135
|
+
error = JSON.parse(response.body.to_s).fetch("error", {})
|
|
136
|
+
[ error["error_code"], error["error_message"] ].compact.join(": ")
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
response.message
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Dash::Autoscale::Provider
|
|
2
|
+
def self.for(autoscale)
|
|
3
|
+
case autoscale.provider_name
|
|
4
|
+
when "upcloud" then Dash::Autoscale::Provider::Upcloud.new(username: -> { autoscale.upcloud_username }, password: -> { autoscale.upcloud_password })
|
|
5
|
+
when "exec" then Dash::Autoscale::Provider::Exec.new(scripts: ->(action) { autoscale.exec_script(action) })
|
|
6
|
+
else raise Dash::Autoscale::ProviderError, "no autoscale provider configured"
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# The provider could not be asked, or refused. dash never puts a credential in the message:
|
|
2
|
+
# UpCloud errors carry the request path and the response status, exec errors the script
|
|
3
|
+
# name and the last line of its stderr - which is the operator's script to keep clean.
|
|
4
|
+
class Dash::Autoscale::ProviderError < StandardError; end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
# How the controller reads Postgres: inside a read-only transaction with a statement
|
|
4
|
+
# timeout, always rolled back, so a signal or guard query can neither write nor hang a tick.
|
|
5
|
+
# The statement timeout bounds the server; DEADLINE bounds the client, for a connection that
|
|
6
|
+
# stops answering at all (pg's `exec` waits on the socket in Ruby, so it can be interrupted).
|
|
7
|
+
module Dash::Autoscale::ReadOnlyQuery
|
|
8
|
+
STATEMENT_TIMEOUT_MS = 5000
|
|
9
|
+
DEADLINE = 10
|
|
10
|
+
|
|
11
|
+
# The connection stopped answering. A Timeout::Error, so Dash::Autoscale::Connections
|
|
12
|
+
# discards the connection - which ends its transaction on the server.
|
|
13
|
+
class Stalled < Timeout::Error; end
|
|
14
|
+
|
|
15
|
+
def self.within(connection, deadline: DEADLINE)
|
|
16
|
+
Timeout.timeout(deadline, Stalled) do
|
|
17
|
+
connection.exec("BEGIN READ ONLY")
|
|
18
|
+
stalled = false
|
|
19
|
+
begin
|
|
20
|
+
connection.exec("SET LOCAL statement_timeout = #{STATEMENT_TIMEOUT_MS}")
|
|
21
|
+
yield
|
|
22
|
+
rescue Stalled
|
|
23
|
+
stalled = true
|
|
24
|
+
raise
|
|
25
|
+
ensure
|
|
26
|
+
# A ROLLBACK on a stalled socket would wait on it again, past the deadline.
|
|
27
|
+
connection.exec("ROLLBACK") unless stalled
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The first column of the first row as a number; nil without a row or for SQL NULL.
|
|
33
|
+
def self.number(connection, sql)
|
|
34
|
+
result = connection.exec(sql)
|
|
35
|
+
value = result.getvalue(0, 0) unless result.ntuples.zero?
|
|
36
|
+
Float(value) unless value.nil?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Reads a signalled role's signal and guards for one evaluation, through the process's
|
|
2
|
+
# Dash::Autoscale::Connections. The controller hands Dash::Autoscale::Evaluation one; tests
|
|
3
|
+
# hand it a fake.
|
|
4
|
+
class Dash::Autoscale::Reader
|
|
5
|
+
def initialize(connections = Dash::Autoscale::Connections.shared)
|
|
6
|
+
@connections = connections
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# [ the signal's Sample, the guards' Dash::Autoscale::Guard::Readings ]
|
|
10
|
+
def read(role, at:)
|
|
11
|
+
[ Dash::Autoscale::Signal.for(role, @connections).sample(at: at), Dash::Autoscale::Guard.read(role.scale.guards, @connections, at: at) ]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def close
|
|
15
|
+
@connections.close
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
# `dash autoscale run --record FILE`: every tick's decisions, with every policy input they
|
|
4
|
+
# were made from, appended to a local JSONL file - one line per tick - for replaying later.
|
|
5
|
+
class Dash::Autoscale::Recorder
|
|
6
|
+
def initialize(path)
|
|
7
|
+
@path = path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def record(at, decisions)
|
|
11
|
+
File.open(@path, "a") { |file| file.puts JSON.generate(at: Dash::Autoscale::Timestamp.dump(at), decisions: decisions.map(&:to_h)) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# What the controller remembers about one scaled role between ticks, in `state.json` on
|
|
2
|
+
# the state host so a restart or a `--once` run from cron carries on where the last tick
|
|
3
|
+
# stopped: when it last scaled out and in (cooldown), when each member it joined came up
|
|
4
|
+
# (warmup), since when each started member has not answered (replacement after
|
|
5
|
+
# `boot_timeout`), since when its signal has been above or below its band (`up.for`,
|
|
6
|
+
# `down.for`), and the last decision it logged (the log only takes changes).
|
|
7
|
+
#
|
|
8
|
+
# The bridge between the file (string keys, ISO 8601 strings) and Dash::Autoscale::Policy
|
|
9
|
+
# (symbols, Time).
|
|
10
|
+
class Dash::Autoscale::RoleState
|
|
11
|
+
attr_reader :unreachable, :joined, :last_decision
|
|
12
|
+
|
|
13
|
+
def self.from(hash)
|
|
14
|
+
hash = hash.is_a?(Hash) ? hash : {}
|
|
15
|
+
|
|
16
|
+
new(last_scale_out_at: Dash::Autoscale::Timestamp.parse(hash["last_scale_out_at"]),
|
|
17
|
+
last_scale_in_at: Dash::Autoscale::Timestamp.parse(hash["last_scale_in_at"]),
|
|
18
|
+
unreachable: times(hash["unreachable"]), joined: times(hash["joined"]),
|
|
19
|
+
last_decision: hash["last_decision"].is_a?(Hash) ? hash["last_decision"] : nil,
|
|
20
|
+
above_since: Dash::Autoscale::Timestamp.parse(hash["above_since"]), below_since: Dash::Autoscale::Timestamp.parse(hash["below_since"]))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.times(hash)
|
|
24
|
+
return {} unless hash.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
hash.filter_map { |host, time| [ host, Dash::Autoscale::Timestamp.parse(time) ] if Dash::Autoscale::Timestamp.parse(time) }.to_h
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(last_scale_out_at: nil, last_scale_in_at: nil, unreachable: {}, joined: {}, last_decision: nil, above_since: nil, below_since: nil)
|
|
30
|
+
@last_scale_out_at, @last_scale_in_at = last_scale_out_at, last_scale_in_at
|
|
31
|
+
@unreachable, @joined, @last_decision = unreachable, joined, last_decision
|
|
32
|
+
@above_since, @below_since = above_since, below_since
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def history
|
|
36
|
+
{ last_scale_out_at: @last_scale_out_at, last_scale_in_at: @last_scale_in_at }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def breach
|
|
40
|
+
{ above_since: @above_since, below_since: @below_since }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Every sample moves the breach windows before the policy reads them: above the band the
|
|
44
|
+
# above window starts (or keeps its start), below it the below window does, and inside
|
|
45
|
+
# the band - or without a number to compare - both end. A gap in samples restarts a window.
|
|
46
|
+
def observe_signal(sample, up_above:, down_below:)
|
|
47
|
+
if sample.nil? || sample.missing?
|
|
48
|
+
@above_since = @below_since = nil
|
|
49
|
+
elsif sample.value > up_above
|
|
50
|
+
@above_since ||= sample.at
|
|
51
|
+
@below_since = nil
|
|
52
|
+
elsif sample.value < down_below
|
|
53
|
+
@below_since ||= sample.at
|
|
54
|
+
@above_since = nil
|
|
55
|
+
else
|
|
56
|
+
@above_since = @below_since = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The members that did not answer this tick: each keeps the time it was first seen
|
|
61
|
+
# unreachable, a new one starts now, and one that answered again is forgotten.
|
|
62
|
+
def observe_unreachable(hosts, now:)
|
|
63
|
+
@unreachable = hosts.to_h { |host| [ host, @unreachable.fetch(host, now) ] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def joined_at(hosts, now:)
|
|
67
|
+
hosts.each { |host| @joined[host] = now }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Only the members still started can be warming up.
|
|
71
|
+
def keep_members(hosts)
|
|
72
|
+
@joined = @joined.slice(*hosts)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scaled(action, now:)
|
|
76
|
+
case action
|
|
77
|
+
when "scale_out" then @last_scale_out_at = now
|
|
78
|
+
when "scale_in" then @last_scale_in_at = now
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def forget(host)
|
|
83
|
+
@unreachable = @unreachable.except(host)
|
|
84
|
+
@joined = @joined.except(host)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Every action is logged; a hold only when its action or reasons differ from the last
|
|
88
|
+
# decision logged, so a steady role adds nothing to the log tick after tick.
|
|
89
|
+
def log?(decision)
|
|
90
|
+
!decision.hold? || last_decision != logged_shape(decision)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def logged(decision)
|
|
94
|
+
@last_decision = logged_shape(decision)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
{ "last_scale_out_at" => Dash::Autoscale::Timestamp.dump(@last_scale_out_at),
|
|
99
|
+
"last_scale_in_at" => Dash::Autoscale::Timestamp.dump(@last_scale_in_at),
|
|
100
|
+
"unreachable" => @unreachable.transform_values { |time| Dash::Autoscale::Timestamp.dump(time) },
|
|
101
|
+
"joined" => @joined.transform_values { |time| Dash::Autoscale::Timestamp.dump(time) },
|
|
102
|
+
"last_decision" => @last_decision }
|
|
103
|
+
.merge({ "above_since" => Dash::Autoscale::Timestamp.dump(@above_since), "below_since" => Dash::Autoscale::Timestamp.dump(@below_since) }.compact)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
def logged_shape(decision)
|
|
108
|
+
{ "action" => decision.action, "reasons" => decision.reasons }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# One reading of a signal or a guard: the number, when it was read, or the error that kept
|
|
2
|
+
# it from being read. A reader never raises out of a tick - a failure is a Sample with an
|
|
3
|
+
# error, and the policy holds the role with `missing_data`.
|
|
4
|
+
Dash::Autoscale::Sample = Struct.new(:value, :at, :error, keyword_init: true) do
|
|
5
|
+
# No number to act on: an error, nothing read, or a NaN/Inf a query returned.
|
|
6
|
+
def missing?
|
|
7
|
+
!error.nil? || !value.is_a?(Numeric) || (value.is_a?(Float) && !value.finite?)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_h
|
|
11
|
+
{ value: (value unless missing?), at: at, error: error.respond_to?(:message) ? error.message : error }
|
|
12
|
+
end
|
|
13
|
+
end
|