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
data/lib/dash/commands/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
+
attr_reader :role, :host, :replica
|
|
19
26
|
|
|
20
|
-
|
|
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:
|
|
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.
|
|
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",
|
|
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
|
data/lib/dash/commands/base.rb
CHANGED
|
@@ -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
|
data/lib/dash/commands/prune.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/dash/commands/server.rb
CHANGED
|
@@ -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"])&.
|
|
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)&.
|
|
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.
|