dash 4.1.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dash/autoscale/connections/builder.rb +51 -0
  3. data/lib/dash/autoscale/connections.rb +102 -0
  4. data/lib/dash/autoscale/controller.rb +296 -0
  5. data/lib/dash/autoscale/cron.rb +75 -0
  6. data/lib/dash/autoscale/decision.rb +89 -0
  7. data/lib/dash/autoscale/duration.rb +19 -0
  8. data/lib/dash/autoscale/evaluation.rb +52 -0
  9. data/lib/dash/autoscale/guard.rb +48 -0
  10. data/lib/dash/autoscale/lease.rb +55 -0
  11. data/lib/dash/autoscale/lease_held.rb +2 -0
  12. data/lib/dash/autoscale/lease_lost.rb +2 -0
  13. data/lib/dash/autoscale/member.rb +28 -0
  14. data/lib/dash/autoscale/metrics.rb +70 -0
  15. data/lib/dash/autoscale/metrics_server.rb +71 -0
  16. data/lib/dash/autoscale/observation.rb +41 -0
  17. data/lib/dash/autoscale/pause.rb +34 -0
  18. data/lib/dash/autoscale/policy.rb +198 -0
  19. data/lib/dash/autoscale/pool.rb +103 -0
  20. data/lib/dash/autoscale/prometheus_client.rb +76 -0
  21. data/lib/dash/autoscale/provider/base.rb +43 -0
  22. data/lib/dash/autoscale/provider/exec.rb +94 -0
  23. data/lib/dash/autoscale/provider/upcloud.rb +140 -0
  24. data/lib/dash/autoscale/provider.rb +9 -0
  25. data/lib/dash/autoscale/provider_error.rb +4 -0
  26. data/lib/dash/autoscale/read_only_query.rb +38 -0
  27. data/lib/dash/autoscale/reader.rb +17 -0
  28. data/lib/dash/autoscale/recorder.rb +13 -0
  29. data/lib/dash/autoscale/role_state.rb +110 -0
  30. data/lib/dash/autoscale/sample.rb +13 -0
  31. data/lib/dash/autoscale/signal/pgbus.rb +19 -0
  32. data/lib/dash/autoscale/signal/prometheus.rb +8 -0
  33. data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
  34. data/lib/dash/autoscale/signal.rb +31 -0
  35. data/lib/dash/autoscale/simulation.rb +200 -0
  36. data/lib/dash/autoscale/state_store.rb +132 -0
  37. data/lib/dash/autoscale/timestamp.rb +15 -0
  38. data/lib/dash/autoscale/tunnel.rb +48 -0
  39. data/lib/dash/cli/accessory.rb +16 -0
  40. data/lib/dash/cli/app/boot.rb +69 -44
  41. data/lib/dash/cli/app/boot_state.rb +70 -0
  42. data/lib/dash/cli/app/rollout_boot.rb +28 -9
  43. data/lib/dash/cli/app/running_targets.rb +39 -0
  44. data/lib/dash/cli/app/slot_version.rb +29 -0
  45. data/lib/dash/cli/app.rb +89 -32
  46. data/lib/dash/cli/autoscale.rb +312 -0
  47. data/lib/dash/cli/base.rb +15 -3
  48. data/lib/dash/cli/lock.rb +6 -1
  49. data/lib/dash/cli/main.rb +77 -8
  50. data/lib/dash/cli/proxy/inspection.rb +52 -0
  51. data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
  52. data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
  53. data/lib/dash/cli/proxy/reboot.rb +4 -9
  54. data/lib/dash/cli/proxy.rb +12 -7
  55. data/lib/dash/cli/prune.rb +5 -3
  56. data/lib/dash/cli/report.rb +4 -1
  57. data/lib/dash/cli/scale/host_plan.rb +63 -0
  58. data/lib/dash/cli/scale/join.rb +128 -0
  59. data/lib/dash/cli/scale/leave.rb +58 -0
  60. data/lib/dash/cli/scale/placement.rb +54 -0
  61. data/lib/dash/cli/scale/power_off.rb +26 -0
  62. data/lib/dash/cli/scale/replica_join.rb +74 -0
  63. data/lib/dash/cli/scale/replica_leave.rb +121 -0
  64. data/lib/dash/cli/scale.rb +276 -0
  65. data/lib/dash/cli/server.rb +41 -0
  66. data/lib/dash/cli/stats_table.rb +34 -0
  67. data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
  68. data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
  69. data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
  70. data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
  71. data/lib/dash/commander.rb +17 -3
  72. data/lib/dash/commands/accessory/proxy.rb +1 -1
  73. data/lib/dash/commands/accessory.rb +21 -2
  74. data/lib/dash/commands/app/containers.rb +28 -3
  75. data/lib/dash/commands/app/logging.rb +13 -2
  76. data/lib/dash/commands/app/proxy.rb +4 -4
  77. data/lib/dash/commands/app.rb +90 -10
  78. data/lib/dash/commands/auditor.rb +2 -2
  79. data/lib/dash/commands/autoscale.rb +133 -0
  80. data/lib/dash/commands/base.rb +4 -0
  81. data/lib/dash/commands/proxy/services.rb +10 -0
  82. data/lib/dash/commands/prune.rb +10 -2
  83. data/lib/dash/commands/server.rb +16 -0
  84. data/lib/dash/configuration/accessory.rb +2 -2
  85. data/lib/dash/configuration/autoscale.rb +123 -0
  86. data/lib/dash/configuration/docs/autoscale.yml +141 -0
  87. data/lib/dash/configuration/docs/configuration.yml +8 -2
  88. data/lib/dash/configuration/docs/proxy.yml +1 -1
  89. data/lib/dash/configuration/docs/role.yml +203 -0
  90. data/lib/dash/configuration/loadbalancer.rb +3 -2
  91. data/lib/dash/configuration/proxy/run.rb +1 -1
  92. data/lib/dash/configuration/proxy.rb +19 -7
  93. data/lib/dash/configuration/role/drain.rb +49 -0
  94. data/lib/dash/configuration/role/replicas.rb +72 -0
  95. data/lib/dash/configuration/role/scale/guard.rb +40 -0
  96. data/lib/dash/configuration/role/scale/signal.rb +93 -0
  97. data/lib/dash/configuration/role/scale/window.rb +37 -0
  98. data/lib/dash/configuration/role/scale.rb +199 -0
  99. data/lib/dash/configuration/role.rb +123 -6
  100. data/lib/dash/configuration/validator/autoscale.rb +126 -0
  101. data/lib/dash/configuration/validator/proxy.rb +13 -0
  102. data/lib/dash/configuration/validator/role.rb +26 -5
  103. data/lib/dash/configuration.rb +126 -15
  104. data/lib/dash/diagnostics/audit.rb +28 -0
  105. data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
  106. data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
  107. data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
  108. data/lib/dash/diagnostics/base.rb +53 -0
  109. data/lib/dash/diagnostics/config.rb +32 -0
  110. data/lib/dash/diagnostics/container_stats.rb +67 -0
  111. data/lib/dash/diagnostics/containers.rb +20 -0
  112. data/lib/dash/diagnostics/controller_status.rb +44 -0
  113. data/lib/dash/diagnostics/docker_ps.rb +40 -0
  114. data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
  115. data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
  116. data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
  117. data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
  118. data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
  119. data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
  120. data/lib/dash/diagnostics/doctor.rb +182 -0
  121. data/lib/dash/diagnostics/drift.rb +168 -0
  122. data/lib/dash/diagnostics/host_stats.rb +59 -0
  123. data/lib/dash/diagnostics/lines.rb +14 -0
  124. data/lib/dash/diagnostics/lock.rb +27 -0
  125. data/lib/dash/diagnostics/logs.rb +76 -0
  126. data/lib/dash/diagnostics/pool.rb +41 -0
  127. data/lib/dash/diagnostics/proxy_services.rb +36 -0
  128. data/lib/dash/diagnostics/redactor.rb +48 -0
  129. data/lib/dash/diagnostics/reports.rb +17 -0
  130. data/lib/dash/diagnostics/scale.rb +51 -0
  131. data/lib/dash/diagnostics/units.rb +31 -0
  132. data/lib/dash/mcp/base_tool.rb +43 -0
  133. data/lib/dash/mcp/runner.rb +28 -0
  134. data/lib/dash/mcp/server.rb +40 -0
  135. data/lib/dash/mcp/session.rb +51 -0
  136. data/lib/dash/mcp/tools/audit_tool.rb +11 -0
  137. data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
  138. data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
  139. data/lib/dash/mcp/tools/config_tool.rb +10 -0
  140. data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
  141. data/lib/dash/mcp/tools/containers_tool.rb +10 -0
  142. data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
  143. data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
  144. data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
  145. data/lib/dash/mcp/tools/drift_tool.rb +16 -0
  146. data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
  147. data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
  148. data/lib/dash/mcp/tools/logs_tool.rb +41 -0
  149. data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
  150. data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
  151. data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
  152. data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
  153. data/lib/dash/mcp/transport.rb +14 -0
  154. data/lib/dash/mcp.rb +56 -0
  155. data/lib/dash/sshkit_with_ext.rb +4 -4
  156. data/lib/dash/utils.rb +7 -0
  157. data/lib/dash/version.rb +1 -1
  158. data/lib/dash.rb +2 -0
  159. metadata +119 -5
  160. data/lib/dash/cli/doctor.rb +0 -113
@@ -0,0 +1,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