dash 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +12 -9
  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,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).collect { |tag| config.env_tag(tag) }.compact
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
- [ container_prefix, version || config.version ].compact.join("-")
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
- [ config.service, name, config.destination ].compact.join("-")
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? && hosts.size > 1 && !proxy.loadbalancer.present? && !proxy.custom_ssl_certificate?
258
- raise Dash::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates or configure a loadbalancer, found #{hosts.size} servers for role #{name}"
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
- return false unless key.to_s == "healthcheck"
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
- case value
22
- when false then true
23
- when Hash then false
24
- else error "should be a hash, or false to accept no readiness gate for this role"
42
+ validate_type! value, Hash
43
+ true
44
+ else
45
+ false
25
46
  end
26
47
  end
27
48