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,199 @@
|
|
|
1
|
+
# How many HOSTS a role may run on: the baseline listed under `hosts`, plus pool members the
|
|
2
|
+
# autoscale provider holds (see Dash::Autoscale::Pool). `min` and `max` count hosts, the
|
|
3
|
+
# baseline included; `replicas` counts containers per host - the two never mix.
|
|
4
|
+
#
|
|
5
|
+
# The controller's keys (`schedule`, `warmup`, `cooldown`, `step`) count containers, the
|
|
6
|
+
# unit of `dash scale set`; see Dash::Autoscale::Policy. `signal`, `up`, `down` and
|
|
7
|
+
# `hold_when` are its reactive rules: the band a signal is held in, and the guards that veto
|
|
8
|
+
# a scale-out it asks for.
|
|
9
|
+
class Dash::Configuration::Role::Scale
|
|
10
|
+
MEMBERS = %w[ power create ]
|
|
11
|
+
ADDRESSES = %w[ private public utility ]
|
|
12
|
+
WINDOW_KEYS = %w[ cron for min ]
|
|
13
|
+
DEFAULT_BOOT_TIMEOUT = 300
|
|
14
|
+
DEFAULT_COOLDOWN_UP = 60
|
|
15
|
+
DEFAULT_COOLDOWN_DOWN = 600
|
|
16
|
+
MAX_WINDOW = 7 * 86_400
|
|
17
|
+
MAX_BREACH = 86_400
|
|
18
|
+
DEFAULT_UP_FOR = 0
|
|
19
|
+
DEFAULT_DOWN_FOR = 600
|
|
20
|
+
|
|
21
|
+
attr_reader :min, :max, :template, :address, :boot_timeout, :schedule, :warmup, :cooldown_up, :cooldown_down, :step, :replicas, :context,
|
|
22
|
+
:signal, :up_above, :up_for, :down_below, :down_for, :guards
|
|
23
|
+
|
|
24
|
+
def initialize(scale_config:, baseline:, replicas:, context:)
|
|
25
|
+
@context = context
|
|
26
|
+
@replicas = replicas
|
|
27
|
+
@min = scale_config.fetch("min", baseline)
|
|
28
|
+
@max = scale_config["max"]
|
|
29
|
+
@members = scale_config.fetch("members", "power").to_s
|
|
30
|
+
@template = scale_config["template"]
|
|
31
|
+
@address = scale_config.fetch("address", "private").to_s
|
|
32
|
+
@boot_timeout = scale_config.fetch("boot_timeout", DEFAULT_BOOT_TIMEOUT)
|
|
33
|
+
@warmup = scale_config.fetch("warmup", boot_timeout)
|
|
34
|
+
@cooldown_up = scale_config.dig("cooldown", "up") || DEFAULT_COOLDOWN_UP
|
|
35
|
+
@cooldown_down = scale_config.dig("cooldown", "down") || DEFAULT_COOLDOWN_DOWN
|
|
36
|
+
@step = scale_config.fetch("step", replicas.max)
|
|
37
|
+
|
|
38
|
+
validate!(baseline)
|
|
39
|
+
@schedule = Array(scale_config["schedule"]).each_with_index.map { |window, index| parse_window(window, index) }
|
|
40
|
+
parse_reactive(scale_config)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def members
|
|
44
|
+
@members.to_sym
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def power?
|
|
48
|
+
members == :power
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create?
|
|
52
|
+
members == :create
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def signal?
|
|
56
|
+
!signal.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether `dash autoscale run` owns the role's count: a schedule, a signal, or both. A
|
|
60
|
+
# scaled role with neither stays manual.
|
|
61
|
+
def controlled?
|
|
62
|
+
schedule.any? || signal?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The role's bounds in containers: every host it must keep at replicas min, every host
|
|
66
|
+
# it may have at replicas max.
|
|
67
|
+
def min_count
|
|
68
|
+
min * replicas.min
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def max_count
|
|
72
|
+
max * replicas.max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether `dash scale set` can place `count` containers: on as few hosts as hold them,
|
|
76
|
+
# never fewer than scale min, each within the replicas bounds (Dash::Cli::Scale::HostPlan).
|
|
77
|
+
def splittable?(count)
|
|
78
|
+
hosts = [ (count.to_f / replicas.max).ceil, min ].max
|
|
79
|
+
hosts <= max && hosts * replicas.min <= count
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
def validate!(baseline)
|
|
84
|
+
error "max", "is required" if max.nil?
|
|
85
|
+
error "members", "must be #{MEMBERS.join(" or ")}, not #{@members}" unless MEMBERS.include?(@members)
|
|
86
|
+
error "address", "must be #{ADDRESSES.to_sentence(two_words_connector: " or ", last_word_connector: " or ")}, not #{address}" unless ADDRESSES.include?(address)
|
|
87
|
+
error nil, "min (#{min}) cannot be less than the #{baseline} hosts listed under hosts" if min < baseline
|
|
88
|
+
error nil, "min (#{min}) cannot be greater than max (#{max})" if min > max
|
|
89
|
+
error "boot_timeout", "must be at least 1 second" unless boot_timeout.positive?
|
|
90
|
+
error "warmup", "must be at least 0" if warmup.negative?
|
|
91
|
+
error "cooldown/up", "must be at least 0" if cooldown_up.negative?
|
|
92
|
+
error "cooldown/down", "must be at least 0" if cooldown_down.negative?
|
|
93
|
+
error "step", "must be at least 1" unless step.positive?
|
|
94
|
+
validate_template!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_template!
|
|
98
|
+
if create?
|
|
99
|
+
error "template", "is required with members: create" if template.blank?
|
|
100
|
+
error "template/network", "is required when address is private" if address == "private" && template["network"].blank?
|
|
101
|
+
elsif template.present?
|
|
102
|
+
error "template", "is only used with members: create"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_window(window, index)
|
|
107
|
+
key = "schedule/#{index}"
|
|
108
|
+
if (unknown = (window.keys.map(&:to_s) - WINDOW_KEYS).first)
|
|
109
|
+
error key, "unknown key: #{unknown}"
|
|
110
|
+
end
|
|
111
|
+
WINDOW_KEYS.each { |required| error "#{key}/#{required}", "is required" if window[required].nil? }
|
|
112
|
+
|
|
113
|
+
Window.new(cron: window_cron(window["cron"], key), duration: window_duration(window["for"], key), min: window_min(window["min"], key))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def window_cron(cron, key)
|
|
117
|
+
Dash::Autoscale::Cron.parse(cron)
|
|
118
|
+
rescue Dash::Autoscale::Cron::Error => e
|
|
119
|
+
error "#{key}/cron", e.message
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def window_duration(value, key)
|
|
123
|
+
duration = begin
|
|
124
|
+
Dash::Autoscale::Duration.parse(value)
|
|
125
|
+
rescue ArgumentError => e
|
|
126
|
+
error "#{key}/for", e.message
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
error "#{key}/for", "must be 1 second to 7 days, not #{value}" unless duration.between?(1, MAX_WINDOW)
|
|
130
|
+
duration
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Below min_count a window changes nothing, so only a min above it must be one
|
|
134
|
+
# `dash scale set` can place.
|
|
135
|
+
def window_min(value, key)
|
|
136
|
+
unless value.is_a?(Integer) && value.between?(1, max_count)
|
|
137
|
+
error "#{key}/min", "must be 1 to #{max_count} (scale max #{max} × replicas max #{replicas.max}), not #{value}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if value > min_count && !splittable?(value)
|
|
141
|
+
error "#{key}/min", "#{value} containers cannot be split over the role's hosts " \
|
|
142
|
+
"(replicas min #{replicas.min}, max #{replicas.max} per host, #{min} to #{max} hosts)"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
value
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_reactive(scale_config)
|
|
149
|
+
if scale_config.key?("signal")
|
|
150
|
+
@signal = Signal.new(signal_config: scale_config["signal"], context: [ context, "signal" ].join("/"))
|
|
151
|
+
%w[ up down ].each { |key| error key, "is required with a signal" unless scale_config.key?(key) }
|
|
152
|
+
else
|
|
153
|
+
%w[ up down hold_when ].each { |key| error key, "needs a signal" if scale_config.key?(key) }
|
|
154
|
+
@guards = []
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@up_above, @up_for = parse_band(scale_config["up"], "up", "above", DEFAULT_UP_FOR)
|
|
159
|
+
@down_below, @down_for = parse_band(scale_config["down"], "down", "below", DEFAULT_DOWN_FOR)
|
|
160
|
+
error nil, "up/above (#{up_above}) must be greater than down/below (#{down_below})" unless up_above > down_below
|
|
161
|
+
@guards = parse_guards(scale_config.fetch("hold_when", []))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `up: { above:, for: }` or `down: { below:, for: }`: the bound, and how long the signal
|
|
165
|
+
# stays past it before the controller acts.
|
|
166
|
+
def parse_band(band, key, bound_key, default_for)
|
|
167
|
+
error key, "should be a hash" unless band.is_a?(Hash)
|
|
168
|
+
if (unknown = (band.keys.map(&:to_s) - [ bound_key, "for" ]).first)
|
|
169
|
+
error key, "unknown key: #{unknown}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
bound = band[bound_key]
|
|
173
|
+
error "#{key}/#{bound_key}", "is required" if bound.nil?
|
|
174
|
+
error "#{key}/#{bound_key}", "should be a finite number" unless bound.is_a?(Numeric) && bound.real? && bound.finite?
|
|
175
|
+
|
|
176
|
+
[ bound, breach_duration(band.fetch("for", default_for), "#{key}/for") ]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def breach_duration(value, key)
|
|
180
|
+
duration = begin
|
|
181
|
+
Dash::Autoscale::Duration.parse(value)
|
|
182
|
+
rescue ArgumentError => e
|
|
183
|
+
error key, e.message
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
error key, "must be 0 seconds to 1 day, not #{value}" unless duration.between?(0, MAX_BREACH)
|
|
187
|
+
duration
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def parse_guards(guards)
|
|
191
|
+
error "hold_when", "should be an array" unless guards.is_a?(Array)
|
|
192
|
+
|
|
193
|
+
guards.each_with_index.map { |guard, index| Guard.new(guard_config: guard, context: [ context, "hold_when/#{index}" ].join("/")) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def error(key, message)
|
|
197
|
+
raise Dash::ConfigurationError, "#{[ context, key ].compact.join("/")}: #{message}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -3,7 +3,10 @@ class Dash::Configuration::Role
|
|
|
3
3
|
|
|
4
4
|
delegate :argumentize, :optionize, to: Dash::Utils
|
|
5
5
|
|
|
6
|
-
attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_proxy, :healthcheck
|
|
6
|
+
attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_proxy, :healthcheck, :replicas, :drain, :scale
|
|
7
|
+
|
|
8
|
+
delegate :numbers, to: :replicas, prefix: :replica
|
|
9
|
+
delegate :signal, :wait, to: :drain, prefix: true
|
|
7
10
|
|
|
8
11
|
alias to_s name
|
|
9
12
|
|
|
@@ -31,19 +34,73 @@ class Dash::Configuration::Role
|
|
|
31
34
|
context: "servers/#{name}/healthcheck"
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
@replicas = Dash::Configuration::Role::Replicas.new \
|
|
38
|
+
replicas_config: specializations["replicas"],
|
|
39
|
+
context: "servers/#{name}/replicas"
|
|
40
|
+
|
|
41
|
+
@drain = Dash::Configuration::Role::Drain.new \
|
|
42
|
+
drain_config: specializations["drain"],
|
|
43
|
+
context: "servers/#{name}/drain"
|
|
44
|
+
|
|
45
|
+
if (scale_config = specializations["scale"])
|
|
46
|
+
@scale = Dash::Configuration::Role::Scale.new \
|
|
47
|
+
scale_config: scale_config,
|
|
48
|
+
baseline: baseline_hosts.size,
|
|
49
|
+
replicas: replicas,
|
|
50
|
+
context: "servers/#{name}/scale"
|
|
51
|
+
end
|
|
52
|
+
|
|
34
53
|
initialize_specialized_proxy
|
|
54
|
+
|
|
55
|
+
if running_proxy? && drain.configured?
|
|
56
|
+
raise Dash::ConfigurationError, "servers/#{name}/drain: a role behind dash-proxy is drained by the proxy, remove drain"
|
|
57
|
+
end
|
|
35
58
|
end
|
|
36
59
|
|
|
37
60
|
def primary_host
|
|
38
61
|
hosts.first
|
|
39
62
|
end
|
|
40
63
|
|
|
64
|
+
# Baseline hosts first, then the pool members the provider reports started. Every command
|
|
65
|
+
# that reads a role's hosts sees the members; nothing else has to know about them.
|
|
41
66
|
def hosts
|
|
67
|
+
scaled? ? (baseline_hosts + active_members.map(&:host)).uniq : baseline_hosts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The hosts deploy.yml lists for the role. Configuration-time checks read these and never
|
|
71
|
+
# #hosts, which for a scaled role asks the autoscale provider.
|
|
72
|
+
def baseline_hosts
|
|
42
73
|
tagged_hosts.keys
|
|
43
74
|
end
|
|
44
75
|
|
|
76
|
+
def scaled?
|
|
77
|
+
!scale.nil?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Whether `dash autoscale run` owns the role's count (a scale schedule or signal).
|
|
81
|
+
def controlled?
|
|
82
|
+
scaled? && scale.controlled?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every pool member of the role, whatever its state.
|
|
86
|
+
def members
|
|
87
|
+
scaled? ? config.pool.members_for(self) : []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def active_members
|
|
91
|
+
members.select(&:started?)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `host` may be an SSHKit::Host - `on` hands those to its blocks.
|
|
95
|
+
def member_host?(host)
|
|
96
|
+
host = host.to_s
|
|
97
|
+
!baseline_hosts.include?(host) && active_members.any? { |member| member.host == host }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A member carries no tags: deploy.yml does not list it.
|
|
45
101
|
def env_tags(host)
|
|
46
|
-
tagged_hosts.fetch(host)
|
|
102
|
+
tags = tagged_hosts.fetch(host) { member_host?(host) ? [] : raise(KeyError, "#{host} is not a host of role #{name}") }
|
|
103
|
+
tags.collect { |tag| config.env_tag(tag) }.compact
|
|
47
104
|
end
|
|
48
105
|
|
|
49
106
|
# The role's own hosts carrying `tag` in deploy.yml. Accessory `tag:`/`tags:` resolution
|
|
@@ -217,11 +274,66 @@ class Dash::Configuration::Role
|
|
|
217
274
|
|
|
218
275
|
|
|
219
276
|
def container_name(version = nil)
|
|
220
|
-
|
|
277
|
+
replica_name(1, version)
|
|
221
278
|
end
|
|
222
279
|
|
|
280
|
+
# Also the dash-proxy service name, which stays one per role whatever the replica count.
|
|
223
281
|
def container_prefix
|
|
224
|
-
|
|
282
|
+
replica_prefix(1)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Replica 1 is the container the role always had, so its name never changed; replica n
|
|
286
|
+
# puts the slot into the role segment, where a free-form version cannot reach it.
|
|
287
|
+
def replica_prefix(replica)
|
|
288
|
+
[ config.service, replica == 1 ? name : "#{name}.#{replica}", config.destination ].compact.join("-")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Whether docker lookups for a slot need its name as well as the role labels: a scalable
|
|
292
|
+
# role's slot 1 would otherwise see slot 2's containers. Slot 2 and up always do, even
|
|
293
|
+
# after `replicas` was lowered to 1, so a deploy can still find and stop them.
|
|
294
|
+
def replica_scoped?(replica)
|
|
295
|
+
replica > 1 || replicas.scalable?
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The `--filter name=` value for a slot's containers. Docker matches it as a regular
|
|
299
|
+
# expression, so a `.` in the slot or the destination has to be literal.
|
|
300
|
+
def replica_name_filter(replica)
|
|
301
|
+
"'name=^#{replica_name_pattern(replica)}-'"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The slot's prefix as a docker name regex: slot n's `.` has to be literal, or
|
|
305
|
+
# `^app-web.2-123$` would also match slot 1's container of a version named `2-123`.
|
|
306
|
+
def replica_name_pattern(replica)
|
|
307
|
+
replica_prefix(replica).gsub(/[.^$*+?()\[\]{}|\\]/) { |char| "\\#{char}" }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def replica_name(replica, version = nil)
|
|
311
|
+
[ replica_prefix(replica), version || config.version ].compact.join("-")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The slot a container of this role is, read from its name - nil when the name is not
|
|
315
|
+
# one of this role's. The `role` label already excludes a sibling role whose name extends
|
|
316
|
+
# this one, so callers only ever hand this names of this role's containers.
|
|
317
|
+
def replica_from_name(container_name)
|
|
318
|
+
if container_name.start_with?("#{replica_prefix(1)}-")
|
|
319
|
+
1
|
|
320
|
+
elsif (match = replica_name_regexp.match(container_name))
|
|
321
|
+
match[1].to_i
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def version_from_name(container_name)
|
|
326
|
+
if (replica = replica_from_name(container_name))
|
|
327
|
+
container_name.delete_prefix("#{replica_prefix(replica)}-")
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def docker_option_keys
|
|
332
|
+
docker_options.keys.map(&:to_s)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def docker_option_values(*keys)
|
|
336
|
+
docker_options.select { |key, _| keys.include?(key.to_s) }.values.flatten
|
|
225
337
|
end
|
|
226
338
|
|
|
227
339
|
|
|
@@ -254,8 +366,8 @@ class Dash::Configuration::Role
|
|
|
254
366
|
|
|
255
367
|
def ensure_one_host_for_ssl
|
|
256
368
|
# Skip SSL validation when a loadbalancer is present or custom certificates are provided
|
|
257
|
-
if running_proxy? && proxy.ssl? &&
|
|
258
|
-
raise Dash::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates or configure a loadbalancer, found #{
|
|
369
|
+
if running_proxy? && proxy.ssl? && baseline_hosts.size > 1 && !proxy.loadbalancer.present? && !proxy.custom_ssl_certificate?
|
|
370
|
+
raise Dash::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates or configure a loadbalancer, found #{baseline_hosts.size} servers for role #{name}"
|
|
259
371
|
end
|
|
260
372
|
end
|
|
261
373
|
|
|
@@ -305,6 +417,11 @@ class Dash::Configuration::Role
|
|
|
305
417
|
end
|
|
306
418
|
end
|
|
307
419
|
|
|
420
|
+
def replica_name_regexp
|
|
421
|
+
destination = "-#{Regexp.escape(config.destination)}" if config.destination
|
|
422
|
+
/\A#{Regexp.escape(config.service)}-#{Regexp.escape(name)}\.(\d+)#{destination}-/
|
|
423
|
+
end
|
|
424
|
+
|
|
308
425
|
def default_labels
|
|
309
426
|
{ "service" => config.service, "role" => name, "destination" => config.destination }
|
|
310
427
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
class Dash::Configuration::Validator::Autoscale < Dash::Configuration::Validator
|
|
2
|
+
PROVIDERS = %w[ upcloud exec ]
|
|
3
|
+
CREDENTIALS = %w[ username password ]
|
|
4
|
+
SCRIPTS = %w[ members start stop create destroy ]
|
|
5
|
+
REQUIRED_SCRIPTS = %w[ members start stop ]
|
|
6
|
+
|
|
7
|
+
MINIMUM_INTERVAL = 5
|
|
8
|
+
|
|
9
|
+
# What a signal or guard reads through, and the URL schemes each one takes.
|
|
10
|
+
SOURCES = {
|
|
11
|
+
"redis" => %w[ redis rediss ],
|
|
12
|
+
"postgres" => %w[ postgres postgresql ],
|
|
13
|
+
"prometheus" => %w[ http https ]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def validate!
|
|
17
|
+
validate_type! config, Hash
|
|
18
|
+
check_unknown_keys! config, example
|
|
19
|
+
validate_controller!
|
|
20
|
+
SOURCES.each_key { |source| validate_source!(source, config[source]) if config.key?(source) }
|
|
21
|
+
|
|
22
|
+
with_context("provider") do
|
|
23
|
+
provider = config["provider"]
|
|
24
|
+
error "is required" if provider.nil?
|
|
25
|
+
validate_type! provider, Hash
|
|
26
|
+
# Against the constants, not the example: the docs show each provider on its own.
|
|
27
|
+
check_unknown_keys! provider, PROVIDERS.index_with(nil)
|
|
28
|
+
error "set exactly one of #{PROVIDERS.join(" or ")}" unless provider.size == 1
|
|
29
|
+
|
|
30
|
+
validate_upcloud!(provider["upcloud"]) if provider.key?("upcloud")
|
|
31
|
+
validate_exec!(provider["exec"]) if provider.key?("exec")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
def validate_controller!
|
|
37
|
+
with_context("controller") do
|
|
38
|
+
host = config["controller"]
|
|
39
|
+
error "should be a host name" unless host.nil? || (host.is_a?(String) && host.present? && host == host.strip)
|
|
40
|
+
end
|
|
41
|
+
with_context("interval") { validate_at_least! config["interval"], MINIMUM_INTERVAL, " seconds" }
|
|
42
|
+
with_context("lock_wait_timeout") { validate_at_least! config["lock_wait_timeout"], 0 }
|
|
43
|
+
|
|
44
|
+
with_context("timezone") do
|
|
45
|
+
timezone = config["timezone"]
|
|
46
|
+
next if timezone.nil?
|
|
47
|
+
|
|
48
|
+
error "should be a string" unless timezone.is_a?(String)
|
|
49
|
+
error "unknown time zone #{timezone}, use an IANA name such as Europe/Stockholm" if ActiveSupport::TimeZone[timezone].nil?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_source!(source, block)
|
|
54
|
+
with_context(source) do
|
|
55
|
+
validate_type! block, Hash
|
|
56
|
+
check_unknown_keys! block, (source == "prometheus" ? %w[ url via token ] : %w[ url via ]).index_with(nil)
|
|
57
|
+
|
|
58
|
+
with_context("url") do
|
|
59
|
+
validate_secret_or_string! block["url"]
|
|
60
|
+
url = block["url"]
|
|
61
|
+
if url.is_a?(String)
|
|
62
|
+
error "should start with #{SOURCES[source].map { |scheme| "#{scheme}://" }.join(" or ")}" if SOURCES[source].none? { |scheme| url.start_with?("#{scheme}://") }
|
|
63
|
+
valid_uri!(url)
|
|
64
|
+
# Postgres may name a socket instead (postgres:///app?host=/run/postgresql).
|
|
65
|
+
error "should include a host" if source == "prometheus" && URI(url).host.blank?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
with_context("via") do
|
|
70
|
+
via = block["via"]
|
|
71
|
+
error "should be a host name" unless via.nil? || (via.is_a?(String) && via.present? && via == via.strip)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
with_context("token") { validate_secret_or_string! block["token"] } if block.key?("token")
|
|
75
|
+
|
|
76
|
+
# Both checked again for a secret URL: rediss when it is resolved
|
|
77
|
+
# (Dash::Configuration::Autoscale#source_url), the host when the tunnel opens.
|
|
78
|
+
if block["via"] && Dash::Configuration::Autoscale.unverifiable_through_tunnel?(source, block["url"])
|
|
79
|
+
error Dash::Configuration::Autoscale::REDISS_VIA_MESSAGE
|
|
80
|
+
end
|
|
81
|
+
error "via needs a URL that names its host" if block["via"] && block["url"].is_a?(String) && URI(block["url"]).host.blank?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def valid_uri!(url)
|
|
86
|
+
URI(url)
|
|
87
|
+
rescue URI::InvalidURIError
|
|
88
|
+
error "is not a valid URL"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_secret_or_string!(value)
|
|
92
|
+
error "is required" if value.blank? || (value.is_a?(Array) && value.first.blank?)
|
|
93
|
+
|
|
94
|
+
unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String))
|
|
95
|
+
error "should be a string or an array with one string (for secret lookup)"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_at_least!(value, minimum, unit = nil)
|
|
100
|
+
return if value.nil?
|
|
101
|
+
|
|
102
|
+
error "should be an integer" unless value.is_a?(Integer)
|
|
103
|
+
error "must be at least #{minimum}#{unit}, not #{value}" if value < minimum
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_upcloud!(upcloud)
|
|
107
|
+
with_context("upcloud") do
|
|
108
|
+
validate_type! upcloud, Hash
|
|
109
|
+
check_unknown_keys! upcloud, CREDENTIALS.index_with(nil)
|
|
110
|
+
|
|
111
|
+
CREDENTIALS.each do |key|
|
|
112
|
+
with_context(key) { validate_secret_or_string! upcloud[key] }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_exec!(exec)
|
|
118
|
+
with_context("exec") do
|
|
119
|
+
validate_type! exec, Hash
|
|
120
|
+
check_unknown_keys! exec, SCRIPTS.index_with(nil)
|
|
121
|
+
|
|
122
|
+
exec.each { |key, value| with_context(key) { validate_type! value, String } }
|
|
123
|
+
REQUIRED_SCRIPTS.each { |key| with_context(key) { error "is required" if exec[key].blank? } }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -645,6 +645,7 @@ class Dash::Configuration::Validator::Proxy < Dash::Configuration::Validator
|
|
|
645
645
|
end
|
|
646
646
|
|
|
647
647
|
validate_min_tls! run_config["min_tls"]
|
|
648
|
+
validate_run_version! run_config["version"]
|
|
648
649
|
|
|
649
650
|
RUN_TIMEOUTS.each do |key|
|
|
650
651
|
error "#{key} cannot be negative" if run_config[key].to_f.negative?
|
|
@@ -673,6 +674,18 @@ class Dash::Configuration::Validator::Proxy < Dash::Configuration::Validator
|
|
|
673
674
|
end
|
|
674
675
|
end
|
|
675
676
|
|
|
677
|
+
# `dash proxy boot` refuses a running proxy older than MINIMUM_VERSION, and a
|
|
678
|
+
# reboot would start the pinned tag again. A tag that is not a version number
|
|
679
|
+
# (a branch build) cannot be compared and is left to the operator.
|
|
680
|
+
def validate_run_version!(value)
|
|
681
|
+
minimum = Dash::Configuration::Proxy::Run::MINIMUM_VERSION
|
|
682
|
+
return unless value.present? && Gem::Version.correct?(value.to_s.delete_prefix("v"))
|
|
683
|
+
|
|
684
|
+
if Dash::Utils.older_version?(value.to_s, minimum)
|
|
685
|
+
error "version #{value} is older than #{minimum}, the oldest dash-proxy this dash runs - remove the pin or raise it"
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
676
689
|
# dash-proxy reads the cache policy only when --cache is set and ignores it
|
|
677
690
|
# otherwise, so a tuned block with no `enabled` is a cache that silently
|
|
678
691
|
# never caches - this issue's own predicted first support question.
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
class Dash::Configuration::Validator::Role < Dash::Configuration::Validator
|
|
2
|
+
# The reactive autoscaling keys whose values the example cannot type: a threshold may be
|
|
3
|
+
# an integer or a float, a source decides which keys apply. Dash::Configuration::Role::Scale
|
|
4
|
+
# validates them in full.
|
|
5
|
+
SCALE_RULES = %w[ signal up down ]
|
|
6
|
+
|
|
2
7
|
def validate!
|
|
3
8
|
validate_type! config, Array, Hash
|
|
4
9
|
|
|
@@ -16,12 +21,28 @@ class Dash::Configuration::Validator::Role < Dash::Configuration::Validator
|
|
|
16
21
|
# `healthcheck: false` is the explicit opt-out from the readiness gate, so the
|
|
17
22
|
# example's hash shape is not the only legal one.
|
|
18
23
|
def validate_key_override!(key, value)
|
|
19
|
-
|
|
24
|
+
case key.to_s
|
|
25
|
+
when "healthcheck"
|
|
26
|
+
case value
|
|
27
|
+
when false then true
|
|
28
|
+
when Hash then false
|
|
29
|
+
else error "should be a hash, or false to accept no readiness gate for this role"
|
|
30
|
+
end
|
|
31
|
+
when "replicas"
|
|
32
|
+
# `replicas: 2` is shorthand for min and max, so the example's hash shape is not
|
|
33
|
+
# the only legal one either.
|
|
34
|
+
case value
|
|
35
|
+
when Integer then true
|
|
36
|
+
when Hash then false
|
|
37
|
+
else error "should be an integer, or a hash with min and max"
|
|
38
|
+
end
|
|
39
|
+
when *SCALE_RULES
|
|
40
|
+
return false unless context.end_with?("scale/#{key}")
|
|
20
41
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
validate_type! value, Hash
|
|
43
|
+
true
|
|
44
|
+
else
|
|
45
|
+
false
|
|
25
46
|
end
|
|
26
47
|
end
|
|
27
48
|
|