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
@@ -5,7 +5,11 @@ class Dash::Commands::App < Dash::Commands::Base
5
5
 
6
6
  # Separates the two answers #boot_state and #stale_state return. A container id is hex
7
7
  # and a version is a name suffix, so neither can produce this line on its own.
8
- BOOT_STATE_SEPARATOR = "--%--"
8
+ BOOT_STATE_SEPARATOR = SECTION_SEPARATOR
9
+
10
+ # Printed in place of the running containers when docker could not list them, so a boot
11
+ # can tell "nothing runs" from "could not ask" - the chain's `;` hides the exit status.
12
+ ACTIVE_CONTAINERS_UNREADABLE = "--unreadable--"
9
13
 
10
14
  # The two halves of a #boot_state or #stale_state capture, raw. Callers decide what an
11
15
  # empty half means; the separator line itself is dropped.
@@ -13,14 +17,30 @@ class Dash::Commands::App < Dash::Commands::Base
13
17
  output.to_s.partition(/^#{Regexp.escape(BOOT_STATE_SEPARATOR)}$/).values_at(0, 2)
14
18
  end
15
19
 
16
- attr_reader :role, :host
20
+ # Every segment of a #boot_states capture, raw, in the order they were asked.
21
+ def self.split_states(output)
22
+ output.to_s.split(/^#{Regexp.escape(BOOT_STATE_SEPARATOR)}$/, -1)
23
+ end
17
24
 
18
- delegate :container_name, to: :role
25
+ attr_reader :role, :host, :replica
19
26
 
20
- def initialize(config, role: nil, host: nil)
27
+ # `replica` is the slot this instance reads and writes. Slot 1 is the container the role
28
+ # always had, so a role without `replicas:` is only ever slot 1 and its commands are
29
+ # exactly what they were before replicas existed.
30
+ def initialize(config, role: nil, host: nil, replica: 1)
21
31
  super(config)
22
32
  @role = role
23
33
  @host = host
34
+ @replica = replica
35
+ end
36
+
37
+ def container_name(version = nil)
38
+ role.replica_name(replica, version)
39
+ end
40
+
41
+ # The same role and host, another slot.
42
+ def for_replica(replica)
43
+ self.class.new(config, role: role, host: host, replica: replica)
24
44
  end
25
45
 
26
46
  def run(hostname: nil)
@@ -34,11 +54,13 @@ class Dash::Commands::App < Dash::Commands::Base
34
54
  "--env", "KAMAL_VERSION=\"#{config.version}\"",
35
55
  "--env", "KAMAL_HOST=\"#{host}\"",
36
56
  *([ "--env", "KAMAL_DESTINATION=\"#{config.destination}\"" ] if config.destination),
57
+ *([ "--env", "DASH_REPLICA=\"#{replica}\"" ] if slotted?),
37
58
  *role.env_args(host),
38
59
  *role.logging_args,
39
60
  *config.volume_args,
40
61
  *role.asset_volume_args,
41
62
  *role.label_args,
63
+ *(argumentize("--label", { "replica" => replica }) if slotted?),
42
64
  *role.option_args,
43
65
  *role.healthcheck_args,
44
66
  config.absolute_image,
@@ -92,7 +114,31 @@ class Dash::Commands::App < Dash::Commands::Base
92
114
  end
93
115
 
94
116
  def info
95
- docker :ps, *container_filter_args
117
+ docker :ps, *container_filter_args(all_replicas: true)
118
+ end
119
+
120
+ # `docker kill --signal`: how a replica without the proxy is told to stop taking work
121
+ # before it is stopped.
122
+ def signal(signal, version:)
123
+ pipe container_id_for_version(version), xargs(docker(:kill, "--signal=#{signal}"))
124
+ end
125
+
126
+ # The slot's container hostname (`--hostname` at boot), which Sidekiq reports as its
127
+ # process's `hostname` - how a drain asks Sidekiq what that container is still working on.
128
+ def container_hostname(version:)
129
+ pipe container_id_for_version(version), xargs(docker(:inspect, "--format", "'{{.Config.Hostname}}'"))
130
+ end
131
+
132
+ # Every running container of the role with docker's status line, for `dash scale status`.
133
+ def replica_status
134
+ active_containers_ps format: '"{{.Names}}\t{{.Status}}"'
135
+ end
136
+
137
+ # The names of every running container of the role, whatever its slot. A slot's version
138
+ # and whether it counts as running both come out of the name, so this is all a boot needs
139
+ # to know how many replicas a host runs.
140
+ def active_containers
141
+ active_containers_ps format: '"{{.Names}}"'
96
142
  end
97
143
 
98
144
 
@@ -101,7 +147,7 @@ class Dash::Commands::App < Dash::Commands::Base
101
147
  end
102
148
 
103
149
  def container_id_for_version(version, only_running: false)
104
- container_id_for(container_name: container_name(version), only_running: only_running)
150
+ container_id_for(container_name: container_name_pattern(version), only_running: only_running)
105
151
  end
106
152
 
107
153
  def current_running_version
@@ -127,6 +173,23 @@ class Dash::Commands::App < Dash::Commands::Base
127
173
  # Everything the stale check needs from a host: every version of the role that has a
128
174
  # container, and the version running now - the difference is what is stale. Same shape
129
175
  # as #boot_state, same separator, same reason for `;` over `&&`.
176
+ # #boot_state for the whole role on this host, still one round trip: slot 1's answers,
177
+ # then every running container of the role (which carries each other slot's version and
178
+ # the replica count), then whether slots 2..max already hold the version being deployed.
179
+ # A role without replicas asks for the running containers too, so a deploy after
180
+ # `replicas` was lowered still finds the slots it has to stop.
181
+ def boot_states(version)
182
+ clashes = role.replica_numbers.drop(1).flat_map do |replica|
183
+ [ [ :echo, BOOT_STATE_SEPARATOR ], for_replica(replica).container_id_for_version(version) ]
184
+ end
185
+
186
+ chain \
187
+ boot_state(version),
188
+ [ :echo, BOOT_STATE_SEPARATOR ],
189
+ [ *active_containers, "||", :echo, ACTIVE_CONTAINERS_UNREADABLE ],
190
+ *clashes
191
+ end
192
+
130
193
  def stale_state
131
194
  chain \
132
195
  list_versions,
@@ -145,6 +208,12 @@ class Dash::Commands::App < Dash::Commands::Base
145
208
  end
146
209
 
147
210
  private
211
+ # The one `docker ps` both #active_containers and #replica_status ask: every running
212
+ # container of the role, whatever its slot.
213
+ def active_containers_ps(format:)
214
+ docker :ps, *container_filter_args(statuses: ACTIVE_DOCKER_STATUSES, all_replicas: true), "--format", format
215
+ end
216
+
148
217
  # The same two readiness sources #status and #health_probe cover, read into `$status`
149
218
  # so the loop around them is the same either way. They differ in what a non-zero exit
150
219
  # means. A probe that exits non-zero IS the answer "not ready", so its output is
@@ -185,8 +254,8 @@ class Dash::Commands::App < Dash::Commands::Base
185
254
  docker :ps, "--latest", *format, *container_filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize("--filter", filters)
186
255
  end
187
256
 
188
- def container_filter_args(statuses: nil)
189
- argumentize "--filter", container_filters(statuses: statuses)
257
+ def container_filter_args(statuses: nil, all_replicas: false)
258
+ argumentize "--filter", container_filters(statuses: statuses, all_replicas: all_replicas)
190
259
  end
191
260
 
192
261
  def image_filter_args
@@ -195,13 +264,24 @@ class Dash::Commands::App < Dash::Commands::Base
195
264
 
196
265
  def extract_version_from_name
197
266
  # Extract SHA from "service-role-dest-SHA"
198
- %(while read line; do echo ${line##{role.container_prefix}-}; done)
267
+ %(while read line; do echo ${line##{role.replica_prefix(replica)}-}; done)
268
+ end
269
+
270
+ # The exact-name lookup for this slot's container of a version. Slot 1 keeps the name as
271
+ # it always was; slot n escapes its prefix (see Role#replica_name_pattern).
272
+ def container_name_pattern(version)
273
+ replica > 1 ? "#{role.replica_name_pattern(replica)}-#{version}" : container_name(version)
274
+ end
275
+
276
+ def slotted?
277
+ role.present? && role.replica_scoped?(replica)
199
278
  end
200
279
 
201
- def container_filters(statuses: nil)
280
+ def container_filters(statuses: nil, all_replicas: false)
202
281
  [ "label=service=#{config.service}" ].tap do |filters|
203
282
  filters << "label=destination=#{config.destination}"
204
283
  filters << "label=role=#{role}" if role
284
+ filters << role.replica_name_filter(replica) if slotted? && !all_replicas
205
285
  statuses&.each do |status|
206
286
  filters << "status=#{status}"
207
287
  end
@@ -24,8 +24,8 @@ class Dash::Commands::Auditor < Dash::Commands::Base
24
24
  combine record(line, **details), *commands
25
25
  end
26
26
 
27
- def reveal
28
- [ :tail, "-n", 50, audit_log_file ]
27
+ def reveal(lines: 50)
28
+ [ :tail, "-n", Integer(lines.to_s, 10), audit_log_file ]
29
29
  end
30
30
 
31
31
  private
@@ -0,0 +1,133 @@
1
+ require "base64"
2
+
3
+ # The autoscaling controller's state files, under `<app_directory>/autoscale/` on the
4
+ # state host (Dash::Autoscale::StateStore.host): `heartbeat.json`, `state.json`, `decisions.jsonl`
5
+ # and one `pause/<role>.json` file per paused role. JSON travels base64-encoded and is decoded
6
+ # on the host, so no value is ever shell-quoted; a file is written beside its target and
7
+ # moved into place, so a reader never sees half of one. See Dash::Autoscale::StateStore.
8
+ class Dash::Commands::Autoscale < Dash::Commands::Base
9
+ # What docker allows in a container name, which every role name ends up in.
10
+ ROLE_NAME = /\A[a-zA-Z0-9_][a-zA-Z0-9_.-]*\z/
11
+ PAUSE_EXTENSION = ".json"
12
+
13
+ def ensure_directory
14
+ make_directory pause_directory
15
+ end
16
+
17
+ def read_heartbeat
18
+ read_file heartbeat_file
19
+ end
20
+
21
+ def write_heartbeat(json)
22
+ write_json json, heartbeat_file
23
+ end
24
+
25
+ # Prints `taken` and writes nothing when the heartbeat names another controller;
26
+ # otherwise - missing, naming `id`, or corrupted - writes it and prints `held`. A write
27
+ # that fails exits non-zero, as any failed command does. The check and the write are one
28
+ # command, so a takeover between them is the width of a `grep`, not of a tick.
29
+ def write_heartbeat_if_held(id, json)
30
+ raise ArgumentError, "#{id.inspect} is not a controller id" unless /\A\h+\z/.match?(id.to_s)
31
+
32
+ any \
33
+ combine(
34
+ [ :grep, "-qF", Dash::Utils.single_quote(%("controller_id":")), heartbeat_file, "2>", "/dev/null" ],
35
+ [ "!", :grep, "-qF", Dash::Utils.single_quote(%("controller_id":"#{id}")), heartbeat_file ],
36
+ [ :echo, "taken" ]),
37
+ [ "(", *combine(write_json(json, heartbeat_file), [ :echo, "held" ]), ")" ]
38
+ end
39
+
40
+ def read_state
41
+ read_file state_file
42
+ end
43
+
44
+ def write_state(json)
45
+ write_json json, state_file
46
+ end
47
+
48
+ # `lines` are JSON objects, one per line.
49
+ def append_decisions(lines)
50
+ append decode(lines.map { |line| "#{line}\n" }.join), decisions_file
51
+ end
52
+
53
+ # The last `lines` entries, or the last `lines` of these roles' (the log writes `role`
54
+ # first), filtered on the host before the tail so other roles cannot crowd them out.
55
+ def read_decisions(lines:, roles: nil)
56
+ lines = Integer(lines.to_s, 10)
57
+
58
+ if roles
59
+ patterns = roles.flat_map { |role| [ "-e", Dash::Utils.single_quote(%("role":"#{role_name(role)}")) ] }
60
+ any pipe([ :grep, "-F", *patterns, decisions_file, "2>", "/dev/null" ], [ :tail, "-n", lines ]), [ :true ]
61
+ else
62
+ any [ :tail, "-n", lines, decisions_file, "2>", "/dev/null" ], [ :true ]
63
+ end
64
+ end
65
+
66
+ def count_decisions
67
+ any [ :wc, "-l", "<", decisions_file, "2>", "/dev/null" ], [ :echo, 0 ]
68
+ end
69
+
70
+ def trim_decisions(keep:)
71
+ combine \
72
+ write([ :tail, "-n", Integer(keep.to_s, 10), decisions_file ], temporary(decisions_file)),
73
+ [ :mv, temporary(decisions_file), decisions_file ]
74
+ end
75
+
76
+ def write_pause(role, json)
77
+ write_json json, pause_file(role)
78
+ end
79
+
80
+ def remove_pause(role)
81
+ [ :rm, "-f", pause_file(role) ]
82
+ end
83
+
84
+ # `<path>:<json>` per file in the pause directory.
85
+ def read_pauses
86
+ any [ :grep, "-H", "\"\"", "#{pause_directory}/*", "2>", "/dev/null" ], [ :true ]
87
+ end
88
+
89
+ private
90
+ def write_json(json, file)
91
+ combine \
92
+ write(decode(json), temporary(file)),
93
+ [ :mv, temporary(file), file ]
94
+ end
95
+
96
+ def decode(content)
97
+ pipe [ :echo, "\"#{Base64.strict_encode64(content)}\"" ], [ :base64, "-d" ]
98
+ end
99
+
100
+ def temporary(file)
101
+ "#{file}.tmp"
102
+ end
103
+
104
+ def directory
105
+ File.join(config.app_directory, "autoscale")
106
+ end
107
+
108
+ def pause_directory
109
+ File.join(directory, "pause")
110
+ end
111
+
112
+ def heartbeat_file
113
+ File.join(directory, "heartbeat.json")
114
+ end
115
+
116
+ def state_file
117
+ File.join(directory, "state.json")
118
+ end
119
+
120
+ def decisions_file
121
+ File.join(directory, "decisions.jsonl")
122
+ end
123
+
124
+ def pause_file(role)
125
+ File.join(pause_directory, "#{role_name(role)}#{PAUSE_EXTENSION}")
126
+ end
127
+
128
+ def role_name(role)
129
+ raise ArgumentError, "#{role.to_s.inspect} is not a role name dash can keep autoscale state for" unless ROLE_NAME.match?(role.to_s)
130
+
131
+ role.to_s
132
+ end
133
+ end
@@ -8,6 +8,10 @@ module Dash::Commands
8
8
  # healthcheck-less container that died still reports why.
9
9
  NO_HEALTHCHECK = "no-healthcheck"
10
10
 
11
+ # Separates the answers of several commands chained into one round trip. Each half is
12
+ # docker or /proc output, none of which prints this line on its own.
13
+ SECTION_SEPARATOR = "--%--"
14
+
11
15
  DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}#{NO_HEALTHCHECK}:{{.State.Status}}{{end}}'"
12
16
 
13
17
  # The statuses a boot accepts as ready. Dash::Cli::Healthcheck::Poller decides what a
@@ -0,0 +1,10 @@
1
+ require "json"
2
+
3
+ # What `dash-proxy list --json` answers, for the per-host proxy and the load balancer
4
+ # alike: {"services": {"<name>": {"host":, "targets": [...], ...}}}. One parser for the
5
+ # reboots' verification and the diagnostics, so they cannot read the list differently.
6
+ module Dash::Commands::Proxy::Services
7
+ def self.parse(output)
8
+ JSON.parse(output).fetch("services", {})
9
+ end
10
+ end
@@ -19,9 +19,13 @@ class Dash::Commands::Prune < Dash::Commands::Base
19
19
  # candidate, and once it is gone every wake 404s. With `retain >= 1` a role's
20
20
  # newest container always survives, and the slept one is always the newest —
21
21
  # sleeping happens to the current release.
22
- def app_containers(retain:, role:)
22
+ #
23
+ # Scoped to one replica slot as well when the role has several, so `retain` holds per
24
+ # slot: a rollback needs every slot's container of the version, and a role-wide window
25
+ # would keep 1/N as many versions.
26
+ def app_containers(retain:, role:, replica: 1)
23
27
  pipe \
24
- docker(:ps, "-q", "-a", *service_filter, *destination_filter, *role_filter(role), *stopped_containers_filters),
28
+ docker(:ps, "-q", "-a", *service_filter, *destination_filter, *role_filter(role), *replica_filter(role, replica), *stopped_containers_filters),
25
29
  "tail -n +#{retain + 1}",
26
30
  "while read container_id; do docker rm $container_id; done"
27
31
  end
@@ -49,4 +53,8 @@ class Dash::Commands::Prune < Dash::Commands::Base
49
53
  def role_filter(role)
50
54
  [ "--filter", "label=role=#{role}" ]
51
55
  end
56
+
57
+ def replica_filter(role, replica)
58
+ role.replica_scoped?(replica) ? [ "--filter", role.replica_name_filter(replica) ] : []
59
+ end
52
60
  end
@@ -9,6 +9,22 @@ class Dash::Commands::Server < Dash::Commands::Base
9
9
  [ :wc, "-l" ]
10
10
  end
11
11
 
12
+ # Load, CPU count, memory, disk for / and Docker's data root, and uptime, one section
13
+ # each in one round trip. df -Pk rather than -B1, which busybox df lacks. getconf is
14
+ # POSIX; where it is missing the CPU count is nil and the rest still answers.
15
+ def stats
16
+ sections = [
17
+ [ :cat, "/proc/loadavg" ],
18
+ [ :getconf, "_NPROCESSORS_ONLN" ],
19
+ [ :grep, "-E", "'^(MemTotal|MemAvailable|SwapTotal|SwapFree):'", "/proc/meminfo" ],
20
+ [ :df, "-Pk", "/" ],
21
+ [ :df, "-Pk", %("$(docker info --format '{{.DockerRootDir}}')") ],
22
+ [ :cat, "/proc/uptime" ]
23
+ ]
24
+
25
+ chain(*sections.flat_map { |section| [ section, [ :echo, SECTION_SEPARATOR ] ] }[0...-1])
26
+ end
27
+
12
28
  # Lists TCP listeners on the given port, one line per listener, no header.
13
29
  def listeners_on(port)
14
30
  [ :ss, "-ltnH", :sport, "=", ":#{port}" ]
@@ -246,9 +246,9 @@ class Dash::Configuration::Accessory
246
246
 
247
247
  def hosts_from_roles
248
248
  if accessory_config.key?("role")
249
- config.role(accessory_config["role"])&.hosts
249
+ config.role(accessory_config["role"])&.baseline_hosts
250
250
  elsif accessory_config.key?("roles")
251
- accessory_config["roles"].flat_map { |role| config.role(role)&.hosts }
251
+ accessory_config["roles"].flat_map { |role| config.role(role)&.baseline_hosts }
252
252
  end
253
253
  end
254
254
 
@@ -0,0 +1,123 @@
1
+ require "active_support/values/time_zone"
2
+
3
+ # The `autoscale:` block: which provider holds the pool members of scaled roles, how the
4
+ # controller (`dash autoscale run`) ticks, and where its signals and guards read from
5
+ # (`redis`, `postgres`, `prometheus`). Only the shape is checked here; credentials and URLs
6
+ # are secrets and are resolved on first use, like the registry password, so loading
7
+ # deploy.yml never needs them.
8
+ class Dash::Configuration::Autoscale
9
+ include Dash::Configuration::Validation
10
+
11
+ DEFAULT_INTERVAL = 10
12
+ DEFAULT_LOCK_WAIT_TIMEOUT = 60
13
+ DEFAULT_TIMEZONE = "UTC"
14
+
15
+ # redis-client connects to the URL's host and verifies the certificate against that same
16
+ # name, so through a tunnel (127.0.0.1) a TLS certificate can never verify.
17
+ REDISS_VIA_MESSAGE = "rediss:// cannot be verified through via: redis-client checks the certificate against the address it " \
18
+ "connects to, which a tunnel makes 127.0.0.1. Reach Redis directly, or use redis:// over the tunnel"
19
+
20
+ def self.unverifiable_through_tunnel?(source, url)
21
+ source.to_s == "redis" && url.is_a?(String) && url.downcase.start_with?("rediss://")
22
+ end
23
+
24
+ attr_reader :autoscale_config
25
+
26
+ def initialize(config:)
27
+ @autoscale_config = config.raw_config.autoscale
28
+ @secrets = config.secrets
29
+ validate! autoscale_config, with: Dash::Configuration::Validator::Autoscale unless autoscale_config.nil?
30
+ end
31
+
32
+ def configured?
33
+ !autoscale_config.nil?
34
+ end
35
+
36
+ def provider_name
37
+ provider_config&.keys&.first&.to_s
38
+ end
39
+
40
+ # The ops host that runs `dash autoscale run` and keeps its state, or nil to keep it on
41
+ # the primary role's first baseline host (see Dash::Autoscale::StateStore.host).
42
+ def controller
43
+ autoscale_config&.fetch("controller", nil)
44
+ end
45
+
46
+ # Seconds between the controller's ticks.
47
+ def interval
48
+ autoscale_config&.fetch("interval", nil) || DEFAULT_INTERVAL
49
+ end
50
+
51
+ # Seconds a tick waits for the deploy lock before holding with `lock_busy`.
52
+ def lock_wait_timeout
53
+ autoscale_config&.fetch("lock_wait_timeout", nil) || DEFAULT_LOCK_WAIT_TIMEOUT
54
+ end
55
+
56
+ # The zone schedule crons are evaluated in.
57
+ def timezone
58
+ autoscale_config&.fetch("timezone", nil) || DEFAULT_TIMEZONE
59
+ end
60
+
61
+ def time_zone
62
+ ActiveSupport::TimeZone[timezone]
63
+ end
64
+
65
+ def upcloud_username
66
+ lookup(provider_config.dig("upcloud", "username"), "provider/upcloud/username")
67
+ end
68
+
69
+ def upcloud_password
70
+ lookup(provider_config.dig("upcloud", "password"), "provider/upcloud/password")
71
+ end
72
+
73
+ def exec_script(action)
74
+ provider_config.dig("exec", action.to_s)
75
+ end
76
+
77
+ # Whether `autoscale/<source>` (redis, postgres or prometheus) is set.
78
+ def source?(source)
79
+ !source_config(source).nil?
80
+ end
81
+
82
+ def source_url(source)
83
+ lookup(source_config(source)["url"], "#{source}/url").tap do |url|
84
+ if source_via(source) && self.class.unverifiable_through_tunnel?(source, url)
85
+ raise Dash::ConfigurationError, "autoscale/#{source}: #{REDISS_VIA_MESSAGE}"
86
+ end
87
+ end
88
+ end
89
+
90
+ # The host to tunnel the source's connection through over SSH, or nil to connect directly.
91
+ def source_via(source)
92
+ source_config(source)&.fetch("via", nil)
93
+ end
94
+
95
+ def prometheus_token
96
+ reference = source_config(:prometheus)&.fetch("token", nil)
97
+ lookup(reference, "prometheus/token") unless reference.nil?
98
+ end
99
+
100
+ private
101
+ attr_reader :secrets
102
+
103
+ def provider_config
104
+ autoscale_config&.fetch("provider", nil)
105
+ end
106
+
107
+ def source_config(source)
108
+ autoscale_config&.fetch(source.to_s, nil)
109
+ end
110
+
111
+ # A one-element array names a secret; anything else is the value as written.
112
+ def lookup(reference, path)
113
+ value = reference.is_a?(Array) ? secrets[reference.first] : reference
114
+ return value if value.present?
115
+
116
+ if reference.is_a?(Array)
117
+ raise Dash::ConfigurationError, "autoscale/#{path}: secret '#{reference.first}' resolved to an empty value — " \
118
+ "if your secrets file forwards it from the environment (#{reference.first}=$#{reference.first}), export the variable before running dash"
119
+ else
120
+ raise Dash::ConfigurationError, "autoscale/#{path}: is blank"
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,141 @@
1
+ # Autoscale
2
+ #
3
+ # Where the pool members of scaled roles (see `scale` in dash docs role) live. dash asks the
4
+ # provider for them at the start of a command — the provider is the source of truth for
5
+ # which extra hosts are running, so there is no state file to drift.
6
+
7
+ # UpCloud
8
+ #
9
+ # Members are UpCloud servers carrying the `dash.service`, `dash.destination` and
10
+ # `dash.role` labels. The API user's credentials are secrets: a one-element array names
11
+ # the secret to read, as for `registry/password`. They are sent to the UpCloud API as
12
+ # HTTP basic auth and nowhere else.
13
+ #
14
+ # Set exactly one provider.
15
+ autoscale:
16
+ provider:
17
+ upcloud:
18
+ username:
19
+ - UPCLOUD_USERNAME
20
+ password:
21
+ - UPCLOUD_PASSWORD
22
+
23
+ # Exec
24
+ #
25
+ # Your own scripts, for any other cloud. Each runs locally with the role's labels as
26
+ # DASH_SERVICE, DASH_DESTINATION (`-` without a destination) and DASH_ROLE in its
27
+ # environment, plus DASH_MEMBER_ID and DASH_HOST for the member it acts on, and
28
+ # DASH_STOP_TIMEOUT (seconds) for `stop`. Only `members`, `start` and `stop` are
29
+ # required; `create` and `destroy` are for `members: create` roles.
30
+ #
31
+ # `members` prints one JSON object per line —
32
+ # `{"id": "...", "host": "10.0.0.22", "role": "payments", "state": "started"}`, where
33
+ # state is started, stopped, maintenance or error — and `create` prints the new
34
+ # member the same way. A script that exits non-zero fails the command, and the last
35
+ # line of its stderr is the error dash shows.
36
+ autoscale:
37
+ provider:
38
+ exec:
39
+ members: bin/pool-members
40
+ start: bin/pool-start
41
+ stop: bin/pool-stop
42
+ create: bin/pool-create
43
+ destroy: bin/pool-destroy
44
+
45
+ # Controller
46
+ #
47
+ # How `dash autoscale run` ticks. Every `interval` seconds (default 10, at least 5) it reads
48
+ # the pool and the running containers, and scales each role with a `scale/schedule` or a
49
+ # `scale/signal` toward its target through `dash scale set`, under the deploy lock. A tick waits up to
50
+ # `lock_wait_timeout` seconds (default 60) for a lock someone else holds, then holds the
51
+ # role until the next tick. Schedule crons are evaluated in `timezone`, an IANA name
52
+ # (default UTC; this example evaluates them in Stockholm time). `controller` names the
53
+ # ops host that runs it and keeps its state (see below); without it, the state lives on
54
+ # the primary role's first host.
55
+ #
56
+ # `redis`, `postgres` and `prometheus` are where signals and guards read from (see
57
+ # `scale/signal` and `scale/hold_when` in dash docs role): a sidekiq signal reads Redis, a
58
+ # pgbus signal and a postgres guard read Postgres. Each `url` is a secret (a one-element
59
+ # array) or a literal URL, read from the machine the controller runs on. `via` names a host
60
+ # to tunnel the connection through over SSH, for a database only the app servers reach;
61
+ # `rediss://` cannot go through `via` (the certificate cannot be verified through a
62
+ # tunnel). Postgres reads run in a read-only transaction. `token` is an optional bearer
63
+ # token for Prometheus. The controller's Gemfile needs `redis-client` for Redis and `pg` for
64
+ # Postgres; dash does not depend on either.
65
+ autoscale:
66
+ provider:
67
+ ...
68
+ controller: 10.0.0.50
69
+ interval: 10
70
+ lock_wait_timeout: 60
71
+ timezone: Europe/Stockholm
72
+ redis:
73
+ url:
74
+ - REDIS_URL
75
+ via: 10.0.0.31
76
+ postgres:
77
+ url:
78
+ - DATABASE_URL
79
+ prometheus:
80
+ url: http://10.0.0.9:9090
81
+ token:
82
+ - PROMETHEUS_TOKEN
83
+
84
+ # Running the controller
85
+ #
86
+ # `dash autoscale run` is a long-lived process on an ops host - a machine with a checkout
87
+ # of the app, its `.dash/secrets` and SSH access to the servers. Name that host in
88
+ # `controller` and run the controller there: it keeps its state on that host, under the
89
+ # app's run directory - a heartbeat, the decision log, the pauses and what it remembers
90
+ # between ticks (cooldowns, warmups, unreachable members) - away from the app servers that
91
+ # are busiest when the app is in trouble. It reaches its own state over SSH, like every
92
+ # other host, so the ops host needs an SSH key (and `ssh/user`) that logs into itself.
93
+ # Every operator's `dash autoscale status`, `history` and `explain`, `pause` and `resume`,
94
+ # `dash doctor` and `dash mcp` read and write it there too.
95
+ #
96
+ # Without `controller`, the state lives on the primary role's first host. Setting or
97
+ # changing `controller` migrates nothing: the controller uses whatever state the selected
98
+ # host holds - none on a new host, so cooldowns, warmups and pauses start fresh; the files
99
+ # it had before, pauses included, on a host it used earlier. The old host's files stay
100
+ # where they were. Stop the old controller first, then check `dash autoscale status` on
101
+ # the new host for pauses that are missing or stale.
102
+ #
103
+ # One controller per app and destination: a second one refuses to start while the first
104
+ # one's heartbeat is fresh (three intervals), unless started with `--takeover`, and the
105
+ # first stops at its next tick. TERM or INT lets the tick in progress finish - a member
106
+ # joining included - before it exits, so give the service manager more time to stop it
107
+ # than the slowest tick takes. A tick acts on its roles one after another, and each
108
+ # action may first wait `lock_wait_timeout` for the deploy lock and then join members one
109
+ # at a time, each within `boot_timeout` plus the app's boot - budget for the roles that
110
+ # can scale out together.
111
+ #
112
+ # ```ini
113
+ # # /etc/systemd/system/dash-autoscale.service
114
+ # [Unit]
115
+ # Description=dash autoscale controller
116
+ # After=network-online.target
117
+ #
118
+ # [Service]
119
+ # WorkingDirectory=/srv/myapp
120
+ # ExecStart=/usr/local/bin/bundle exec dash autoscale run --metrics-port 9394
121
+ # Restart=always
122
+ # TimeoutStopSec=900
123
+ #
124
+ # [Install]
125
+ # WantedBy=multi-user.target
126
+ # ```
127
+ #
128
+ # Or in a container, with the checkout and an SSH key mounted:
129
+ #
130
+ # ```sh
131
+ # docker run -d --restart unless-stopped --name dash-autoscale \
132
+ # -v /srv/myapp:/app -w /app -v ~/.ssh:/root/.ssh:ro \
133
+ # ruby:3.4 sh -c "bundle install && bundle exec dash autoscale run"
134
+ # ```
135
+ #
136
+ # `--once` runs a single tick and exits, for cron; the state on the controller host carries
137
+ # cooldowns and warmups from one run to the next. `--dry-run` decides and prints without
138
+ # acting or writing the controller's state (only the `--record` file, when given). `--metrics-port` serves Prometheus metrics at `/metrics`
139
+ # (on 127.0.0.1 unless `--metrics-bind` says otherwise). `dash autoscale explain ROLE`
140
+ # shows what the next tick would decide and why; `dash autoscale pause ROLE --for 2h`
141
+ # keeps the controller's hands off a role, `resume ROLE` gives it back.